io

Netty 系列 阻塞/非阻塞/同步/异步

Posted by lichao modified on March 12, 2021

同步与异步

同步和异步关注的是消息通信机制:

  • 同步是指在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。
  • 异步是指在调用发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

同步和异步的概念描述的是用户线程与内核的交互方式:

  • 同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;
  • 异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞与非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态:

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式:

  • 阻塞是指IO操作需要彻底完成后才返回到用户空间;
  • 非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

I/O 模型

根据 UNIX 网络编程对 I/O 模型的分类,UNIX 提供了 5 种 I/O 模型。

NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,是一种解决高并发与大量连接、I/O处理问题的有效方式。

阻塞式I/O(BIO)

默认情况下,所有套接字都是阻塞的。一个输入操作通常包括两个阶段:

  1. 等待数据准备好
  2. 从内核向进程复制数据

阻塞IO模型

Linux 下的阻塞式 I/O 模型就对应了 Java 下的 BIO 模型,BIO 的底层实现是调用操作系统的 API 去执行的,也就是调用操作系统的 Socket 套接字。

非阻塞式I/O(NIO)

非阻塞IO模型 应用进程通过系统调用 recvfrom 不断的去和内核交互,直到内核数据报准备好,而如果内核无数据准备好,转而立即返回一个 EWOULDBLOCK 的错误,过一段时间再次发送 recvfrom 请求,在此期间进程可以做其他事情,不用一直等待,这就是非阻塞。

当一个应用进程循环调用 recvfrom 时,我们称之为轮询(polling),应用进程持续轮询内核,以查看某个操作是否就绪。Java 的 NIO 映射到 Linux 操作系统就是如上图所示的非阻塞I/O模型。

多路复用(事件轮询)

目前支持 I/O 多路复用的系统调用函数有 select,poll,epoll。在 Linux 网络编程中,很长一段时间都使用 select 做轮询和网络事件通知。然而因为 select 的一些固有缺陷导致它的应用受到了很大的限制,比如 select 单个进程打开的最大句柄数是有限的。最终在 Linux 2.6 选择 epoll 替代了select, Java NIO和AIO底层就是用 epoll。

在 UNIX 系统上,一切皆文件。套接字也不例外,每一个套接字都有对应的 fd(即文件描述符)。

select

select模型

流程上来看,使用 select 函数进行 IO 请求和同步阻塞模型没有太大的区别,甚至还多了添加监视 socket,以及调用 select 函数的额外操作。但是,使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

而在 Java NIO 中也可以实现多路复用,主要是利用多路复用器 Selector,与这里的 select 函数类型,Selector 会不断轮询注册在其上的通道 Channel,如果有某一个 Channel 上面发生读或写事件,这个Channel 处于就绪状态,就会被 Selector 轮询出来

select:比较直接轮询,select使用了一个代理来检测多个I/O的状态,如果I/O还没完成,就会阻塞掉这个处理I/O的线程,使得CPU通过调度处理别的线程,当有I/O完成时,再唤醒这个线程,让程序轮询一遍所有的I/O流,找到完成的I/O并进行下一步的处理。

最简单的事件轮询 API 是 select 函数,它是操作系统提供给用户程序的 API。输入是读、写文件描述符列表,输出是与之对应的可读可写事件。同时还提供了一个 timeout 参数,如果没有任何事件到来,那么就最多等待 timeout 时间,线程处于阻塞状态。一旦期间有任何事件到来,就可以立即返回。时间过了之后还是没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应的事件。处理完了继续过来轮询。于是线程就进入了一个死循环,这个死循环称为事件循环,一个循环为一个周期。

select 是一种内核无状态的实现。即对于每一次系统调用,内核不会记录下任何信息,所以每次调用都需要重复传递相同信息。

1
select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

select() 需要传 3 个集合,r、w 和 e。其中,r 表示对哪些 fd 的可读事件感兴趣,w 表示对哪些 fd 的可写事件感兴趣。每个集合其实是一个 bitmap,通过 0/1 表示我们感兴趣的 fd。例如,对于 fd 为 6 的可读事件感兴趣,那么r 集合的第 6 个 bit 需要被设置为 1。这个系统调用会阻塞,直到感兴趣的事件(至少一个)发生。调用返回时,内核同样使用这 3 个集合来存放 fd 实际发生的事件信息。也就是说,调用前这 3 个集合表示我们感兴趣的事件,调用后这3个集合表示实际发生的事件。

