本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

为了方便理解,以下所有代码都是伪代码,知道其表达的意思即可。

阻塞 IO

服务端为了处理客户端的连接和请求的数据,写了如下代码。

  1. listenfd = socket(); // 打开一个网络通信端口
  2. bind(listenfd); // 绑定
  3. listen(listenfd); // 监听
  4. while(1) {
  5. connfd = accept(listenfd); // 阻塞建立连接
  6. int n = read(connfd, buf); // 阻塞读数据
  7. doSomeThing(buf); // 利用读到的数据做些什么
  8. close(connfd); // 关闭连接,循环等待下一个连接
  9. }

这段代码会执行得磕磕绊绊,就像这样。

IO模型动图 - 图1

可以看到,服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。

如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。

IO模型动图 - 图2

这就是传统的阻塞 IO

整体流程如下图

IO模型动图 - 图3

所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。这肯定是不行的。

非阻塞 IO

为了解决上面的问题,其关键在于改造这个 read 函数。

有一种小聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。

  1. while(1) {
  2. connfd = accept(listenfd); // 阻塞建立连接
  3. pthread_createdoWork); // 创建一个新的线程
  4. }
  5. void doWork() {
  6. int n = read(connfd, buf); // 阻塞读数据
  7. doSomeThing(buf); // 利用读到的数据做些什么
  8. close(connfd); // 关闭连接,循环等待下一个连接
  9. }

这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。

IO模型动图 - 图4

不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。

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

非阻塞的read函数

这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。用户线程可以做点其它事,可以设置隔一会调用一次read,看是否有返回;

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

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

IO模型动图 - 图5

这里我们注意到一个细节。
非阻塞的 read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。
当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。

整体流程如下图

IO模型动图 - 图6

IO 多路复用

如果 为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。
IO模型动图 - 图7

当然还有个小聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。

  1. fdlist.add(connfd);

然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。

  1. while(1) {
  2. for(fd <-- fdlist) {
  3. if(read(fd) != -1) {
  4. doSomeThing();
  5. }
  6. }
  7. }

这样,我们就成功用一个线程处理了多个客户端连接。

IO模型动图 - 图8

你是不是觉得这有些多路复用的意思?

但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。

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

select

select 是操作系统提供的系统调用函数,通过它,我们可以把 一批文件描述符以数组的形式 发给操作系统, 让操作系统去遍历,每次调用 select函数都会返回就绪的文件描述符数量count,用户层需要遍历这个数组,还记得read函数非阻塞io的调用中,没有数据的时候操作系统返回值是-1,所以我们只需要遍历数组的时候判断这个文件描述符的返回值不等于-1,且最多找count个文件描述符即可,不用过多遍历,用户层将不会再有无意义的系统调用开销(如上面的非阻塞io调用read函数 需要用户线程循环调用 read,直到返回值不为 -1,每一次调用read都是一次系统开销;阻塞io调用read需要阻塞等待read函数返回;)
IO模型动图 - 图9

select 系统调用的函数定义如下

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

服务端代码,这样来写。

1 一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里

  1. while(1) {
  2. connfd = accept(listenfd);
  3. fcntl(connfd, F_SETFL, O_NONBLOCK);
  4. fdlist.add(connfd);
  5. }

2 另一个线程不再自己遍历,而是调用 select,将这批文件描述符 list 交给操作系统去遍历

  1. while(1) {
  2. // 把一堆文件描述符 list 传给 select 函数
  3. // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
  4. nready = select(list);
  5. ...
  6. }

不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。

只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。

  1. while(1) {
  2. nready = select(list);
  3. // 用户层依然要遍历,只不过少了很多无效的系统调用
  4. for(fd <-- fdlist) {
  5. if(fd != -1) {
  6. // 只读已就绪的文件描述符
  7. read(fd, buf);
  8. // 总共只有 nready 个已就绪描述符,不用过多遍历
  9. if(--nready == 0) break;
  10. }
  11. }
  12. }

整个 select 的流程图如下

IO模型动图 - 图10

可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。

select 缺点

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

poll

poll 也是操作系统提供的系统调用函数。

  1. int poll(struct pollfd *fds, nfds_tnfds, int timeout);
  2. struct pollfd {
  3. intfd; /*文件描述符*/
  4. shortevents; /*监控的事件*/
  5. shortrevents; /*监控事件中满足条件返回的事件*/
  6. };

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

