Loading

IO多路复用原理&场景

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

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

IO多路复用的历史

阻塞 IO

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

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

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

640

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

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

20220202233518

这就是传统的阻塞 IO。

整体流程如下图。

20220202223908

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

这肯定是不行的。

非阻塞 IO

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

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

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

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

20220202224402

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

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

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

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

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

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

640 (7)

这里我们注意到一个细节。

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

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

整体流程如下图

20220202223923

IO 多路复用

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

20220202223927

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

fdlist.add(connfd);

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

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

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

20220202223932

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

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

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

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

select

select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:

20220202223939

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;
    }
  }
}

正如刚刚的动图中所描述的,其直观效果如下。(同一个动图消耗了你两次流量,气不气?)

20220202223800

可以看出几个细节:

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

  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)

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

整个 select 的流程图如下。

20220202224828

可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 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);

使用起来,其内部原理就像如下一般丝滑。

20220202224951

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

后记

大白话总结一下。

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

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

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

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

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

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


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

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

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

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

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

一个道理。

以上来源于 你管这破玩意叫 IO 多路复用?

里面的动图特别的形象,为了怕文章删除,因此完全copy过来。


IO多路复用高效的原因

IO多路复用之所以高效的原因是用一个线程监控多个文件描述符(socket句柄)的状态,根本原因是操作系统提供了系统调用(select、epollo),使得原来用户代码内的while循环内的多次系统调用变成了一次系统调用+内核层遍历这些文件描述符。

select的三个缺点:

1.连接数受限

2.采用遍历文件句柄集合方式获取就绪的句柄,在文件连接数多的情况下效率低

3.数据由内核copy到用户态

poll只是改善了select第一个缺点,连接数不再受限。

epoll改变了select三个缺点。

select和poll获取就绪状态句柄事件复杂度O(n),epoll获取就绪状态句柄事件复杂度O(1)。

epoll会把哪个channel发生了什么IO就绪通知给用户,采用的是事件驱动模型,因此复杂度是O(1)。。

表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定。

IO多路复用解决的什么问题

IO多路复用解决的是阻塞IO中1连接1线程模式在高并发场景下线程过多导致的切换效率问题,采用多路复用减少线程数量,从而减少线程切换次数,提高cpu利用率,但并不能提高IO。因此只有当服务器瓶颈是大量连接的线程切换时,才会提高效率,比如web就非常适合采用IO多路复用。相反连接数少的情况下,没必要使用多路复用。

web server的特点是:并不能限定某个时间段有多少个用户对服务器发起请求,即不能让连接数成为你服务的瓶颈。而且高并发web应用有一个特点就是fast fail:快速响应,如果在指定时间内响应不了,直接返回失败。所以每个连接处理的业务逻辑,不会过于复杂——否则就放到异步任务中。大量连接、每个连接的负载较轻的情况,是nio的常用场景。所以web server非常适用nio。

epoll比selector性能一定更好吗

从IO多路复用分析来看,selector采用轮询socket句柄集合来判断是否有事件就绪,epoll是通过回调机制来通知用户来唤醒socket就绪,epoll比selector要高效,但是为什么实际中使用的中间件dubbbo、rocketmq的通信netty都是使用的NIO selector方式,而非epoll呢?

以前时候我也一直以为epoll效率比select高(毕竟select和poll都是轮询,即每次调用都扫描整个文件描述符集合,将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件算法复杂度是o(n),epoll采用回调方式,内核检测到就绪的文件描述符,触发回调,回调将该文件描述符对应的事件插入内核就绪队列,内核最后在适当的时间将该就绪队列中的内容拷贝到用户空间。因此epoll无须轮询整个文件描述符集合来检测哪些事件就绪,其算法复杂度是o(1)),在linux上netty要选择epoll,但是通过看dubbo、rocketmq等中间件的通信,发现都使用的select,经过查询资料,认为这些中间件选择选择select的原因肯定是有过实际选型调用和实践。

