什么是IO多路复用

https://mp.weixin.qq.com/s?__biz=MzAxOTc0NzExNg%3D%3D&idx=1&mid=2665525639&scene=21&sn=d0881894cfeca626c4e6b0953a32810b#wechat_redirect

640IO.gif

IO多路复用(IO Multiplexing) 是这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。

epoll相对于select/poll,节省了fd集合复制到内核的时间(只需要epoll_create的时候在内核创建红黑树,后续通过epoll_ctl在红黑树上注册fd,轮询监听就只需epoll_wait)、节省了遍历所有fd哪些就绪的时间(内核中callback机制将已就绪的fd写到一个链表中再copy到用户空间),打破了1024个fd监听的限制(红黑树,理论上无限个)


image.png

epoll事件通知机制:LT——水平触发、ET——边沿触发。LT容易造成惊群,但实际上可能只有一个线程会处理。但LT简单,ET的话需要循环判断缓冲区数据是否读取完成。

IO多路复用是要和NIO一起使用的。尽管在操作系统级别,NIO和IO多路复用是两个相对独立的事情。NIO仅仅是指IO API总是能立刻返回,不会被Blocking;而IO多路复用仅仅是操作系统提供的一种便利的通知机制。操作系统并不会强制这俩必须得一起用——你可以用NIO,但不用IO多路复用,就像上一节中的代码;也可以只用IO多路复用 + BIO,这时效果还是当前线程被卡住。但是,IO多路复用和NIO是要配合一起使用才有实际意义。因此,在使用IO多路复用之前,请总是先把fd设为O_NONBLOCK

这么说吧,就read而言,BIO如果因为没数据阻塞了则线程直接就卡住了,为了应对源源不断的连接请求需要不断创建线程消耗系统性能,所以由操作性能提供了非阻塞模式的read,则就是NIO。而如果NIO没有多路复用,则多个channel的读取请求需要由用户线程来进行socket缓冲区数据是否ready的系统调用轮询(性能效果可以类比于在循环中调用rpc,非常差),而有了多路复用,则由操作系统内核线程去轮询,直接返回ready的socket数组(epoll),减少了大量无效的系统调用。

为了讲多路复用,当然还是要跟风,采用鞭尸的思路,先讲讲传统的网络 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);     // 关闭连接,循环等待下一个连接
}

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

[图片上传失败...(image-386956-1657359166673)]

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

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

[图片上传失败...(image-bb6dda-1657359166673)]

这就是传统的阻塞 IO。

整体流程如下图。

[图片上传失败...(image-d48521-1657359166673)]

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

这肯定是不行的。

非阻塞 IO

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

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

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

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

[图片上传失败...(image-6e95b8-1657359166673)]

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

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

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

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

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

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

[图片上传失败...(image-34547a-1657359166673)]

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

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

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

整体流程如下图

[图片上传失败...(image-34d889-1657359166673)]

IO 多路复用

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

[图片上传失败...(image-2ee6f2-1657359166673)]什么

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

<pre style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;">

fdlist.add(connfd);

</pre>

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

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

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

[图片上传失败...(image-dd2bec-1657359166673)]

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

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

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

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

select

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

[图片上传失败...(image-b9badd-1657359166673)]

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

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

[图片上传失败...(image-780-1657359166673)]s'm

可以看出几个细节:

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

整个 select 的流程图如下。

[图片上传失败...(image-1c070d-1657359166673)]

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

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

[图片上传失败...(image-3ed14b-1657359166673)]

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

后记

大白话总结一下。一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO。为了破这个局,程序员在用户态通过多线程来防止主线程卡死。后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO。但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是** IO 多路复用**。多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。


所以,IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。如果你建立了这样的思维,很容易发现网上的一些错误。比如好多文章说,多路复用之所以效率高,是因为用一个线程就可以监控多个文件描述符。这显然是知其然而不知其所以然,多路复用产生的效果,完全可以由用户态去遍历文件描述符并调用其非阻塞的 read 函数实现。而多路复用快的原因在于,操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。就好比我们平时写业务代码,把原来 while 循环里调 http 接口进行批量,改成了让对方提供一个批量添加的 http 接口,然后我们一次 rpc 请求就完成了批量添加。一个道理。找时间我再专门写一篇,讲讲这块网络上鱼龙混杂的花式错误理解。 <mpprofile class="js_uneditable" data-pluginname="mpprofile" data-id="Mzk0MjE3NDE0Ng==" data-headimg="http://mmbiz.qpic.cn/mmbiz_png/GLeh42uInXRVh3YhnV9yvZK9TGpSsFykUQPtHm4TuNAOUOr5qtPyZlg4DNciaYWiasRx1Fgt6UFTDjaK9qc08Llw/0?wx_fmt=png" data-nickname="低并发编程" data-alias="dibingfa" data-signature="战略上藐视技术,战术上重视技术" has-insert-preloading="1" style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;">[图片上传失败...(image-f477a0-1657359166671)]s

低并发编程

战略上藐视技术,战术上重视技术

180篇原创内容

公众号</mpprofile>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,640评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,254评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,011评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,755评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,774评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,610评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,352评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,257评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,717评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,894评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,021评论 1 350
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,735评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,354评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,936评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,054评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,224评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,974评论 2 355

推荐阅读更多精彩内容