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

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 发送给某个子进程处理。
  • 优点:兼容性高,适用于旧内核。
  • 缺点:父进程可能成为性能瓶颈。
项目简介: 采用I/O复用技术select实现socket通信,采用多线程负责每个客户操作处理,完成Linux下的多客户聊天室! OS:Ubuntu 15.04 IDE:vim gcc make DB:Sqlite 3 Time:2015-12-09 ~ 2012-12-21 项目功能架构: 1. 采用client/server结构; 2. 给出客户操作主界面(注册、登录、帮助和退出)、登录后主界面(查看在线列表、私聊、群聊、查看聊天记录、退出); 3. 多客户可同时连接服务器进行自己操作; ##服务器端## 1. server.c:服务器端主程序代码文件; 2. config.h:服务器端配置文件(包含需要的头文件、常量、数据结构及函数声明); 3. config.c:服务器端公共函数的实现文件; 4. list.c:链表实现文件,用于维护在线用户链表的添加、更新、删除操作; 5. register.c:服务器端实现用户注册; 6. login.c:服务器端实现用户登录; 7. chat.c:服务器端实现用户的聊天互动操作; 8. Makefile:服务器端make文件,控制台执行make命令可直接生成可执行文件server ##客户端## 1. client.c:客户端主程序代码文件; 2. config.h:客户端配置文件(包含需要的头文件、常量、数据结构及函数声明); 3. config.c:客户端公共函数的实现文件; 4. register.c:客户端实现用户注册; 5. login.c:客户端实现用户登录; 6. chat.c:客户端实现用户的聊天互动操作; 7. Makefile:客户端make文件,控制台执行make命令可直接生成可执行文件client;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值