Linux的IO模型
# Linux 的 IO 模型
百度百科: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 上对于进程(包括线程)进行以下的活动:
- 挂起一个进程,将这个进程在 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,上下文从用户空间切换为内核空间。 - CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间的读缓冲区(Read Buffer)。
- CPU 将读缓冲区(Read Buffer)中的数据拷贝到用户空间的用户缓冲区(User Buffer)。
- 上下文从内核空间切换回用户态(User Space),
read
调用执行返回。
写操作
基于传统的 I/O 写入方式,write()
系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝。
- 用户进程通过
write()
函数向 kernel 发起 System Call,上下文从用户空间切换为内核空间。 - CPU 将用户缓冲区(User Buffer)中的数据拷贝到内核空间的网络缓冲区(Socket Buffer)。
- CPU 利用 DMA 控制器将数据从网络缓冲区(Socket Buffer)拷贝到 NIC 进行数据传输。
- 上下文从内核空间切换回用户空间,
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 流)买奶茶,下完单后,需要一直等着奶茶准备好,不能干其他事。
流程:
- 第一阶段:准备数据(对于网络 IO 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的 UDP 包。这个时候 kernel 就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。这个阶段,用户进程会被阻塞。
- 第二阶段:拷贝数据。当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。
代码案例
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()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
优点:
- 能够及时返回数据,无延迟;
- 模型简单,容易开发。
缺点:
- 用户进程需要干等,不能干其他事,性能较低。
# 非阻塞 IO(NIO)
非阻塞 IO(Non-Blocking IO) 跟阻塞 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(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()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
优点:
- 用户进程准备数据时不会被阻塞了,可以干其他事情,相比阻塞 IO 稍微提高了一点性能。
缺点:
- 任务完成的响应延迟增大了,因为每过一段时间才去轮询一次 read 操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
# 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
,注册信号处理函数 - 内核返回成功,开始监听 FD
- 用户进程不阻塞等待,可以执行其它业务
- 当内核数据就绪后,回调用户进程的 SIGIO 处理函数
- 用户进程调用
- 第二阶段:
- 收到 SIGIO 回调信号
- 调用 recvfrom,准备读取数据
- 内核将数据拷贝到用户空间
- 用户进程处理数据
缺点:
当有大量 IO 操作时,信号较多,SIGIO 处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。
# 异步 IO(Asychronous IO)
相对于同步 IO,异步 IO(Asychronous IO) 不是顺序执行。用户进程进行aio_read
系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket
数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO 两个阶段,进程都是非阻塞的。
流程:
- 第一阶段:
- 用户进程调用
aio_read
,创建信号回调函数 - 内核等待数据就绪
- 用户进程无需阻塞,可以做任何事情
- 用户进程调用
- 第二阶段:
- 内核数据就绪
- 内核数据拷贝到用户缓冲区
- 拷贝完成,内核递交信号触发
aio_read
中的回调函数 - 用户进程处理数据
优点:
- 用户进程不需要被阻塞着,可以干其他事,提高了性能
- 不再像非阻塞 IO 那样需要轮询来检查 IO 是否就绪,而是由系统信号来通知
缺点:
- 在高并发场景下,因为 IO 效率较低,所以会积累很多任务在系统中,容易导致系统崩溃。(可以用限流等方式解决,但是实现方式繁琐复杂)
# 总结
- 同步和异步的讨论对象是被调用者,重点在于调用结果的消息通知方式上
- 同步:调用着要一直等待调用结果的通知后才能进程后续的执行
- 异步:指被调用放线返回应答让调用者先回去做其他事,然后再计算调用结果,计算完最终结果后再通知并返回给调用者
- 阻塞和非阻塞的讨论对象是调用者,重点在于等消息时候的行为,调用者是否能干其他事
- 阻塞:调用方一直在等待而且别的事情什么都不做,当前进/线程被挂起,啥都不干
- 非阻塞:调用在发出去后,调用方先去忙别的事,不会阻塞当前进/线程,而会立即返回
- 四种组合方式:
- 同步阻塞
- 同步非阻塞
- 异步阻塞
- 异步非阻塞
5种 I/O 模型的比较: