前言


一、五种IO模型


首先我们要明确的是在任何IO操作中,均包含两个步骤,等待和拷贝,而在实际的业务中,等待所消耗的时间往往大于拷贝的时间,因此,让IO操作更高效,核心的方法就是将等待的时间缩短,而这两个过程是在内核当中的过程。

  • 低阶IO:是指类似于将用户输入的内容读取到某个变量中,将变量中的值打印在屏幕上等等,简单来说就是对C库自己所维护的缓冲区进行I/O操作。
  • 高阶IO:通常应用于网络Socket编程,对UDP(TCP)所维护的发送缓冲区和接收缓冲区进行I/O操作。并且高阶IO分为同步IO和异步IO,同步IO又分为阻塞IO、非阻塞IO、信号驱动IO和多路转接IO。

1.阻塞IO

  • 阻塞IO:在内核将数据准备好之前(等待+拷贝),系统调用会一直进行等待。并且所有的套接字默认均是阻塞方式。

2.非阻塞IO

  • 非阻塞IO:若当前内核没有将数据准备好,则系统调用会直接返回,并返回一个EWOULDBLOCK错误码。
  • 非阻塞IO一般都是搭配循环来使用的(也叫轮询),这对系统资源是较大的浪费,一般都是在特定的场景下使用。

3.信号驱动IO

信号驱动IO:当内核将数据准备好之后,或者说告诉应用进程何时才可以开始拷贝数据,会给应用进程发送一个SIGIO的信号,通知其进行IO操作。当应用程序接收到该信号之后,证明数据已经准备好了,接下来就会调用系统调用函数对其进行相应的IO操作。

4.异步IO

由内核在数据拷贝完成时, 通知应用程序进行相关操作(而信号驱动是当内核中数据准备好了就通知应用程序)。

注:为了性能和效率的优先,C++默认采用的是异步IO的方式。

5.IO多路转接

内核帮我们监控了多个文件描述符,当某一个或者若干个文件描述符就绪的时候,就会通知调用者,调用者调用系统调用函数针对就绪的文件描述符进行操作。

二、高级IO重要概念

1.同步通信vs异步通信

  • \[ \] 同步和异步关注的是消息通信机制。
  • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果。
  • 异步则是相反, 调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后, 被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
  • \[ \] 另外, 我们回忆在讲多进程多线程的时候, 也提到同步和互斥. 这里的同步通信和进程之间的同步是完全不想干的概念。
  • 进程/线程同步也是进程/线程之间直接的制约关系。
  • 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候 。
  • \[ \] 我们以后在看到 “同步” 这个词, 一定要先搞清楚大背景是什么. 这个同步, 是同步通信异步通信的同步, 还是同步与互斥的同步。

2.阻塞vs非阻塞

  • \[ \] 阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。
  • 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

三、I/O多路转接之select

首先我们要知道多路复用函数的作用是什么,其本质上就是让内核帮助程序员监控多个文件描述符的IO事件,一旦监控的某个文件描述符对应的事件产生(IO就绪),就会通知调用者,也就是说可以并行的处理多条客户端的请求,换句话说就是实现了高并发。

1.select函数的作用

作用:监控多个文件描述符,就绪之后,通知调用者。

2.select函数的原型

select的函数原型如下: #include <sys/select.h>

int select(int nfds, fd\\_set readfds, fd\\_set writefds,fd\\_set \* exceptfds, struct timeval timeout);
  • \[ \] 参数解释:
  • 参数nfds是需要监视的最大的文件描述符值+1。
  • rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合。
  • 参数timeout为结构timeval,用来设置select()的等待时间。
  • \[ \] 参数timeout取值:
  • NULL:则表示select()没有timeout, select将一直被阻塞,直到某个文件描述符上发生了事件。
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。

  • nfds:<select监控事件集合(fd\\_set)的范围,范围是从\[0,1023\]之间去选择范围;
  • nfds的取值为:监控的最大文件描述符数值+1
  • fd\\_set:事件集合类型
  • readfds:可读事件集合
  • writefds:可写事件集合
  • exceptfds:异常事件集合
  • timeout:
        阻塞方式:传递NULL
        非阻塞方式:传递0
        带有超时时间的方式:

3.fd\\_set结构解释


使用vim打开/usr/include/sys/select.h路径下查看源码:





内核在使用该数组的时候采用的是位图的方式,一共有16 8 8=1024个比特位

