【计算机网络】多路复用的三种方案

文章目录


🔎Linux提供三种不同的多路转接(又称多路复用)的方案,分别是:select,poll和epoll。它们表现为不同的系统调用接口。

前置知识:"IO事件就绪"

即读事件就绪或写事件就绪

  • 读事件就绪,指接收缓冲区中有数据可以读取
  • 写事件就绪,指发送缓冲区中有空间可以写入

1. select

select是Linux中用于同时监视多个文件描述符是否就绪的系统调用接口,当程序运行到select调用处,默认会阻塞等待(也可以设为非阻塞)监视的文件描述符至少有一个IO事件就绪为止。

select函数

cpp 复制代码
#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数介绍:

  • nfds:需要监视的文件描述符最大值+1;
  • readfds/writefds/exceptfds :分别是监视的读文件描述符集合,写文件描述符集合,异常文件描述符集合。这三个fd_set*类型的参数是输入输出型函数 ,如:你需要关心某个fd的读事件,就将其设置到readfds中并调用select,select返回时,若该fd的读事件就绪,则readfds存在该fds,否则不存在。writefdsexceptfds同理。总而言之,这三个参数既让用户告知内核"我"要关心哪个文件fd,又让内核向用户通知哪些文件fd的哪些事件已就绪。
  • timeout:select等待超时时间,设为nullptr则为阻塞等待。

返回值:

  • 成功,则返回三个fd_set中事件就绪的fd的个数,若为0,表示超时timeout了也没有fd的事件就绪

  • 失败,返回-1,错误码被设置,此时参数readfds, writefds,exceptfds和timeout的值失效

fd_set结构:

fd_set底层是一个long int的数组,上层视作一个位图结构,每个比特位,下标代表文件fd,内容表示fd是否有效。若调用select时,参数readfds的位图结构为...00001000 (前面全0省略),则表示用户需要监视3号文件描述符的读事件,select返回时,若readfds依然为...00001000,表示3号文件描述符读事件就绪,否则未就绪。

提供了一组操作fd_set的接口,来比较方便的操作位图

cpp 复制代码
void FD_CLR(int fd, fd_set *set);  // 将fd从set中去除
int  FD_ISSET(int fd, fd_set *set);// 判断fd是否存在于set中
void FD_SET(int fd, fd_set *set);  // 将fd设置入set中
void FD_ZERO(fd_set *set);		   // 清空set

select的工作特性

  1. select在内核中会去遍历三个fd_set,对于每个fd,若用户关心,则检测该fd的对应事件是否就绪,就绪则比特位置1,反之置0,若用户不关心,则跳过。参数nfds是底层遍历的终点。这种遍历在阻塞情况下会持续进行,直到检测到有一个或多个fd的IO事件就绪为止。

  2. select能够监视的文件fd是有限的,这受限于fd_set的大小,fd个数 = sizeof(fd_set) * 8。不同环境下的fd_set大小不同,在我的本地测试sizeof(fd_set) = 128,那么能够监视的fd个数即为1024。

  3. 将关心的fd加入select的监控集合后,还需要在上层维护一个数组array来保存这些关心的fd。原因如下:

    • 一,select的三个fd_set都是输入输出型参数,那么select前设置的fd,在select后可能就不存在了(因为该fd的事件未就绪),因此每次select之前都需要重置fd_set,重置就需要有一个数组array始终保存着用户关心的fd。

    • 二,select返回后,用户需要手动遍历fd_set,判断哪些fd已就绪(FD_ISSET),而上层保存fd的数组array就是判断的根据。

select的缺点

  1. 每次select之前都要手动重置fd_set,太麻烦了
  2. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  3. 每次调用select,都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  4. select支持的文件描述符数量太小

2. poll

poll的作用和工作特性与select基本相同,但使用方法不同。

poll函数

cpp 复制代码
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

poll以结构体struct pollfd的形式规定了监视的fd,其中包含更多信息。

fd:监视的fd

events:这是用户设置的,用于用户告知内核本fd要监视什么事件,是输入型参数

revents:这是poll函数返回的,用于内核通知用户本fd的哪些事件已就绪,是输出型参数

events/revents的事件类型:

POLLIN:读事件

POLLOUT:写事件

POLLERR:错误异常

POLLHUP:挂起异常,可能是对端关闭了连接

以上事件类型都是<poll.h>中定义的宏,events/revents可以同时承载多个事件,只需多个事件类型进行与操作即可。

