详解IO多路复用

1 概述

常见的IO模型有五种,IO多路复用模型是其中之一。

阻塞式IO

非阻塞式IO

IO多路复用

信号驱动式IO

异步IO

其中前四种都是同步IO

1.1 什么是IO?

IO:Input/Output,即数据的读取(接收)/写入(发送)操作,针对不同的数据存储媒介,大致可以分为网络 IO 和磁盘 IO 两种。在 Linux 系统中,为了保证系统安全,操作系统将虚拟内存划分为内核空间用户空间两部分。因此用户进程无法直接操作IO设备资源,需要通过系统调用完成对应的IO操作。

举例说明,一个网络数据输入操作包括两个不同的阶段:

等待数据准备好

从内核空间向用户空间复制数据

1.2 什么是缓冲区?

应用层的 IO 操作基本都是依赖操作系统提供的 read 和 write 两大系统调用实现。但由于计算机外部设备(磁盘、网络)与内存、CPU 的读写速度相差过大,若直接读写涉及操作系统中断,因此为了减少 OS 频繁中断导致的性能损耗和提高吞吐量,引入了缓冲区的概念。根据内存空间的不同,又可分为内核缓冲区进程缓冲区。操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行 IO 设备的中断处理,集中执行物理设备的实际 IO 操作,通过这种机制来提升系统的性能。至于具体什么时候执行系统中断(包括读中断、写中断)则由操作系统的内核来决定,应用程序不需要关心。

1.3 什么是同步/异步?什么是阻塞/非阻塞?

首先要消除一个错误的思想:同步就是阻塞,异步就是非阻塞。(这是错误的!)

阻塞/非阻塞

关注的是用户态进程/线程的状态,它要访问的数据是否就绪,进程/线程是否需要等待。

阻塞 IO指的是需要内核 IO 操作彻底完成后才返回到用户空间执行用户程序的操作指令。

非阻塞 IO(Non-Blocking IO,NIO) 指的是用户进程不需要等待内核 IO 操作彻底完成,即可返回用户空间执行后续指令。与此同时,内核会立即返回给用户一个 IO 状态值。

阻塞是指用户进程一直在等待,而不能做别的事情;非阻塞是指用户进程获得内核返回的状态值就返回自己的空间,可以去做别的事情。

同步/异步

关注的是消息通信机制(内核与应用程序的交互)

同步 IO是指用户空间(进程或者线程)是主动发起 IO 请求的一方,系统内核是被动接收方。

异步 IO则反过来,系统内核是主动发起 IO 请求的一方,用户空间是被动接收方。异步只需要关注IO操作完成的通知,并不主动读写数据,由操作系统内核完成数据的读写。

同步和异步最大的区别就是被调用方的执行方式和返回时机。同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方(此时数据已 ready,调用方可以直接使用)。

阻塞和非阻塞关注的是调用方进程状态,而同步与异步更关注调用双方进程通信方式。

举例说明:

场景:小明去新华书店买《三体》

如果书店没有,就一直等待,直至书店有了书之后才走。(同步阻塞)

如果书店没有,先离开书店;然后每天都去书店逛一次,直到书店有货了,买了就走。(同步非阻塞)

如果书店没有,留下电话;书店有货时,老板打电话通知他,他再去书店买书。(信号驱动IO,同步非阻塞)

如果书店没有,留下地址;书店有货时,老板直接把书送到家。(异步非阻塞)

对应于程序:用户进程调用系统调用

用户进程对应小明,内核对应书店老板,书对应数据,买书就是 一个系统调用,而内核拷贝数据到进程这个过程近似于老板送书到小明手中。同时可以看到,同步都是小明主动去书店买书,而异步是老板将书送到小明家中。

2 五种IO模型

2.1 (同步)阻塞式IO

同步阻塞IO(Blocking IO) 指的是用户进程(或线程)主动发起,需要等待内核 IO 操作彻底完成后才返回到用户空间的 IO 操作。在 IO 操作过程中,发起 IO 请求的用户进程处于阻塞状态。

2.2 (同步)非阻塞IO

同步非阻塞IO(Non-Blocking IO,NIO) 指的是用户进程主动发起,不需要等待内核 IO 操作彻底完成就能立即返回用户空间的 IO 操作。在 IO 操作过程中,发起 IO 请求的用户进程处于非阻塞状态。