fd\\_set事件集合占用比特位的个数和宏\\_FD\\_SETSIZE强相关,即,\\_FDSETSIZE多大,fd\\_set事件集合就有多少个比特位。

其实fd\\_set结构就是一个整数数组,更严格的是,是一个“位图”,使用位图中对应的位来表示要监控的文件描述符,如下图所示:

  • \[ \] 如上图所示。
  • 如果关心某个文件描述符对应的某个事件,则将文件描述符添加到对应的事件集合当中。eg:关心3号文件描述符的可读事件,则将3号文件描述符添加到read\\_fds当中。
  • 添加文件描述符到事件集合的时候,是将文件描述符对应的比特位设置为1。
  • 如果一个文件描述符关心多种事件(可读,可写,异常),则将文件描述符添加到不同的事件集合当中。
  • select的监控效率会随着监控文件描述符的增多而下降,本质原因是由于监控轮询的范围变大了。

提供了一组操作fd\\_set的接口, 来比较方便的操作位图:

从事件集合当中删除一个文件描述符

void FD\\_CLR(int fd, fd\\_set \*set);

判断文件描述符是否在某一个事件集合当中

int FD\\_ISSET(int fd, fd\\_set \*set);

设置文件描述符到事件集合当中

void FD\\_SET(int fd, fd\\_set \*set);

清空事件集合,将所有的比特位全部置为0

void FD\\_ZERO(fd\\_set \*set);

4.关于timeval结构体

imeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。

5.select函数返回值

  • 监控成功:返回就绪的文件描述符个数,会将事件集合当中未就绪的文件描述符去掉。
  • 监控失败:当有错误发生时则返回-1,错误原因存于errno,此时参数readfds, writefds, exceptfds和timeout的值变成不可预测。
  • 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回。
  • 【注意】:select返回之后,需要重新添加文件描述符。

6.select函数优缺点

  • \[ \] 优点:
  • elect遵循的是POSIX标准,说明select函数是一个跨平台的函数,既可以在Windows当中运行,也可以在Linux当中运行。
  • select在带有超时时间监控的时候,超时时间单位可以是微秒。。
  • \[ \] 缺点:
  • 监控文件描述符个数的上限为1024。
  • 随着文件描述符的增多,select监控效率在下降(本质是select在轮询进行监控)。
  • 可读、可写、异常这些事件需要单独的添加到不同的事件集合当中。
  • 当select监控成功之后,会从事件集合当中去除掉未就绪的文件描述符,这使程序下一次调用select时,还需要重新添加文件描述符。

    • 在每次select进行监控的时候,都会将准备好的事件集合拷贝到内核空间,select返回的时候都会将内核空间拷贝给用户空间。

7.select函数监控代码



我们使用select对0号文件描述符(读缓冲区)进行监控,如果监控到了我们从0号文件描述符中去读取内容并将读取到的内容打印出来。

1 #include<iostream> 2 #include<sys/select.h> 3 #include<unistd.h> 4 #include<stdio.h> 5 using namespace std; 6 int main() 7 { 8 fd\_set readfds; 9 10 FD\_ZERO(&readfds); 11 FD\_SET(0,&readfds); 12 13 while(1) 14 { 15 int ret=select(1,&readfds,NULL,NULL,NULL); 16 17 if(ret<0) 18 { 19 perror("select"); 20 return 0; 21 } 22 23 char buf\[1024\]={0}; 24 read(0,buf,sizeof(buf)-1); 25 26 cout<<buf<<endl; 27 } 28 return 0; 29 } 1234567891011121314151617181920212223242526272829

四、I/O多路转接之poll


前提:均是监控多个文件描述符,就绪之后,然后通知调用者。与select相比并不支持跨平台,与epoll相比,没有epoll的效率高。

1.函数原型及参数解释

struct pollfd:事件结构

  • 想让poll监控多个文件描述符,只需要在定义事件结构数组的时候,多传递几个元素
  • eg:struct pollfd arr\[10\];
      arr\[0\].fd = 0;
      arr\[0\].events = POLLIN;
  • nfds:事件结构数组中有效元素的个数
  • timeout:
      >0:带有超时时间,单位:秒
      ==0:非阻塞
      <0:阻塞
  • 返回值:就绪文件描述符的个数