poll的参数介绍

  • fdsstruct pollfd类型的指针,指向一段连续存放多个pollfd的空间。fds由用户自己维护。
  • nfds:fds指向空间的大小
  • timeout:超时时间,单位是毫秒ms

💭一些小细节:

  1. 在pollfd中,若fd为负数,则events无效,调用poll后,revents被设为0
  2. 在pollfd中,若events为0,表示不关心该fd上的任何时间,那么调用poll后,revents也将被设为0。
  3. poll只会监视fd用户关心的事件,并通过revents返回

poll的返回值:

  • 成功,返回有事件就绪的fd的个数,即revents不为0的pollfd的个数。若为0,表示超时timeout了也没有fd的事件就绪。
  • 失败,返回-1,错误码被设置。

poll与select的对比

  1. poll相比于select的优势有两点:
    • 输入与输出分离,不用在每次调用poll前手动设置关心的fd
    • 能够关心的fd个数不受限制,fds指向的空间用户可以扩容,参数nfds也可以修改
  2. 与select一样存在频繁遍历的劣势。监视的fd一多,每次调用poll,要将大量的pollfd结构从用户态拷贝到内核中,内核要遍历传递进来的所有pollfd结构,检查有哪些事件就绪。每次poll返回,都需要遍历上层维护的pollfd结构,根据revents判断哪些fd的什么事件就绪了。
  3. 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,遍历速度降低,其效率也会线性下降。

3. epoll

epoll是目前公认的效率最高,性能最佳的多路复用组件。

epoll的三个接口

  1. epoll_create

    功能:创建一个epoll模型的句柄,返回该句柄的文件描述符

    函数原型:

    cpp 复制代码
    #include <sys/epoll.h>
    
    int epoll_create(int size);

    注意事项:

    • 自从linux2.6.8之后,size参数是被忽略的,随便给一个就行
    • 使用结束,必须调用close()关闭epoll句柄
  2. epoll_ctl

    功能:对epoll模型进行增、删、改的操作(本质是对红黑树的操作)

    函数原型:

    cpp 复制代码
    #include <sys/epoll.h>
    
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

    参数:

    epfd :epoll句柄的文件描述符,由epoll_create获得;

    fd:目标文件描述符;

    op:操作类型,有如下三种:

    • EPOLL_CTL_ADD:将新的fd注册到epoll模型中,表示关心fd的事件发生
    • EPOLL_CTL_MOD:修改fd的关心事件
    • EPOLL_CTL_DEL:将fd从epoll模型中移除,表示不再关心fd的事件发生

    event:struct epoll_event结构体类型如下

    cpp 复制代码
               typedef union epoll_data {
                   void        *ptr;
                   int          fd;
                   uint32_t     u32;
                   uint64_t     u64;
               } epoll_data_t;
    
               struct epoll_event {
                   uint32_t     events;      /* Epoll events */
                   epoll_data_t data;        /* User data variable */
               };

    events:表示fd关心的事件。events可以是以下几个宏的集合:

    宏名称 意义 用户设置 内核返回
    EPOLLIN 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭)
    EPOLLOUT 表示对应的文件描述符可以写
    EPOLLERR 表示对应的文件描述符发生错误
    EPOLLHUP 表示对应的文件描述符被挂断
    EPOLLET 将EPOLL设为边缘触发(Edge Triggered)模式

    data:epoll_data类型的联合体,包含文件描述符的信息,可选择四种不同类型表示,一般选用int fd

    返回值:成功返回0,错误返回-1,错误码被设置。

  3. epoll_wait

    功能:等待epoll模型上的就绪事件

    函数原型:

    cpp 复制代码
    #include <sys/epoll.h>
    
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

    参数:

    epfd :epoll句柄的文件描述符,由epoll_create获得;

    events:指向一组连续存放的epoll_event结构体,epoll_wait会从内核拷贝已就绪的事件到events指向的空间中;

    maxevents:events最多存epoll_event数量,maxevents的值不能大于创建epoll_create()时的size;

    timeout:超时时间,单位是毫秒ms。-1表示阻塞等待。

    epol_wait的返回值:

    • 成功,返回有事件就绪的fd的个数,即events的单元个数。若为0,表示超时timeout了也没有fd的事件就绪。
    • 失败,返回-1,错误码被设置。

epoll的工作原理

epoll的内核实现,采用所谓的"epoll模型",是由一个红黑树(rbtree)和一个就绪队列(rdlist)组成,用于高效的监控文件描述符的状态变化。在内核中搭建epoll模型(创建rbtree和rdlist),并创建一个eventpoll句柄,用户持有eventpoll句柄的文件描述符,eventpoll句柄存有rbtree的根节点指针和rdlist的头结点指针,能找到内核中的epoll模型。eventpoll句柄成为用户与内核epoll沟通的桥梁。

