两年前我接手一个运行在老旧嵌入式板子上的Linux网络管理模块,单进程、阻塞式accept、每来一个连接就fork一个子进程去处理。业务量一旦上来,进程满天飞,内存和CPU齐刷刷报警。当时第一个想到的是改用select,结果一查,默认FD_SETSIZE只有1024,手里连接一多就到天花板。后来老老实实把poll研究了一遍,用poll重写了整个事件循环,问题才算真正解决。
poll在Linux网络编程里属于那种“用过才会觉得简单”的系统调用。它和select干的是同一件事:让一个线程同时盯着多个文件描述符,谁就绪了就通知你。但它把select的fd_set位图换成了结构化数组,没有了硬编码的1024限制,也让你能更清楚地看到每个socket当前发生的是什么事件。
这篇东西不打算照本宣科讲man手册,而是从我的实际使用经验出发,把poll的数据结构、事件掩码、写一个能用的TCP服务器、以及踩过的坑完整讲一遍。适合刚学完socket编程、正在多路复用上犯迷糊的同学,也适合在考虑从select迁移或做嵌入式网络服务选型的同行参考。
1. 为什么单线程服务的IO瓶颈只能靠多路复用解决
1.1 阻塞式多进程/多线程的代价
先把问题说清楚。很多初学Linux socket编程的人写出的第一个并发服务器,思路都是这样的:主进程accept到一个连接,然后fork一个子进程或者pthread_create一个线程,让子进程/线程去阻塞read、处理业务、阻塞write。这个模式能跑,但代价不小。
系统创建进程的开销不只是内存和调度器负担,fork要复制页表、复制文件描述符表,线程也要分配独立的内核栈。连接数量一旦上千,光线程切换就能把CPU吃到满。更别提多线程共享同一套业务数据时,锁竞争和竞态条件这些坑。
我当年那台板子是单核处理器,内存只有256MB,起几百个进程后直接OOM。这就是阻塞模型的天花板——每个连接独占一个执行流,而执行流是稀缺资源。真正的网络服务器需要的是:用尽量少的线程管理尽量多的连接。多路复用就是干这个的。
1.2 select先解决了问题,也把限制留给了后人
select是教科书里最早出现的多路复用接口。它把用户关心的文件描述符按“可读、可写、异常”分成三个位图集合,每次调用时把位图传给内核,内核修改这些位图后返回,遍历三个集合找出就绪的fd处理。
问题在于几个地方:
- fd_set是位图,长度固定为FD_SETSIZE,在glibc里默认是1024。也就是说,你很难直接使用大于等于1024的文件描述符编号,就算连接数不到1024,如果某个fd正好是2048,select也管不到它。
- 每次调用select都要重新构造三个集合,返回后还要从3个集合里分别遍历,代码写起来很啰嗦。
- 如果想关注某个socket同时可读又可写,select会让同一个fd在可读集合和可写集合各出现一次,处理逻辑容易绕晕。
所以我在选择多路复用方案的时候,第一轮就淘汰了select。可能有人说可以把FD_SETSIZE重新define大一点,但那属于补丁式方案,而且还要处理位图遍历的效率问题,不值当。
1.3 poll的登场:从位图到数组
poll的模型和select完全不同。它用一个struct pollfd数组来表达关注的文件描述符,数组长度由你自己决定,没有硬编码的1024上限。系统能打开多少文件描述符,poll原则上就能管理多少个fd——当然这还要受进程的RLIMIT_NOFILE限制。
更重要的区别是,poll把“请求关注的事件”和“实际发生的事件”拆开了。你只需要在pollfd.events里填上想关注的事件,内核在返回时把实际发生的事件写到pollfd.revents里。同一个结构体包含输入和输出两个部分,思路比select的并发修改位图清晰太多。
后面我会详细展开pollfd的结构,但先记住一个结论:poll是解决“如何用一个线程稳定管理上百上千个连接”的基础工具,它的设计思想也是后续epoll、kqueue这些高级多路复用机制的起点。
2. poll的核心数据结构:pollfd与事件掩码逐项拆解
2.1 pollfd的三个字段与内存布局
先看核心定义。在<poll.h>里,pollfd结构体长这样:
struct pollfd { int fd; /* 文件描述符,如果为负值则该条目被忽略 */ short events; /* 请求关注的事件掩码,由调用者填写 */ short revents; /* 实际发生的事件掩码,由内核在poll返回时填写 */ };三个字段各有各的职责。fd是你要管理的文件描述符,注意:如果fd填的是负数,poll会直接忽略这个条目,revents会被置0,不会报错。这个特性在实际数组管理里很有用,后面写服务器时会用到。
events和revents都是short类型,按位掩码工作。events由你填写,表示“我关心这个fd上的哪些事件”;revents是输出,表示“内核告诉我这个fd上实际发生了什么”。调用poll前应该手动把revents清零吗?不需要,内核每次poll返回时都会重新计算并覆盖revents,但为了可读性,很多代码仍然会在构造pollfd时用memset或者单独置0。
内存布局上面有个容易忽略的点:pollfd在常见平台上大小是8字节,events和revents虽然声明为short占2字节,但结构体整体对齐后中间有填充。不过这个布局对应用层无感,不需要手动字节操作。
2.2 events与revents:请求和结果是两套东西
事件掩码表是理解poll的关键,我直接列出Linux上的常见取值:
| 事件宏 | Linux上的值 | 含义 | 能否在events中请求 |
|---|---|---|---|
| POLLIN | 0x001 | 有数据可读,或者对端关闭连接(读返回0) | 可以 |
| POLLPRI | 0x002 | 有紧急数据可读,比如TCP带外数据 | 可以 |
| POLLOUT | 0x004 | 可以写数据,发送缓冲区不阻塞 | 可以 |
| POLLERR | 0x008 | 发生错误,比如socket收到RST | 不能,内核写revents |
| POLLHUP | 0x010 | 对端挂起,连接已断开 | 不能,内核写revents |
| POLLNVAL | 0x020 | fd未打开,或者fd已经关闭 | 不能,内核写revents |
| POLLRDHUP | 0x2000 | 对端关闭了写方向(TCP半关闭),Linux特有条目 | 可以,需_GNU_SOURCE |
这里有几个新手很容易踩的认知坑。
第一,POLLIN和“对端关闭”之间的关系。TCP对端调用close或者shutdown后,本端的recv最终会返回0,在poll看来这就是一种“可读事件”,会同时触发POLLIN。很多人以为连接断开只体现在POLLHUP上,结果只监听POLLIN没做关闭处理,发现对方掉线后自己这边一直不知道。
第二,POLLERR、POLLHUP、POLLNVAL这三个事件不能通过events请求,它们是内核在返回时强加给你的。你在events里写POLLERR没有任何报错,但也没有意义。处理的时候实际看的是revents。
第三,POLLOUT和POLLIN的语义并不对称。POLLIN代表此刻有数据可读,POLLOUT只代表此刻发送缓冲区不阻塞,并不意味着“此刻发送的数据对方一定收到了”。TCP全双工管道里,发送缓冲可写和对方接收是两回事,这个语义差异在做应用层协议时要格外小心。
2.3 timeout的三种取值与精度陷阱
poll的第三个参数是超时时间,单位是毫秒。三种取值对应三种行为:
int poll(struct pollfd *fds, nfds_t nfds, int timeout); // timeout == -1:永久阻塞,直到至少一个fd就绪或被信号中断 // timeout == 0 :立即返回,相当于一次非阻塞的轮询检查 // timeout > 0 :最多等待这么多毫秒最简单的使用方式是这样的:
int ret = poll(pfds, nfds, 500); // 最多阻塞500毫秒 if (ret < 0) { if (errno == EINTR) { /* 被信号打断,继续业务即可 */ } perror("poll"); }精度陷阱在于poll的时间粒度是毫秒,不是select的微秒。想用poll做高精度定时器并不合适——比如你希望10微秒后超时,poll做不到。另外,timeout参数是整体等待时间,受系统调度影响,实际阻塞时长只会比设定的长,不会更短,这在做超时控制时要留出余量。
poll还有一个和signal交互的问题:如果进程被信号中断,poll会返回-1且errno为EINTR。这时候连接数据没有任何变化,直接重新调用poll就行。很多生产事故就是没处理EINTR,导致进程误以为poll出错退出。
3. 从零写一个基于poll的TCP回显服务器
3.1 设计思路与连接状态管理
动手写代码之前,先把设计理清楚。poll服务器说白了就是三件事:
- 维护一个pollfd数组,里面装着所有要监听的fd。
- 调用poll阻塞等待,返回后遍历数组,看每个fd的revents。
- 根据revents执行read、write、accept、close等操作。
我倾向于把pollfd数组和一个连接信息数组一一对应。pollfd负责告诉内核“我在关注谁”,由内核返回事件;连接信息数组负责保存每个连接自己的状态,比如发送缓冲区、当前是否在等POLLOUT等。
套接字要不要设置非阻塞?如果要写真正能用的服务器,答案是要。poll虽然是事件驱动,但事件就绪不代表你的一次read/write一定能完成全部工作。比如recv返回的数据没读完,下次POLLIN还会再触发;但如果recv在阻塞模式下遇到恰好没有数据可读,线程就卡住了。非阻塞加EAGAIN判断是最稳的组合,后面还会详细讲。
设计如下:监听fd放在pollfd数组的第0位,接进来的客户端连接依次放在1到nfds-1位。每个客户端连接挂一个发送缓冲区,数据先写进缓冲区,能立即发送就立即发送,发不完就把POLLOUT注册上,等可写事件再发。
3.2 监听套接字的accept与数组增量
监听socket的fd放进pfds[0],events只关注POLLIN。poll返回后,先检查pfds[0].revents,如果是POLLIN,说明有新的连接排队等待accept。
一个比较容易漏的细节:POLLIN只代表“至少有一个连接待接受”,不代表只有一个。如果同时来了几十个连接,poll只会唤醒一次,但accept队列里可能还有一堆。所以要用while循环连续accept,直到accept返回EAGAIN,表示队列已经清空。
新建连接后,要找一个空位放进pollfd数组。因为pollfd里fd为负数会被忽略,所以把连接信息数组里fd为-1的slot当作空闲位。找到后填上pfds[slot].fd和events,nfds调整为最大有效slot加1。
3.3 读写处理与连接关闭状态机
客户端事件的轮询处理是核心状态机,按顺序分四步:
- 先处理POLLIN。有数据就读,读到的数据追加到发送缓冲区,如果recv返回0,说明对端关闭,直接关闭连接并清空数组条目。
- 再处理POLLOUT。如果发送缓冲区有残留且当前可写,就把数据尽量发出去,发完了就把POLLOUT从events里去掉,避免反复被唤醒空转。
- 最后处理POLLERR、POLLHUP、POLLNVAL。这些表示连接异常或断开,正常情况直接关闭。
为什么要把POLLIN放在POLLERR之前?这就是我后面要讲的坑:TCP对端close后,poll可能同时返回POLLIN和POLLHUP,如果先处理POLLHUP直接关闭socket,接收缓冲区里剩余的数据就丢了。正确的做法是,只要有POLLIN,就把数据读干净,读到0再关闭;读完后如果还有POLLHUP等错误事件,此时关闭才是安全的。
3.4 完整代码(可编译运行)
下面是一个带发送缓冲的完整poll回显服务器,支持多客户端并发收发,代码可以直接编译运行测试。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <unistd.h> #include <fcntl.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <poll.h> #define MAX_CONN 256 #define BUFSIZE 4096 typedef struct { int fd; char wbuf[BUFSIZE]; int wlen; int want_write; } conn_t; static conn_t conns[MAX_CONN]; static struct pollfd pfds[MAX_CONN]; static int nfds = 1; static void close_conn(int idx) { if (idx <= 0 || conns[idx].fd < 0) return; close(conns[idx].fd); conns[idx].fd = -1; conns[idx].wlen = 0; conns[idx].want_write = 0; pfds[idx].fd = -1; pfds[idx].events = 0; while (nfds > 1 && conns[nfds - 1].fd == -1) nfds--; } static int set_nonblock(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static int add_conn(int cfd, struct sockaddr_in *cli) { int slot = -1; for (int i = 1; i < MAX_CONN; i++) { if (conns[i].fd == -1) { slot = i; break; } } if (slot == -1) { printf("connection full, reject fd=%d\n", cfd); close(cfd); return -1; } set_nonblock(cfd); conns[slot].fd = cfd; conns[slot].wlen = 0; conns[slot].want_write = 0; pfds[slot].fd = cfd; pfds[slot].events = POLLIN; if (slot >= nfds) nfds = slot + 1; printf("accept %s:%d fd=%d slot=%d\n", inet_ntoa(cli->sin_addr), ntohs(cli->sin_port), cfd, slot); return slot; } int main(void) { int lfd = socket(AF_INET, SOCK_STREAM, 0); if (lfd < 0) { perror("socket"); return 1; } int opt = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8899); if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(lfd, 64) < 0) { perror("listen"); return 1; } set_nonblock(lfd); memset(conns, 0, sizeof(conns)); for (int i = 0; i < MAX_CONN; i++) conns[i].fd = -1; conns[0].fd = lfd; pfds[0].fd = lfd; pfds[0].events = POLLIN; printf("poll echo server listening on 8899\n"); for (;;) { int ret = poll(pfds, nfds, -1); if (ret < 0) { if (errno == EINTR) continue; perror("poll"); break; } if (ret == 0) continue; if (pfds[0].revents & POLLIN) { for (;;) { struct sockaddr_in cli; socklen_t clen = sizeof(cli); int cfd = accept(lfd, (struct sockaddr *)&cli, &clen); if (cfd < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK) perror("accept"); break; } if (add_conn(cfd, &cli) < 0) break; } } for (int i = 1; i < nfds; i++) { if (conns[i].fd < 0) continue; short rev = pfds[i].revents; if (rev == 0) continue; if (rev & POLLIN) { char buf[BUFSIZE]; for (;;) { ssize_t n = recv(conns[i].fd, buf, sizeof(buf), 0); if (n > 0) { if (conns[i].wlen + n > BUFSIZE) { close_conn(i); break; } memcpy(conns[i].wbuf + conns[i].wlen, buf, n); conns[i].wlen += n; } else if (n == 0) { close_conn(i); break; } else { if (errno != EAGAIN && errno != EWOULDBLOCK) close_conn(i); break; } } if (conns[i].fd >= 0 && conns[i].wlen > 0) { ssize_t n = send(conns[i].fd, conns[i].wbuf, conns[i].wlen, 0); if (n > 0) { memmove(conns[i].wbuf, conns[i].wbuf + n, conns[i].wlen - n); conns[i].wlen -= n; } } if (conns[i].fd >= 0 && conns[i].wlen > 0 && !conns[i].want_write) { conns[i].want_write = 1; pfds[i].events |= POLLOUT; } } if (conns[i].fd < 0) continue; if (rev & POLLOUT) { while (conns[i].wlen > 0) { ssize_t n = send(conns[i].fd, conns[i].wbuf, conns[i].wlen, 0); if (n > 0) { memmove(conns[i].wbuf, conns[i].wbuf + n, conns[i].wlen - n); conns[i].wlen -= n; } else if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK) close_conn(i); break; } else { close_conn(i); break; } } if (conns[i].fd >= 0 && conns[i].wlen <= 0) { conns[i].want_write = 0; pfds[i].events &= ~POLLOUT; } } if (conns[i].fd < 0) continue; if (rev & (POLLERR | POLLHUP | POLLNVAL)) { printf("fd=%d closed, rev=0x%x\n", conns[i].fd, rev); close_conn(i); } } } close(lfd); return 0; }编译和测试命令很简单:
gcc -O2 -Wall -o pollserver pollserver.c ./pollserver另开一个终端,用nc模拟客户端:
nc 127.0.0.1 8899输入一行字符串,服务器会立即原样回显。再多开几个nc窗口,就能看到poll支持多连接并发处理的效果。如果想做简单的多连接压测,可以循环后台起nc:
for i in $(seq 1 50); do echo "hello $i" | nc -q 1 127.0.0.1 8899 & done这段代码我实际跑过,连接管理、发送缓冲、关闭清理都走的是最朴素的状态机,作为poll入门模板非常合适。
4. poll实战中的坑:事件顺序、非阻塞与性能边界
4.1 先读POLLIN再处理POLLHUP:顺序决定会不会丢数据
这个问题我在生产环境里真实遇到过。当时我的代码把POLLHUP判断放在POLLIN前面,一旦revents包含POLLHUP就close套接字。结果某个客户端发完最后一批数据后立刻close,poll同时返回POLLIN|POLLHUP,我的程序直接关闭连接,那最后一批数据就丢了。
为什么poll会同时返回这两个事件?因为TCP关闭是一个过程,对端close后,内核把已经到达的数据放在接收缓冲区,然后才通知本端连接关闭。在poll看来,缓冲区还有数据可读,于是填POLLIN;同时连接关闭这一事实也已经发生,于是填POLLHUP。这两个事件不是互斥的。
正确的处理顺序必须这样:有POLLIN就先循环读,读到0说明对端关闭,主动关闭本地socket;读到的其他数据正常处理。数据读干净之后,再检查POLLERR、POLLHUP、POLLNVAL,这时候关闭才不会有数据残留问题。
这是我经验里poll编程最值得记住的原则:不要用POLLHUP判断“要不要关闭”,要用POLLIN触发recv,以recv返回0作为关闭依据。
4.2 非阻塞套接字为什么是必要的
poll返回POLLIN之后,如果你用阻塞模式去recv,绝大多数情况下不会卡住,因为内核已经告诉你“有数据可读了”。但有两个例外值得注意。
第一个例外是多个线程同时处理同一个fd。比如你之后把poll和线程池合起来用,事件到达后主线程把fd交给工作线程,工作线程read时数据可能已经被其他线程读走,阻塞read就卡住了。
第二个例外发生在accept上。poll只告诉你“连接队列里有至少一个待接受连接”,如果同时有大量连接排队,你while循环一直accept,直到队列空了,阻塞模式在最后一次accept时会阻塞等待新连接。等于一次poll唤醒来,反而卡死在accept里,这是最典型的阻塞模式坑。
所以套接字要设置O_NONBLOCK,配合EAGAIN/EWOULDBLOCK判断。非阻塞模式下,recv或accept返回-1且errno是EAGAIN,表示“当前没有数据/没有新连接”,这是正常流程而不是错误。我的代码里几个循环都用这个方式跳出,测试的时候观察一下运行日志,你会发现这套组合非常顺滑。
4.3 慢速客户端与POLLOUT发送缓冲
很多poll示例代码都不处理发送阻塞,直接recv到数据后马上send。这样的代码在数据量小、客户端及时读取时没问题,一旦遇到慢速客户端,send就会出问题。
慢速客户端的经典场景:客户端连着服务器但不及时调用recv,或者网络链路很差,TCP发送缓冲区被填满。这时候你调用send,内核告诉你“缓冲区满”,在阻塞模式下线程会卡住;在非阻塞模式下返回EAGAIN。如果不处理这个EAGAIN,那段数据可能就被你丢掉了,或者你为了等可用而忙轮询,把CPU烧光。
解决方式就是我代码里的发送缓冲加POLLOUT轮询:recv到的数据先写进自己的应用层发送缓冲区,尝试立即发送一次;发不完的数据留在缓冲区,同时在events上注册POLLOUT。等内核说“发送缓冲区可写”了,poll会返回POLLOUT,再继续把遗留数据发出去。全部发完,立刻把POLLOUT从events里去掉。
这个机制理解起来很简单,和“快递小哥送包裹送不完先寄存,第二天再送”一个道理。但写代码时容易忘记三个细节:
- 注册POLLOUT后,如果数据很快发完了,必须清除POLLOUT,否则内核会不停唤醒你,CPU空转。
- 应用层发送缓冲区要有上限。如果对端一直不读,你一直攒数据,内存会爆。我的示例里缓冲区满时直接关闭连接,一般业务里应该结合协议层做流量控制。
- 发送缓冲移动数据时用memmove而不是memcpy,因为源区间和目标区间可能重叠。
4.4 poll的O(n)扫描与数组拷贝代价
poll不是银弹,它的性能瓶颈有两个。
第一个是线性扫描。poll返回后,你必须遍历整个pollfd数组,把所有fd的revents都检查一遍才能找到就绪的连接。连接数少感觉不到,连接数到几千后,每次唤醒都要白白扫一遍,CPU有效利用率很低。
第二个是内核和用户态之间的数组拷贝。每次调用poll,内核都要把整个pollfd数组从用户态拷贝到内核态,然后逐个检查这些fd的状态。select也有类似问题,但select的位图在fd数量少时拷贝代价小;poll的数组长度由连接数决定,长度越大拷贝越贵。
实际量级大概是这样:在几千个空闲连接里活跃只有几十个的场景,poll每次唤醒都要拷贝几千个pollfd结构体、扫描几千个fd,而真正就绪的只有几十个。这就像每周只收几封信,却要把整个小区的信箱挨个翻一遍。
这也是epoll出现的根本原因。不过要记住,poll的这些代价在连接数几百以内完全不是问题,很多嵌入式场景根本摸不到它的性能天花板。了解这个边界就好,不要一边用poll一边骂它,它本来就是为几十几百连接这个量级设计的。
5. poll到epoll:什么时候继续用poll,什么时候换跑道
5.1 epoll的三个改进点
epoll是Linux 2.6之后的高性能多路复用方案,它针对poll的两个痛点做了改进。
第一个改进是内核事件表。epoll通过epoll_ctl把fd注册到内核里,内核维护一棵红黑树来管理这些fd。后续epoll_wait时再也不需要把整个fd数组从用户态拷到内核态,而是直接在内核事件表上工作。你把一个fd注册一次,即使它没有活动,也不会在每次调用时产生拷贝开销。
第二个改进是就绪链表。epoll_wait返回的不是全部注册的fd,而是一组“发生了事件”的fd,直接放在一个事件数组里返回给用户。这意味着即使你有几万个连接,只有10个活跃,每次唤醒你只需要处理那10个,不需要线性遍历全部。
第三个改进是触发模式。poll只有水平触发(LT):只要数据没读完,就一直通知你。epoll除了LT,还有边沿触发(ET):只有在状态发生变化时才通知一次。ET模式配合非阻塞socket,可以做到一次唤醒处理尽可能多的数据,但编程难度也随之上升,很容易踩丢事件的坑。
5.2 选型建议:结合场景做决定
我的建议从来不是“epoll比poll强所以就无脑epoll”,而是看场景选方案。
如果你要开发的是一个Linux专用、动辄几万连接的高并发网关,那从一开始就应该用epoll,别折腾poll。
如果你在写嵌入式Linux程序,内核版本比较老,甚至只有busybox那套环境,poll是更稳妥的选择。它符合POSIX标准,在大多数Unix系统上都有,比epoll有更好的可移植性。当年我在那台老旧板子上就用poll,因为内核编译里根本没启用epoll,我总不能为这事去重新编译内核。
如果你的连接数在几百以内,数据收发频率也不高,poll完全够用。它代码简单、逻辑清晰、调试方便,这种规模下它和epoll的性能差距基本测不出来。
如果是在写跨平台的网络库,比如libevent、glib这种,底层往往会在epoll、kqueue、poll之间做抽象,poll是最基础的兜底后端,哪怕其他高级机制不可用,poll也一定能跑。
5.3 扩展思路:poll加多线程怎么组合
很多人把poll学完后会问:单线程poll还是不够用,业务处理太重怎么办?
一种做法是“poll加工作线程池”。主线程里只做poll事件接收和连接管理,把每个fd上收到的数据打包成任务,投递给工作线程去处理业务逻辑,处理完的结果再由主线程统一发送。这种方式要注意:同一时间只有一个线程在一个socket上读写,不能在poll线程和工作线程同时操作同一个fd,否则数据会互相覆盖、连接状态会错乱。
另一种做法是“主poll线程只accept,然后把新连接分发给多个worker线程,每个worker线程有自己的poll事件循环”。这是很多老牌网络库的经典模型。每个线程维护一个独立的pollfd数组,连接按某种哈希规则分给不同线程,线程之间互不干扰,也没有锁竞争,扩展性要好很多。
这里衍生出一个关键技巧:跨线程唤醒。如果worker线程阻塞在poll(-1)上,主线程accept到新连接后怎么让worker立刻醒来?方法是用socketpair或者eventfd,把新连接信息写入这个唤醒fd,worker线程的poll自然就会被POLLIN唤醒,然后从队列里取出新连接。这个模式在poll和epoll里都通用,理解了它,你就把多路复用和线程模型真正打通了。
最后说点实际的。我在那台嵌入式板子上用poll跑了两年,稳定运行,期间踩过的最深一个坑不是poll本身,而是有一个客户端连接建立后一直不说话也不关闭,poll一直返回POLLIN然后recv返回0,我当时没处理半关闭,把连接关错了。后来才明白TCP半关闭在poll里表现就是可读事件,读到0就该把连接从数组里清掉。类似这样的细节,只有自己写一遍、跑一遍、抓一次包才能真的记住。如果你是要学Linux网络编程,建议拿到这篇代码后,先多开几个客户端连上去发数据,再故意用kill把客户端杀掉,观察服务端的反应。操作几次之后,你重写poll逻辑时会顺畅很多。