C++如何实现非阻塞I/O?(select/poll/epoll封装)

linux 上不推荐 select 用于高并发,因其 fd_set 固定大小(通常 1024),每次调用需全量拷贝和线性扫描,连接数超千时性能断崖下降;poll 虽无数量限制但仍为 o(n) 时间复杂度;epoll 才是高并发首选,但需注意 epollet 使用规范、fd 关闭顺序及多线程安全。

C++如何实现非阻塞I/O?(select/poll/epoll封装)

select 为什么在 Linux 上不推荐用于高并发

因为 select 的 fd_set 是固定大小(通常 1024),每次调用都要把整个位图从用户态拷贝到内核态,且内核需线性扫描所有 fd 判断就绪状态。连接数一过千,性能断崖式下降。

实操建议:

立即学习C++免费学习笔记(深入)”;

  • 仅在跨平台兼容性要求极高、且并发量稳定低于 100 的场景用 select
  • Linux 下避免用 FD_SETSIZE 手动扩大上限——它只是改了栈上数组大小,内核仍按默认值检查,容易触发 EBADF 或静默失败
  • select 返回后必须重置 fd_set,否则下次调用会漏掉新加入的 fd;常见错误是只清空一次、复用旧集合

poll 比 select 好在哪,但仍有硬伤

poll 用动态数组替代位图,理论上无 fd 数量硬限制,也不再需要每次重置结构体。但它仍要遍历全部注册的 fd,时间复杂度 O(n),且内核与用户空间仍需拷贝整个 struct pollfd 数组。

实操建议:

立即学习C++免费学习笔记(深入)”;

  • pollfd 数组时,记得为每个元素显式设 events = POLLIN | POLLOUT,否则默认为 0,永远等不到事件
  • 处理 POLLHUPPOLLERR 必须和 POLLIN 分开判断——它们不保证有数据可读,直接 recv() 可能返回 0 或 -1 + EAGAIN
  • 不要在循环里反复 poll() 同一个 fd 而不 read()/write(),会陷入“就绪→无数据→再就绪”死循环

epoll_create1(0) 和 epoll_ctl 的关键参数陷阱

Linux 下真正实用的非阻塞 I/O 底层是 epoll,但封装时几个参数极易出错:epoll_create1(0)epoll_create(1024) 更安全(后者参数被忽略,仅作兼容);epoll_ctlop 参数若传错 EPOLL_CTL_MOD 却没先 ADD 过,会返回 ENOENT

实操建议:

立即学习C++免费学习笔记(深入)”;

  • 注册 fd 时,event.events 至少包含 EPOLLIN,如果要边写边读,加上 EPOLLET 启用边缘触发,否则默认水平触发可能反复唤醒
  • 边缘触发下,必须循环 recv() 直到返回 -1errno == EAGAIN,否则会丢数据
  • epoll_wait() 的 timeout 设为 -1 表示永久阻塞,0 表示纯轮询(极耗 CPU),生产环境慎用 0

封装成类时最容易漏掉的资源清理点

很多 C++ 封装把 epoll_fd 放在 RAII 类里,却忘了 fd 关闭顺序:必须先 close() 所有被监听的 socket,再 close(epoll_fd)。否则内核可能残留引用,导致 epoll_wait() 返回已关闭 fd 的就绪事件,后续 read() 触发 EBADF

实操建议:

立即学习C++免费学习笔记(深入)”;

  • 析构函数中,先遍历保存的 socket fd 列表并 close(),再关 epoll_fd;别依赖容器自动析构——socket fd 是裸 int,不会自动 close
  • epoll_ctl(EPOLL_CTL_DEL) 删除 fd 时,即使失败(如 fd 已关闭),也应继续执行后续逻辑,不能 throw 或 abort,否则 cleanup 流程中断
  • 多线程环境下,不要在 worker 线程里直接 epoll_ctl 修改监听列表——epoll 本身线程安全,但你的 fd 管理逻辑未必,加锁或统一由 event loop 线程操作

边缘触发、fd 生命周期、事件漏判——这三个地方出问题,比语法错误更难 debug,因为现象往往是偶发连接卡住或数据截断,而不是崩溃或报错。