
🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:

文章目录
- 前言:
- [一. 完善 select 服务器:事件编程思想](#一. 完善 select 服务器:事件编程思想)
-
- [1.1 为什么要拆分事件处理](#1.1 为什么要拆分事件处理)
- [1.2 核心代码解读](#1.2 核心代码解读)
- [1.3 辅助数组的核心作用](#1.3 辅助数组的核心作用)
- [1.4 完整代码展示(selectServer.hpp)](#1.4 完整代码展示(selectServer.hpp))
- [二. select 核心概念与缺陷复盘](#二. select 核心概念与缺陷复盘)
-
- [2.1 fd_set 与操作宏](#2.1 fd_set 与操作宏)
- [2.2 socket 就绪条件](#2.2 socket 就绪条件)
- [2.3 select 的四大核心缺陷](#2.3 select 的四大核心缺陷)
- [三. 深入内核:select 是怎么「同时等多个 fd」的](#三. 深入内核:select 是怎么「同时等多个 fd」的)
-
- [3.1 前置知识铺垫](#3.1 前置知识铺垫)
- [3.2 do_select 执行流程:双循环机制](#3.2 do_select 执行流程:双循环机制)
- [3.3 本质总结](#3.3 本质总结)
- [四. poll:select 的改进版](#四. poll:select 的改进版)
-
- [4.1 poll 函数原型](#4.1 poll 函数原型)
- [4.2 pollfd 结构体:输入输出分离](#4.2 pollfd 结构体:输入输出分离)
- [4.3 常用事件宏](#4.3 常用事件宏)
- [4.4 poll 解决了什么,没解决什么](#4.4 poll 解决了什么,没解决什么)
- [五. 实战:把 select 服务器改写成 poll 服务器](#五. 实战:把 select 服务器改写成 poll 服务器)
-
- [5.1 核心结构改动](#5.1 核心结构改动)
- [5.2 核心代码解读](#5.2 核心代码解读)
- [5.3 完整代码演示(pollServer.hpp)](#5.3 完整代码演示(pollServer.hpp))
- [5.4 运行效果](#5.4 运行效果)
- [六. select vs poll 全面对比](#六. select vs poll 全面对比)
- 结尾:
前言:
上一篇我们从零搭建了 select 服务器的基础框架,实现了新连接的接受与托管。但当时的代码还比较粗糙,所有逻辑都挤在一个处理函数里,也没有处理客户端数据读写与连接关闭。这一篇我们先把 select 服务器完善成一个完整的 echo 服务器,引出事件编程的核心思想;然后深入内核层面,拆解 select 到底是怎么实现「同时等待多个 fd」的;最后我们再认识 poll ------ 作为 select 的改进版,它解决了什么问题,又留下了什么遗憾,并亲手把 select 服务器改写成 poll 版本。

一. 完善 select 服务器:事件编程思想
1.1 为什么要拆分事件处理
在上一版代码中,HandlerEvent 只负责处理新连接。但实际上,select 监听的 fd 分为两类:
- 监听套接字 :就绪代表有新连接到来,需要
accept - 普通套接字 :就绪代表有数据到来,需要
recv读取
如果都塞在一个函数里,代码会越来越臃肿,也不符合高内聚低耦合的设计思想。因此我们对事件处理逻辑进行拆分,引出四个核心角色:
- Dispatcher(事件派发器):主循环,负责调用 select 等待事件,然后分发给处理器
- EventHandler(事件处理器):遍历所有 fd,判断哪些就绪,并派发到对应处理函数
- Acceptor(连接接收器):专门处理监听套接字的就绪事件,接受新连接
- IOHandler(IO 处理器):专门处理普通套接字的就绪事件,读写数据
这种基于「事件就绪 → 派发 → 处理」的编程模式,就是事件驱动编程,也是所有高并发服务器的核心思想。


1.2 核心代码解读
事件派发器 Dispatcher
这是服务器的主循环,也是整个 select 机制的入口:
cpp
void Dispatcher()
{
fd_set rfds;
while(true)
{
PrintFds();
// 1. 每次循环必须重置 fd_set(输入输出型参数会被内核覆盖)
FD_ZERO(&rfds);
int max_fd = gdefaultfd;
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] == gdefaultfd)
continue;
FD_SET(arr_fds[i], &rfds);
if(max_fd < arr_fds[i])
max_fd = arr_fds[i]; // 更新最大文件描述符
}
// 2. 调用 select 阻塞等待事件就绪
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case 0:
LOG(LogLevel::DEBUG) << "time out...";
break;
case -1:
LOG(LogLevel::DEBUG) << "select error...";
break;
default:
LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
// 3. 派发给事件处理器
EventHandler(rfds);
break;
}
}
}
这里要强调两个细节:
fd_set是输入输出型参数,每次调用 select 后,未就绪的 fd 会被内核清 0,所以每次循环必须重新构建。max_fd也要动态计算,而不是固定用 listenfd,因为后续添加的客户端 fd 数值会更大。
事件处理器 EventHandler
遍历所有 fd,先判断合法性,再判断是否就绪,最后分发到对应处理函数:
cpp
void EventHandler(fd_set &rfds)
{
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] == gdefaultfd) continue;
// 合法 fd 还需判断是否真的就绪
if(FD_ISSET(arr_fds[i], &rfds))
{
if(arr_fds[i] == _listenfd->Socketfd())
{
// 监听套接字就绪 → 新连接
Acceptor();
}
else
{
// 普通套接字就绪 → 有数据
IOHandler(i);
}
}
}
}
这里体现了一个很重要的原则:合法 ≠ 就绪。辅助数组里的都是我们关心的 fd,但只有被 select 标记过的才是真正有事件的。
连接接收器 Acceptor
和上一版逻辑一致,专门处理新连接:
cpp
void Acceptor()
{
InetAddr clientaddr;
int fd = _listenfd->Accepter(&clientaddr);
LOG(LogLevel::INFO) << "get a new link...";
if(fd >= 0)
{
// 在辅助数组中找空闲位置
int pos = 0;
for(;pos < NUM; pos++)
{
if(arr_fds[pos] == gdefaultfd)
break;
}
if(pos >= NUM)
{
LOG(LogLevel::WARNING) << "server is full!";
close(fd);
}
else
{
// 新 fd 加入数组,下轮循环自动被 select 监听
arr_fds[pos] = fd;
}
}
else
{
LOG(LogLevel::ERROR) << "Accept error!";
}
}
注意 :这里的 accept 不会阻塞。因为 select 已经告诉我们监听套接字读就绪了,全连接队列里一定有已经完成三次握手的连接。
IO 处理器 IOHandler
处理普通套接字的读事件,实现 echo 回显逻辑,同时处理连接关闭与错误:
cpp
void IOHandler(int i)
{
char inbuffer[1024];
// select 已保证就绪,直接读,不会阻塞
ssize_t n = recv(arr_fds[i], inbuffer, sizeof(inbuffer) - 1, 0);
if(n > 0)
{
inbuffer[n] = 0;
std::cout << "client say@ " << inbuffer << std::endl;
// 简单 echo 回写
std::string send_string = "echo# ";
send_string += inbuffer;
send(arr_fds[i], send_string.c_str(), send_string.size(), 0);
}
else if(n == 0)
{
// 对端正常关闭连接
LOG(LogLevel::INFO) << "sockfd is close";
close(arr_fds[i]);
arr_fds[i] = gdefaultfd; // 从辅助数组移除,停止监听
}
else
{
// 读取出错
LOG(LogLevel::ERROR) << "read error";
close(arr_fds[i]);
arr_fds[i] = gdefaultfd;
}
}
这里有两个关键点:
recv返回 0 是 TCP 连接关闭的标志,必须及时关闭 fd 并从辅助数组中清理,否则下轮 select 会监听一个非法 fd,导致报错。- 写操作我们这里直接调用
send,没有交给 select 监听写事件。因为发送缓冲区默认就是空闲的,写事件默认就绪;只有当发送缓冲区满了,才需要交给 select 等待可写。

1.3 辅助数组的核心作用
到这里我们可以总结一下,辅助数组 arr_fds 承担了三个核心职责:
- 持久化保存所有待监听的 fd :因为
fd_set每次都会被内核覆盖,必须有一个地方保存完整的 fd 列表。 - 作为 fd 生命周期的管理者:新连接加入、连接关闭移除,都通过数组维护。
- 辅助计算 max_fd :每次重建
fd_set时,顺便算出当前最大 fd,传给 select 的第一个参数。
1.4 完整代码展示(selectServer.hpp)
cpp
#ifndef __SELECTSERVER__HPP
#define __SELECTSERVER__HPP
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstdint>
#include <memory>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
// NUM 定义了当前系统 select 能处理的最大 fd 数量,通常是 1024
#define NUM (sizeof(fd_set) * 8)
using namespace LogModule;
// 定义一个无效的文件描述符常量,用于初始化和清理辅助数组
const int gdefaultfd = -1;
class selectServer
{
public:
selectServer(uint16_t port)
:_port(port)
,_listenfd(std::make_unique<TcpSocket>())
{
_listenfd->BulidSocketMethod(_port);
// -------------------------------------------------------------
// [初始化环节]
// 核心思想:使用一个用户态的数组 arr_fds 来维护我们需要监听的套接字。
// 因为 select 的 fd_set 是输入输出型参数,每次调用都会被内核修改,
// 所以我们必须有一个干净的"底稿"来每次重新构建传给内核的 fd_set。
// -------------------------------------------------------------
for(int i = 0; i < NUM; i++)
{
arr_fds[i] = gdefaultfd;
}
// 首先把_listenfd 放入数组里面,服务器启动最开始只能关心新连接的到来
arr_fds[0] = _listenfd->Socketfd();
}
// 事件派发器 (Server 的主循环)
void Dispatcher()
{
fd_set rfds; // read fd set
while(true)
{
PrintFds();
// -------------------------------------------------------------
// [重置参数环节]
// 由于 select 会清空没有事件就绪的 fd,因此每次循环必须从头构建 rfds。
// -------------------------------------------------------------
FD_ZERO(&rfds);
int max_fd = gdefaultfd; // 记录集合中最大的 fd,select 的第一个参数需要
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] == gdefaultfd)
continue;
// 把新的添加进去(将辅助数组中合法的 fd 添加到内核监听集合中)
FD_SET(arr_fds[i], &rfds);
// 动态更新最大文件描述符
if(max_fd < arr_fds[i]) max_fd = arr_fds[i];
}
// struct timeval timeout = {5,0}; // 我们今天这里先不使用这个
// -------------------------------------------------------------
// [系统调用环节]
// 将准备好的 rfds 交给内核,进程在此处阻塞,直到有 fd 的事件就绪。
// 注意:select 返回后,rfds 中只剩下真正发生了读事件的 fd。
// -------------------------------------------------------------
// int n = select(max_fd + 1, &rfds, nullptr, nullptr, &timeout);
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n)
{
case 0:
LOG(LogLevel::DEBUG) << "time out...";
break;
case -1:
LOG(LogLevel::DEBUG) << "select error...";
break;
default:
// n > 0,说明有 n 个事件就绪,交给专门的事件处理器去路由分配
LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
EventHandler(rfds);
break;
}
}
}
~selectServer()
{
// 不需要 arr_fds[i] = gdefaultfd
// 因为对象马上就销毁了,数组也没了
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] != gdefaultfd)
close(arr_fds[i]);
}
}
private:
// -------------------------------------------------------------
// [事件分发环节]
// 负责诊断是被内核修改后的 rfds 中,到底是谁就绪了,并做分类处理。
// -------------------------------------------------------------
void EventHandler(fd_set &rfds)
{
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] == gdefaultfd) continue;
// 走到这里可以判断是合法的, 但是我们还需要判断是否就绪
// FD_ISSET 检查当前遍历的 fd 是否在就绪集合 rfds 中
if(FD_ISSET(arr_fds[i], &rfds))
{
// 走到这里就肯定是就绪了
// 再判断是普通的还是listen:这两种 fd 代表的事件含义完全不同
if(arr_fds[i] == _listenfd->Socketfd())
{
// listensockfd 就绪,意味着底层全连接队列有新的客户端完成了三次握手
Acceptor();
}
else
{
// normal sockfd 就绪,意味着客户端发来了数据,或者断开了连接
IOHandler(i);
}
}
}
}
// 处理新连接的专用模块
void Acceptor()
{
InetAddr clientaddr;
// 这里的 accept 绝对不会阻塞,因为是被 select 通知就绪后才调用的
int fd = _listenfd->Accepter(&clientaddr);
LOG(LogLevel::INFO) << "get a new link...";
// 你得到了一个新的连接,这个连接怎么处理??
// recv(fd)?? 等 + 拷贝, 不能!!
// fd -> 托管给select-> 只有select具有"等"的能力!-> 如何托管?? -> 只要把fd添加的辅助数组即可!
if(fd >= 0)
{
// 找一个空闲的位置
int pos = 0;
for(;pos < NUM; pos++)
{
if(arr_fds[pos] == gdefaultfd)
break;
}
if(pos >= NUM)
{
// 遍历完了找不到,说明当前服务器并发数量已达到 select 的极限
LOG(LogLevel::WARNING) << "server is full!";
close(fd);
}
else
{
// 把这个新获取到的加进辅助数组即可
// 下一轮循环的时候, Dispatcher 的重置环节会自动将其纳入 select 监听集合
arr_fds[pos] = fd;
}
}
else
{
LOG(LogLevel::ERROR) << "Accept error!";
}
}
// 处理普通数据 IO 的专用模块
void IOHandler(int i)
{
char inbuffer[1024];
// 在之前的逻辑中已经select等待过了, 走到这里说明接收缓冲区一定有数据,直接读就行
// 这里的 recv 绝对不会阻塞
ssize_t n = recv(arr_fds[i], inbuffer, sizeof(inbuffer) - 1, 0);
if(n > 0)
{
// 正常读取到客户端数据
inbuffer[n] = 0;
std::cout << "client say@ " << inbuffer << std::endl;
// 需要写回去, 这里我们需要等待嘛
// 其实是先不需要的,写缓冲区默认有数据(只要写缓冲区没满,写事件默认就是就绪的)
std::string send_string = "echo# ";
send_string += inbuffer;
send(arr_fds[i], send_string.c_str(), send_string.size(), 0);
}
else if(n == 0)
{
// recv 返回 0 的核心含义:对端(客户端)正常关闭了 TCP 连接
LOG(LogLevel::INFO) << "sockfd is close";
// -------------------------------------------------------------
// [清理环节] - 极其重要,否则会造成资源泄漏和 select 报错
// -------------------------------------------------------------
close(arr_fds[i]); // 关闭底层文件描述符
arr_fds[i] = gdefaultfd; // 辅助数组里面也处理一下,告诉后续的 select 别再监视它了
}
else
{
// recv 返回小于 0,读取发生异常错误
LOG(LogLevel::ERROR) << "read error";
close(arr_fds[i]); // 关闭文件描述符
arr_fds[i] = gdefaultfd; // 辅助数组里面也处理一下
}
}
// 调试辅助函数:打印当前正在被监听的合法 fd
void PrintFds()
{
std::cout << "Select Server fds list: ";
for(int i = 0; i < NUM; i++)
{
if(arr_fds[i] == gdefaultfd) continue;
std::cout << arr_fds[i] << " ";
}
std::cout << std::endl;
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listenfd;
// 需要一个辅助数组来记录用户态需要监听的 fd
// 它是解决 select 参数会被覆盖修改的核心数据结构
int arr_fds[NUM];
};
#endif
二. select 核心概念与缺陷复盘
2.1 fd_set 与操作宏
fd_set 本质上是一个位图结构,每一个比特位对应一个文件描述符,比特位为 1 表示关心该 fd 的事件。
c
typedef struct {
long int fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
系统提供了四个宏来操作这个位图:
c
void FD_ZERO(fd_set *set); // 清空所有位
void FD_SET(int fd, fd_set *set); // 将 fd 加入集合
void FD_CLR(int fd, fd_set *set); // 将 fd 从集合移除
int FD_ISSET(int fd, fd_set *set);// 测试 fd 是否就绪
默认 __FD_SETSIZE 为 1024,也就是说 select 最多只能同时监听 1024 个文件描述符。
2.2 socket 就绪条件
很多人写 select 代码时不知道什么时候才算就绪,这里再系统梳理一遍:
读就绪条件(满足任意一条)
- 内核接收缓冲区字节数 ≥ 低水位标记
SO_RCVLOWAT(默认 1),可无阻塞读取 - TCP 对端关闭连接,此时
read返回 0 - 监听套接字上有新的连接请求
- socket 上有未处理的错误
写就绪条件(满足任意一条)
- 内核发送缓冲区可用字节数 ≥ 低水位标记
SO_SNDLOWAT(默认 1),可无阻塞写入 - socket 写操作被关闭,继续写会触发
SIGPIPE - 非阻塞
connect连接成功或失败之后 - socket 上有未读取的错误
异常就绪 收到 TCP 带外数据(紧急数据),实际开发很少使用。
2.3 select 的四大核心缺陷
- 连接数有硬上限:默认最多 1024 个 fd,想要修改必须重新编译内核,兼容性差。
- 输入输出耦合 :
fd_set既是输入也是输出,每次调用前都必须手动重置,使用繁琐。 - 用户态 - 内核态拷贝开销 :每次调用都要把整个
fd_set从用户态拷贝到内核态,fd 越多开销越大。 - 内核线性遍历:内核需要遍历所有传入的 fd 来检查是否就绪,时间复杂度 O (n)。连接数越多,性能下降越明显,大量空闲连接时效率极低。

三. 深入内核:select 是怎么「同时等多个 fd」的
很多同学学到这里都会有疑问:select 到底在内核里做了什么?为什么一个函数就能同时等几十个、上百个 fd?就绪了又是怎么通知进程的?
我们结合 Linux 内核 do_select 的核心逻辑,用通俗的方式拆解它的工作原理。
3.1 前置知识铺垫
在讲流程之前,先明确两个内核机制:
- 等待队列(wait_queue):每个 socket / 文件对象内部都有一个等待队列头。当设备未就绪时,进程可以把自己挂到这个队列上睡眠;当设备就绪时,内核会遍历队列,唤醒上面的进程。
- 驱动 poll 方法 :
struct file_operations中有一个poll函数指针,由具体的设备驱动实现。它的作用是检查当前设备是否就绪,同时可以通过参数把进程注册到设备的等待队列上。
3.2 do_select 执行流程:双循环机制
select 的内核实现核心是「外层死循环 + 内层遍历 fd」,大致分为四个阶段:
阶段 1:初始化与状态设置
进入 do_select 后,先初始化 poll 表,设置当前进程状态为 TASK_INTERRUPTIBLE(可中断睡眠)。
阶段 2:内层循环:遍历所有 fd 检查状态
这是第一次遍历:
- 逐个取出用户传入的 fd,调用对应驱动的
poll方法 - 如果该 fd 已经就绪,就把它标记到结果集中,就绪计数 +1
- 如果还没就绪,就通过
poll_wait把当前进程挂到这个 fd 的等待队列上
阶段 3:判断是否需要睡眠
第一次遍历完后,检查三种情况:
- 已经有就绪的 fd → 直接退出循环,返回结果
- 已经超时 → 退出循环,返回 0
- 收到信号 → 退出循环,返回 -EINTR
如果以上都不满足,就调用 schedule_timeout() 让出 CPU,当前进程进入睡眠。
阶段 4:被唤醒后二次遍历
当任意一个 fd 就绪时,内核会唤醒它等待队列上的进程。进程被唤醒后,会再次遍历所有 fd,收集所有就绪的事件,然后返回给用户。

3.3 本质总结
select 的本质可以概括为一句话:两次遍历 + 挂等待队列。
第一次遍历检查有没有就绪的,没有就把进程挂到所有 fd 的等待队列上睡觉;任何一个 fd 就绪了,就唤醒进程;进程醒了再遍历一次,收集所有就绪的 fd 返回。
这也从底层解释了 select 的性能瓶颈:
- fd 越多,两次遍历的开销越大
- 每次调用都要重复「挂队列 → 删队列」的操作
- 哪怕只有一个活跃连接,也要遍历全部 fd
四. poll:select 的改进版
正因为 select 有这么多缺点,后来的 POSIX 标准又推出了 poll 作为改进方案。
4.1 poll 函数原型
c
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
fds:pollfd结构体数组的首地址nfds:数组的元素个数timeout:超时时间,单位为毫秒-1:永久阻塞,直到有事件就绪0:立即返回,非阻塞轮询>0:阻塞指定毫秒数,超时返回 0
4.2 pollfd 结构体:输入输出分离
poll 最大的改进,就是把「用户关心的事件」和「内核返回的事件」拆成了两个字段:
c
struct pollfd {
int fd; // 文件描述符,-1 表示忽略该位置
short events; // 输入:用户告诉内核关心哪些事件
short revents; // 输出:内核告诉用户发生了哪些事件
};
events 由用户设置,内核不会修改;revents 由内核填充就绪状态。这就彻底解决了 select「输入输出耦合」的问题 ------不需要每次调用都重置事件集合 ,只需要关注 revents 即可。
4.3 常用事件宏
poll 支持的事件比 select 更丰富,最常用的有三个:
| 事件宏 | 含义 | 输入 / 输出 |
|---|---|---|
| POLLIN | 有数据可读(普通数据 + 优先数据) | 输入 + 输出 |
| POLLOUT | 可以写入数据 | 输入 + 输出 |
| POLLERR | 发生错误 | 仅输出 |
| POLLHUP | 对端挂起 / 关闭 | 仅输出 |
| POLLRDHUP | TCP 连接被对方关闭写端 | 输入 + 输出 |
使用时通过按位与 判断事件,例如:if (_fdevent[i].revents & POLLIN) 就表示读事件就绪。
4.4 poll 解决了什么,没解决什么
解决的问题
- 没有 1024 硬限制:数组大小由用户自己定义,可以动态扩容,理论上限就是系统允许的最大文件描述符数。
- 输入输出分离 :
events和revents各司其职,无需每次调用都重建整个集合,接口更易用。 - 事件类型更丰富:支持更多精细的事件,比如对端关闭、带外数据等。
没解决的问题
- 内核依然是线性遍历:时间复杂度还是 O (n),高并发下性能随连接数线性下降。
- 依然存在用户态 - 内核态拷贝:需要把 pollfd 数组在用户态和内核态之间来回拷贝。
- 大量空闲连接时效率低:和 select 一样,不管活不活跃,都要全部遍历一遍。
简单说:poll 是 select 的「接口优化版」,但没有从根本上解决性能问题。

五. 实战:把 select 服务器改写成 poll 服务器
理解了原理,我们把上面的 select 服务器改写成 poll 版本,你会发现业务逻辑几乎不用变,只是把 fd 管理的部分替换掉就行。
5.1 核心结构改动
原来的辅助数组 int arr_fds[NUM] 替换成 struct pollfd _fdevent[NUM],每个元素同时保存 fd、关心的事件、返回的事件。
5.2 核心代码解读
构造与初始化
cpp
pollServer(uint16_t port)
:_port(port)
,_listenfd(std::make_unique<TcpSocket>())
{
_listenfd->BulidSocketMethod(_port);
// 初始化数组,fd=-1 表示该位置空闲
for(int i = 0; i < NUM; i++)
{
_fdevent[i].fd = gdefaultfd;
_fdevent[i].events = _fdevent[i].revents = 0;
}
// 添加监听套接字,关心读事件
_fdevent[0].fd = _listenfd->Socketfd();
_fdevent[0].events |= POLLIN;
}
事件派发器
对比 select 版,这里不需要每次重置事件集合了,代码更简洁:
cpp
void Dispatcher()
{
while(true)
{
int timeout = 2000; // 毫秒单位
// 直接调用 poll,无需重置 events
int n = poll(_fdevent, NUM, timeout);
switch (n)
{
case 0:
LOG(LogLevel::DEBUG) << "time out...";
break;
case -1:
LOG(LogLevel::DEBUG) << "poll error...";
break;
default:
LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
EventHandler();
break;
}
}
}
事件处理器
通过 revents 判断就绪事件,逻辑和 select 版高度一致:
cpp
void EventHandler()
{
for(int i = 0; i < NUM; i++)
{
if(_fdevent[i].fd == gdefaultfd) continue;
if(_fdevent[i].revents & POLLIN)
{
if(_fdevent[i].fd == _listenfd->Socketfd())
{
Acceptor();
}
else
{
IOHandler(i);
}
}
}
}
连接接收器
添加新连接时,除了设置 fd,还要设置 events,并清空 revents:
cpp
void Acceptor()
{
InetAddr clientaddr;
int fd = _listenfd->Accepter(&clientaddr);
LOG(LogLevel::INFO) << "get a new link...";
if(fd >= 0)
{
int pos = 0;
for(;pos < NUM; pos++)
{
if(_fdevent[pos].fd == gdefaultfd)
break;
}
if(pos >= NUM)
{
LOG(LogLevel::WARNING) << "poll is full!";
close(fd);
}
else
{
_fdevent[pos].fd = fd;
_fdevent[pos].events |= POLLIN;
_fdevent[pos].revents = 0;
}
}
}
IO 处理器
读写逻辑完全不变,只是关闭连接时要把 events 和 revents 也清空:
cpp
void IOHandler(int i)
{
char inbuffer[1024];
ssize_t n = recv(_fdevent[i].fd, inbuffer, sizeof(inbuffer) - 1, 0);
if(n > 0)
{
inbuffer[n] = 0;
std::cout << "client say@ " << inbuffer << std::endl;
std::string send_string = "echo# ";
send_string += inbuffer;
send(_fdevent[i].fd, send_string.c_str(), send_string.size(), 0);
}
else if(n == 0)
{
LOG(LogLevel::INFO) << "sockfd is close";
close(_fdevent[i].fd);
// 完整清理该位置
_fdevent[i].fd = gdefaultfd;
_fdevent[i].events = _fdevent[i].revents = 0;
}
else
{
LOG(LogLevel::ERROR) << "read error";
close(_fdevent[i].fd);
_fdevent[i].fd = gdefaultfd;
_fdevent[i].events = _fdevent[i].revents = 0;
}
}
5.3 完整代码演示(pollServer.hpp)
cpp
#include "Logger.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include <bits/types/struct_timeval.h>
#include <cstdint>
#include <memory>
#include <sys/poll.h>
#include <sys/socket.h>
#include <poll.h>
#include <unistd.h>
// [核心注释] 这里借用了 select 的 fd_set 大小来定义 poll 数组的容量(通常为 1024)
// 虽然 poll 本身没有最大连接数的硬性限制,但这里为了演示设定了一个固定上限
#define NUM (sizeof(fd_set) * 8)
using namespace LogModule;
// [核心注释] 定义一个无效的文件描述符标志,用于初始化和判断 pollfd 数组中的空闲槽位
const int gdefaultfd = -1;
class pollServer
{
public:
pollServer(uint16_t port)
:_port(port)
,_listenfd(std::make_unique<TcpSocket>())
{
_listenfd->BulidSocketMethod(_port);
// 初始化
// [核心注释] 将 pollfd 数组全部置为无效状态。
// poll_wait 底层遍历时,如果看到 fd 为负数,会自动忽略该节点,不会报错。
for(int i = 0; i < NUM; i++)
{
_fdevent[i].fd = gdefaultfd;
_fdevent[i].events = _fdevent[i].revents = 0;
}
// 首先把_litenfd 放入数组里面
// [核心注释] 服务器启动的基石:必须先让 poll 帮忙盯着监听套接字 (listen socket)
_fdevent[0].fd = _listenfd->Socketfd();
_fdevent[0].events |= POLLIN; // there is a data to read
// [核心注释] events 是"输入型"变量:告诉内核,我只关心这个 fd 的"读事件(POLLIN)"
}
// 事件派发器
void Dispatcher()
{
while(true)
{
PrintFds();
int timeout = 2000;
// 不需要参数重置了
// [核心注释] poll 相比 select 的一大优势:接口分离。
// 传入的 events 和内核修改的 revents 分开了,所以每次循环不需要像 select 那样重新设定关心的事件。
int n = poll(_fdevent, NUM, timeout);
switch (n)
{
case 0:
LOG(LogLevel::DEBUG) << "time out...";
break;
case -1:
LOG(LogLevel::DEBUG) << "poll error...";
break;
default:
LOG(LogLevel::DEBUG) << "事件就绪...: n: " << n;
// [核心注释] n > 0,说明有 n 个 fd 发生了你关心的事件,进入事件分发处理逻辑
EventHandler();
break;
}
}
}
~pollServer()
{
// 不需要 arr_fds[i] = gdefaultfd
// 因为对象马上就销毁了,数组也没了
// [核心注释] 析构时统一清理资源,防止文件描述符泄漏
for(int i = 0; i < NUM; i++)
{
if(_fdevent[i].fd != gdefaultfd)
close(_fdevent[i].fd);
}
}
private:
void EventHandler()
{
// [核心注释] poll 的劣势体现:依然是 O(N) 的遍历。
// 即便只有 1 个 fd 就绪,也必须从头到尾遍历整个数组来寻找是谁就绪了。
for(int i = 0; i < NUM; i++)
{
if(_fdevent[i].fd == gdefaultfd) continue;
// 走到这里可以判断是合法的, 但是我们还需要判断是否就绪
// [核心注释] revents 是"输出型"变量:内核填写的实际发生事件。
// 按位与(&)判断内核是否反馈了读事件就绪。
if(_fdevent[i].revents & POLLIN)
{
// 走到这里就肯定是就绪了
// 再判断是普通的还是listen
if(_fdevent[i].fd == _listenfd->Socketfd())
{
// listensockfd
// [核心注释] 身份确认:如果是 listen_fd 就绪,说明有新的客户端发起了连接请求
Acceptor();
}
else
{
// normal sockfd
// [核心注释] 身份确认:如果是普通 fd 就绪,说明已连接的客户端发来了数据报文
IOHandler(i);
}
}
}
}
void Acceptor()
{
InetAddr clientaddr;
int fd = _listenfd->Accepter(&clientaddr);
LOG(LogLevel::INFO) << "get a new link...";
// 你得到了一个新的连接,这个连接怎么处理??
// recv(fd)?? 等 + 拷贝, 不能!!
// fd -> 托管给select-> 只有select具有"等"的能力!-> 如何托管?? -> 只要把fd添加的辅助数组即可!
// [核心注释] 上面的原注释极其重要:获取新连接后绝对不能直接阻塞读!
// 必须把这个新的 fd 扔给 poll,让 poll 代替进程去"等"它来数据。
if(fd >= 0)
{
// 找一个空闲的位置
// [核心注释] 线性遍历寻找 pollfd 数组中可用(即 fd 为 gdefaultfd)的槽位
int pos = 0;
for(;pos < NUM; pos++)
{
if(_fdevent[pos].fd == gdefaultfd)
break;
}
// 为了健壮性 >=
if(pos >= NUM)
{
// Poll这里, 扩容
// [核心注释] 数组满了。如果是真实的生产环境,这里可以通过动态分配(比如 std::vector)来进行扩容,
// 这是 poll 优于 select(上限写死1024) 的另一大特点。这里暂时做拒绝连接处理。
LOG(LogLevel::WARNING) << "poll is full!";
close(fd);
}
else
{
// 把这个新获取到的加进辅助数组即可
// 下一轮循环的时候, 会关心上的
// [核心注释] 注册新任务:把新连接的 fd 填入空白槽位,并告诉内核:"下一次轮询,请帮我也盯紧这个新 fd 的读事件"
_fdevent[pos].fd = fd;
_fdevent[pos].events |= POLLIN;
_fdevent[pos].revents = 0; // 保险起见,清空历史状态
}
}
else
{
LOG(LogLevel::ERROR) << "Accept error!";
}
}
void IOHandler(int i)
{
char inbuffer[1024];
// 在之前的逻辑中已经select等待过了, 走到这里直接读就行
// [核心注释] 因为是 poll 通知我们就绪的,说明底层接收缓冲区一定有数据。
// 此时调用 recv 绝对不会被阻塞挂起,保证了单线程事件循环的高效运转。
ssize_t n = recv(_fdevent[i].fd, inbuffer, sizeof(inbuffer) - 1, 0);
if(n > 0)
{
// [核心注释] 正常读到了数据,业务逻辑处理(这里是简单的 Echo 打印和回写)
inbuffer[n] = 0;
std::cout << "client say@ " << inbuffer << std::endl;
// 需要写回去, 这里我们需要等待嘛
// 其实是先不需要的,写缓冲区默认有数据
std::string send_string = "echo# ";
send_string += inbuffer;
send(_fdevent[i].fd, send_string.c_str(), send_string.size(), 0);
}
else if(n == 0)
{
// 对端关闭了连接
LOG(LogLevel::INFO) << "sockfd is close";
// [核心注释] 客户端主动断开。清理双重奏:1. 关闭文件描述符释放资源;2. 将当前槽位置回无效态,从 poll 监控列表中"注销"。
close(_fdevent[i].fd); // 关闭文件描述符
_fdevent[i].fd = gdefaultfd;
_fdevent[i].events = _fdevent[i].revents = 0;
}
else
{
// [核心注释] 读异常处理。清理逻辑同上。
LOG(LogLevel::ERROR) << "read error";
close(_fdevent[i].fd); // 关闭文件描述符
_fdevent[i].fd = gdefaultfd;
_fdevent[i].events = _fdevent[i].revents = 0;
}
}
void PrintFds()
{
// [核心注释] 调试辅助工具:打印当前 poll 正在监控的所有有效 fd
std::cout << "Poll Server fds list: ";
for(int i = 0; i < NUM; i++)
{
if(_fdevent[i].fd == gdefaultfd) continue;
std::cout << _fdevent[i].fd << " ";
}
std::cout << std::endl;
}
private:
uint16_t _port;
std::unique_ptr<Socket> _listenfd;
struct pollfd _fdevent[NUM];
};
#endif
5.4 运行效果
编译运行后,用多个 telnet 客户端连接测试,效果和 select 版完全一致:支持多客户端同时在线、echo 回显正常、客户端退出后服务器能正确清理资源。


- 下面这种就不测试了,只需要修改timeout就行


六. select vs poll 全面对比
| 对比维度 | select | poll |
|---|---|---|
| 数据结构 | 位图 fd_set |
pollfd 结构体数组 |
| fd 数量上限 | 默认 1024,修改需重编译内核 | 无硬限制,可动态扩容 |
| 参数特性 | 输入输出耦合,每次调用必须重置集合 | events 与 revents 分离,无需重置关心事件 |
| 事件类型 | 仅读、写、异常三类 | 支持更多精细事件,能力更丰富 |
| 内核实现 | 线性遍历所有 fd | 线性遍历所有 fd |
| 拷贝开销 | 用户态 ↔ 内核态拷贝 fd_set |
用户态 ↔ 内核态拷贝 pollfd 数组 |
| 跨平台性 | 全 POSIX 平台支持,通用性最好 | 多数 Unix-like 支持,Windows 兼容性弱 |
| 使用复杂度 | 高,需维护 maxfd、每次重置 | 中,接口更简洁 |
结尾:
html
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!
结语:select 和 poll 都是经典的同步 IO 多路复用模型,核心思想都是「统一等待、就绪再处理」。poll 在接口设计和连接数限制上比 select 进步了一大截,但本质上都没有解决「内核遍历所有 fd」的性能瓶颈。当连接数很高但活跃连接很少的时候,它们的效率都会急剧下降。这也是为什么后来 Linux 推出了 epoll ------ 它用红黑树 + 就绪队列的设计,彻底解决了遍历问题,让高并发场景下的性能实现了质的飞跃。下一篇我们就会正式进入 epoll 的世界,拆解它的底层原理,看它是怎么成为高并发服务器标配的。
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど
