本节重点
理解五种IO模型的基本概念, 重点是IO多路转接.
掌握select编程模型, 能够实现select版本的TCP服务器.
掌握poll编程模型, 能够实现poll版本的TCP服务器.
掌握epoll编程模型, 能够实现epoll版本的TCP服务器.
理解epollLT模式和ET模式.
理解selectepoll的优缺点对比.

1.五种IO模型

阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式
阻塞IO是最常见的IO模型.
非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用.
信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作
IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态.
异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).
小结
任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间. IO更高效, 最核心的办法就是让等待的时间尽量少.

2.高级IO重要概念

在这里, 我们要强调几个概念

2.1同步通信 vs 异步通信(synchronous communication/ asynchronous communication)

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

2.2阻塞 vs 非阻塞

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

2.3理解这四者的关系

1. 同步 vs 异步

关心的是:通信双方怎么协作、结果怎么返回

同步(Synchronous)

  • 你发起调用 → 必须等对方处理完 → 拿到结果才继续
  • 你主动等结果

例子:

  • 打电话:你说一句,等对方说完,你再说下一句
  • 函数调用:ret = func(); 必须等 func 执行完

特点:

  • 顺序执行
  • 逻辑简单
  • 不等完不能干别的

异步(Asynchronous)

  • 你发起调用 → 立刻返回,不用等结果
  • 对方做完了 主动通知你(回调 / 事件 / 信号)

例子:

  • 发邮件:发完就走,对方回信是后来通知你
  • 网络异步请求、消息队列

特点:

  • 不阻塞当前流程
  • 靠回调 / 通知拿结果
  • 并发能力强

2. 阻塞 vs 非阻塞

关心的是:调用时当前线程 / 进程会不会卡住

阻塞(Blocking)

  • 调用发起 → 一直等,啥也不干,直到完成
  • 线程被挂起,不占 CPU

例子:

  • recv() 没数据就一直等
  • scanf() 等待输入

非阻塞(Non-blocking)

  • 调用发起 → 立刻返回
    • 成了 → 返回结果
    • 没成 → 返回错误 / 状态,不卡住
  • 你可以轮询,也可以去干别的

例子:

  • 非阻塞 socket:没数据直接返回 EAGAIN
  • 异步 IO 的发起阶段

3. 四者真正关系:两个正交维度

它们不是二选一,而是可以组合

组合 1:同步阻塞(最常见)

  • 同步:你等结果
  • 阻塞:调用时卡死不动
  • 例子:普通文件读写、阻塞 socket 收发

组合 2:同步非阻塞

  • 同步:最终还是要你自己拿结果
  • 非阻塞:调用不卡住,立刻返回
  • 你要轮询反复问:好了没?好了没?
  • 例子:非阻塞 socket + 循环 recv

组合 3:异步阻塞(极少用)

  • 异步:结果将来通知你
  • 阻塞:你非要等着通知到来
  • 等于把异步用成了同步,一般不这么写

组合 4:异步非阻塞(高性能标配)

  • 异步:结果靠通知 / 回调
  • 非阻塞:发起时绝不卡住
  • 例子:
    • Linux aio
    • epoll/io_uring
    • Node.js、Nginx、高性能网络框架

2.4其他高级IO

非阻塞IO,纪录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readvwritev函数以及存储映射 IO(mmap),这些统称为高级IO.
我们此处重点讨论的是I/O多路转接

2.5非阻塞IO

2.5.1fcntl

一个文件描述符, 默认都是阻塞IO.
函数原型如下.
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的cmd的值不同, 后面追加的参数也不相同
fcntl函数有5种功能:
复制一个现有的描述符(cmd=F_DUPFD.
获得/设置文件描述符标记(cmd=F_GETFDF_SETFD).
获得/设置文件状态标记(cmd=F_GETFLF_SETFL).
获得/设置异步I/O所有权(cmd=F_GETOWNF_SETOWN).
获得/设置记录锁(cmd=F_GETLK,F_SETLKF_SETLKW).
我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞.

2.5.2实现函数SetNoBlock

基于fcntl, 我们实现一个SetNoBlock函数, 将文件描述符设置为非阻塞.
void SetNoBlock(int fd) { 
 int fl = fcntl(fd, F_GETFL); 
 if (fl < 0) { 
 perror("fcntl");
 return; 
 }
 fcntl(fd, F_SETFL, fl | O_NONBLOCK); 
}
使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图).
然后再使用F_SETFL将文件描述符设置回去. 设置回去的同时, 加上一个O_NONBLOCK参数

2.5.3轮询方式读取标准输入

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd) {
 int fl = fcntl(fd, F_GETFL);
 if (fl < 0) {
 perror("fcntl");
 return;
 }
 fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main() {
 SetNoBlock(0);
 while (1) {
 char buf[1024] = {0};
 ssize_t read_size = read(0, buf, sizeof(buf) - 1);
 if (read_size < 0) {
 perror("read");
 sleep(1);
 continue;
 }
 printf("input:%s\n", buf);
 }
 return 0;
}

2.I/O多路转接之select

2.1初识select

系统提供select函数来实现多路复用输入/输出模型.
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;

2.2select函数原型

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

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

1. 参数详细说明

参数 作用
nfds 需要监听的最大文件描述符 + 1(核心!select 会轮询 0 ~ nfds-1 的 fd)
readfds 要监听可读事件的 fd 集合(比如 socket 有数据可接收、文件有数据可读取)
writefds 要监听可写事件的 fd 集合(比如 socket 发送缓冲区有空位、文件可写入)
exceptfds 要监听异常事件的 fd 集合(比如 socket 收到带外数据 OOB)
timeout 超时时间(阻塞时长),分 3 种情况:1. NULL:永久阻塞,直到有事件触发2. tv_sec=0, tv_usec=0:非阻塞,立刻返回3. 非 0 值:阻塞指定时长(秒 + 微秒)

辅助宏(操作 fd_set 集合)

fd_set 是一个位图结构,不能直接操作,必须用系统提供的宏:

// 清空 fd 集合
void FD_ZERO(fd_set *set);

// 将 fd 加入集合
void FD_SET(int fd, fd_set *set);

// 将 fd 从集合中移除
void FD_CLR(int fd, fd_set *set);

// 检查 fd 是否在集合中(返回非 0 表示存在)
int  FD_ISSET(int fd, fd_set *set);

timeout 结构体定义

struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒(1 秒 = 1000000 微秒)
};

2. 返回值说明

  • 成功:返回触发事件的 fd 总数(可读 + 可写 + 异常的总数)。
  • 超时:返回 0(没有任何 fd 触发事件)。
  • 失败:返回 -1,并设置 errno(常见:EBADF 无效 fd、EINTR 被信号中断、EINVAL 参数非法)。

2.3理解select执行过程

理解select模型的关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则1字节长的fd_set最大可以对应8fd.
*1)执行fd_set set; FD_ZERO(&set);set用位表示是0000,0000 *2)若fd5,执行FD_SET(fd,&set); 后set变为0001,0000(5位置为1) *3)若再加入fd2fd=1,set变为0001,0011 *4)执行select(6,&set,0,0,0)阻塞等待 *5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011。注意:没有事件发生的fd=5被清空。

2.4socket就绪条件

读就绪
socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
socket TCP通信中, 对端关闭连接, 此时对该socket, 则返回0;
监听的socket上有新的连接请求;
socket上有未处理的错误
写就绪
socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记
SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;
socket使用非阻塞connect连接成功或失败之后;
socket上有未读取的错误;
异常就绪(选学)
socket上收到带外数据. 关于带外数据, TCP紧急模式相关(回忆TCP协议头中, 有一个紧急指针的字段)

2.5select的特点

可监控的文件描述符个数取决与sizeof(fd_set)的值. 我这边服务器上sizeof(fd_set)512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096.
fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd
一是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。
二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

2.6select缺点

每次调用select, 都需要手动设置fd集合, 从接口使用角度来说也非常不便.
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
select支持的文件描述符数量太小.

2.7select使用示例: 检测标准输入输出

只检测标准输入:
#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>
int main() {
 fd_set read_fds;
 FD_ZERO(&read_fds);
 FD_SET(0, &read_fds)
 for (;;) {
 printf("> ");
 fflush(stdout);
 int ret = select(1, &read_fds, NULL, NULL, NULL);
 if (ret < 0) {
 perror("select");
 continue;
 }
 if (FD_ISSET(0, &read_fds)) {
 char buf[1024] = {0};
 read(0, buf, sizeof(buf) - 1);
 printf("input: %s", buf);
 } else {
 printf("error! invaild fd\n");
 continue;
 }
 FD_ZERO(&read_fds);
 FD_SET(0, &read_fds);
 }
 return 0;
}
说明:
当只检测文件描述符0(标准输入)时,因为输入条件只有在你有输入信息的时候,才成立,所以如果一直不输入,就会产生超时信息?

2.8select使用示例

使用 select 实现字典服务器
tcp_select_server.hpp
// 防止头文件重复包含(替代传统的#ifndef #define #endif)
#pragma once
// STL 容器:存储多个socket、fd和socket的映射关系
#include <vector>
#include <unordered_map>
// 函数对象:用于封装业务处理逻辑
#include <functional>
// select相关的系统头文件
#include <sys/select.h>
// 自定义的TCP Socket封装类(包含Socket/Bind/Listen/Accept/Recv/Send等接口)
#include "tcp_socket.hpp"

// 调试函数:打印fd_set中被监听的文件描述符(方便排查问题)
inline void PrintFdSet(fd_set* fds, int max_fd) {
  printf("select fds: ");
  // 遍历0 ~ max_fd的所有fd,打印被置位(监听)的fd
  for (int i = 0; i < max_fd + 1; ++i) {
    if (!FD_ISSET(i, fds)) {
      continue;
    }
    printf("%d ", i);
  }
  printf("\n");
}

// 业务处理函数的类型别名:接收客户端请求,填充响应
// 参数:req-客户端请求内容,resp-输出参数,用于存储要返回给客户端的响应
typedef std::function<void (const std::string& req, std::string* resp)> Handler;

// Selector类:封装select I/O多路复用的核心逻辑
// 注意:该类仅保存TcpSocket对象的副本,不管理socket的内存生命周期
class Selector {
public:
  // 构造函数:初始化fd_set和最大fd值
  Selector() {
    // 【关键】max_fd_必须初始化,否则默认随机值会导致select出错
    max_fd_ = 0;
    // 【关键】fd_set必须用FD_ZERO清空,否则内存随机值会监听无效fd
    FD_ZERO(&read_fds_);
  }

  // 向Selector中添加一个监听的socket
  // 参数:sock - 要监听的TCP socket对象
  // 返回值:true-添加成功,false-添加失败(fd已存在)
  bool Add(const TcpSocket& sock) {
    // 获取socket对应的文件描述符
    int fd = sock.GetFd();
    printf("[Selector::Add] %d\n", fd);

    // 检查fd是否已存在,避免重复添加
    if (fd_map_.find(fd) != fd_map_.end()) {
      printf("Add failed! fd has in Selector!\n");
      return false;
    }

    // 1. 将fd和对应的socket存入映射表
    fd_map_[fd] = sock;
    // 2. 将fd加入读事件监听集合(select会监听该fd的可读事件)
    FD_SET(fd, &read_fds_);
    // 3. 更新最大fd值(select需要max_fd+1作为第一个参数)
    if (fd > max_fd_) {
      max_fd_ = fd;
    }
    return true;
  }

  // 从Selector中移除一个监听的socket
  // 参数:sock - 要移除的TCP socket对象
  // 返回值:true-移除成功,false-移除失败(fd不存在)
  bool Del(const TcpSocket& sock) {
    int fd = sock.GetFd();
    printf("[Selector::Del] %d\n", fd);

    // 检查fd是否存在,不存在则移除失败
    if (fd_map_.find(fd) == fd_map_.end()) {
      printf("Del failed! fd has not in Selector!\n");
      return false;
    }

    // 1. 从映射表中删除fd对应的记录
    fd_map_.erase(fd);
    // 2. 从读事件监听集合中清除该fd
    FD_CLR(fd, &read_fds_);

    // 3. 重新计算最大fd值(从当前max_fd_往前找,效率更高)
    // 原因:如果删除的是当前最大的fd,需要找到下一个最大的有效fd
    for (int i = max_fd_; i >= 0; --i) {
      // 找到第一个仍在监听集合中的fd,作为新的max_fd_
      if (FD_ISSET(i, &read_fds_)) {
        max_fd_ = i;
        break;
      }
      // 如果遍历到0都没有有效fd,max_fd_会保持0(不影响select,因为select会处理0)
    }
    return true;
  }

  // 等待可读事件触发,返回就绪的socket列表
  // 参数:output - 输出参数,存储就绪的TcpSocket对象
  // 返回值:true-等待成功,false-等待失败(select调用出错)
  bool Wait(std::vector<TcpSocket>* output) {
    // 清空输出容器,避免残留旧数据
    output->clear();

    // 【核心注意点】select会修改传入的fd_set,因此必须用临时副本
    // 若直接传read_fds_,会导致原集合被清空,后续无法继续监听
    fd_set tmp = read_fds_;

    // 调试:打印当前监听的fd集合
    PrintFdSet(&tmp, max_fd_);

    // 调用select,监听可读事件(只关注readfds,writefds和exceptfds设为NULL)
    // 参数1:max_fd_ + 1(select会监听0 ~ max_fd_的所有fd)
    // 参数5:timeout=NULL → 永久阻塞,直到有fd就绪
    int nfds = select(max_fd_ + 1, &tmp, NULL, NULL, NULL);
    if (nfds < 0) {
      // select调用失败(如被信号中断、fd无效等)
      perror("select");
      return false;
    }

    // 遍历所有可能的fd,找出就绪的fd
    // 【关键】循环条件必须是i < max_fd_ + 1,否则会漏掉最大的fd
    for (int i = 0; i < max_fd_ + 1; ++i) {
      // 检查fd是否在就绪的临时集合中
      if (!FD_ISSET(i, &tmp)) {
        continue;
      }
      // 找到就绪fd,从映射表中取出对应的socket,加入输出列表
      output->push_back(fd_map_[i]);
    }
    return true;
  }

private:
  fd_set read_fds_;          // 监听可读事件的fd集合(核心)
  int max_fd_;               // 监听的最大fd值(select的关键参数)
  std::unordered_map<int, TcpSocket> fd_map_;  // fd到TcpSocket的映射,快速查找
};

// TcpSelectServer类:基于Selector(select)实现的TCP服务器
class TcpSelectServer {
public:
  // 构造函数:初始化服务器的IP和端口
  // 参数:ip - 服务器绑定的IP地址(如"0.0.0.0"表示所有网卡)
  //       port - 服务器绑定的端口号
  TcpSelectServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
  }

  // 启动服务器,进入事件循环
  // 参数:handler - 业务处理函数,由用户自定义(处理请求并生成响应)
  // 返回值:true-启动成功(理论上不会返回,除非循环出错),false-启动失败
  bool Start(Handler handler) const {
    // 1. 创建监听socket(TCP)
    TcpSocket listen_sock;
    bool ret = listen_sock.Socket();
    if (!ret) {
      printf("创建监听socket失败!\n");
      return false;
    }

    // 2. 绑定IP和端口
    ret = listen_sock.Bind(ip_, port_);
    if (!ret) {
      printf("绑定IP:%s 端口:%d 失败!\n", ip_.c_str(), port_);
      return false;
    }

    // 3. 开始监听(backlog=5,表示半连接队列长度)
    ret = listen_sock.Listen(5);
    if (!ret) {
      printf("监听失败!\n");
      return false;
    }
    printf("服务器启动成功,IP:%s 端口:%d,等待客户端连接...\n", ip_.c_str(), port_);

    // 4. 创建Selector对象,用于监听fd的可读事件
    Selector selector;
    // 将监听socket加入Selector(监听其可读事件:有新客户端连接)
    selector.Add(listen_sock);

    // 5. 进入无限事件循环(服务器核心)
    for (;;) {
      // 存储就绪的socket列表
      std::vector<TcpSocket> output;
      // 等待可读事件触发
      bool ret = selector.Wait(&output);
      if (!ret) {
        // select调用失败,跳过本次循环,继续监听
        continue;
      }

      // 6. 遍历就绪的socket,处理事件
      for (size_t i = 0; i < output.size(); ++i) {
        // 区分事件类型:监听socket就绪 vs 客户端socket就绪
        if (output[i].GetFd() == listen_sock.GetFd()) {
          // 【事件1】监听socket就绪 → 有新客户端连接
          TcpSocket new_sock;  // 新客户端的socket
          // 接受客户端连接(不关心客户端IP和端口,设为NULL)
          listen_sock.Accept(&new_sock, NULL, NULL);
          // 将新客户端socket加入Selector,监听其可读事件(有数据发送过来)
          selector.Add(new_sock);
          printf("新客户端连接,fd=%d\n", new_sock.GetFd());
        } else {
          // 【事件2】客户端socket就绪 → 有数据可读
          std::string req, resp;
          // 接收客户端请求
          bool ret = output[i].Recv(&req);
          if (!ret) {
            // 接收失败(客户端断开连接/网络错误)
            printf("客户端fd=%d 断开连接\n", output[i].GetFd());
            // 从Selector中移除该fd
            selector.Del(output[i]);
            // 【关键】关闭socket,释放文件描述符
            output[i].Close();
            continue;
          }
          printf("收到客户端fd=%d 请求:%s\n", output[i].GetFd(), req.c_str());

          // 调用用户自定义的业务处理函数,生成响应
          handler(req, &resp);

          // 将响应发送回客户端
          output[i].Send(resp);
          printf("向客户端fd=%d 发送响应:%s\n", output[i].GetFd(), resp.c_str());
        }
      } // end for (遍历就绪socket)
    } // end for (;;) 事件循环

    return true;
  }

private:
  std::string ip_;   // 服务器绑定的IP地址
  uint16_t port_;    // 服务器绑定的端口号
};
dict_server.cc
这个代码和之前相同, 只是把里面的 server 对象改成 TcpSelectServer 类即可.
客户端和之前的客户端完全相同, 无需单独开发

3.I/O多路转接之poll [选学]

