从 select 到 poll —— 打破位图天花板
前置阅读:手写 select 版 TCP echo 服务器(了解 select 的基本思路和痛点后再读这篇,体验最佳)
select 的两个硬伤
如果你刚用 select 写完一个并发服务器,你一定对这两件事印象深刻:
痛 1:永远要维护两套数据
fd_set rfds; // 位图:给内核看的
int fd_array[gsize]; // 数组:自己管理连接用的
// 每轮循环两次遍历:
// 遍历 fd_array → FD_SET 到位图
// 遍历 fd_array → FD_ISSET 检查谁就绪
两个数据结构表示同一组 fd,数据冗余就是 bug 温床——“FD_SET 传了下标”、"newfd 当了数组下标"这类坑,根因都在这里。
痛 2:fd 上限 1024
fd_set 是 1024 个 bit 的位图。超过就装不下。虽然可以改内核重编译,但治标不治本。
poll 来了:一个结构体数组搞定一切
思路
poll 用一个 pollfd 结构体数组,一举取代 select 的位图 + 辅助数组:
select 方案: fd_array[] → FD_SET → fd_set → select → FD_ISSET → fd_array[]
└── 辅助数组 ──┘ └── 位图 ──┘ └── 还得两套 ──┘
poll 方案: pollfd 数组 ← 一套数据,既是"监视名单",又是"连接管理器"
├── fd:要监视的文件描述符
├── events:我想关注什么事件(我来设)
└── revents:实际发生了什么事件(内核来填)
类比:
| 大堂经理用 1024 页登记簿,每次换新的要重抄 | 经理用可以随时增减的名单卡,想多长多长 |
| 客人走了要在两本账上划掉 | 客人走了直接把卡抽出来就行 |
核心接口
头文件 <poll.h>,一个结构体 + 一个函数:
struct pollfd {
int fd; // -1 表示此槽位为空,poll 自动跳过
short events; // 输入:我想监视什么(POLLIN / POLLOUT)
short revents; // 输出:内核告诉我实际发生了什么
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// nfds:数组元素个数(不是 maxfd+1!)
// timeout:-1=死等,0=立刻返回,>0=等 N 毫秒
关键规则:poll 只修改 revents,不碰 fd 和 events。所以你不需要每轮重建数组,只要清零 revents 就行。这和 select 的"值传入值传出导致必须 FD_ZERO 重建"完全不同。
事件类型
| POLLIN | POLLIN | 可读 |
| POLLOUT | POLLOUT | 可写 |
| 不用设 | POLLERR | 出错(内核自动监测) |
| 不用设 | POLLHUP | 挂断(内核自动监测) |
| 不用设 | POLLNVAL | fd 无效(内核自动监测) |
POLLERR/POLLHUP/POLLNVAL 不需要在 events 中设置,内核自动在 revents 中填入。
代码:与 select 不同的三个地方
底层 Socket、InetAddr、Logger 封装完全一样,只看 PollServer 独有的部分。
1. 数据结构:一个数组替代两套
// select 版
static const int gsize = sizeof(fd_set) * 8; // 1024,绑死
int fd_array[gsize];
// poll 版
static const int NFDS = 2048; // 你想多大就多大
struct pollfd _fds[NFDS];
2. 构造函数:初始化一步到位
PollServer(uint16_t port)
: _listenfd(std::make_unique<TcpSocket>())
{
_listenfd->BuildListenSocketMethod(port);
for (int i = 0; i < NFDS; i++)
_fds[i].fd = –1; // -1 表示空位
_fds[0].fd = _listenfd->Sockfd();
_fds[0].events = POLLIN; // 直接设 events,一步到位
}
3. Run() 主循环:不需要重建监视列表
void Run()
{
while (true)
{
for (int i = 0; i < NFDS; i++)
_fds[i].revents = 0; // 只需清零 revents
int n = poll(_fds, NFDS, –1); // NFDS=数组大小,-1=死等
switch (n)
{
case –1: /* 出错 */ break;
case 0: /* 超时 */ break;
default: EventDispatcher(); break;
}
}
}
对比 select 版:
- 不需要 FD_ZERO + FD_SET 重建
- 传给 poll 的是数组大小,不是 maxfd+1
- timeout 单位是毫秒,不是 select 的秒+微秒
4. EventDispatcher:用位运算检查 revents
void EventDispatcher()
{
for (int i = 0; i < NFDS; i++)
{
if (_fds[i].fd == –1) continue;
// 用位与(&),不是相等(==)——revents 可能同时包含多个标志
if (_fds[i].revents & POLLIN)
{
if (_fds[i].fd == _listenfd->Sockfd())
Accepter();
else
Recver(i);
}
// 用位或(|),不是逻辑或(||)
if (_fds[i].revents & (POLLERR | POLLHUP | POLLNVAL))
{
close(_fds[i].fd);
_fds[i].fd = –1;
}
}
}
两个细节:
- revents & POLLIN 用位与(&),因为 revents 可能同时是 POLLIN | POLLHUP
- POLLERR | POLLHUP | POLLNVAL 用位或(|),|| 会把整个表达式变成 0 或 1
Accepter、Recver 和 select 版完全一致,不再重复。
poll 解决了什么,没解决什么
| fd 上限 1024 | ✗ | ✓ 无上限 |
| 两套数据维护 | ✗ | ✓ 一个数组 |
| 每轮重建监视列表 | ✗ | ✓ 只需清 revents |
| O(n) 遍历开销 | ✗ | ✗ |
| 用户↔内核全量拷贝 | ✗ | ✗ |
poll 解决了数据结构的问题,但性能问题没解决——1000 个连接只有 1 个就绪,poll 还是要遍历全部 1000 个检查 revents,还是要每次拷贝整个 pollfd 数组进内核。
这两个问题,要等到 epoll 才彻底解决——用回调机制实现 O(1) 就绪通知,只有就绪的 fd 被返回。
select 是登记簿,poll 是名单卡。epoll 是装了呼叫铃的总管——谁有事主动报告,不用挨个问。
补一个容易踩的坑
revents & POLLIN 别写成 revents == POLLIN。
因为对端关闭连接时,revents 可能同时出现 POLLIN 和 POLLHUP——用 == 会漏掉,连接永远收不到关闭通知。用 & 做位与检查才是正确的。




