背景概念

阻塞和非阻塞

同步和异步

阻塞、非阻塞和同步、异步的区别

首先,前面已经提到过,阻塞、非阻塞和同步、异步其实针对的对象是不一样的。
阻塞、非阻塞说的是调用者。同步、异步说的是被调用者。
阻塞、非阻塞说的是调用者。同步、异步说的是被调用者。
阻塞、非阻塞说的是调用者。同步、异步说的是被调用者。
有人认为阻塞和同步是一回事儿,非阻塞和异步是一回事。但是这是不对的。
同步包含阻塞和非阻塞异步包含阻塞和非阻塞

文件描述符

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

IO 模型

UNIX 系统下, IO 模型一共有 5 种: 
同步阻塞 I/O同步非阻塞 I/OI/O 多路复用信号驱动 I/O  和异步 I/O

下面以 read 读入数据来介绍。

同步阻塞 IO (BIO, Blocking IO)

|500|600

缺点:2 个阶段都被阻塞了,不适合高并发的情景。

同步非阻塞 IO (Non-blocking I/O)

Java 中的 NIO 于 Java 1.4 中引入,对应  java.nio  包,提供了  Channel , SelectorBuffer  等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。对于高负载、高并发的(网络)应用,应使用 NIO 。

NIO 过程:
阶段 1 中,用户进程可选择做其他事情。期间采取轮询的方式来查看内核缓冲区是否就绪,即用户在阶段 1 发起系统调用后,内核会返回给进程缓冲区是否就绪的信息。如果数据就绪,则执行阶段 2。阶段 1 过程中,进程属于非阻塞状态。轮询时,用户进程发起系统调用之后,进程并没有被阻塞。阶段 2 拷贝数据时,进程属于阻塞状态。
|500|600

所以,nonblocking IO 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。

缺点:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。

I/O 多路复用

IO 多路复用模型,用的最多,也称为事件驱动 IO 模型。

等待阶段:用户进程通过 select、poll、epoll 系统调用请求内核监视该进程需要的文件描述符,当文件描述符就绪时,会通知程序进行相应的读写操作。

复制阶段:用户进程通过 recvfrom 进行系统调用,内核空间数据复制到用户空间,内核返回成功。

对于用户进程来说,这两个阶段都是阻塞的,可以归为同步阻塞。但与同步阻塞 IO 不同的在于,IO 多路复用模型的等待阶段,内核进程会监控多个进程需要的文件描述符,相较于同步阻塞模型监控一个进程的文件描述符,效率更高。相较于同步非阻塞 IO,轮询的步骤交给了内核去完成。

一个进程能同时等待 IO 文件描述符,内核监视这些文件描述符(套接字描述符),其中的任意一个进入读就绪状态,select, poll,epoll 函数就可以返回,通知程序进行相应的读写操作。

以 select 系统调用为例,先将 fd 数组拷贝到内核空间,内核会遍历所有进程的 fd 数组。若有满足条件的 fd,对应进程的 select 函数会返回。如果没有满足条件的 fd,内核会进行休眠,在 socket 可读写时唤醒,或在超时后唤醒。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用多线程 + 阻塞 IO 的 web server 性能更好,可能延迟还更大。也就是说,select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

|500|600

信号驱动式 I/O 模型

我们首先开启套接字的信号驱动 I/O 功能,通过 sigaction 系统调用安装一个信号处理函数,系统调用立即返回,进程继续工作,当数据包准备好时内核产生一个 SIGIO 信号通知,我们通过 recvfrom 调用读取数据报。信号驱动式 I/O 模型的优点是我们在数据报到达期间进程不会被阻塞,我们只要等待信号处理函数的通知即可

|500|600

异步非阻塞 IO (AIO, Asynchronous IO)

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2, 它是异步 IO 模型。

异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

|500|600

信号驱动模型是内核通知我们何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知我们 I/O 何时完成。

目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。

总结

|600

综上,阻塞式 I/O 模型、非阻塞式 I/O 模型、I/O 复用模型和信号驱动模型都是同步 I/O 模型,他们真正的 I/O 操作将进程阻塞,只有异步 I/O 模型是异步 I/O 操作

Reactor 模式与 Preactor 模式

它们是网络框架的两种设计模式,无论操作系统的网络 I/O 模型的设计,还是上层网络框架的网络 I/O 模型的设计,用的都是这两种设计模式之一。

  1. Reactor 模式:主动模式。所谓主动,是指应用程序不断地轮询,询问操作系统或者网络框架、I/O 是否就绪。Linux 系统下的 select、poll、epoll 就属于主动模式,需要应用程序中有一个循环一直轮询;Java 中的 NIO 也属于这种模式。在这种模式下,实际的 I/O 操作还是应用程序执行的。
  2. Proactor 模式:被动模式。应用程序把 read 和 write 函数操作全部交给操作系统或者网络框架,实际的 I/O 操作由操作系统或网络框架完成,之后再回调应用程序。asio 库就是典型的 Proactor 模式。

所以,上文提到的应用层面的语境中所说的“异步 I/O”是 Proactor 模式。

select、poll、epoll

select、poll、epoll 的区别

selectpollepoll
底层数据结构数组存储文件描述符链表存储文件描述符红黑树存储监控的文件描述符,双链表存储就绪的文件描述符
从 fd 数据中获取就绪的 fd遍历数组遍历链表回调
时间复杂度OnOn有就绪事件时,系统注册的回调函数就会被调用,将就就绪的 fd 插入链表,O1
数据拷贝每次调用 select,需要将 fd 从用户空间拷贝到内核空间每次调用 select,需要将 fd 从用户空间拷贝到内核空间内存映射,不需要频繁拷贝
最大连接数有限,一般 1024无限制无限制

select,不知道到底哪个文件描述符就绪了。当文件描述符就绪后,需要遍历一遍数组,因此是 O(n) 的复杂度。poll,同理。

epoll,当文件描述符就绪后,直接根据回调函数,将其放到链表中,因此是 O(1) 的复杂度。

select

int select(int maxfdp1, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

关于此函数,有几点说明:

  1. 因为 fd 是一个 int 值,所以 fd_set 其实是一个 bit 数组,每 1 位表示一个 fd 是否有读事件或者写事件发生。
  2. 第一个参数是 readfds 或者 writefds 的下标的最大值+1。因为 fd 从 0 开始,+1 才表示个数。
  3. 返回结果还在 readfdswritefds 里面,操作系统会重置所有的 bit 位,告知应用程序到底哪个 fd 上面有事件,应用程序需要自己从 0 到 maxfds-1 遍历所有的 fd,然后执行相应的 read/write 操作。
  4. 每次当 select 调用返回后,在下一次调用之前,要重新维护 readfds 和 writefds。

poll

int poll(struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd
{
	int fd;
	short events;    // 每个 fd, 两个 bit 数组,一个进去,一个出来
	short revents;
}

select、poll 每次调用都需要应用程序把 fd 的数组传进去,这个 fd 的数组每次都要在用户态和内核态之间传递,影响效率。为此,epoll 设计了“逻辑上的 epfd”。epfd 是一个数字,把 fd 数组关联到上面,然后每次向内核传递的是 epfd 这个数字。

epoll 大致过程

int epoll_create(int size);//创建一个epoll的句柄,size用来预估大小,和ArrayList初始size类似
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);// 添加、删除和修改对fd的监听事件。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); // 等待就绪的

整个 epoll 的过程分成三个步骤:

  1. 事件注册。通过函数 epoll_ctl 实现。对于服务器而言,是 accept、read、write 三种事件;对于客户端而言,是 connect、read、write 三种事件。
  2. 轮询这三个事件是否就绪。通过函数 epoll_wait 实现。有事件发生,该函数返回。
  3. 事件就绪,执行实际的 I/O 操作。通过函数 accept/read/write 实现。

这里要特别解释一下什么是“事件就绪”:

  1. read 事件就绪:这个很好理解,是远程有新数据来了,socket 读取缓存区里有数据,需要调用 read 函数处理。
  2. write 事件就绪:是指本地的 socket 写缓冲区是否可写。如果写缓冲区没有满,则一直是可写的,write 事件一直是就绪的,可以调用 write 函数。只有当遇到发送大文件的场景,socket 写缓冲区被占满时,write 事件才不是就绪状态。
  3. accept 事件就绪:有新的连接进入,需要调用 accept 函数处理。

要深刻理解 epoll,首先得了解 epoll 的三大关键要素:mmap、红黑树、链表

  1. mmap:epoll 是通过内核与用户空间 mmap 同一块内存实现的。mmap 将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。内核可以直接看到 epoll 监听的句柄,效率高。概念可以参考操作系统 IO 概念 & 文件拷贝
  2. 红黑树:将存储 epoll 所监听的套接字。上面 mmap 出来的内存如何保存 epoll 所监听的套接字,必然也得有一套数据结构,epoll 在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度 O(logN)。也不会存在重复添加的问题。同时,事件添加完后也会建立回调函数,将这个事件添加到双向链表中。

添加以及返回事件

通过 epoll_ctl 函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序建立回调关系,当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就所把这个事件添加到 rdllist 这个双向链表中。一旦有事件发生,epoll 就会将该事件添加到双向链表中。那么当我们调用 epoll_wait 时,epoll_wait 只需要检查 rdlist 双向链表中是否有存在注册的事件,效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。

epoll_wait 的工作流程:

  1. epoll_wait 调用 ep_poll,当 rdlist 为空(无就绪 fd)时挂起当前进程,直到 rdlist 不空时进程才被唤醒。
  2. 文件 fd 状态改变(buffer 由不可读变为可读或由不可写变为可写),导致相应 fd 上的回调函数 ep_poll_callback()被调用。
  3. ep_poll_callback 将相应 fd 对应 epitem 加入 rdlist,导致 rdlist 不空,进程被唤醒,epoll_wait 得以继续执行。
  4. ep_events_transfer 函数将 rdlist 中的 epitem 拷贝到 txlist 中,并将 rdlist 清空。
  5. ep_send_events 函数(很关键),它扫描 txlist 中的每个 epitem,调用其关联 fd 对用的 poll 方法。此时对 poll 的调用仅仅是取得 fd 上较新的 events(防止之前 events 被更新),之后将取得的 events 和相应的 fd 发送到用户空间(封装在 struct epoll_event,从 epoll_wait 返回)。

epoll 的 LT 和 ET 模式

epoll 里面有两种模式:LT(水平触发)和 ET(边缘触发)。水平触发又称条件触发,边缘触发又称状态触发。

  • 水平触发:读缓冲区只要不为空,就会一直触发读事件;写缓冲区只要不满,就会一直触发写事件。
  • 边缘触发:读缓冲区的状态,从空转为非空的时候触发一次;写缓冲区的状态,从满转为非满的时候触发一次。比如用户发送一个大文件,把写缓存区塞满了,之后缓存区可以写了,就会发生一次从满到不满的切换。

关于 LT 和 ET,有两个要注意的问题:

  1. 对于 LT 模式,要避免“写的死循环”问题:写缓冲区为满的概率很小,即“写的条件”会一直满足,所以当用户注册了写事件却没有数据要写时,它会一直触发,因此在 LT 模式下写完数据一定要取消写事件。
  2. 对于 ET 模式,要避免“short read”问题:例如用户收到 100 个字节,它触发 1 次,但用户只读到了 50 个字节,剩下的 50 个字节不读,它也不会再次触发。因此在 ET 模式下,一定要把“读缓冲区”的数据一次性读完。

在实际开发中,大家一般都倾向于用 LT,这也是默认的模式,Java NIO 用的也是 epoll 的 LT 模式。因为 ET 容易漏事件,一次触发如果没有处理好,就没有第二次机会了。虽然 LT 重复触发可能有少许的性能损耗,但代码写起来更安全。

服务器编程的 1+N+M 模型

epoll 编程的三个步骤是由不同的线程负责的,即服务器编程的 1+N+M 模型。

整个服务器有 1+N+M 个线程,一个监听线程,N 个 I/O 线程,M 个 Worker 线程。N 的个数通常等于 CPU 核数,M 的个数根据上层决定,通常有几百个。

700|600

  1. 监听线程:负责 accept 事件的注册和处理。和每一个新进来的客户端建立 socket 连接,然后把 socket 连接移交给 I/O 线程,完成任务,继续监听新的客户端;
  2. I/O 线程:负责每个 socket 连接上面 read/write 事件的注册和实际的 socket 的读写。把读到的 Reqeust 放入 Request 队列,交由 Worker 线程处理。
  3. Worker 线程:纯粹的业务线程,没有 socket 读写操作。对 Request 队列进行处理,生成 Response 队列,然后写入 Response 队列,由 I/O 线程再回复给客户端。

图 4-6 只是展示了 1+N+M 的一种实现方式,实际上不同系统的实现方式会有一些差异。图 4-7 展示了 Tomcat6 的 NIO 网络模型。

700|600

I/O 线程只负责 read/write 事件的注册和监听,执行了 epoll 里面的前两个阶段,第三个阶段是在 Worker 线程里面做的。I/O 线程监听到一个 sokcet 连接上有读事件,于是把 socket 移交给 Worker 线程,Woker 线程读出数据,处理完业务逻辑,直接返回给客户端。之所以可以这么做,是因为 I/O 线程已经检测到读事件就绪,所以当 Worker 线程在读的时候不会等待。I/O 线程和 Worker 线程之间交互,不再需要一来一回两个队列,直接是一个 socket 集合。有兴趣的读者可以参看 Tomcat6 NIO 模块的源代码,对此模型进行更为仔细的分析。

对于编写服务器程序,无论用 epoll,还是 Java NIO,或者基于 Netty 等网络框架,大体都是按照 1+N+M 的思路来做。另外,在实际的系统中,这里的 M 可能又会按业务职责分成几组不同的线程,就变成了 1+N+M1+M2+……的模型。