本文浅谈Linux I/O模型。

Linux IO模型

背景知识:

缓存I/O:缓存IO称为标准IO,大多数文件系统的默认操作就是缓存IO。Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的缓存页中。具体来说,数据会先拷贝到操作系统的内核缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。这个拷贝过程的CPU和内存开销很大。

同步和异步:同步是主动等待消息通知,异步则是被动接收消息通知,比如回调、通知、状态。

阻塞和非阻塞:进程状态。

1
2
3
4
5
6
7
8
9
10
11
12
import socket

server = socket.socket()
server.bind((IP, PORT))
server.listen(5)
sock, client = server.accept()
data = sock.read()

# read分为两步。(1)等待数据的准备(2)将数据从内核区拷贝到进程中
# 对于socket流。(1)等待网络分组到达,复制到内核的某个缓冲区(2)把数据从内核的缓冲区复制到应用进程的缓冲区。

# 在网络通信中,等待分组需要的时间相对于缓冲区数据拷贝需要更多的时间。网络延时给系统带来性能瓶颈。

为了解决这个问题,在Linux上引入网络I/O模型。

网络通信I/O模型

同步模型

  1. 阻塞I/O blocking I/O
  2. 非阻塞I/O non-blocking I/O
  3. 多路复用I/O multiplexing I/O
  4. 信号驱动式I/O signal-driven I/O

异步I/O

对比表格

type blocking non-blocking
Synchronous read/write read/write(O_NONBLOCK)
Asynchronous multiplexing I/O(select/poll) AIO

I/O复用模型参考select_poll_epoll

同步阻塞I/O

这是编程中最常见、最简单的I/O模型,Linux默认情况下socket都是阻塞式I/O。用户空间的应用程序调用recvfrom、recv函数后,不如数据没有准备好,就会导致程序阻塞,直到数据从内核复制到用户进程。等待数据分两个阶段:(1)等待数据:内核在接收数据中(2)将数据从内核复制到用户空间。

过程:

应用进程发起recv系统调用 —> 内核接收数据,进程阻塞(进程主动行为) —> 内核接收数据完毕,数据从内核缓冲区拷贝到用户进程 —> 拷贝完成 —> 唤醒进程,调用返回

  • 缺点:I/O等待消耗时间

同步非阻塞I/O

非阻塞说明发起I/O调用后,系统还没有返回recv的数据到用户进程,但调用返回了,进程并没有主动阻塞自己。为了检测调用何时结束,进程会每隔一段时间检测返回状态,这称为轮询(poll)大致过程如下:

应用进程 —> 发起recvfrom,触发系统调用 —> 系统返回,数据没有准备好 —> 隔断时间 —> 发起recvfrom,触发系统调用 —> 系统返回,数据没有准备好 —> … —> 数据准备好,复制完成 —> 返回成功指示

  • 优点:在等待数据返回过程应用进程可以执行其他流程。
  • 缺点:响应延时增大。由于是每个一段时间轮询,可能会导致系统整体吞吐量降低。

I/O多路复用

I/O多路复用也称为事件驱动I/O,例如Node.js教程中。参看深入浅出Node

Linux上的select、poll、epoll就是I/O多路复用模型,它们本质是还是同步阻塞,通过一种机制使一个进程同时等待多个I/O文件描述符。通过轮询多个任务的状态,只要任何一个任务的状态改变了就马上返回,进行状态改变的相应处理。这三者和同步非阻塞的区别在于它们是内核级别的,同时轮询多个socket,可以实现多个I/O事件的监听。这三个内核调用如果没有可用的套接字(状态改变)时是会阻塞的,直到至少一个socket状态发生改变。正是由于可以处理多个I/O,它们对I/O的处理顺序就变得不确定了。

过程如下:
应用进程调用select、poll、epoll任一个 —> 发起系统调用 —> 内核数据没有准备好 —> 等待数据,进程受阻于三个调用之一 —> 数据准备好 —> 返回读条件 —> 应用进程发起系统调用 —> 数据从内核复制到用户空间 —> 复制完成 —> 返回成功指示

select、poll、epoll是有区别的,参看比较

  • 优点:比起同步非阻塞,I/O复用可以同时处理多个socket连接。比起线程、进程并发,它的开销更小。
  • 缺点:它并不能保证比单连接的通知非阻塞模型处理的更快。

信号驱动式I/O signal-driven I/O

信号驱动式I/O:首先我们允许Socket进行信号驱动I/O,并安装一个信号处理函数,进程继续运行但不阻塞。当数据准备好后,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。可以类比到数据库中的触发器,注册触发器后当事件发生变化了就作相应的处理货向上通知。

异步非阻塞I/O

异步不同于同步的特点之一是同步是顺序执行的,异步不是顺序执行的。用户进程发起aio_read系统调用之后,无论内核是否准备好,都直接返回给用户进程,然后用户态可以继续进行其他的处理。等到数据准备好,内核直接把数据复制给进程,然后kernel向进程发起通知。

目前有很多异步I/O的库:libeventlibevlibuv

异步的调用过程如下:

应用进程发起aio_read调用 —> 系统调用 —> 内核没有准备好数据 —> 不管了,直接返回,用户进程继续进行其他的执行 —> 数据准备好,复制数据到用户进程 —> 数据复制 —> 递交在aio_read中指定的信号或者执行一个基于回调的函数来处理这些数据 —> 信号处理和程序处理数据包

信号的具体过程:

转载请包括本文地址:https://allenwind.github.io/blog/5822
更多文章请参考:https://allenwind.github.io/blog/archives/