ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux poll多路复用实战:从select到epoll的桥梁

Linux poll多路复用实战:从select到epoll的桥梁 两年前我接手一个运行在老旧嵌入式板子上的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正好是2048select也管不到它。每次调用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中请求POLLIN0x001有数据可读或者对端关闭连接读返回0可以POLLPRI0x002有紧急数据可读比如TCP带外数据可以POLLOUT0x004可以写数据发送缓冲区不阻塞可以POLLERR0x008发生错误比如socket收到RST不能内核写reventsPOLLHUP0x010对端挂起连接已断开不能内核写reventsPOLLNVAL0x020fd未打开或者fd已经关闭不能内核写reventsPOLLRDHUP0x2000对端关闭了写方向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和eventsnfds调整为最大有效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, rev0x%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支持多连接并发处理的效果。如果想做简单的多连接压测可以循环后台起ncfor 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套接字。结果某个客户端发完最后一批数据后立刻closepoll同时返回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把新连接信息写入这个唤醒fdworker线程的poll自然就会被POLLIN唤醒然后从队列里取出新连接。这个模式在poll和epoll里都通用理解了它你就把多路复用和线程模型真正打通了。最后说点实际的。我在那台嵌入式板子上用poll跑了两年稳定运行期间踩过的最深一个坑不是poll本身而是有一个客户端连接建立后一直不说话也不关闭poll一直返回POLLIN然后recv返回0我当时没处理半关闭把连接关错了。后来才明白TCP半关闭在poll里表现就是可读事件读到0就该把连接从数组里清掉。类似这样的细节只有自己写一遍、跑一遍、抓一次包才能真的记住。如果你是要学Linux网络编程建议拿到这篇代码后先多开几个客户端连上去发数据再故意用kill把客户端杀掉观察服务端的反应。操作几次之后你重写poll逻辑时会顺畅很多。
返回列表