资讯动态

select实现单线程高并发:原理剖析与HTTP服务器实战

发布时间:2026/10/21 3:04:23 来源:尧图企业网站定制
你还记不记得第一次用 accept 写服务器时的样子一个 while 循环接一个客户端然后调用 recv 等数据。数据不来代码就停在哪后面的连接全部排队。后来有人告诉你可以开线程一个客户端一个线程于是一百个连接开一百个线程听起来很直接。但有没有可能一台服务器要扛几千个连接却很少真正有数据在流动绝大多数线程只是占着栈内存等一次根本不知道什么时候会来的 recv——这才是多进程/多线程方案在高并发场景里最尴尬的地方。这篇文章想带你换个思路用 select 在单线程里维护一大堆连接谁有数据进来我就处理谁没数据的人继续睡觉。整个服务器只有一个线程在跑却能同时服务成百上千的连接。我会从 select 的底层机制讲起再给一个可以直接编译跑起来的最小 HTTP 服务器实现把实际运行中踩过的坑也一并交代清楚最后说一说它和 poll、epoll 到底差在哪。文章适合已经会写 socket 阻塞服务、想往高并发方向迈一步的读者。1. 先想通一个问题单线程凭什么接管高并发1.1 阻塞服务器的一线程一连接模式最早写网络服务时大家几乎都是同一个模板用 listen 监听端口然后 accept 等待新连接进入 recv 读数据。我最早做的文件传输 Demo 就是这个路子int cfd accept(lfd, NULL, NULL); while (1) { n recv(cfd, buf, sizeof(buf), 0); if (n 0) break; // 处理请求 } close(cfd);这段代码在只有两三个客户端的时候没什么问题但稍微一上量就完蛋。问题不在代码写得好坏而在模型天然是串行的一次只能处理一个连接。如果这个连接的客户端迟迟不发数据recv 就一直卡住后面的 accept 永远轮不到。哪怕别的新连接已经握好手内核里排着队服务器也看不见。于是大家开始做多进程/多线程每个连接开一个进程或线程去执行同样的阻塞逻辑。这个方案确实解决了并发等待的问题代价却藏得很深。1.2 多线程方案的隐性账单进程太贵大家很快改用线程。线程让每个连接都有自己的执行流去阻塞读看起来解决了并发但成本是实打实的栈内存线程默认栈在 Linux 上通常是 8MB 虚拟内存。一万个连接意味着要准备一万个线程光栈就要预留出 80GB 的虚拟地址空间。实际物理内存没有立刻占满但地址空间和换页压力是明摆着的。上下文切换线程数量一旦超过 CPU 核数操作系统就要频繁切换线程上下文每次都涉及寄存器、栈指针、页表的保存与恢复。线程越多CPU 时间越浪费在切换上真正干业务的时间比例反而下降。锁与同步多个线程共享日志、统计、连接池时必然要加锁。锁竞争一上来代码的性能和复杂度都失控。编程复杂度一个线程出错能隔离但竞态、死锁、共享状态的维护会让代码越写越提心吊胆。最关键的一点是真实场景下大部分连接是空闲的。聊天服务器开着几千个连接每个连接大多数时间都在发呆。每连接一线程的方案依然要为每个空闲连接支付一整套线程开销。线程池也救不了这种局面因为池化只是复用线程并没有改变空闲连接也要占用执行单元的本质。1.3 把等待外包给内核I/O 多路复用的核心思路换个角度看问题与其让每个线程各自阻塞在 recv 上不如把所有连接的等待一次性交给内核这些 fd 你帮我盯着哪个有数据了告诉我。这就是 I/O 多路复用。select 是这种思路里最经典、也是最好懂的实现。它做的事非常朴素你给它一组读事件、一组写事件它阻塞等待直到其中至少一个 fd 就绪然后告诉你哪个就绪了。单线程程序只需要一个循环select 一返回把就绪的 fd 挨个处理掉再回到 select 继续等。单线程高并发能够成立靠的不是一个线程同时干很多事而是把等这件事集中起来把真正干活的时间压缩到事件发生的那一瞬间。这就是事件驱动模型最原始的形态。2. select 的底层工作方式一次调用监听几百个连接2.1 fd_set 位图与三个集合select 的函数签名是int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);四个参数的含义readfds要监听可读事件的 fd 集合。writefds要监听可写事件的 fd 集合。exceptfds要监听异常事件的 fd 集合一般很少用。timeout阻塞等待的时间。NULL 表示永久等timeval 里填 0 表示立刻返回轮询填具体值表示最多等多久。fd_set 本质上是一个位图每一位对应一个文件描述符编号。操作它的宏就四个FD_ZERO(fd_set*)把整个位图清零。FD_SET(int fd, fd_set*)把 fd 对应位置 1表示我关心它。FD_CLR(int fd, fd_set*)把 fd 对应位清 0。FD_ISSET(int fd, fd_set*)检查 fd 对应位是否为 1select 返回后用它判断谁就绪了。位图的优点是紧凑、处理快缺点是大小写死。FD_SETSIZE 在 Linux 上默认是 1024也就是说 select 最多能监听 1024 个 fd。想突破这个值得重新编译内核或者调配置极其麻烦。对玩具 Demo 够用对生产场景就是天花板。2.2 一次 select 调用的完整旅程一次 select 调用看起来只是一句函数背后实际是五步用户态把 fd_set 复制进内核空间。内核遍历 readfds、writefds、exceptfds 覆盖范围内的所有 fd挨个问协议栈你现在可读吗 / 可写吗 / 有异常吗。只要有一个 fd 就绪函数立刻返回否则内核让当前进程睡眠等待事件或超时。内核把更新过的 fd_set 复制回用户空间。注意select 返回后的 fd_set 只保留就绪位没就绪的会被清零。用户程序拿到新 fd_set用 FD_ISSET 遍历一遍找出哪些 fd 发生事件。这套逻辑最关键的开销在第 2 步默认情况下内核会对集合覆盖范围内的每个 fd 都做一次状态检查。假设有 800 个连接但只有两个有数据内核依然会把 800 个全问一遍。这就是 select 无法绕开的 O(n) 全量扫描。2.3 select 的三个硬伤上限、O(n)、水平触发第一个硬伤是 fd 数量上限。FD_SETSIZE 通常 1024不管机器内存多大一个 select 能监听的 fd 就这么多。压测时连接数超过 1023新连接就进不来了。第二个硬伤是 O(n) 的重复劳动。每次 select 调用fd_set 都要从用户态拷到内核态内核全量扫描一遍再拷回用户态用户态再遍历一遍。这个过程无法缓存因为 select 本身无状态——它不记得你上一次关注过谁每次都要完整重来。连接越多浪费越明显。第三个硬伤是水平触发的工作方式。select 只报告现在可读/可写这个瞬时状态不负责你只关心这一次。只要某个 fd 一直可读并且你一直把它放在 readfds 里select 就会每次都返回它。写事件也一样只要 socket 缓冲区一直可写写事件就会反复触发。这不是 bug用好了是特性用不好就是忙等CPU 会在空转中耗尽。补充一个容易被忽略的坑select 的 timeout 参数在 Linux 上返回后可能被修改成剩余时间。如果你在循环里反复用同一个 timeval 变量每次调用前都要重新赋值否则第二次调用可能变成永久阻塞。3. 单线程服务器怎么搭骨架连接表、状态机、非阻塞 IO3.1 核心数据结构fd 到连接节点的映射表单线程服务器要同时维护大量连接第一件事就是建立fd 到连接上下文的映射。我的做法是直接用数组以 fd 为下标typedef struct conn_node { int fd; int state; // 连接当前处于哪个阶段 char rbuf[8192]; // 读缓冲 int rlen; // 读缓冲已用长度 char wbuf[4096]; // 写缓冲 int wlen; // 写缓冲总长度 int wsent; // 已发送字节数 } conn_node; static conn_node *fd_nodes[FD_SETSIZE];为什么用数组而不是链表或哈希表因为 select 返回后我们要用 FD_ISSET 快速判断这个 fd 是否就绪然后立刻拿到对应的连接上下文。fd 本身数值小直接当下标是最快的内存寻址方式也省得处理哈希冲突。代价是 fd 会被内核复用。一个连接关闭后它的 fd 可能很快被新连接重新分配到。如果删除时忘了把 fd_nodes[fd] 置为 NULL新连接就会继承到上一个连接残留的缓存和状态出现莫名其妙的串包。3.2 监听 socket 为什么也要非阻塞listen_fd 是服务器接收新连接的大门它同样要放进 readfds由 select 在有连接等待 accept时通知我们。但 listen_fd 必须是非阻塞的原因在于select 只会通知有连接不会告诉你到底有几个。可能一瞬间来了一百个连接请求你需要在一次事件回调里全部 accept 掉否则这些连接就会在内核的接受队列里堆积。如果 listen_fd 是阻塞的accept 成功一次之后会继续等待下一个连接整个事件循环就卡在 accept 上后续所有读写事件全部得不到处理。非阻塞模式下的正确写法是循环 accept直到出现 EAGAIN 或 EWOULDBLOCK说明队列已经空了再回到 select 等待下一批。static void handle_accept(int listen_fd) { for (;;) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; break; } if (conn_fd FD_SETSIZE) { close(conn_fd); continue; } set_nonblocking(conn_fd); conn_node *node calloc(1, sizeof(conn_node)); node-fd conn_fd; node-state CONN_READ; fd_nodes[conn_fd] node; } }3.3 读写缓冲与状态机驱动连接既然要单线程处理几百个连接连接的生命周期就不可能是recv 一下等到数据send 一下发完数据这么简单。每个连接的同一时刻可能读了一半请求、还没生成响应也可能响应已经准备好但客户端收得慢发了一部分卡住了。所以每个连接必须有自己的读缓冲和写缓冲并且要有一个明确的状态机。我习惯给连接定义这几个状态CONN_READ正在接收请求数据等待完整请求头。CONN_WRITE请求已经解析完正在发送响应。CONN_CLOSE数据交互结束准备关闭。事件循环只看状态决定下一步。处于 CONN_READ 的连接每次读到数据就追加到 rbuf直到在 rbuf 里找到 \r\n\r\n说明 HTTP 请求头收完整了于是构造响应把状态切到 CONN_WRITE立刻尝试发送。处于 CONN_WRITE 的连接每次可写事件来了就继续发直到 wsent 追平 wlen再关闭连接。3.4 为什么不能直接在事件循环里一直 send/recv第一次写事件循环的人很容易犯一个错既然 select 说这个 fd 可读我就用 while 循环 recv 读到 EAGAIN既然可写我就用 while 循环 send 直到发完。在实验室环境里这没问题但真实环境会踩中慢客户端陷阱。假设有个客户端网速很差服务器要给它回 2KB 数据结果一次 send 只发出去 512 字节再 send 就返回 EAGAIN。如果你在事件循环里继续死等它其他几百个连接瞬间全部断线——因为事件循环被这一个慢连接给卡死了。正确做法是每次 recv/send 都只做当前内核允许我做的事情。recv 收到 EAGAIN 就认为这次读已经结束send 发不动了就把状态留在 CONN_WRITE等下一次写事件再继续。整个服务器的吞吐量不是由连接数量决定而是由真正发生事件的时刻决定。慢客户端只会拖累自己不会拖累别人。4. 完整可编译的核心代码一个最小单线程 HTTP 服务器下面这套代码我去掉了 bind 和 listen 的初始化细节保留了最关键的部分事件循环、accept、读处理、写处理。你可以把它补成一个完整的 .c 文件直接编译运行。4.1 主循环重建 fd_set、select、分发事件static void event_loop(int listen_fd) { for (;;) { fd_set rset, wset; FD_ZERO(rset); FD_ZERO(wset); FD_SET(listen_fd, rset); int max_fd listen_fd; for (int i 0; i FD_SETSIZE; i) { conn_node *n fd_nodes[i]; if (!n) continue; if (n-state CONN_READ) { FD_SET(i, rset); } else if (n-state CONN_WRITE) { FD_SET(i, wset); } if (i max_fd) max_fd i; } int ret select(max_fd 1, rset, wset, NULL, NULL); if (ret 0) { if (errno EINTR) continue; break; } if (FD_ISSET(listen_fd, rset)) { handle_accept(listen_fd); } for (int i 0; i max_fd; i) { if (!FD_ISSET(i, rset) !FD_ISSET(i, wset)) continue; conn_node *n fd_nodes[i]; if (!n) continue; if (FD_ISSET(i, rset)) { handle_read(i); } if (fd_nodes[i] FD_ISSET(i, wset) fd_nodes[i]-state CONN_WRITE) { handle_write(i); } } } }这段代码里有几个关键点值得展开。第一rset 和 wset 在每次循环开头都用 FD_ZERO 重建。select 会修改传入的 fd_set把未就绪的位清零。如果你试图复用上一次的集合位图里会残留脏位select 会返回一堆并不存在的事件。第二max_fd 不是全局变量而是每次循环临时扫描出来的最大值。它告诉内核你只需要检查到这里传小一点能少扫描几位。如果删除了一个 fd 却没有重算 max_fd下一次 select 可能拿到一个偏小的 nfds导致高位的就绪 fd 被内核忽略反过来偏大也只是白扫效率差一点。第三handle_read 内部可能关闭连接、把 fd_nodes[i] 置空所以处理写事件前必须重新判断 fd_nodes[i] 是否为 NULL并检查状态仍然是 CONN_WRITE否则会操作一个已经被释放的连接对象。4.2 读事件recv 循环、请求边界判断与响应构造static void handle_read(int fd) { conn_node *n fd_nodes[fd]; if (!n) return; for (;;) { if (n-rlen RBUF_SIZE) { close_conn(fd); return; } ssize_t r recv(fd, n-rbuf n-rlen, RBUF_SIZE - n-rlen, 0); if (r 0) { n-rlen (int)r; if (strstr(n-rbuf, \r\n\r\n) ! NULL) { build_response(n); n-state CONN_WRITE; n-wsent 0; try_send(fd); } } else if (r 0) { close_conn(fd); return; } else { if (errno EAGAIN || errno EWOULDBLOCK) break; close_conn(fd); return; } } }读事件的逻辑核心是用循环 recv 到 EAGAIN把数据追加进 rbuf然后检查请求是否完整。HTTP 请求头的结束标志是 \r\n\r\n只要在 rbuf 里找到它就可以判定请求已经收完。对于 GET 请求这种没有 body 的情况到这一步就足够进入响应阶段了。这里有一个生产环境必须处理的边界如果客户端无限发送数据却始终不带 \r\n\r\nrbuf 很快就会填满。我这版代码在 rlen 超过 RBUF_SIZE 时直接关闭连接当作恶意请求处理。否则内存会一直增长等于被人用流量打爆。响应构造很简单这里只回了固定内容static void build_response(conn_node *n) { const char *body OK; n-wlen snprintf(n-wbuf, sizeof(n-wbuf), HTTP/1.1 200 OK\r\n Content-Length: 2\r\n Connection: close\r\n \r\n OK); }现实中当然要解析请求行、路由到不同处理器但这个骨架的状态推进逻辑不变读完请求生成响应切状态尝试发送。4.3 写事件半包发送、写缓冲与关闭static void try_send(int fd) { conn_node *n fd_nodes[fd]; if (!n || n-state ! CONN_WRITE) return; while (n-wsent n-wlen) { ssize_t s send(fd, n-wbuf n-wsent, n-wlen - n-wsent, MSG_NOSIGNAL); if (s 0) { n-wsent (int)s; } else if (s 0) { if (errno EAGAIN || errno EWOULDBLOCK) return; close_conn(fd); return; } } close_conn(fd); } static void handle_write(int fd) { try_send(fd); }最容易漏掉的技巧是当状态切到 CONN_WRITE、wbuf 填好后不要傻等写事件先主动 try_send 一次。因为刚 accept 的 socket 几乎总是可写的等写事件等于白白多一次 select 轮询。只有真的遇到 EAGAINwrite 状态才会在下一轮被选入 wset。写事件是迫不得已才用的不是默认下一步。采用 Connection: close 的策略后响应发完就直接 close 连接。这样省掉了 HTTP keep-alive 下继续读下一个请求的复杂逻辑。第一版先把单请求生命周期跑通后再扩展长连接也不难——只要把 close_conn 改成重置 rbuf 和状态为 CONN_READ。4.4 代码里的防御细节SIGPIPE、空指针、fd 复用这套代码还有几个防御点必须说明白。第一忽略 SIGPIPE。客户端在没读完响应时突然 close服务端再 send 就会触发 SIGPIPE 信号默认行为是直接杀死进程。我在 send 里加了 MSG_NOSIGNAL 标志还在初始化时写了一行 signal(SIGPIPE, SIG_IGN)双保险。这个坑几乎所有写 socket 服务的人都会踩而且一开始没有任何报错进程说没就没。第二删连接的顺序要严格。close_conn 里要 close(fd)、free(node)、把 fd_nodes[fd] 置 NULL。置 NULL 必须做否则内核复用 fd 时新连接会拿到旧连接的内存指针轻则串包重则双重 free。第三accept 回来的 fd 如果大于等于 FD_SETSIZEselect 根本没法监听它直接 close 掉。这个分支在连接数逼近 1024 时会触发属于 select 天花板的现实写照。5. 实测与避坑记录让 select 服务器接受现实考验5.1 压测结果单线程的性能和 CPU 账单我把这套服务器放到一台普通的测试机器上写了个脚本开并发连接每个连接发一次小请求。结果如下500 个并发连接同时在线进程 CPU 占用在 15% 上下徘徊平均响应时间在毫秒级。把连接数推到接近 1000整体依然平稳但新连接开始进不来了——因为 FD_SETSIZE 上限到了。作为对比同一台机器用每连接一线程的模型跑200 个线程时已经能感觉到系统调用频繁上下文切换开始影响响应稳定性线程数涨到 800 以后光是线程栈的内存占用和调度的压力就拖慢了整体性能。这里的数字在不同机器上会有差异但趋势是一致的连接数几千以内多线程勉强能顶再往上线程模型要付出非常小心的调优成本而 select 模型的问题其实不是性能是 1024 这个硬上限。5.2 坑 1忘了给连接 socket 设非阻塞我最初写的时候只给 listen_fd 设了非阻塞连接 socket 沿用默认的阻塞模式。压测时发现一个非常诡异的现象只要有一个客户端发送数据特别慢整个服务器就卡死了。查了半天才发现问题出在 try_send 里的 send 调用——对端接收窗口很小send 发了一点后缓冲区就满了阻塞模式下的 send 直接停在那里等空间事件循环被这一次 send 彻底冻住。教训很简单非阻塞必须对所有 socket 生效。监听的和连接的都要用 fcntl 设置 O_NONBLOCK否则任何一个慢客户端都能把整个事件循环绑架。5.3 坑 2fd_set 被 select 改写没重建导致事件错乱第二版代码我在进入 select 前重建了集合但一开始没意识到 select 会修改传入的 fd_set以为它只是只读参数。于是第一轮 select 返回后rset 里没就绪的位被清零第二轮我在残留的 rset 上继续 FD_SET位图里历史位和新位混在一起结果 select 返回了大量根本不存在的就绪事件连接被无脑关闭。这个坑的根因是对 fd_set 的输入输出双重身份缺乏认知。以后每次循环都要老老实实 FD_ZERO FD_SET不要有任何复用上一次集合的想法。5.4 坑 3删除 fd 后 max_fd 没重算还有一版我在 accept 新连接时更新全局 max_fd但关闭连接时没有重算。问题在压测中等到连接频繁建立和关闭时才暴露最大 fd 的编号是 900这个连接一关全局 max_fd 还停在 900select 多扫描几百个无意义位CPU 白涨。更严重的是另一种情况——如果关闭后再 accept 回一个更大的 fd而更新逻辑有遗漏select 传入的 nfds 比真实最大 fd 小就绪事件会被内核直接忽略表现为连接偶尔没有反应。这让我改成每次 select 前重新遍历一次连接表计算 max_fd一劳永逸。反正 fd_set 都要重建顺手扫一遍连接表不增加什么额外负担。5.5 坑 4对端关闭时 send 触发 SIGPIPE压测中经常有客户端在响应还没读完的时候就把连接关了。第一次遇到时服务器进程毫无征兆地退出没有任何日志。排查了很久才想到 SIGPIPE。从那以后我养成了两个习惯抓 socket 服务初始化必写 signal(SIGPIPE, SIG_IGN)所有 send 都上 MSG_NOSIGNAL。两个都做防御力比只做一个强得多。6. 往上看select 和 poll、epoll 的差距与单线程模型的选型边界6.1 poll 解决数量上限但没解决全量扫描poll 的诞生是冲着 select 最讨人厌的 1024 上限去的。它不再用位图而是一个 pollfd 数组长度由调用者自己决定struct pollfd { int fd; short events; // 要监听的事件 short revents; // 实际发生的事件 };你可以监听几万个 fd不受编译期常量的限制。而且 revents 是输出字段原 events 不会被修改省去了重建的麻烦。但 poll 的底层机制没有变化每次调用仍然要把整个数组从用户态拷到内核态内核再全量扫描一遍所有 fd最后带回结果。它没有记忆不记得上一次监控过谁连接数上万时这个 O(n) 的重复劳动依然存在。6.2 epoll 怎么把复杂度降成 O(就绪数)epoll 的思路和 select/poll 完全不同。它先把要监听的 fd 通过 epoll_ctl 注册进内核维护的一份兴趣列表之后 epoll_wait 只返回真正发生事件的一小撮 fd。内核不是每次重新扫描所有 fd而是由协议栈在 fd 状态变化时主动把对应的引用挂到就绪队列epoll_wait 的工作只是把这个队列拷出来。在连接数巨大、就绪比例低的场景差距非常明显。select 每次是 O(总连接数)epoll 是 O(就绪数)。这就是现代高并发服务普遍转向 epoll 的核心原因。但要注意epoll 下的开发模型跟我前面写的 select 版本完全一致非阻塞 socket、应用层读写缓冲、状态机驱动连接、事件循环分发。把 select 版本迁移到 epoll你要改的只是注册事件和等待事件的 API事件分发逻辑几乎原样保留。6.3 选型建议什么时候继续用 select有了 epoll不代表 select 就该被淘汰。我自己会在这几类场景继续选 select跨平台需求。select 在 Windows、Linux、macOS、各种嵌入式系统上都有epoll 只在 Linux 上存在。如果写的网络库要在多个平台编译select 是最省事的兼容方案。教学与调试。select 的 API 足够简单几百行代码就能把单线程事件循环讲得明明白白。先跑通 select再往 epoll 迁移比直接上手 epoll 舒服得多。连接数明确小于几百的网络服务。比如局域网设备管理、串口采集、小型网关同时管理的连接不会超过几百个select 完全够用引入 epoll 属于杀鸡用牛刀。单线程事件驱动模型的影响也不止于这些。你看那些以高并发闻名的开源基础软件核心思想就是这条路单线程负责监听就绪事件阻塞式 IO 全部换成非阻塞配合应用层缓冲用状态机管理连接生命周期。线程不是越多越好而是要把等集中起来把做的时间片高效利用起来。最后分享一点我个人踩出来的体会。如果你正准备照这个思路写自己的单线程服务器别急着跳到 epoll。先把 select 版本跑熟把所有 socket 非阻塞化fd_set 每次重建max_fd 每次重算这些细节刻进肌肉记忆再去迁移到 poll 或 epoll你会发现事件分发逻辑几乎不用动。等到你真正用单线程模型扛住几百个活生生的并发连接对高并发这三个字的理解就跟以前完全不一样了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