当数据 Ready 之后,用户线程仍然会进入阻塞状态(也就是上图”复制数据这个过程“),直到数据复制完成,并不是绝对的非阻塞。

NIO 实时性好,内核态数据没有 Ready 会立即返回,但频繁的轮询内核,会占用大量的 CPU 资源,降低效率。

2.3 IO多路复用

IO 多路复用(IO Multiplexing) 实际上就解决了 NIO 中的频繁轮询 CPU 的问题,并且引入一种新的 select 系统调用。

复用 IO 的基本思路就是通过 select 调用来监控多 fd(文件描述符),来达到不必为每个 fd 创建一个对应的监控线程的目的,从而减少线程资源创建的开销。一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核就能够将文件描述符的就绪状态返回给用户进程(或者线程),用户空间可以根据文件描述符的就绪状态进行相应的 IO 系统调用。

2.4 信号驱动IO

当进程发起一个 IO 操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用 IO 读取数据。

2.5 异步IO

异步IO(Asynchronous IO,AIO) 指的是用户空间的线程变成被动接收者,而内核空间成为主动调用者。在异步 IO 模型中,当用户线程收到通知时,数据已经被内核读取完毕并放在用户缓冲区内,内核在 IO 完成后通知用户线程直接使用即可。而此处说的 AIO 通常是一种异步非阻塞 IO。

3 IO多路复用

IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄。一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出CPU。IO是指网络 IO,多路指多个TCP连接(即 socket),复用指复用一个或几个线程

意思说一个或一组线程处理多个 TCP 连接。最大优势是减少系统开销小,不必创建过多的进程/线程,也不必维护这些进程/线程。

IO 多路复用的三种实现方式:select、poll、epoll

举例说明:

假设你是一名老师,让30个学生完成一道题目,并检查他们的结果是否正确,现在有以下几种情况:

按顺序逐个检查,先检查A,然后B,之后C、D...这中间如果有学生卡住,后面的学生都会被耽搁

找来30个助教,每个助教负责检查一个学生

站在讲台上,那个学生写完了举手示意,谁举手就去检查谁

上述场景1对应传统单线程socket模型,缺点非常明显:同时只能处理一个客户端的请求,多余的请求可以通过队列的方式保存起来,后续一次遍历处理。但如果处理某个请求时阻塞了,那么后续所有请求的处理都会阻塞。

上述场景2对应传统多线程socket模型,一般都是通过主线程阻塞等待客户端连接,每个客户端连接创建新的工作线程来处理请求的方式实现。当并发量不是很大时,这种处理方式还可以使用。一旦并发量很大,频繁创建的线程会带来巨大的资源消耗以及上下文切换消耗。

上述场景3就可以理解为I/O多路复用技术︰将客户端对应socket的fd(文件描述符)注册到select或poll或epoll上,当socket流就绪时,select线程就会执行:轮询找到就绪的socket,将它返回给应用,执行相应流处理。

从这里也就可以看出,IO多路复用技术的核心是减少服务端线程的创建,通过使用较少线程处理所有请求的方式提高整体效率。

3.1 select机制

底层使用bitmap(位图)标识fd

// 相关函数#include<sys/select.h>#include<sys/time.h>#include<sys/types.h>#include<unistd.h>intselect(intnfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,structtimeval *timeout);// nfds 所有监听的fd,数值最大的一个+1// readfds 读事件集合// writefds 写事件集合// exceptfds 异常事件集合// timeout 最长阻塞时间,NULL表示永久阻塞voidFD_CLR(intfd, fd_set *set);intFD_ISSET(intfd, fd_set *set);voidFD_SET(intfd, fd_set *set);voidFD_ZERO(fd_set *set);

一般使用步骤:

创建监听集合:fd_set

初始化监听集合:FD_ZERO

增加监听:FD_SET

调用select函数:进程陷入阻塞,当监听的所有fd,有任何一个就绪,select解除阻塞

fd_set从监听集合变为就绪集合,使用FD_ISSET判断某个fd是否就绪

示例代码:

// 用两个管道实现两个进程的全双工通信#include<stdio.h>#include<sys/select.h>#include<fcntl.h>#include<unistd.h>#include<string.h>#defineARGS_CHECK(argc,num) {if(argc != num){fprintf(stderr,"args error!\n");return -1;}}#defineERROR_CHECK(ret,num,msg) {if(ret == num){perror(msg);return -1;}}intmain(intargc,char*argv[]){// ./client 1.pipe 2.pipeARGS_CHECK(argc,3);intfdw = open(argv[1],O_WRONLY);    ERROR_CHECK(fdw,-1,"open fdw");intfdr = open(argv[2],O_RDONLY);    ERROR_CHECK(fdr,-1,"open fdr");printf("chat established!\n");charbuf[4096] = {0};    fd_set rdset;//创建监听集合while(1){        FD_ZERO(&rdset);//初始化监听集合FD_SET(STDIN_FILENO,&rdset);//监听stdinFD_SET(fdr,&rdset);//监听2.pipe管道的读端select(fdr+1,&rdset,NULL,NULL,NULL);// 第一个参数 监听集合中最大的fd再+1// 第二个参数 由于读阻塞的监听集合// 第三、四个参数 写阻塞和异常阻塞的监听集合均为空// 第五个参数 最长等待时间 NULL表示永久等待// 当select就绪了以后,rdset就成为了就绪集合// 检查某个具体的文件描述符是否在就绪集合当中if(FD_ISSET(STDIN_FILENO,&rdset)){// 说明stdin有数据memset(buf,0,sizeof(buf));ssize_tsret = read(STDIN_FILENO,buf,sizeof(buf));// 当sret为0时,说明本端退出if(sret ==0)            {printf("bye~\n");break;            }            write(fdw,buf,strlen(buf));        }if(FD_ISSET(fdr,&rdset)){// 说明管道有数据memset(buf,0,sizeof(buf));ssize_tsret = read(fdr,buf,sizeof(buf));// 当sret为0时,说明对方已经关闭了if(sret ==0){printf("peer exit!\n");break;            }printf("buf = %s\n", buf);        }    }    close(fdr);    close(fdw);return0;}

底层原理:

// fd_set的底层,实际就是用的 位图 这个数据结构typedef__kernel_fd_set    fd_set;#undef__FD_SETSIZE#define__FD_SETSIZE    1024typedefstruct{unsignedlongfds_bits[__FD_SETSIZE / (8*sizeof(long))];} __kernel_fd_set;

首先看数组的大小:sizeof(long) 如果是64位计算机系统,则占8个字节,所以这个数组就是(1024/(8*8)) = 16个元素。由此可知fd_set实际上是一个16个元素的long数组,用位存储的思想可以存放16 * 8 * 8 = 1024个文件描述符。

好处:节省空间,可以用更小的空间存储更多的映射关系。

select机制说白了也是要借助底层驱动的支持,即当有事件发生时能够触发事件回调。由硬件层通知到我们的内核态,然后由内核态通知到我们的应用态的一个过程。

以fd_set为例,每次都要从用户态拷贝至内核态(select函数就是做的这个工作),同时还要在内核态进行循环遍历,然后把有事件的响应的文件描述符fd_set返回,又要从内核态拷贝至用户态。用户态拿到这个有事件的文件描述符返回,还要针对返回的描述符进行遍历,才能知道哪个文件描述符对应的socket可写可读,总共经历了两次遍历,两次拷贝,所以说为什么select在文件描述符比较多的情况,效率为什么是低下的原因。

select的优点:

select是最古老的IO复用的API,所以是支持跨平台的。而epoll是Linux内核独有的IO复用。

select的缺点:

单个进程可监视的fd数量被限制,默认是1024。

需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

在大量文件描述符的情况下,每次在内核中轮询扫描所有文件描述符,会导致系统效率下降。

3.2 poll机制

底层使用链表存储fd

基本原理与select一致

#include<poll.h>intpoll(structpollfd *fds,nfds_tnfds,inttimeout);structpollfd{intfd;/* file descriptor */shortevents;/* requested events */shortrevents;/* returned events */};fds:文件描述符数组。events:POLLIN/POLLOUT/POLLERR    nfds:监控数组中有多少文件描述符需要被监控。timeout 毫秒级等待-1:阻塞等,#defineINFTIM -1 Linux中没有定义此宏0:立即返回,不阻塞进程>0:等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值。函数返回值:满足监听条件的文件描述符的数目。

示例代码:

