IO多路复用模型演变(从阻塞IO到epoll)

IO多路复用模型演变(从阻塞IO到epoll),第1张

网络通信就是通过服务端监听客户端的请求连接,建立连接,读取数据,进行处理。

我们来讲讲IO多路复用:

由于CPU的速度IO速度的上百倍,所以如何处理IO速度减少性能差距就成了难题。

为了方便理解,本节大部分采用伪代码的形式。

1、通过soclass="superseo">cket进行http通信(阻塞IO)

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

代码执行顺序如下:

服务端会阻塞在accept函数,在客户端connect建立连接后, 

又会阻塞在read函数,直到客户端执行write函数。

 

整体流程图如下:

阻塞IO的方法一旦有多个用户访问的情况下就需要一个个排队,且若客户端建立连接之后没有发送数据,则服务端会一直阻塞在read阶段无法往下进行,为了解决这个方法,需要使用多进程/线程。

2、非阻塞IO

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

多线程关键点就在于主线程只负责监听连接请求,子线程处理业务逻辑,这里指的是读数据。

主线程在建立连接之后,创建子线程安排任务后,继续监听连接请求;而子线程来读取数据。

 这样就可以通过多线程来解决卡死在read函数的问题,不过这样可算不上是非阻塞IO。而真正的非阻塞IO需要通过设置,将read函数稍作改变,执行read时会判断fd文件描述符(下文简称fd)是否就绪,若没有则返回-1直接退出,而不是阻塞在原地一直等待fd就绪。

设置的代码如下:

//对文件描述符设置非阻塞
int setnonblocking(int fd)
{
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}

流程如下:

 这里有一个小瑕疵,当数据到内核缓冲区之前是非阻塞的,也就是fd就绪前,但从内核缓冲区到用户缓冲区的时候是阻塞的。整体流程如下:

 虽然有所瑕疵,不过性能还是得到了较好的优化效果。但存在一个问题,当前的程序是主线程监听连接,为每一个用户创建一个子线程读取数据,处理业务,当处于高并发的环境下时,给每一个用户都创建线程资源很容易就被消耗殆尽。而仔细想想,资源浪费在每一个子线程都只循环read自己的fd上面,这就是最大的优化点。

所以解决方法就是,再分化出一个管理线程,管理线程来循环read每一个fd,当就绪时才分配工作线程。主线程在建立连接时就将fd放入fd数组中即可。

那么现在线程就分成了三类:主线程(监听连接);管理线程(遍历fd);工作线程(处理业务) 

这样就使用了一个线程管理多个客户端,慢慢开始有框架出来了,开始有多路复用的意思了。

但还存在一个问题,虽然从每一个子线程read自己的fd变成了只有一个管理线程read所有的fd,但fd一旦多起来,管理线程在用户态read未就绪的fd时也存在着一定的性能消耗,于是为了解决这个问题,select就登场了。

3、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,检查描述字后立即返回,轮询

 让我们来整理下思路,主线程监听连接,将建立连接的fd放入数组,管理线程通过select只遍历已就绪的fd,工作线程处理业务。

当 select 函数返回后, *** 作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。

可以看出几个瑕疵:

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

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

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

可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。

4、poll

poll跟select几乎一模一样,只是取消了select的最大fd个数1024的限制,因为select是通过线性表存储,poll是通过链表,这里就不再多说了。

5、epoll

epoll也就是现在市面上使用最多的方式,解决了刚刚select所说的三个缺点:内核存在拷贝,内核是同步IO,只返回个数。epoll的底层是通过红黑树存储的。

(1)epoll仅仅只需要在第一次运行时将fd拷贝到内核态中,其余时刻仅需对一些仅有的fd进行传输。

(2)内核不再是通过轮询的方式找出就绪的文件描述符,而是通过异步IO通知唤醒,也就是文件就绪的时候自动通知,而非自己主动监听。

(3)内核只会返回就绪的fd,而非整体再进行遍历。

同时epoll支持ET和LT模式,而select和poll只支持LT。

LT是指电平触发(level trigger),当每有一个IO事件就绪时,内核会通知所有就绪但未处理的fd,直到该IO事件被处理;

ET是指边沿触发(Edge trigger),当每有一个IO事件就绪时,内核只会通知这次就绪的fd而非全部,如果在这次没有及时处理,该IO事件就不会再次通知了。

系统给出了epoll相关的三个函数:

int epoll_create(int size);

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

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

typedef union epoll_data {
               无效* ptr;
               int fd;
               uint32_t u32;
               uint64_t u64;
           } epoll_data_t;
 
           struct epoll_event {
               uint32_t事件; / * Epoll事件* /
               epoll_data_t数据; / *用户数据变量* /
 };

其中epoll_ctl的op参数的有效值为:
       EPOLL_CTL_ADD:在文件描述符epfd所引用的epoll实例上注册目标文件描述符fd,并将事件事件与内部文件链接到fd。

       EPOLL_CTL_MOD:更改与目标文件描述符fd相关联的事件事件。

       EPOLL_CTL_DEL:从epfd引用的epoll实例中删除(注销)目标文件描述符fd。

events  成员变量:

可以是以下几个宏的集合,实际应用中经常用到(如游双的TinyWebServer):

EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);

EPOLLOUT:表示对应的文件描述符可以写;

EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);

EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断;

EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。

EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

epoll是用额外的空间(空间换时间)将活跃的fd挑出来返回过来,所以在高并发的情况下,只会处理活跃的连接数,非活跃的连接占用的资源非常少。但并不是所有情况下epoll的性能都优于select和poll的,若活跃的连接占大多数(有点像占空比的意思),理想点就是所有连接都是活跃的,由于epoll还需要占用额外的空间再保存活跃的连接数,反而会降低了效率,但实际应用中这种情况少之又少。

 IO多路复用部分差不多就到这了,我们来总结一下:

第一阶段:IO阻塞,服务端监听连接,创建连接,读取数据,处理业务。若其中有多个客户端,则需要等待。

第二阶段:多线程,主线程监听,创建连接,子线程读取数据,处理业务。若高并发环境下,每一用户都拥有自己的线程,系统资源很容易消耗殆尽。

第三阶段:非阻塞IO,主线程监听,创建连接,管理线程循环监听fd数组,分配工作线程处理业务。若更高并发环境下,fd数组一旦多了管理线程每次read的性能消耗也不容忽略。

第四阶段:select,主线程监听,创建连接,管理线程调用select遍历fd数组,但未就绪的不会执行read进行IO *** 作,工作线程处理业务。三缺点,内核存在拷贝,内核是同步IO,只返回个数。

第五阶段:poll,同select,主要解决了fd上限为1024的限制。

第六阶段:epoll,主线程监听,创建连接,将fd注册至epoll内核表中,其中fd在内核态只全部拷贝一次,fd就绪会IO异步唤醒,用户态只遍历就绪的fd数组,工作线程处理业务。

这就是IO多路复用模型的演变,需求增加技术也要随之优化。IO多路复用之所以快还是得通过系统内核给出的条件,真正的优化还是需要学习内核,目前我们只是学习使用,学习之路还很漫长。

-----------------------------------------------------------------分割线,如有不对请指出

 参考资料(图也是他的,本文只是加了点自己的理解):你管这破玩意叫 IO 多路复用?_程序员小灰的博客-CSDN博客

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/yw/928765.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-17
下一篇 2022-05-17

发表评论

登录后才能评论

评论列表(0条)

保存