文章

从阻塞IO到IO多路复用到异步IO

虽然之前也了解过NIO的原理,但是涉及到select、epoll还是有点儿不够理解,时间一长又忘了。这次看到一篇非常好的文章:

算是彻底解决了内心的疑惑!跟着梳理一下,顺便对比着看一下之前写的记录。

  1. 阻塞io - read
  2. 伪非阻塞 - 多线程
  3. 真正的非阻塞read() - 第一阶段非阻塞
  4. 伪多路复用 - 轮询
  5. 真正的多路复用 - select/poll/epoll
    1. select()
    2. poll
    3. epoll
  6. 异步IO - 第二阶段交给别人去干
  7. 感想

阻塞io - read

网络上的数据传输流程:

  1. 网线;
  2. 电脑网卡;
  3. 内核缓冲区;
  4. 用户缓冲区;

关于这两个缓冲区,可以把内核态和用户态想象成两个需要进行远程调用的服务,当在两个区域之间交互的时候,自然是很费时间。

read()主要在两个地方阻塞:

  1. 数据到达内核缓冲区之前,调用read()的线程都会处于阻塞状态;
  2. 数据到达内核缓冲区,此时就是文件描述符就绪状态,线程醒来,开始阻塞式读数据,将数据从内核缓冲区读到用户缓冲区;

所谓阻塞,就是epoll所说的,把线程从runnable队列里拎出去,放入等待队列。

而这两部分阻塞,第一部分时间最长,在Java NIO里有说明。

伪非阻塞 - 多线程

多线程,只是通过线程数量的优势,把主线程变成了非阻塞的。但每个线程还是在用read()读数据,还是阻塞的。

程序员在用户态通过多线程来防止主线程卡死,这是不得已的手段。

真正的非阻塞read() - 第一阶段非阻塞

函数还是read函数,只不过提供了非阻塞参数,可以通过最后一个参数设置为非阻塞的:

1
2
fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。

操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。

所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数

且这个非阻塞,只是把原来read()在第一阶段的阻塞改成了非阻塞的,read()第二阶段读数据,还是阻塞的。

那第二阶段也非阻塞,岂不就是——AIO!

伪多路复用 - 轮询

  • 菜鸡这么看待NIO:什么!数据没有的时候直接返回-1?那我怎么读数据?while循环不停读?那还不如阻塞io!
  • 大神这么看待NIO:6啊!这样我就可以用一个线程去管理一堆文件描述符了!

可恶,我之前竟一直是菜鸡思想!o(╥﹏╥)o

是啊,每accept一个连接,就把文件描述符放到一个数组里,最后用一个线程来管理这个数组:轮询每一个fd,看哪个有数据了,就读哪个

一个线程,干了之前多个线程干的事情!正如 Http Server线程模型:NIO vs. BIO里所说的,类似一个人管理一堆鱼竿。

这是IO多路复用吗?是的,多个input在这里就像汇聚到了一起一样,哪个有数据哪个就被读出来。

但这是 在用户态所进行的伪多路复用!因为轮询里的每一次非阻塞read()判断都需要一次系统调用——相当于用while轮询一堆rpc服务,开销非常大

开销:不断的系统调用。

真正的多路复用 - select/poll/epoll

所以,还是得恳请操作系统老大,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个开销问题。

相当于从“我们去轮询远程集群,挑一个负载最小的服务”,转变为“给集群发一个请求,让它帮我们挑一个负载最小的服务”。显然后者网络开销非常小,所用时间也最短——集群内部的轮询比我们的远程轮询快多了

select()

select()就是操作系统交出的答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
//  1.NULL,永远等下去
//  2.设置timeval,等待固定时间
//  3.设置timeval里时间均为0,检查描述字后立即返回,轮询
  1. 我们扔一坨fd给内核;
  2. 内核自己遍历(相当于集群内的遍历)fd,看哪个有数据;
  3. 返回int,代表有几个fd有数据;

当内核返回的时候,一定有至少一个fd有数据。但是尴尬的是,只告诉我们几个fd有数据,没告诉我们哪几个fd有数据……

所以用户还需要:

  1. 遍历所有的fd,判断是否有数据(不需要进行系统调用,内核已经标记了哪些fd有数据);
  2. 如果这个fd有数据,读它;

select真正由内核提供了多路复用IO,但是还有可改进的地方:

  1. 为啥要拷贝一堆fd到内核;
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知);
  3. 为啥要返回就绪的fd个数,直接告诉我谁就绪了不行吗;

开销:一次select + x次就绪fd的read。

poll

它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

存在感有点儿低啊……

epoll

解决之前的三个问题:

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可;
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒
  3. 只返回就绪的fd;

epoll也不是一个函数,而是三个函数

第一步,创建一个 epoll 句柄:

1
int epoll_create(int size);

第二步,向内核添加、修改或删除要监控的文件描述符:

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

第三步,类似发起了 select() 调用

1
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);

示例看这里:epoll

感觉epoll的优化主要是第二部分,但它是内部的,对程序员并不可见。程序员看到的是入参(不copy fd了)、返回值(不用遍历fd了)变了,但这些都是小优化

异步IO - 第二阶段交给别人去干

原本阻塞read()的两个阻塞阶段,第一阶段的阻塞已经变成非阻塞,不用死等数据了。现在第二个阻塞也不想等,但干这个活必须需要时间,不能立即返回,所以必然是阻塞的。那就交给别人去干!异步!

第一阶段本来就不需要自己干,是由OS干的,所以对于用户线程来说,谈不上“异步”(只涉及到我们要不要眼巴巴等着OS干完,即:blocking/non-blocking)。只有把该自己干的活交给别人干,才叫异步!所以是否异步,只存在于第二阶段。

异步IO之前总结的还不错,Java的NIO2其实就是AIO:AIO

  • 菜鸡这么看待AIO:什么!返回Future?那我还得调用Future#get或者isDone判断异步线程是不是帮我完成了?更麻烦了!
  • 大神这么看待AIO:好耶!这样我只需要留下一个回调函数,告诉异步线程读完之后怎么做就行了。有小弟就是爽,自己只发指令就行了!

这次我一开始留下了菜鸡的眼泪,后来又把眼泪咽了回去——异步我还是相对熟悉的……我发指令,别人干活!

感想

IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。

把用户态和内核态想象成需要rpc调用的两个服务,代价瞬间清晰起来。

如果你建立了这样的思维,很容易发现网上的一些错误。

比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。

这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。

而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。

就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。

多路复用快,是因为真多路复用比伪多路复用快。而不是一个线程管理多个文件描述符,因为伪多路复用也有这个功能。

本文由作者按照 CC BY 4.0 进行授权