for(i=0;i<5;i++)  {memset(&client,0,sizeof(client));    addrlen =sizeof(client);    pollfds[i].fd = accept(sockfd,(structsockaddr*)&client, &addrlen);    pollfds[i].events = POLLIN;  }  sleep(1);while(1){puts("round again");poll(pollfds,5,50000);for(i=0;i<5;i++) {// 与select不同的地方,有数据会将revents置位if(pollfds[i].revents & POLLIN){pollfds[i].revents =0;// 这里还会恢复为0,所以每次while进来不需要重新设置pollfd(可以重用)memset(buffer,0,MAXBUF);read(pollfds[i].fd, buffer, MAXBUF);puts(buffer);}}  }

C++复制全屏

优点:

支持数目较大,不受限制:poll()函数支持的文件描述符数量没有 select() 函数的限制,因此可以更灵活地适应各种环境和场景;

3.3 epoll机制

intepoll_create(intsize);intepoll_create1(intflags);

size参数告诉内核这个epoll对象处理的事件大致数量,而不是能够处理的最大数量

在现在的linux版本中,这个size函数已经被废弃(但是size不要传0,会报invalid argument错误)

内核会产生一个epoll 实例数据结构并返回一个文件描述符,这个特殊的描述符就是epoll实例的句柄,之后针对该epoll的操作需要通过该句柄来标识该epoll对象。

也就是创建红黑树的根节点

#include<sys/epoll.h>//控制某个epoll监控的文件描述符上的事件:注册、修改、删除intepoll_ctl(intepfd,intop,intfd,structepoll_event *event);epfd:epoll_create函数返回的值op:EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DELfd:将哪个文件描述符以op的方式加在以epfd建立的树上event:告诉内核需要监听的事情。

structepoll_event{uint32_tevents;epoll_data_tdata;};typedefunionepoll_data{void*ptr;intfd;uint32_tu32;uint64_tu64;}epoll_data_t;//等待所监控文件描述符上有事件的产生intepoll_wait(intepfd,structepoll_event *events,intmaxevents,inttimeout);events:用来存内核得到事件的集合(这里是个传出参数)maxevents:告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create时的sizetimeout:是超时时间-1:阻塞=0:立即返回,非阻塞>0:指定毫秒返回值:成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1

示例代码:

structepoll_eventevents[5];intepfd = epoll_create(10);  ...  ...for(i=0;i<5;i++)  {staticstructepoll_eventev;memset(&client,0,sizeof(client));    addrlen =sizeof(client);    ev.data.fd = accept(sockfd,(structsockaddr*)&client, &addrlen);    ev.events = EPOLLIN;    epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);  }while(1){puts("round again");  nfds = epoll_wait(epfd, events,5,10000);for(i=0;i

底层原理:

通过调用epoll_create,在epoll文件系统建立了个file节点,并开辟epoll自己的内核高速cache区,建立红黑树,分配好想要的size的内存对象,建立一个list链表,用于存储准备就绪的事件。(epoll_create 方法生成一个 epoll 专用的文件描述符epfd,epfd在用户态和内核态之间共享,避免了poll和select用户态和内核态文件描述符状态的拷贝)

通过调用epoll_ctl,把要监听的socket放到对应的红黑树上,给内核中断处理程序注册一个回调函数,通知内核,如果这个句柄的数据到了,就把它放到就绪列表。

通过调用 epoll_wait,观察就绪列表里面有没有数据,并进行提取和清空就绪列表,非常高效。

水平触发LT(level trigger)和边缘触发ET(edge trigger)

3.4 epoll与select、poll的对比

1.用户态将文件描述符传入内核的方式

select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024

poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听(不受数量限制了)

epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点

2.内核态检测文件描述符读写状态的方式

select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值

poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历

epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中

3. 找到就绪的文件描述符并传递给用户态的方式

select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断

poll:同select方式

epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。这里返回的文件描述符是通过mmap让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝

3.5 总结

select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。

select、poll、epoll虽然都会返回就绪的文件描述符数量。但是select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。

select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。

epoll的边缘触发模式效率高,系统不会充斥大量不关心的就绪文件描述符

select、poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核态中,系统调用返回时利用mmap()文件映射内存加速与内核空间的消息传递:即epoll使用mmap减少复制开销

__EOF__

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

推荐阅读更多精彩内容