🔎epoll的工作机制,由以下三部分构成:

  1. 红黑树rbtree

    根据先描述再组织的管理思想,epoll对于每一个关心的文件描述符,先将其描述为epitem结构体,再挂载到红黑树rbtree当中,并在rbtree中完成各种操作。如:epoll_ctl的EPOLL_CTL_ADD操作,就是先将新的文件描述符描述为一个epitem结构体,再插入到内核epoll的红黑树中。同理,EPOLL_CTL_MOD和EPOLL_CTL_DEL就是对红黑树的改和删的工作。

    cpp 复制代码
    struct epitem{
    	struct rb_node rbn;		  //红黑树节点
    	struct list_head rdllink; //双向链表节点
    	struct epoll_filefd ffd;  //文件描述符的句柄信息
    	struct eventpoll *ep; 	  //指向其所属的eventpoll对象
    	struct epoll_event event; //期待发生的事件类型
    }

    struct epoll_event是epitem的key值,也是用户告知内核关心事件,内核通知用户就绪事件的结构体。

  2. 就绪队列rdlist

    rdlist就绪队列,其实是一个双向链表结构,用于存放rbtree中已就绪的事件节点。epoll会将rbtree中事件就绪的epitem节点,推送到rdlist中,用户调用epoll_wait实际上就是将rdlist中的就绪事件拷贝到参数events中,时间复杂度为O(1)。值得注意的是,rdlist中的节点并不是rbtree中的副本,"节点从rbtree推送到rdlist"这个动作实际上只是修改节点的连接关系(epitem中的struct list_head rdllink),而不是拷贝一份到rdlist中。

    关于rdlist的增与删

    rdlist的增由回调机制决定,当某个fd的IO事件就绪,执行回调函数将fd对应的epitem节点连接到rdlist中。对于LT模式,只要fd的IO事件依然就绪,就继续保留fd对应的epitem节点在rdlist中;对于ET模式,epoll_wait读取一次,就将节点从rdlist中移除。

  3. 回调机制ep_poll_callback

    "节点从rbtree推送到rdlist"是怎么做到的?首先,在调用epoll_ctl注册新的文件描述符时,会为这个文件描述符对应的epitem在底层的注册一个回调函数,这个回调的功能是将节点连接到rdlist中(回调函数注册到fd的文件句柄中)。此后,当底层IO事件就绪(协议栈决定), 检测到对应文件句柄的回调函数存在,就会调用这个回调函数,将epitem节点连接到rdlist中。

epoll的优点

  1. 内核采用红黑树结构管理关心的文件描述符,上层不需要再像select/poll那样自行维护关心的文件描述符,也不需要再手动设置每次要关心的文件描述符。
  2. 红黑树结构的增删查改效率很高(时间复杂度是O(lgN)),不会因为关心fd的增多,导致新文件描述符的添加、关心事件的修改、文件描述符的移除等操作的效率降低。
  3. 不需要再像select/poll那样轮询检测哪些fd的事件就绪,而是采用回调机制将活跃节点连接到rdlist中,用户仅需通过就绪队列rdlist获取就绪事件,这样一来避免了频繁的遍历,即使文件描述符很多,也没有影响。

总而言之,epoll只关注活跃的fd,不会像select/poll一样总是全局扫描所有的fd,这大大提高了它的效率。

LT和ET模式

epoll有两种工作模式,水平触发(LT, Level Triggered)和边缘触发(ET, Edge Triggered)

  • LT模式:只要fd的IO事件一直就绪,就一直通知用户。在epoll底层表现为,一个epitem节点通过回调被连接到rdlist就绪队列中,只要该epitem对应的fd上IO事件还就绪着(比如对于读事件,socket接收缓冲区还有数据),就不会将其从rdlist中移除,因此用户每次调用epoll_wait都能获知该fd上的IO事件就绪。这是epoll的默认工作模式。

  • ET模式只在fd的事件状态变化时通知用户一次。在epoll底层表现为,一个epitem节点通过回调被连接到rdlist就绪队列中,说明该epitem对应的fd上IO事件就绪,这里以读事件为例,用户调用epoll_wait后,获知该fd上的读事件就绪,用户可能把缓冲区中所有数据读完,也可能只读了一部分,epoll不管缓冲区中还有没有数据,即无论fd上的读事件是否依然就绪,fd对应的epitem直接从rdlist中移除,用户下次调用epoll_wait就读不到了。只有在下次新数据包到来时,ET才会再通知上层一次,这就是在"事件状态变化"时通知用户,即边缘触发。

🔎LT与ET的区别

从效率层面

  • 从效率角度来说,ET的效率高于LT。反复通知代表上层要多次调用epoll_wait来获取就绪事件信息,而一次通知只需要一次系统调用,系统调用从用户态到内核态开销较大,ET模式有效地减少了系统调用的次数。

ET与LT的区别更显著地体现在对上层的影响

  • LT会反复通知事件就绪,这样一来,用户可能不会立刻处理事件,而是在需要的时候再处理。

  • ET只会在fd事件状态变化时通知用户一次,这样一来,就倒逼用户必须立刻处理完就绪事件,否则可能会错过事件。例如,fd读事件就绪,接收缓冲区上有2KB的数据,如果是ET模式通知用户,用户收到后就必须尽快将fd接收缓冲区上的所有数据读完,如果这次通知只读了1KB数据,且往后该fd没有新数据到来了,那么剩下的1KB数据就会丢失,因为ET模式不会再通知一次!

  • ET倒逼用户尽快取走数据,本质也是提高效率:使得底层的TCP接收窗口更大,从而在较大概率上使得对端的滑动窗口更大,提高通信效率。

如何设置ET模式?

设置fd的event为EPOLLET即可,这会让epoll对于该fd以ET模式工作。

如何保证一次处理完就绪事件?

以读事件为例,一次处理完读事件,就是一次性将接收缓冲区上的数据全部读完。调用read/recv接口循环读取fd上的数据,默认情况下,如果数据读完了,read/recv会阻塞等待,这样虽然能读完数据,但是上层无法获知。因此,必须使用非阻塞的方式读取数据!以非阻塞方式循环读取数据,当数据读完时,非阻塞read/recv不会挂起等待,而是以错误的形式返回,错误码为 EAGAIN or EWOULDBLOCK,这样一来,用户就可以通过对错误码的判断,获知数据是否读完了。对于写事件也是一样的,以非阻塞方式write/send,若发送缓冲区被写满了,表示写事件未就绪,错误码也会被设为EAGAIN or EWOULDBLOCK。

综上所述:使用 ET 模式的 epoll,需要将文件描述设置为非阻塞。 这个不是接口上的要求,而是"工程实践"上的要求,因为ET模式的机制总是要求程序员一次就绪响应就将事件处理完毕。

💭其它的理解细节:

事实上,LT也可以通过非阻塞的方式,通知一次就将所有数据取完,但由于LT是反复通知上层,就算不将数据一次读完,上层也不会错过就绪事件,只有ET的机制才倒逼用户必须立刻处理完就绪事件

epoll的应用场景

🔎并不是说使用epoll就一定是最高效的多路复用,还是要具体问题具体分析。epoll主要用于处理大规模、多并发、多连接的场景,特别是在高性能的网络服务器应用中。epoll在 Linux 上提供了一种高效的 I/O 多路复用机制,相较于selectpoll具有更好的性能和扩展性。而对于一些较小规模、连接较少的服务器,epoll带来的内存开销可能会比较大。因此,要根据具体问题和环境,选用具体的多路复用IO模型。


END...

相关推荐
wdxylb1 小时前
云原生俱乐部-shell知识点归纳(1)
linux·云原生
飞雪20072 小时前
Alibaba Cloud Linux 3 在 Apple M 芯片 Mac 的 VMware Fusion 上部署的完整密码重置教程(二)
linux·macos·阿里云·vmware·虚拟机·aliyun·alibaba cloud
路溪非溪2 小时前
关于Linux内核中头文件问题相关总结
linux
三坛海会大神5554 小时前
计算机网络参考模型与子网划分
网络·计算机网络
Lovyk5 小时前
Linux 正则表达式
linux·运维
Fireworkitte6 小时前
Ubuntu、CentOS、AlmaLinux 9.5的 rc.local实现 开机启动
linux·ubuntu·centos
sword devil9006 小时前
ubuntu常见问题汇总
linux·ubuntu
ac.char6 小时前
在CentOS系统中查询已删除但仍占用磁盘空间的文件
linux·运维·centos
淮北也生橘128 小时前
Linux的ALSA音频框架学习笔记
linux·笔记·学习
华强笔记11 小时前
Linux内存管理系统性总结
linux·运维·网络