百度百科:I/O 输入/输出(Input/Output),分为IO设备和IO接口两个部分。 在POSIX兼容的系统上,例如Linux系统 [1] ,I/O操作可以有多种方式,比如DIO(Direct I/O),AIO(Asynchronous I/O,异步I/O),Memory-Mapped I/O(内存映射I/O)等,不同的I/O方式有不同的实现方式和性能,在不同的应用中可以按情况选择不同的I/O方式。
基本概念
用户空间与内核空间
内核空间(Kernel space)是 Linux 内核的运行空间,用户空间(User space)是用户程序的运行空间。为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。
在内核空间里可以执行任意命令,调用系统的一切资源;用户空间只能执行简单的运算,不能直接调用系统资源,必须通过系统调用(system call),才能向内核发出指令。
进程切换
为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:
注意:进程切换很耗资源。
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程 (获得 CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。
文件描述符 fd
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。
Linux 中一切都可以看作文件,包括普通文件、链接文件、Socket 以及设备驱动等,对其进行相关操作时,都可能会创建对应的文件描述符。文件描述符(file descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过文件描述符。
缓存 IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存 (page cache) 中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 IO 的缺点:
- 数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
流
计算机中的“流”是指可以进行 I/O 操作的内核对象,例如文件、管道、socket 等。
流的入口:文件描述符(fd)。
I/O 操作
所有对流的读写操作,我们都可以称之为 I/O 操作。
当一个流中, 在没有数据的时候进行 read 操作,或者说在流中已经写满了数据,再 write 操作,就会出现阻塞现象。
读操作
基于传统的 I/O 读取方式,read 系统调用会触发 2 次上下文切换,1 次 DMA 拷贝和 1 次 CPU 拷贝。
read()
函数向 Kernel 发起 System Call,上下文从用户空间切换为内核空间。read
调用执行返回。写操作
基于传统的 I/O 写入方式,write()
系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝。
write()
函数向 kernel 发起 System Call,上下文从用户空间切换为内核空间。write
系统调用执行返回。Linux/UNIX 的 IO 模型
网络应用需要处理的无非就是两大类问题:网络 IO和数据计算。相对于后者,网络 IO 的延迟,给应用带来的性能瓶颈大于后者。网络 IO 的模型大致有如下几种:
- 阻塞 IO(Blocking IO)
- 非阻塞 IO(Non-Blocking IO)
- IO 多路复用(IO Multiplexing)
- 信号驱动 IO(Signal driven IO)
- 异步 IO(Asychronous IO)
阻塞IO(BIO)
阻塞 IO(Blocking IO) 指的是在读写 IO 的时候,如果 IO 流没有数据(无数据可读),或者流已满(缓冲区已满,暂时写不了了),进程就会被挂起,接入等待队列,当 IO 流可读或者可写后,该进程就会被放入就绪队列,可以被再次执行了。
打个比方,顾客(客户端进程)去奶茶店(IO 流)买奶茶,下完单后,需要一直等着奶茶准备好,不能干其他事。
流程:
代码案例
import socket
HOST = ''
PORT = 50007
def server():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen(1)
while True:
conn, addr = s.accept() # 会阻塞在此,直到又客户端连接
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024) # 会阻塞在此,直到收到客户端的请求
if not data: break
print(f"message from {addr}: {data}")
conn.sendall(data)
if __name__ == '__main__':
server()
优点:
缺点:
非阻塞 IO(NIO)
非阻塞 IO(Non-Blocking IO) 跟阻塞 IO 正好相反,如果 IO 流没有数据(无数据可读),或者流已满(缓冲区已满,暂时写不了了),系统调用返回一个错误代码。此时用户进程不会被挂起,可以做其他事,但需要通过轮询的方式查询 IO 就绪。
流程
代码案例
import socket
HOST = ''
PORT = 50007
def server():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen(10) # 设置最大监听数目,并发
s.setblocking(False) # 设置为非阻塞
clients = [] # 保存客户端 socket
while True:
try:
conn, addr = s.accept() # 非阻塞,轮询检查是否有连接
conn.setblocking(False)
clients.append((conn, addr)) # 存放客户端 socket
print('Connected by', addr)
except BlockingIOError:
pass
for cs, ca in clients:
try:
data = cs.recv(1024) # 接收数据,非阻塞
if len(data) > 0: # 收到了数据
print(f"message from {ca}: {data}")
cs.sendall(data)
else:
cs.close()
clients.remove((cs, ca))
except Exception:
pass
if __name__ == '__main__':
server()
优点:
缺点:
IO 多路复用 (IO multiplexing)
多路复用技术是为了充分利用传输媒体,人们研究了在一条物理线路上建立多个通信信道的技术。多路复用技术的实质是,将一个区域的多个用户数据通过发送多路复用器进行汇集,然后将汇集后的数据通过一个物理线路进行传送,接收多路复用器再对数据进行分离,分发到多个用户。
由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的 CPU 时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是用户的进程,而是有人帮忙就好了。这就是所谓的 “IO 多路复用”。UNIX/Linux 下的 select、poll、epoll 就是干这个的。
多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel),指的是多条 TCP 连接。
复用:用一个进程/线程来处理多条连接,使用单进程/线程就能够实现同时处理多个客户端的连接。
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个 Sock(I/O流) 的状态来同时管理多个 I/O 流. 目的是尽量多的提高服务器的吞吐能力。像 NGINX 和 Redis 使用了 IO 多路复用的技术。
信号驱动 IO(Signal driven IO)
信号驱动 IO 是与内核建立 SIGIO 的信号关联并设置回调,当内核有 FD 就绪时,会发出 SIGIO 信号通知用户程序,期间用户应用可以执行其它业务,无需阻塞等待。
流程:
sigaction
,注册信号处理函数缺点:
当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
异步 IO(Asychronous IO)
相对于同步 IO,异步 IO(Asychronous IO) 不是顺序执行。用户进程进行aio_read
系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket
数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO 两个阶段,进程都是非阻塞的。
流程:
aio_read
,创建信号回调函数aio_read
中的回调函数优点:
缺点:
总结
- 同步和异步的讨论对象是被调用者,重点在于调用结果的消息通知方式上
- 同步:调用着要一直等待调用结果的通知后才能进程后续的执行
- 异步:指被调用放线返回应答让调用者先回去做其他事,然后再计算调用结果,计算完最终结果后再通知并返回给调用者
- 阻塞和非阻塞的讨论对象是调用者,重点在于等消息时候的行为,调用者是否能干其他事
- 阻塞:调用方一直在等待而且别的事情什么都不做,当前进/线程被挂起,啥都不干
- 非阻塞:调用在发出去后,调用方先去忙别的事,不会阻塞当前进/线程,而会立即返回
- 四种组合方式:
- 同步阻塞
- 同步非阻塞
- 异步阻塞
- 异步非阻塞
5种 I/O 模型的比较:
参考
-
Linux NIO 系列(01) 五种网络 IO 模型
-
Linux系统中I/O操作的数据读写流程介绍