关于linux:IO-多路复用原理

1次阅读

共计 5019 个字符,预计需要花费 13 分钟才能阅读完成。

为了讲多路复用,当然还是要跟风,采纳鞭尸的思路,先讲讲传统的网络 IO 的弊病,用拉踩的形式捧起多路复用 IO 的劣势。

为了不便了解,以下所有代码都是伪代码,晓得其表白的意思即可。

Let’s go

阻塞 IO

服务端为了解决客户端的连贯和申请的数据,写了如下代码。

listenfd = socket();   // 关上一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {connfd = accept(listenfd);  // 阻塞建设连贯
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 敞开连贯,循环期待下一个连贯
}

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

能够看到,服务端的线程阻塞在了两个中央,一个是 accept 函数,一个是 read 函数。

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

这就是传统的阻塞 IO。

整体流程如下图。

所以,如果这个连贯的客户端始终不发数据,那么服务端线程将会始终阻塞在 read 函数上不返回,也无奈承受其余客户端连贯。

这必定是不行的。

非阻塞 IO

==============

为了解决下面的问题,其关键在于革新这个 read 函数。

有一种聪慧的方法是,每次都创立一个新的过程或线程,去调用 read 函数,并做业务解决。

while(1) {connfd = accept(listenfd);  // 阻塞建设连贯
  pthread_create(doWork);  // 创立一个新的线程
}
void doWork() {int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 敞开连贯,循环期待下一个连贯
}

这样,当给一个客户端建设好连贯后,就能够立即期待新的客户端连贯,而不必阻塞在原客户端的 read 申请上。

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

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

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

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

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

这样,就须要用户线程循环调用 read,直到返回值不为 -1,再开始解决业务。

这里咱们留神到一个细节。

非阻塞的 read,指的是在数据达到前,即数据还未达到网卡,或者达到网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。

当数据已达到内核缓冲区,此时调用 read 函数依然是阻塞的,须要期待数据从内核缓冲区拷贝到用户缓冲区,能力返回。

整体流程如下图

IO 多路复用

===============

为每个客户端创立一个线程,服务器端的线程资源很容易被耗光。

当然还有个聪慧的方法,咱们能够每 accept 一个客户端连贯后,将这个文件描述符(connfd)放到一个数组里。

fdlist.add(connfd);

而后弄一个新的线程去一直遍历这个数组,调用每一个元素的非阻塞 read 办法。

while(1) {for(fd <-- fdlist) {if(read(fd) != -1) {doSomeThing();
    }
  }
}

这样,咱们就胜利用一个线程解决了多个客户端连贯。

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

但这和咱们用多线程去将阻塞 IO 革新成看起来是非阻塞 IO 一样,这种遍历形式也只是咱们用户本人想出的小把戏,每次遍历遇到 read 返回 -1 时依然是一次浪费资源的零碎调用。

在 while 循环里做零碎调用,就好比你做分布式我的项目时在 while 里做 rpc 申请一样,是不划算的。

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

select


select 是操作系统提供的零碎调用函数,通过它,咱们能够把一个文件描述符的数组发给操作系统,让操作系统去遍历,确定哪个文件描述符能够读写,而后通知咱们去解决:

select 零碎调用的函数定义如下。

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,查看形容字后立刻返回,轮询 

服务端代码,这样来写。

首先一个线程一直承受客户端连贯,并把 socket 文件描述符放到一个 list 里。

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

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

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

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

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

while(1) {nready = select(list);
  // 用户层仍然要遍历,只不过少了很多有效的零碎调用
  for(fd <-- fdlist) {if(fd != -1) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不必过多遍历
      if(--nready == 0) break;
    }
  }
}

正如刚刚的动图中所形容的,其直观成果如下。(同一个动图耗费了你两次流量,气不气?)

能够看出几个细节:

1. select 调用须要传入 fd 数组,须要拷贝一份到内核,高并发场景下这样的拷贝耗费的资源是惊人的。(可优化为不复制)

2. select 在内核层依然是通过遍历的形式查看文件描述符的就绪状态,是个同步过程,只不过无零碎调用切换上下文的开销。(内核层可优化为异步事件告诉)

3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户本人遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做有效的遍历)

整个 select 的流程图如下。

能够看到,这种形式,既做到了一个线程解决多个客户端连贯(文件描述符),又缩小了零碎调用的开销(多个文件描述符只有一次 select 的零碎调用 + n 次就绪状态的文件描述符的 read 零碎调用)。

poll


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

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /* 文件描述符 */
  shortevents; /* 监控的事件 */
  shortrevents; /* 监控事件中满足条件返回的事件 */
};

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

epoll


epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。

还记得下面说的 select 的三个细节么?

1. select 调用须要传入 fd 数组,须要拷贝一份到内核,高并发场景下这样的拷贝耗费的资源是惊人的。(可优化为不复制)

2. select 在内核层依然是通过遍历的形式查看文件描述符的就绪状态,是个同步过程,只不过无零碎调用切换上下文的开销。(内核层可优化为异步事件告诉)

3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户本人遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做有效的遍历)

所以 epoll 次要就是针对这三点进行了改良。

1. 内核中保留一份文件描述符汇合,无需用户每次都从新传入,只需通知内核批改的局部即可。

2. 内核不再通过轮询的形式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。

3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符汇合。

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

第一步,创立一个 epoll 句柄

int epoll_create(int size);

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

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

第三步,相似发动了 select() 调用

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

应用起来,其外部原理就像如下个别丝滑。

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

后记

大白话总结一下。

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

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

起初操作系统发现这个需要比拟大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就能够在一个线程内实现多个文件描述符的读取,这就是 非阻塞 IO

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

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

多路复用有三个函数,最开始是 select,而后又创造了 poll 解决了 select 文件描述符的限度,而后又创造了 epoll 解决 select 的三个有余。


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

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

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

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

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

一个情理。

找工夫我再专门写一篇,讲讲这块网络上泥沙俱下的花式谬误了解。

低并发编程

策略上蔑视技术,战术上器重技术

59 篇原创内容

公众号

正文完
 0