欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 科技 > IT业 > 【Linux】关于IO多路复用:select/poll/epoll的笔记

【Linux】关于IO多路复用:select/poll/epoll的笔记

2025/4/2 3:47:56 来源:https://blog.csdn.net/m0_74826803/article/details/146768996  浏览:    关键词:【Linux】关于IO多路复用:select/poll/epoll的笔记

IO多路复用:select/poll/epoll

  • select
    • select函数
    • 代码演示
    • select的特点
    • select的缺点
  • poll
    • poll的优点
    • poll的缺点
    • 代码演示
  • epoll
    • epoll相关调用
    • epoll的两种触发方式
      • 水平触发(LT)
      • 边缘触发(ET)
    • epoll的优点
    • epoll中的惊群问题
      • 1. 惊群问题的典型场景
      • 2. 惊群问题的危害
      • 3. 为什么 epoll 会触发惊群?
      • 4. 解决方案

select

  在 Linux 中,select 是一种早期的 I/O 多路复用技术,允许程序通过单个线程同时监视多个文件描述符(如套接字、管道等),并在其中任何一个文件描述符就绪(可读、可写或发生异常)时通知应用程序。

select函数

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

nfds:需要监视的最大文件描述符+1
readfdswritefdsexceptfds:输入输出型参数,其中readfds只关心读事件(传入时表明有多少文件需要被关心读事件,传出时表明有多少文件的读事件已就绪,wirtefds和exceptfds同理),wirtefds只关心写事件,exceptfds只关心异常事件
timeout:

  • NULL:表示select中没有timeout,即select会一直被阻塞
  • 0:仅检测描述符集合的状态,然后返回,并不阻塞
  • 特定的时间:仅阻塞指定的时间,如果没有事件发生则返回
struct timeval 
{long    tv_sec;         /* seconds */long    tv_usec;        /* microseconds */
};

fd_set

#define __FD_SETSIZE 1024  // 默认支持的最大文件描述符数量typedef struct {unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;

这个结构中包含了一个数组,该数组充当了位图的作用,select就是通过位图来对文件描述符进行监视的
操作fd_set的接口

void FD_CLR(int fd, fd_set *set);//清除set中相关fd的位
int  FD_ISSET(int fd, fd_set *set);//测试set中fd位是否为真
void FD_SET(int fd, fd_set *set);//设置set中的fd
void FD_ZERO(fd_set *set);//清空set

函数返回值

  • 成功则返回文件状态以改变的文件个数
  • 返回0表示已经超时且没有监听到事件就绪
  • 出错返回-1,错误原因存于errno中,所有参数的值都失效

代码演示

#include <sys/select.h>
#include <stdio.h>
#include <unistd.h>int main()
{fd_set readset;int fd=0;//从stdin中获取数据FD_SET(fd,&readset);select(fd+1,&readset,NULL,NULL,NULL);if(FD_ISSET(fd,&readset)==1){char buffer[1024];int n = read(0,buffer,1024);buffer[n]='\0';printf("%s\n",buffer);}return 0;
}

select的特点

  • 可监控的文件描述符个数取决于sizeof(fd_set)的值,即select可管理的文件描述符的个数是有限制的
  • 将fd加入到select中时,还要使用一个数组保存放到select中的fd
    • 一是用于在select返回之后,数组作为源数据和fd_set进行FD_ISSET判断
    • 二是select返回后会将以前加入的但无事发生的fd清空,则每次开始select之前都需要从数组中取得fd逐一加入,扫描数组的同时取得fd的最大值maxfd,用于select的第一个参数

select的缺点

  • 每次调用select,都需要手动设置fd集合,使用上不方便
  • 每次调用select,都需要将fd集合从用户态拷贝到内核态,当fd很多时,开销也会变得很大
  • 每次调用select时都需要在内核遍历传递进来的所有fd
  • select支持的文件描述符数量太少

poll

int ppoll(struct pollfd *fds, nfds_t nfds,const struct timespec *tmo_p, const sigset_t *sigmask);
struct pollfd {int   fd;         /* file descriptor */short events;     /* requested events */short revents;    /* returned events */
};

fds:是一个poll函数监听的结构列表,每个元素中包含三部分内容:文件描述符、监听的事件集合和返回的事件集合
nfds:表示fds数组的长度
timeout:表示poll函数的超时时间(单位:毫秒),设置-1表示永久阻塞
events和revents:取值最常用的是POLLIN(读事件)和POLLOUT(写事件)
返回值:小于0,表示出错;大于0,表示poll函数等待超时;大于0表示poll由于监听的文件描述符就绪而返回