3.1poll函数接口

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
 int fd; /* file descriptor */
 short events; /* requested events */
 short revents; /* returned events */
};
参数说明
fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.
nfds表示fds数组的长度.
timeout表示poll函数的超时时间, 单位是毫秒(ms).
eventsrevents的取值:
返回结果
返回值小于0, 表示出错;
返回值等于0, 表示poll函数等待超时;
返回值大于0, 表示poll由于监听的文件描述符就绪而返回.

3.2socket就绪条件

select

3.3poll 相对于 select 的 优点(解决了 select 的硬伤)

  1. 没有 1024 个 fd 的上限

    • select:受 fd_set 位图限制,最多监听 1023 个 fd。
    • poll:用动态数组 struct pollfd[],大小你自己定,只受系统最大打开文件数限制。
  2. 不需要每次重新构造、重置监听集合

    • select:fd_set 会被内核直接修改,每次 select 前必须重新拷贝、重置。
    • poll:把要监听的事件存在 events 字段,就绪结果存在 revents 字段。原始监听集合不会被破坏,下一轮循环直接复用,代码更简单、更少坑。
  3. 参数更简单,不用算 max_fd+1

    • select:必须传 max_fd + 1,算错就漏监听。
    • poll:直接传数组长度即可,心智负担极低。
  4. 支持的事件类型更丰富poll 可以监听:

    • 可读 POLLIN
    • 可写 POLLOUT
    • 带外数据 POLLPRI
    • 挂断 POLLHUP
    • 错误 POLLERR比 select 只有读 / 写 / 异常强得多。
  5. 代码更干净、更易封装不需要维护 max_fd、不需要备份 fd_set,封装成类比 select 简单太多。

3.4poll 依然存在的缺点(和 select 一样的性能瓶颈)

  1. 本质还是 O (n) 轮询,效率不高

    • 每次调用都要从头到尾遍历整个 pollfd 数组
    • 1 万个 fd 里只有 1 个就绪,也要扫 1 万次。
  2. 用户态 ↔ 内核态数据拷贝开销大

    • 每次 poll() 都要把整个 pollfd[] 从用户态拷贝到内核态。
    • fd 越多,拷贝开销越大。
  3. 内核没有缓存就绪队列

    • 内核每次都要全量扫描所有 fd,不会记住哪些上次就绪。
    • 高并发下性能急剧下降。
  4. 不支持高效的边缘触发(ET)

    • poll 默认水平触发(LT)。
    • 虽然某些系统支持边缘触发,但不通用、不稳定,不能像 epoll 那样大规模高性能使用。
  5. 无法直接拿到 “就绪列表”

    • 你仍然要自己遍历所有 fd,检查 revents
    • 不像 epoll 直接返回就绪数组,不用瞎遍历。

3.6poll示例: 使用poll监控标准输入

#include <poll.h>
#include <unistd.h>
#include <stdio.h>
int main() {
 struct pollfd poll_fd;
 poll_fd.fd = 0;
 poll_fd.events = POLLIN;
 
 for (;;) {
 int ret = poll(&poll_fd, 1, 1000);
 if (ret < 0) {
 perror("poll");
 continue;
 }
 if (ret == 0) {
 printf("poll timeout\n");
 continue;
 }
 if (poll_fd.revents == POLLIN) {
 char buf[1024] = {0};
 read(0, buf, sizeof(buf) - 1);
 printf("stdin:%s", buf);
 }
 }
}

4.I/O多路转接之epoll

一、为什么需要 epoll?(对比 select/poll 痛点)

先回顾 select/poll 的核心问题,才能理解 epoll 的设计初衷:

痛点(select/poll) epoll 的解决方案
最大 fd 数限制(select 1024) 无上限,仅受系统最大打开文件数限制
每次调用要遍历所有 fd(O (n)) 内核维护「就绪链表」,只返回就绪的 fd(O (1))
每次要拷贝所有 fd 到内核 仅在注册 / 修改时拷贝,后续复用(一次拷贝,多次使用)
无缓存,每次全量扫描 fd 内核缓存监听的 fd(红黑树),无需重复扫描

用通俗比喻理解:

  • select/poll:你每天要挨个问 1 万个同事「有没有事」,哪怕只有 1 个人有事,也要问完 1 万次。
  • epoll:你建了一个「有事请举手」的群,只有有事的人会主动举手,你只需要看举手的人就行。

二、epoll 核心概念

epoll 的核心是内核态维护的三个关键结构,不用记底层源码,记住功能即可:

1. epoll 实例(epoll_fd)

  • epoll_create() 创建,是一个特殊的文件描述符(epoll_fd)。
  • 每个 epoll 实例对应内核中的两个核心结构:
    • 红黑树:存储你要监听的所有 fd 和对应的事件(比如可读、可写)→ 相当于「监听注册表」。
    • 就绪链表:内核主动把就绪的 fd 加入这个链表 → 相当于「待办事项列表」。