epoll引入了新的数据结构,带来了复杂性,如果连接都是活跃连接,那么select直接对整个socket句柄进行遍历就可以获取到整个连接就绪句柄,对于epoll来说,每次都要进行回调,回调太频繁了(通常回调都是通过遍历监听器),这样效率反而不如select。

对于dubbo、rocketmq采用selector是因为连接都是活动连接,且连接并不是特别多,那么使用selector直接对整个socket句柄集合进行轮询效率很高。而epoll更适合巨量的连接数,活动连接较少的情况,比如IM通信等。

总结:select时候连接大多数是活跃状态,epoll适合连接数量多,但是活动连接较少的情况。

下图解释了select和epoll的压测情况

1419485-20210412231112777-1215259567

IO多路复用在中间件的使用场景

IO多路复用即一个select/epollo管理多个channe,多个channel共用一个IO(这个IO认为是一个reactor线程,线程和select/epoll绑定)。

以下框架和中间件使用了IO多路复用,如netty,nginx、redis

为什么nginx使用IO多路复用是多进程(单线程)

nginx通信也采用了IO多路复用,不同的是它采用的是多进程(单线程)形式。

netty使用IO多路复用是多线程形式,即多个IO线程,但是nginx是一个master进程用于accept(等同netty的boss线程),多个worker进程(每个worker进程只有一个IO线程)用于IO操作(等同netty的work线程,即IO线程),nginx这样做的原因是为了高可用,如果Nginx 使用了多线程的模式,由于线程之间是共享同一个地址空间的,当某一个第三方模块引发了一个地址空间导致的断错时 (eg: 地址越界), 会导致整个Nginx全部挂掉; 当采用多进程来实现时, 往往不会出现这个问题。nginx开放了插件机制,为了高可用,因此设置为多进程(单线程)模式。

参考

https://blog.csdn.net/qq422431474/article/details/108244352

redis的网络模型

redis在6.0之前采用的是单reactor模型,利用 select/epolle 等多路复用技术,在单线程(一个redis进程只有一个线程)的事件循环中不断去处理事件(客户端请求),操作内存,最后回写响应数据到客户端:因此6.0之前为了在多核服务器发挥redis性能,通常是一个服务器部署多个redis实例。redis采用单线程的原因是避免上下文切换,且因为操作的是内存,不会导致阻塞,因此cpu不是瓶颈,网络IO才是瓶颈因此采用了单reactor模型。

随着互联网的高速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,为了提升 Redis 的性能因此需要优化网络IO模型,因此redis6.0开始redis网络模型采用的主从reactor模型,和netty的线程模型相同。

参考

https://strikefreedom.top/multiple-threaded-network-model-in-redis

https://javamana.com/2021/12/202112270226346085.html

netty为什么选择NIO而非AIO

NIO模型

我们常说的NIO指的是同步非阻塞,非阻塞是因为select检测到socket句柄没有就绪事件(该socket网卡到内核没有数据),直接返回;同步指的是读就绪(数据到了内核),select调用,把数据从内核读取到用户空间。

AIO模型

AIO异步非阻塞,客户端的I/O请求都是由内核先完成了再通知(回调)用户线程进行处理,AIO又称为NIO2.0,在JDK7才开始支持。

看起来AIO要比NIO高效的多,但是netty为什么选择NIO而非AIO呢?

从netty issue上查看到netty作者的原话,主要原因总结如下:

Netty.4.Final 删除了AIO的原因如下:

1.Netty不看重Windows上的使用,在Linux系统上,AIO的底层实现仍使用EPOLL,没有很好实现AIO,因此在性能上没有明显的优势,而且被JDK封装了一层不容易深度优化。

2.Netty整体架构是reactor模型, 而AIO是proactor模型, 混合在一起会非常混乱,把AIO也改造成reactor模型看起来是把epoll绕个弯又绕回来。