epoll

epoll 主要就是针对select 的三个缺点进行的改进

  1. 内核中保存一份文件描述符集合,和用户空间共享一块内存,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次
内存拷贝,利用 mmap() 文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销
image.png

步骤

具体,操作系统提供了这三个函数。

第一步,创建一个 epoll 句柄

  1. int epoll_create(int size);

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

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

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

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

使用起来,其内部原理就像如下一般丝滑。
IO模型动图 - 图12

如果你想继续深入了解 epoll 的底层原理,推荐阅读飞哥的《图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!》,从 linux 源码级别,一行一行非常硬核地解读 epoll 的实现原理,且配有大量方便理解的图片,非常适合源码控的小伙伴阅读。

总结

一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO

为了破这个局,程序员在用户态通过多线程来防止主线程卡死。

后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO

但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。

后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用

多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。


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

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

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

这显然是知其然而不知其所以然,类似多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。

IO多路复用到底是不是异步的?

同步与异步:主要关注消息通信机制。

  • 同步就是在发出一个调用之后,调用没有得到结果之前该调用不返回。即同步是主动等待消息。
  • 异步调用不会等待结果而是立即返回,然后等待被调用者使用消息、通知或者回调函数来通知调用者。即异步是被动接收消息。

阻塞与非阻塞:主要关注程序在等待时的状态。

  • 阻塞是指程序在等待结果的时候被挂起,不能去完成别的任务【浪费时间】;
  • 非阻塞是指程序在等待的过程中可以做别的事情【需要切换开销】。

  • 同步阻塞IO:在内核等待数据和将数据复制到进程地址空间的两个过程,除了等待啥也不做。及时返回数据,无延迟。

  • 同步非阻塞IO:在内核等待数据的阶段,进程可以轮询(瞅瞅它准备好数据了没),后一阶段等待。此外,在轮询之外的时间可以干其他活儿了,但是这样会拉长此进程的时延(也许人家在你轮询之前准备好了)。
  • 异步IO:异步模式时被动接收消息,如通过回调、通知、状态等方式被动获取;不是顺序执行。异步非阻塞IO中,用户进程进行系统调用后,无论内核是否准备好都会返回响应,进程可以去做别的事情,内核复制好数据之后会通知进程。
  • 信号驱动IO:建立SIGIO信号处理函数,数据准备好后进程会收到SIGIO信号。可以在信号处理函数中调用IO操作函数处理数据。
  • IO多路复用
    • 多路:多个连接,复用:一个或少量线程。即使用一个或少量的线程去处理多个连接。
    • 不停地查看多个任务的完成状态,只要有任何一个任务完成了,就去处理它。
    • 三种模式:
      • select:轮询,任何一个进程的数据准备好了就来通知一声,限制只能同时监视1024个接口;
      • poll:和select一样,不过去掉了1024的限制;
      • epoll:回调,不用去轮询了。

一般我们说同步还是异步,都是对外部调用来讲的:epoll 这个系统调用对外部来说,是一个同步的接口。
select,pselect,poll,epoll本质上都是同步阻塞I/O,因为他们都需要在读写事件就绪后自己负责进行读写(同步),且这个读写过程是阻塞的

虽然io多路复用是同步阻塞模型,但并不妨碍它的高效率,多路复用快的原因在于,我们可以在一个线程中监控多个文件描述符,select函数调用下,由操作系统 通过轮询的方式找到就绪的文件描述符,或者epoll函数的异步 IO 事件唤醒就绪的文件描述符,将就绪的文件描述符返给用户;用户只需要调用read函数就能直接读取就绪的文件描述符;无需像之前那样,用户端通过循环不断的去调用read,阻塞模式下,等待read返回;非阻塞模式下不断尝试调用read;每一次调用read都是一次系统开销;而io多路复用下,只需要一次系统调用(select或者epoll)+ 内核找就绪的文件描述符;

有的地方说同步,有的地方说异步,其实是不同分层的视角看。

  • epoll 这个系统调用,是同步的,也就是必须等待操作系统返回值。
  • 而底层用了 epoll 的封装后的框架,可以是异步的,只要你暴露给外部的接口,无需等待你的返回值即可。
  • epoll 这个系统调用的底层内核设计里,每个 IO 事件的通知等待,是异步的