2. 事件触发模式(LT/ET)

epoll 支持两种触发模式,这是新手最容易混淆的点,用大白话解释:

模式 全称 通俗解释 特点
LT(默认) Level Trigger 水平触发 只要 fd 有数据 / 事件,就一直通知你,直到你处理完 简单、不易漏事件,新手首选
ET Edge Trigger 边缘触发 只在 fd 状态「变化」时通知一次(比如从无数据→有数据),必须一次性处理完所有数据 高效、减少通知次数,高并发首选(需配合非阻塞 I/O)

举例子:客户端给你发了 1000 字节数据:

  • LT:你第一次读了 500 字节,epoll 还会继续通知你「还有数据」,直到你读完 1000 字节。
  • ET:只通知你一次「有数据」,如果你只读了 500 字节,剩下的 500 字节不会再通知,除非客户端又发新数据。

3. 核心事件结构(epoll_event)

struct epoll_event {
    uint32_t events;  // 要监听的事件/就绪的事件(比如 EPOLLIN 可读)
    epoll_data_t data;// 自定义数据,通常存 fd(方便后续处理)
};

// data 是一个联合体,最常用的是 fd 字段
typedef union epoll_data {
    void        *ptr;
    int          fd;  // 存储要监听的文件描述符
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

常用事件宏:

  • EPOLLIN:fd 可读(比如 socket 有数据、有新连接)。
  • EPOLLOUT:fd 可写(比如 socket 发送缓冲区有空位)。
  • EPOLLERR:fd 出错。
  • EPOLLHUP:fd 挂断(比如客户端断开)。
  • EPOLLET:启用边缘触发(默认 LT)。
  • EPOLLONESHOT:只监听一次事件,触发后需重新注册。

三、epoll 三个核心函数

epoll 的 API 非常简洁,只有 3 个核心函数:

1. epoll_create ():创建 epoll 实例

#include <sys/epoll.h>
// size 参数:已废弃(历史遗留),传任意正数即可(比如 1024)
// 返回值:成功返回 epoll_fd(epoll 实例的句柄),失败返回 -1
int epoll_create(int size);
  • 作用:在内核中创建 epoll 实例,初始化红黑树和就绪链表。
  • 示例:int epoll_fd = epoll_create(1024);

2. epoll_ctl ():控制监听事件(增 / 删 / 改)

// epfd:epoll 实例的 fd(epoll_create 的返回值)
// op:操作类型:
//   EPOLL_CTL_ADD:添加 fd 和监听事件
//   EPOLL_CTL_MOD:修改 fd 的监听事件
//   EPOLL_CTL_DEL:删除 fd 的监听
// fd:要监听的文件描述符(比如 listen_sock、client_sock)
// event:要监听的事件(epoll_event 结构体)
// 返回值:成功返回 0,失败返回 -1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 核心作用:往 epoll 实例的红黑树里「加 / 删 / 改」监听的 fd 和事件。
  • 示例(添加监听可读事件):
    struct epoll_event ev;
    ev.events = EPOLLIN;  // 监听可读事件
    ev.data.fd = listen_fd;  // 绑定要监听的 fd
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
    

3. epoll_wait ():等待就绪事件

// epfd:epoll 实例的 fd
// events:输出参数,存储就绪的事件(epoll_event 数组)
// maxevents:events 数组的最大长度(最多接收多少个就绪事件)
// timeout:超时时间(毫秒):
//   -1:永久阻塞,直到有事件就绪
//    0:非阻塞,立刻返回
//   >0:阻塞指定毫秒数
// 返回值:成功返回就绪的事件数,超时返回 0,失败返回 -1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 核心作用:从 epoll 实例的就绪链表中取出就绪事件,无需遍历所有 fd。
  • 示例:
    struct epoll_event events[1024];  // 存储就绪事件,大小自定义
    // 等待就绪事件,永久阻塞
    int n = epoll_wait(epoll_fd, events, 1024, -1);
    // 遍历就绪事件
    for (int i = 0; i < n; ++i) {
        int fd = events[i].data.fd;  // 取出就绪的 fd
        if (events[i].events & EPOLLIN) {
            // 处理可读事件
        }
    }
    

四、epoll 基本使用流程(新手实战模板)

结合 TCP 服务器场景,给你一个极简的 epoll 使用模板(新手可直接参考):

#include <stdio.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

#define MAX_EVENTS 1024  // 最多处理 1024 个就绪事件

int main() {
    // 1. 创建监听 socket(TCP)
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serv_addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    listen(listen_fd, 10);

    // 2. 创建 epoll 实例
    int epoll_fd = epoll_create(1024);
    if (epoll_fd == -1) {
        perror("epoll_create failed");
        return 1;
    }

    // 3. 注册监听 socket 的可读事件(LT 模式)
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    // 4. 事件循环
    struct epoll_event ready_events[MAX_EVENTS];
    while (1) {
        // 4.1 等待就绪事件(永久阻塞)
        int n = epoll_wait(epoll_fd, ready_events, MAX_EVENTS, -1);
        if (n == -1) {
            perror("epoll_wait failed");
            break;
        }

        // 4.2 遍历就绪事件
        for (int i = 0; i < n; ++i) {
            int fd = ready_events[i].data.fd;
            // 事件1:监听 socket 就绪 → 新客户端连接
            if (fd == listen_fd) {
                int client_fd = accept(listen_fd, NULL, NULL);
                // 注册客户端 socket 的可读事件
                ev.events = EPOLLIN;
                ev.data.fd = client_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
                printf("新客户端连接:fd=%d\n", client_fd);
            }
            // 事件2:客户端 socket 就绪 → 有数据可读
            else if (ready_events[i].events & EPOLLIN) {
                char buf[1024] = {0};
                ssize_t ret = read(fd, buf, sizeof(buf)-1);
                if (ret <= 0) {
                    // 客户端断开,删除监听
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                    printf("客户端断开:fd=%d\n", fd);
                } else {
                    printf("收到数据(fd=%d):%s\n", fd, buf);
                    // 简单回显
                    write(fd, buf, ret);
                }
            }
        }
    }

    // 5. 清理资源
    close(listen_fd);
    close(epoll_fd);
    return 0;
}

五、epoll vs select/poll 核心对比

特性 select poll epoll
最大 fd 数 1024(FD_SETSIZE) 无上限 无上限
遍历方式 遍历所有 fd(O (n)) 遍历所有 fd(O (n)) 只遍历就绪 fd(O (1))
数据拷贝 每次调用全量拷贝 每次调用全量拷贝 仅注册 / 修改时拷贝
触发模式 仅 LT 仅 LT LT/ET(推荐 ET + 非阻塞)
适用场景 小并发(<1000 fd) 中小并发(<5000 fd) 高并发(万级 + fd)
系统支持 跨平台(Linux/BSD/macOS) 跨平台 仅 Linux

5.epoll工作原理

一、核心结构体拆解

1. eventpoll:epoll 实例的内核本体

对应我们调用 epoll_create() 得到的 epoll 文件描述符(epfd),是 epoll 的「大脑」,包含:

  • rbr:红黑树的根节点,用来管理所有被监听的 fd
    • 每个被监听的 fd 都会封装成 epitem 节点,挂载到这棵红黑树上
    • 红黑树的 key 是 fd 本身,增删改查时间复杂度 O (log n),高效管理海量 fd
  • rdllist:双向链表(就绪链表),用来存放已经就绪的 fd
    • 只有触发了事件的 fd 对应的 epitem 才会被链入这个链表
    • epoll_wait() 时,内核直接把这个链表的节点拷贝给用户态,无需遍历所有 fd
  • wq/pwqlist:等待队列,用于阻塞 epoll_wait() 调用,直到有事件就绪
  • lock/mtx:同步锁,保证多线程操作红黑树和就绪链表的线程安全
  • ovflist:溢出链表,处理高并发下临时的就绪节点
  • user:关联的用户空间信息

2. epitem:被监听 fd 的封装节点

每个被 epoll_ctl() 加入监听的 fd,都会被内核封装成一个 epitem 结构体,它有「双重身份」:

  • rbn:红黑树节点,让 epitem 挂载到 eventpoll 的红黑树 rbr 上,用于管理监听集合
  • rdllink:双向链表节点,当 fd 就绪时,epitem 会通过这个成员链入 eventpollrdllist(就绪链表)
  • ffd:关联的文件描述符信息(包含 fd 本身)
  • event:要监听的事件(如 EPOLLIN 可读、EPOLLOUT 可写)
  • 其他成员next/pwqlist/ep/fllink 等,用于链表管理、等待队列绑定和关联到 eventpoll 实例

二、epoll 完整工作流程(结合结构)

1. 创建 epoll 实例(epoll_create()

内核创建 eventpoll 结构体:

  • 初始化红黑树 rbr(空树)、就绪链表 rdllist(空链表)
  • 初始化锁、等待队列等同步机制
  • 返回 epoll 文件描述符(epfd),用户态通过它操作这个 eventpoll 实例

2. 注册 / 修改 / 删除监听 fd(epoll_ctl()

以「添加监听 fd」(EPOLL_CTL_ADD)为例:

  1. 内核为目标 fd 创建 epitem,填充 ffd(关联 fd)、event(监听事件)等
  2. epitemrbn 挂载到 eventpoll 的红黑树 rbr
    • 红黑树保证了海量 fd 下的快速查找 / 插入 / 删除(O (log n))
  3. epitem 绑定到对应 fd 的设备等待队列(如 socket 的接收队列)
    • 当 fd 上有事件发生时(如 socket 收到数据),设备驱动会唤醒等待队列,触发 epitem 进入就绪状态

修改(EPOLL_CTL_MOD)/ 删除(EPOLL_CTL_DEL):

  • 修改:通过红黑树找到 epitem,更新 event 监听事件
  • 删除:通过红黑树找到 epitem,从红黑树和设备等待队列中移除,若在就绪链表中也会被清除

3. 等待并获取就绪事件(epoll_wait()

  1. 调用 epoll_wait() 时:
    • 内核先检查 eventpollrdllist 是否为空
      • 若不为空:直接将 rdllist 中的 epitem 拷贝到用户态的 events 数组,返回就绪事件数
      • 若为空:当前进程 / 线程阻塞在 eventpoll 的等待队列 wq 上,直到有事件就绪或超时
  2. 当某个 fd 触发事件时:
    • 设备驱动唤醒等待队列,找到对应的 epitem,将其通过 rdllink 链入 eventpollrdllist
    • 若有进程在 wq 上阻塞,会被唤醒,然后执行「拷贝就绪事件到用户态」的操作
  3. 用户态遍历返回的 events 数组,直接处理就绪的 fd 和事件,无需遍历所有监听的 fd

三、epoll 高性能的本质(从结构看)

  1. O (log n) 管理监听集合:红黑树 rbr 替代了 select/poll 的线性遍历,海量 fd 下的增删改查效率极高
  2. O (k) 获取就绪事件:就绪链表 rdllist 只存就绪的 fd,epoll_wait() 直接拷贝就绪节点,避免了 select/poll 中 O (n) 遍历所有 fd 的开销
  3. 一次注册,多次复用epitem 只在 epoll_ctl() 时绑定到内核,后续 epoll_wait() 无需重复拷贝所有 fd 到内核,减少了用户态↔内核态的开销
  4. 内核主动通知:fd 就绪时由内核主动将 epitem 加入就绪链表,而非用户态轮询,实现了「事件驱动」

四、图中结构的直观理解

  • 下方的红黑树:代表所有被监听的 fd 集合,每个节点是 epitemrbn
  • 上方的双向链表:代表就绪的 fd 集合,每个节点是 epitemrdllink
  • epitem 是连接两者的桥梁:既是红黑树的管理节点,又是就绪链表的事件节点,保证了监听状态和就绪状态的一致性

6.epoll的优点( select 的缺点对应)

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

6.1epoll工作方式

你妈喊你吃饭的例子
你正在吃鸡, 眼看进入了决赛圈, 你妈饭做好了, 喊你吃饭的时候有两种方式:
1. 如果你妈喊你一次, 你没动, 那么你妈会继续喊你第二次, 第三次...(亲妈, 水平触发)
2. 如果你妈喊你一次, 你没动, 你妈就不管你了(后妈, 边缘触发)
epoll2种工作方式-水平触发(LT)和边缘触发(ET)
假如有这样一个例子:
我们已经把一个tcp socket添加到epoll描述符
这个时候socket的另一端被写入了2KB的数据
调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
然后调用read, 只读取了1KB的数据
继续调用epoll_wait.....
水平触发Level Triggered 工作模式
epoll默认状态下就是LT工作模式.
epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait , epoll_wait
仍然会立刻返回并通知socket读事件就绪.
直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
支持阻塞读写和非阻塞读写
边缘触发Edge Triggered工作模式
如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.
epoll检测到socket上事件就绪时, 必须立刻处理.
如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,
epoll_wait 不会再返回了.
也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
只支持非阻塞的读写
select和poll其实也是工作在LT模式下. epoll既可以支持LT, 也可以支持ET.

6.2对比LTET

LT epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把 所有的数据都处理完.
相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到 每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的. 另一方面, ET 的代码复杂程度更高了

6.3理解ET模式和非阻塞文件描述符

使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求.假设这样的场景: 服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个10k请求.
如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中
此时由于 epoll ET模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据. epoll_wait 才能返回
但是问题来了.
服务器只读到1k个数据, 10k读完才会给客户端返回响应数据.
客户端要读到服务器的响应, 才会发送下一个请求
客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.
所以, 为了解决上述问题(阻塞read不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.
而如果是LT没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.

6.4epoll的使用场景

epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根 据需求和场景特点来决定使用哪种IO模型
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