select 为最早期的 UNIX 系统调用,它存在 4 个问题:

  1. 这 3 个 bitmap 有大小限制(FD_SETSIZE,通常为 1024),select 单个进程打开的最大句柄数是有限的。
  2. 由于这 3 个集合在返回时会被内核修改,因此每次调用时都需要重新设置。
  3. 在调用完成后需要扫描这 3 个集合才能知道哪些 fd 的读/写事件发生了,一般情况下全量集合比较大而实际发生读/写事件的 fd 比较少,效率比较低下。
  4. 内核在每次调用都需要扫描这 3 个 fd 集合,然后查看哪些 fd 的事件实际发生,在读/写比较稀疏的情况下同样存在效率问题。

默认情况下,单个进程最多允许打开的文件句柄数(包括 socket 连接数)是有限制的,当大于这个系统限制时,程序会抛出大量的无法打开文件的报错。可修改配置

poll

poll 同样 是一种内核无状态的实现。

1
2
3
4
5
6
7
poll(struct pollfd *fds, int nfds, int timeout)

struct pollfd {
    int fd;
    short events;
    short revents;
}

poll 调用需要传递的是一个 pollfd 结构的数组,调用返回时结果信息也存放在这个数组里面。 pollfd 的结构中存放着 fd、我们对该 fd 感兴趣的事件(events)以及该 fd 实际发生的事件(revents)。poll 传递的不是固定大小的bitmap,因此 select 的问题 1 解决了;poll 将感兴趣事件和实际发生事件分开了,因此 select 的问题 2 也解决了。但 select 的问题 3 和问题 4 仍然没有解决。

epoll

epoll 是一种内核有状态的实现。epoll 是 Linux 中的实现。

1
2
3
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_create 的作用是创建一个context,这个context相当于状态保存者的概念。 epoll_ctl 的作用是,当对一个新的fd的读/写事件感兴趣时,通过该调用将fd与相应的感兴趣事件更新到context中。 epoll_wait 的作用是,等待context中fd的事件发生。

select VS epoll

IO多路复用技术通过把多个IO的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端的请求。

  1. epoll 相比于 select,支持一个进程打开的socket描述符(FD)不受限制
  2. IO 效率不会随着FD数目的增加而线性下降。select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。epoll只会对活跃的socket进行操作(epoll是根据每个fd上面的callbac函数实现的,只有活跃的socket才会去主动调用callback函数,epoll实现了一个伪AIO)
  3. select、poll和 epoll 需要内核把FD消息通知给用户空间。epoll 是通过内核和用户空间mmap同一块内存来实现的
  4. epoll API更加简单。创建一个 epoll描述符、添加监听事件、阻塞等待所监听的事件发生、关闭 epoll描述符。

信号驱动式 I/O(SIGIO)

信号驱动IO 应用进程预先向内核安装一个信号处理函数,然后立即返回,进程继续工作,不阻塞,当数据报准备好读取时,内核就为该进程产生一个信号通知进程,然后进程再调用recvfrom读取数据报。 信号驱动式IO不是异步的。 信号驱动式IO在数据准备阶段是异步的,当内核中有数据报准备后再通知进程,但是在调用 recvfrom 操作进行数据拷贝时是同步的,所以总体来说,整个IO过程不能是异步的。

异步 I/O(POSIX的 aio_系列函数)

告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到用户空间)完成后通知我们。注意红线标记处说明在调用时就可以立马返回,等函数操作完成会通知我们 异步IO 应用进程调用aio_read函数,给内核传递描述符,缓存区指针,缓存区大小和文件偏移,并告诉内核当整个操作完成时如何通知进程,然后该系统调用立即返回,而且在等待 I/O 完成期间,我们的进程不被阻塞,进程可以去干其他事情,然后内核开始等待数据准备,数据准备好以后再拷贝数据到进程缓冲区,最后通知整个 IO 操作已完成。 Java 的 AIO 提供了异步通道 API,其操作系统底层实现就是这个异步 I/O 模型。

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

I/O 模型比较

IO模型比较 由上图可以再次看出,IO操作主要分为两个阶段:

  • 等待数据报准备阶段
  • 数据拷贝阶段

前4种IO模型都是同步IO模型,因为它们在第二步数据拷贝阶段都是阻塞的,这会导致整个请求进程存在阻塞的情况,而异步IO模型不会导致请求进程阻塞。

在处理 IO 的时候,阻塞和非阻塞都是同步IO。只有使用了特殊的 API 才是异步 IO。 netty

epoll 应该是同步的。属于 IO 多路复用的一种(IO多路复用还有一个名字叫做事件驱动)。但从高层(业务层次)来看是异步的。

参考文献

Redis与Reactor模式 Reactor论文 NIO