poll的优点

  • 不同于select使用三个位图来标识三个fdset的方式,poll使用一个pollfd的指针来实现
  • pollfd结构包含了要监视的event和发生的event,不再使用select参数-值传递的方式,使用更加方便
  • poll没有最大数量的限制

poll的缺点

  • 当poll监听的文件描述符增多之后,和select一样,poll返回之后,需要轮询pollfd来获取就绪的描述符
  • 每次调用poll都需要将大量的pollfd结构从用户态拷贝到内核态当中

代码演示

#include <poll.h>
#include <stdio.h>
#include <unistd.h>int main()
{struct pollfd fd;fd.fd=0;fd.events=POLLIN;poll(&fd,1,-1);if(fd.revents=POLLIN){char buffer[1024];int n=read(0,buffer,sizeof(buffer));buffer[n]='\0';printf("%s\n",buffer);}return 0;
}

epoll

  在 Linux 中,epoll 是一种高效的 I/O 多路复用机制,专为处理大规模并发连接而设计。它克服了传统 select 和 poll 的性能瓶颈

epoll相关调用

epoll_create

int epoll_create(int size);
  • 创建成功后返回 epoll 文件描述符(epfd)
  • 自linux2.6.8以后,size参数是被忽略的
  • 用完之后,必须调用close关闭

epoll_ctl:管理监控的 fd

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

作用:向 epoll 实例添加、修改或删除监控的 fd。
参数:
op:操作类型(EPOLL_CTL_ADD(注册一个fd到epfd中)、EPOLL_CTL_MOD(修改已经注册的fd的监听事件)、EPOLL_CTL_DEL(从epfd中删除一个fd))。
event:指定监控的事件类型(如可读、可写)及用户数据。
struct epoll_event结构

typedef union epoll_data {void    *ptr;   // 用户自定义数据指针int      fd;    // 文件描述符uint32_t u32;   // 32位整数uint64_t u64;   // 64位整数
} epoll_data_t;struct epoll_event {uint32_t     events;   // 监控的事件类型(位掩码)epoll_data_t data;     // 用户数据(联合体)
};

(1) events 字段
作用:指定要监控的事件类型,通过 位掩码(bitmask) 组合多个事件。
常用事件类型:

事件类型描述
EPOLLIN文件描述符可读(有数据到达或连接关闭)。
EPOLLOUT文件描述符可写(发送缓冲区未满)。
EPOLLERR发生错误(自动监控,无需显式设置)。
EPOLLHUP对端关闭连接或挂起(自动监控)。
EPOLLET边缘触发模式(Edge-Triggered),默认是水平触发(Level-Triggered)。
EPOLLONESHOT单次触发,事件处理后需重新注册(用于多线程安全场景)。
示例:
// 监控可读事件,并启用边缘触发模式
event.events = EPOLLIN | EPOLLET;

(2) data 字段

  • 作用:存储用户自定义数据,事件触发时可通过该字段快速关联上下文。
  • 联合体成员
    成员类型用途
    ptrvoid*指向用户自定义数据结构(如连接上下文)。
    fdint直接存储文件描述符。
    u32uint32_t存储32位整数(较少使用)。
    u64uint64_t存储64位整数(较少使用)。

epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 作用:阻塞等待就绪事件,返回就绪的 fd 数量。
  • 参数
    • events:输出参数,存储就绪事件的数组。epoll会将发生的事件拷贝到events数组中(events数组不可以是空指针,内核只负责拷贝,不会主动分配内存)
    • maxevents:数组大小,防止溢出。
    • timeout:超时时间(ms),-1 表示阻塞,0 表示非阻塞。
    • 如果函数调用成功,返回对应IO上已准备好的文件描述符的数目,如果返回0则表示已经超时,返回小于0表示函数失败

epoll的两种触发方式

水平触发(LT)

  • 行为:若 fd 的 I/O 事件未处理完,epoll_wait 会持续通知。
  • 优点:编程简单,容错性高。
  • 缺点:可能重复触发,增加无效检查。
  • 适用场景:常规应用开发(如 HTTP 服务器)。

边缘触发(ET)

  • 行为:仅在 fd 的 I/O 状态变化时通知一次(如从不可读变为可读)。
  • 优点:减少事件触发次数,提高性能。
  • 缺点:需一次性处理完所有数据,否则可能丢失事件。
  • 适用场景:高性能服务器(需结合非阻塞 I/O)。