2.pool的优缺点

  • \[ \] 优点:
  • 提出了事件结构的方式,在给poll函数传递参数的时候,不需要分别添加到“事件集合”中。
  • 事件结构数组的大小可以根据程序员自己进行定义,并没有上限要求。
  • 不用在监控到就绪文件描述符之后,重新添加文件描述符。
  • \[ \] 缺点:
  • 不支持跨平台。
  • 内核对事件结构数组的监控也是采用轮询遍历的方式,即随着监控文件描述符的增多,监控效率会下降。
  • 每次调用poll都需要把大量的pollf结构从用户态拷贝到内核态,poll返回的时候,会将内核空间拷贝给用户空间(从内核态到用户态会调用do\\_signal会有开销)。

3.poll的代码验证

利用poll函数对系统的0号文件描述符(读缓冲区)进行监控,一旦监控到读的事件,则将其读入的内容打印到屏幕上。

1 #include<iostream> 2 #include<stdio.h> 3 #include<poll.h> 4 #include<unistd.h> 5 6 using namespace std; 7 int main() 8 { 9 struct pollfd pf; 10 11 pf.fd=0; 12 pf.events=POLLIN; 13 14 while(1) 15 { 16 int ret=poll(&pf,1,-1); 17 if(ret<0) 18 { 19 perror("poll"); 20 } 21 else if(ret==0) 22 { 23 cout<<"TimeOut!"<<endl; 24 sleep(1); 25 continue; 26 } 27 char buf\[1024\]={0}; 28 read(0,buf,sizeof(buf)-1); 29 cout<<buf<<endl; 30 31 } 32 return 0; 33 } 123456789101112131415161718192021222324252627282930313233

五、I/O多路转接之epoll

epoll函数是目前世界上公认在Linux下,多路转接监控效率最高的模型。

1.epoll相关系统调用


① 创建epoll操作句柄

  • size:自从Linux2.6.8之后,size参数是被忽略的;但不要传递小于0的数字
  • 返回值:返回epoll的操作句柄

② 注册待要监控的文件描述符

  • epfd:epoll操作句柄。
  • op:告诉epoll要做什么是事。
     ① EPOLL\\_CTL\\_ADD:添加一个文件描述符对应的事件结构到epoll当中。
     ② EPOLL\\_CTL\\_MOD:修改一个文件描述符的事件结构。
     ③ EPOLL\\_CTL\\_DEL:从epoll当中删除一个文件描述符对应的事件结构。
  • fd:待处理(添加、修改、删除)的文件描述符。
  • event:文件描述符对应的事件结构。
  • epoll\\_event结构体
  • 返回值

③ epoll的等待接口

  • epfd:epoll的操作句柄。
  • events:时间结构数组(集合),从epoll当中获取就绪的事件结构。
  • maxevents:最多一次获取多少个事件结构。
  • timeout:
      0:带有超时事件
      ==0:非阻塞
      <0:阻塞
  • 返回值:就绪的文件描述符个数。

2.epoll的原理

当某一个进程调用epoll\\_create函数时,LInux内核会创建一个eventpoll的结构体,这个结构体中有两个成员与epoll的使用方式密切相关。


当调用epoll\\_create函数时,会在内核创建一个eventpoll结构体,在该结构体中有一个rdlist成员和rbr成员,它两分别是一个双向链表和红黑树,而调用epoll\\_ctl函数添加、修改、删除文件描述符对应的事件集合其实是对红黑树中的节点进行相应的添加、修改、删除操作,而所有添加到epoll的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当文件描述符准备就绪后,内核会回调ep\\_poll\\_callback函数,将准备就绪的事件集合添加到rdlist双向链表中,而当调用epoll\\_wait进行监控的时候,如果双向链表为空,则表明当前没有就绪的事件发生,如果不为空,则将双向链表中的内容复制到用户态,并返回将事件数量返回给用户。

【注意】:这里的双向链表其实实现的是一个队列,虽然是一个双向链表,但是他只支持先进先出(FIFO),是队列的特性。每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll\\_ctl方法向epoll对象中添加进来的事件。

3.epoll的优点

  • 事件回调机制,当文件描述符就绪之后,会调用回调函数将事件结构复制到双向链表中,epoll\\_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响。
  • 数据拷贝轻量: 只在合适的时候调用 EPOLL\\_CTL\\_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)。
  • 没有数量限制: 文件描述符数目无上限。
  • 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开。

4.epoll的简单代码

