Linux I/O 模型详解
by FlyFlyPeng
Linux I/O 模型
在 Linux/Unix 系统环境下进行 I/O 编程时,我们经常会遇到这些似懂非懂的名词:
- Synchronous I/O
- Asynchronous I/O
- Blocking I/O
- Non-blocking I/O
首先,这里讲的 I/O 指的是用户空间的 I/O,并且主要侧重于网络编程中的 I/O 操作,而用户空间的 I/O 操作主要分为两个阶段:
- 等待数据准备就绪(waiting for the data to be ready)
- 将数据从内核拷贝到进程中(copying the data from kernel to the process)
正是因为这两个阶段,Linux 系统中的 I/O 模型主要可以分为下面五种:
- Blocking I/O
- Non-blocking I/O
- I/O multiplexing
- Asynchronous I/O
- Signal driven I/O
Blocking I/O
在 Linux 中所有的 socket 文件描述符默认都是 blocking 的,一个典型的网络 socket 文件描述符的 read 操作的流程如上图所示。
当用户进程调用 read 这个系统调用,kernel 就开始了 I/O 的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候 kernel 就要等待足够的数据到来),这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。
而在用户进程这边,整个进程一直处于阻塞状态。当 kernel 中数据准备好之后,系统就会将数据从 kernel 中拷贝到用户空间进程的缓冲区中,然后 kernel 返回结果,用户进程才解除 blocking 的状态,重新运行起来。
blocking I/O 的特点是用户进程在 I/O 操作的两个阶段都被 block 了。
Non-blocking I/O
Linux 系统中可以在调用 open
函数打开 socket 文件时或者调用 fcntl
函数来设置 socket 文件描述符为非阻塞状态,当对一个 non-blocking 的 socket 文件描述符执行读操作时,它的执行流程如下图所示:
在 non-blocking I/O 的情境下,当用户进程调用 read 系统调用函数时,如果 kernel 中的数据没有准备好,那么系统并不会 block 用户进程,而是立即返回一个 error。
从用户进程角度看 ,当它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送read操作。
一旦 kernel 中的数据准备好之后,并且再次收到了用户进程发送的 read 系统调用时,它就马上将准备好的数据拷贝到用户空间的缓冲区中。
non-blocking I/O 的特点就是用户进程需要不断地询问 kernel 数据是否准备好了。
I/O Multiplexing
I/O multiplexing
就是我们经常说的 select、poll和epoll,有些地方也称这种 I/O 方式为 event driven I/O
。I/O multiplexing 的好处就是一个进程可以处理多个网络连接 I/O,它的工作原理就是 select/poll/epoll 函数会不断的查询所监测的 socket 文件描述符中是否有 socket 准备好读写了,如果有,那么系统就会通知用户进程。
I/O multiplexing 的特点是通过一种机制让一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入可读的就绪状态,select/poll/epoll函数就可以立即返回。
这个图和 blocking I/O
的图其实并没有太大的不同,事实上,开销还会更大一点。因为这里需要使用两个 system call (select 和 read),而 blocking I/O 只调用了一个system call (read)。但是,用 select 的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking I/O 的 web server 性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在 I/O multiplexing
模型中,实际中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的process 其实是一直被 block 的。只不过用户进程是被 select 这个函数 block,而不是被 socket I/O 给 block。
Signal Driven I/O
Signal Driven I/O 的工作原理就是用户进程首先和 kernel 之间建立信号的通知机制,即用户进程告诉 kernel,如果 kernel 中数据准备好了,就通过 SIGIO
信号通知我。然后用户空间的进程就会调用 read 系统调用将准备好的数据从 kernel 拷贝到用户空间。
但是这种 I/O 模型存在一个非常重大的缺陷问题:SIGIO
这种信号对于每个进程来说只有一个!如果使该信号对进程中的两个描述符(这两个文件描述符都等待着 I/O 操作)都起作用,那么进程在接到此信号后就无法判别是哪一个文件描述符准备好了。所以 Signal Driven I/O 模型在现实中用的非常少。
Asynchronous I/O
Linux 系统中 Asynchronous I/O 的操作流程如下图所示:
用户进程通过 aio_read()
函数进行读取操作时,就可以立刻返回到进程中,接着执行其他的操作。从 kernel 的角度来看,当它收到 asynchronous read 操作时,它会立刻返回,并不会阻塞用户进程。然后,kernel 会等待数据准备完成,接着将数据拷贝到用户空间进程的缓冲区中。当这一切都完成之后,kernel 会给用户发送一个 signal 通知用户空间的进程,告诉它 read 操作完成了。
Asynchronous I/O 操作最大的特点就是整个 I/O 操作流程中,用户进程始终没有被 block。
在 POSIX 异步 I/O 中,会有一个 struct aiocb 结构体来描述异步 I/O 操作,这个结构体的定义如下所示:
struct aiocb {
int aio_fildes; /* file descriptor */
off_t aio_offset; /* file offset for I/O */
volatile void *aio_buf; /* buffer for I/O */
size_t aio_nbytes; /* number of bytes to transfer */
int aio_reqprio; /* priority */
struct sigevent aio_sigevent; /* signal infomation */
int aio_lio_opcode; /* operation for list I/O */
};
这个 POSIX 异步 I/O 控制块结构体描述了,异步 I/O 操作对应的文件描述符,用户空间的进程存放读/写的数据的缓冲区,以及异步 I/O 操作完成之后,对接收到信号的处理结构体等等。
Synchronous I/O 和 Asynchronous I/O 的区别
在说明 Synchronous I/O 和 Asynchronous I/O 的区别之前,需要先给出两者的定义,POSIX 中两者的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;
两者的区别就在于 Synchronous I/O 做 IO operation
的时候会将用户空间的进程阻塞。
按照这个定义,之前所说的 Blocking I/O
,Non-blocking I/O
,IO multiplexing
都属于 Synchronous I/O。
有人会说,Non-blocking I/O
并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的 IO operation
是指真实的I/O操作,就是例子中的 read 这个 system call。Non-blocking I/O
在执行 read 这个system call 的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。但是,当kernel 中数据准备好的时候,read 会将数据从kernel 拷贝到用户空间的缓冲区中,这个时候进程是被 block 的。
而Asynchronous I/O则不一样,当进程发起 I/O 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说 I/O完成。在这整个过程中,进程完全没有被 block。
Subscribe via RSS