注意 :在 Linux 的 epoll 边缘触发模式(ET 模式)下,必须将文件描述符(fd)设置为非阻塞模式,否则可能导致数据丢失、性能下降甚至死锁。以下是详细原因和解释:
(1) 避免阻塞导致事件丢失

  • 场景
    假设 recv 读取数据时,缓冲区中有 10KB 数据,但用户只读取了 5KB 后停止。
    • 非阻塞模式recv读完后再次调用 recv 会立即返回 EAGAINEWOULDBLOCK,表示数据已读完。
    • 阻塞模式recv读完后当再次调用 recv 会阻塞线程,直到新数据到达或连接关闭。
  • 风险: 在阻塞模式下,若线程卡在 recv,即使其他 fd 有事件就绪,也无法及时处理(单线程场景),导致事件堆积或服务瘫痪。
    (2) 确保一次性处理所有数据
  • ET 模式的黄金法则:必须循环读取/写入数据,直到返回错误 EAGAIN(表示当前无数据可读或缓冲区已满)。
  • 非阻塞模式的必要性:只有非阻塞 fd 才会在数据读完时返回 EAGAIN,而阻塞 fd 会一直等待,导致无法判断是否处理完数据。
    (3) 防止死锁
  • 场景: 若使用阻塞 fd,且 recv 卡在等待数据时,对端可能已关闭连接,但本端无法及时检测,导致线程永久阻塞。

epoll的优点

  • 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列(通常情况下是一个双向链表)中,epoll_wait返回直接访问就绪队列就知道文件描述符就绪,这个操作的时间复杂度O(1),即使文件描述符数目很多,效率也不会收到影响
  • 没有文件描述符的数量限制
  • 底层采用红黑树存储监控的fd,插入、删除、查找的时间复杂度为O(logN)

epoll中的惊群问题

  • 在 Linux 的 epoll 机制中,惊群问题(Thundering Herd Problem) 是指当多个进程或线程同时监听同一个文件描述符(如监听 socket)时,内核会将事件同时通知所有等待者,但最终只有一个进程/线程能成功处理该事件,导致大量无效的上下文切换和资源竞争,从而降低系统性能。

1. 惊群问题的典型场景

假设一个多进程服务器模型如下:

  • 父进程:创建多个子进程(Worker 进程)。
  • 子进程:共享同一个监听 socket,并各自调用 epoll_wait 等待新连接。
  • 事件触发:当新连接到达时,所有子进程的 epoll_wait 被唤醒,但只有一个子进程能成功调用 accept() 获取连接,其他进程被唤醒后因无事件而空转。

2. 惊群问题的危害

问题描述
CPU 资源浪费大量进程/线程被无效唤醒,导致上下文切换开销。
锁竞争加剧多个进程/线程同时争抢共享资源(如 accept() 队列),增加延迟。
吞吐量下降系统忙于处理无效唤醒,真正处理请求的吞吐量降低。

3. 为什么 epoll 会触发惊群?

  • 内核默认行为
    当监听 socket 有新连接到达时,内核会唤醒所有通过 epoll_wait 等待该 socket 的进程/线程(即“水平触发”模式的默认行为)。
  • 历史原因
    早期 Linux 内核未针对多进程监听同一 socket 的场景优化,导致惊群问题普遍存在。

4. 解决方案

(1) 使用 EPOLLEXCLUSIVE 标志(Linux 4.5+)

  • 作用:确保同一时间只有一个进程被唤醒处理新连接。

  • 用法:在注册事件时添加 EPOLLEXCLUSIVE 标志。

    struct epoll_event event;
    event.events = EPOLLIN | EPOLLEXCLUSIVE;  // 启用独占唤醒
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &event);
    
  • 优点:内核级别解决惊群,无需修改应用逻辑。

  • 限制:仅适用于 Linux 4.5 及以上内核。

(2) 使用 SO_REUSEPORT 选项(Linux 3.9+

  • 作用:允许多个进程绑定到同一 IP 和端口,内核自动分配连接给不同进程。

  • 用法:在每个子进程中独立创建监听 socket 并设置 SO_REUSEPORT

    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
    bind(listen_fd, ...);
    listen(listen_fd, ...);
    
  • 优点:彻底避免惊群,连接负载均衡到不同进程。

  • 限制:需每个进程独立监听,适用于无共享状态的 Worker 模型。

(3) 单进程监听 + 派发连接(传统方案)

  • 作用:仅由一个进程负责监听新连接,通过进程间通信(如管道、共享内存)将连接派发给其他 Worker 进程。
  • 示例
    • 父进程调用 epoll_wait 监听 socket。
    • 新连接到达后,父进程 accept(),将连接 fd 发送给某个子进程处理。
  • 优点:兼容性高,适用于旧内核。
  • 缺点:父进程可能成为性能瓶颈。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词