我们使用epoll对0号文件描述符进行监控,如果监控到了我们从0号文件描述符中去读取内容并将读取到的内容打印出来。

1 #include<iostream> 2 #include<stdio.h> 3 #include<unistd.h> 4 #include<sys/epoll.h> 5 6 using namespace std; 7 int main() 8 { 9 int epfd=epoll\_create(3); 10 if(epfd<0) 11 { 12 perror("epoll\_create"); 13 return 0; 14 } 15 16 struct epoll\_event ee; 17 ee.events=EPOLLIN; 18 ee.data.fd=0; 19 epoll\_ctl(epfd,EPOLL\_CTL\_ADD,0,&ee); 20 21 while(1) 22 { 23 struct epoll\_event arr\[2\]; 24 int ret=epoll\_wait(epfd,arr,sizeof(arr)/sizeof(arr\[0\]),-1); 25 if(ret<0) 26 { 27 perror("epoll\_wait"); 28 continue; 29 } 30 31 for(int i=0;i<ret;i++) 32 { 33 if(arr\[i\].events==EPOLLIN) 34 { 35 char buf\[1024\]={0}; 36 37 read(arr\[i\].data.fd,buf,sizeof(buf)-1); 38 cout<<buf<<endl; 39 } 40 } 41 } 42 return 0; 43 } 12345678910111213141516171819202122232425262728293031323334353637383940414243


select、poll、epoll对比:

5.epoll的工作方式

  • \[ \] 举个例子:当你在中午饭点玩游戏的时候,如果这个时候饭刚好做好了。
  • LT:家里人第一次通知的时候,你没有动,那他们还会通知第二次、第三次…
  • ET:家里人在第一次通知的时候,你没有动,那么他们就不会在通知你了。

① LT(Level Triggered) 水平触发工作模式

  • \[ \] epoll默认状态下就是LT工作模式。
  • 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分。
  • 如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll\\_wait 时, epoll\\_wait仍然会立刻返回并通知socket读事件就绪。
  • 直到缓冲区上所有的数据都被处理完, epoll\\_wait 才不会立刻返回。
  • 支持阻塞读写和非阻塞读写。
在LT模式下,当epoll检测到事件就绪的时候,可以不处理或处理一部分,但是可以连续多次调用epoll\\_wait对事件进行处理,简单点来说的话就是如果事件来了,不管来了几个,只要仍然有未处理的事件,epoll都会通知你

② ET(Edge Triggered) 边缘触发工作模式

  • \[ \] 如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式。
在ET模式下,当epoll检测到事件就绪的时候,会立即进行处理,并且只会处理一次,换句话说就是文件描述符上的事件就绪之后,只有一次处理机会。 简单来说就是如果事件来了,不管来了几个,你若不处理或者没有处理完,除非下一个事件到来,否则epoll将不会再通知你。ET的性能比LT性能更高( epoll\\_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll。只支持非阻塞的读写。
  • \[ \] LT模式存在的问题:
  • 如果可读或者可写事件未进行处理,会频繁反复的激活未处理事件。
  • \[ \] LT模式存在的问题解决方法:
  • 在不想处理某个事件的时候就将它从epoll中移除,需要时再添加上。
  • \[ \] ET模式存在的问题:
  • 如果可读或者可写事件没有全部处理,会有老数据残留,需要等待新数据的到来才会被处理。
  • \[ \] ET模式存在的问题解决方法:
  • 循环读取或者写入数据,直至返回值未EAGAIN或者EWOULDBLOCK(循环调用)。
  • 读取或写入数据后,通过epoll\\_ctl设置EPOLL\\_CTL\\_MOD,激活未处理事件(相当于将当前未处理事件设置未新事件)。

6.对比ET和LT


总结
以上就是今天要讲的内容,本文详细介绍了Linux高级IO中的5中ION模型的原理及用法,高级IO提供了大量的方法供我们使用,非常的便捷,我们务必掌握。希望大家多多支持!另外如果上述有任何问题,请懂哥指教,不过没关系,主要是自己能坚持,更希望有一起学习的同学可以帮我指正,但是如果可以请温柔一点跟我讲,爱与和平是永远的主题,爱各位了。加油啊!
————————————————
原文链接:https://blog.csdn.net/qq\\_44918090/article/details/119838006

最后修改:2023 年 01 月 13 日
如果觉得我的文章对你有用,请随意赞赏