3.AIO还有个缺点是接收数据需要预先分配缓存, 而不是NIO那种需要接收时才需要分配缓存, 所以对连接数量非常大但流量小的情况, 内存浪费很多。

4.Linux上AIO不够成熟。

image-20220204000615082

BIO 和 NIO 在应用场景上的区别?它们各有什么优势劣势?

BIO 方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,对访问响应速度没有太高要求的架构中可以考虑,优点开发简单,易上手,但是不适合连接数多且高并发的场景。小连接数,追求极快响应的场景比较适合 BIO。在文件传输方面,也适合使用bio,没有线程切换。

NIO常说的是同步非阻塞IO,适合于连接数多且高并发IO场景,比如web服务器、rpc等场景。NIO(netty实现)不适合大文件传输,会导致IO线程一直处理这个socket的读取从而导致其它socket的读取阻塞。缺点是:用NIO同时保持连接数多了,会导致单个连接的网络响应时间下降,因为总带宽不变,且TCP本来就是非保证带宽的技术实现。

典型场景:

BIO: 数据库网络引擎

NIO: web 服务/rpc服务

为什么数据库的网络模型不选择IO多路复用

工作中通常对db(mysql)都有连接数的监控,如果连接数达到了阈值(比如3000)会报警给dba,从而dba督促连接此db的项目组进行整改。而且测试环境还会对db进行定时kill连接。我们都知道mysql有连接数的限制,过高的连接会导致mysql性能下降(mysql是bio方式,为每个连接分配一个线程),那么为什么mysql不选用IO多路复用呢,不就没有这个连接数限制问题了吗?

要从IO多路复用和BIO的场景说起

bio为需要为每个连接分配个线程,在连接数多的情况下,导致频繁线程上下文切换,cpu得不到充分利用。

IO多路复用解决的是阻塞IO中1连接1线程模式在高并发场景下线程过多导致的切换效率问题,采用多路复用减少线程数量,从而减少线程切换次数,提高cpu利用率,但并不能提高IO。因此只有当服务器瓶颈是大量连接的线程切换时,才会提高效率,比如web就非常适合采用IO多路复用。相反连接数少的情况下,没必要使用多路复用。比如DB是IO密集型,瓶颈通常在磁盘IO上,不在连接数上。

因此原因如下:

1.jdbc规范发布的早,那会只有bio,nio出现的晚,因此数据库驱动都是针对BIO设计的。且 jdbc接口是同步化的。数据库厂商只提供基于bio的jdbc实现。

2.对于DB而言,DB的瓶颈实际上是硬盘IO,用NIO引入更多的客户端session最后的结果是session都停留在等待磁盘IO上,并没法带来业务实质优化。反而因为每个连接响应时间都变长,从而造成业务响应变坏,而且数据库的一些实现,比如等锁,抢锁等,因为同时重入的session变多,更加容易造成连接等待时间变长,综上,NIO对DB没有什么特别的好处。

3.DB访问一般采用连接池这种现象是生态造成的。历史上的BIO+连接池的做法经过多年的发展,已经解决了主要的问题。在Java的大环境下,这个方案是非常靠谱的,成熟的。而基于IO多路复用的方式尽管在性能上可能有优势,但是其对整个程序的代码结构要求过多,过于复杂。当然,如果有特定的需要,希望使用IO多路复用管理DB连接,是完全可行的。

当然采用IO多路复用的DB也有,比如redis。只是传统的RDBMS数据库由于历史生态和收益(优势)问题,通常还是采用的是BIO+线程池模式。

redis采用IO多路复用的原因是redis是基于内存操作,IO上不是瓶颈,瓶颈是网络IO,因此采用IO多路复用。

参考https://www.zhihu.com/question/23084473

posted @ 2022-02-04 01:16  不晓得侬  阅读(460)  评论(2编辑  收藏  举报