资讯详情

手写一个单线程select服务器:高并发不一定需要多线程

📅 2026/10/20 12:37:50 | 华诺云谱 👁 阅读
手写一个单线程select服务器:高并发不一定需要多线程
写这篇之前我得先说明一下背景。我在接手一个中小型项目的服务端改造时遇到了一个很现实的问题业务量不算大但连接数不少用多线程写起来爽扛不住多久就开始出现各种莫名其妙的资源问题。换回多进程模型又显得笨重。后来我把核心通信模块改成基于 select 的单线程事件循环代码量少了一大截线上也清静了。这篇文章就把当时从零实现的过程、原理和坑完整梳理一遍给同样在纠结“到底要不要上多线程”的朋友一个参考。1. “高并发”不一定等于“多线程”先看问题到底在哪很多朋友一提到高并发第一反应就是多线程。这本身没错但你要搞清楚并发的瓶颈到底出现在哪里。线程切换、锁竞争、资源占用这些在连接数低的时候根本感觉不到等连接数上来之后多线程模型的问题反而比它解决的问题更麻烦。1.1 多线程/多进程模型不被注意的隐形成本先说多进程。每个客户端连接进来就 fork 一个子进程这种模型写起来最直接编程思维完全同步业务逻辑随便写。可代价在哪里fork 一次进程要复制整个进程地址空间虽然 Linux 有写时拷贝机制但进程表项、文件描述符表、内存管理结构这些是实打实要复制的。连接多起来以后光进程调度就让 CPU 忙于上下文切换CPU 真正花在业务处理上的时间占比越来越少。多线程模型比多进程轻一些线程切换没有进程那么重但线程也有栈空间默认 8MB 虚拟内存加上线程之间共享数据必须加锁锁竞争一激烈线程大量时间都在等待锁搞不好还有死锁风险。更麻烦的是线程池大小怎么定都是个玄学开少了排队开多了上下文切换开销反而大于收益。我实际遇到过的一个案子可以说明问题一个游戏聊天室后端初期用“每用户一个线程”的模型用户量从 500 涨到 3000 的时候线程数从 500 涨到 3000然后系统负载直接飙到 20 以上。top 里看全是线程切换消耗业务 CPU 占比不到 30%。后来改成单线程事件循环负载降到了 1 以内。1.2 同步阻塞模型与 I/O 复用的本质区别上例的问题根源不是线程本身而是“阻塞式处理”。一个线程陷在 recv 里等数据干不了任何别的事只能干等着。如果你能让一个线程同时等待成千上万个连接的输入问题就解决了。这就是 I/O 复用I/O Multiplexing的核心思想一个线程同时监听多个 socket 描述符哪个描述符有数据到达就去处理哪个没有数据就继续等待。select 就是最早、最普及的一种 I/O 复用系统调用。这里的“复用”指的是复用一个控制线程复用的是等待的过程。原来 N 个线程各自忙等现在 N 个连接共享一个线程来统一等待真正干活的成本只有处理就绪事件那部分。1.3 select 真正的定位一个同步的事件通知器要理解 select 的正确打开方式我建议把它当成一个“事件提醒器”而非“异步处理器”。select 本身是同步阻塞的——调用 select 之后整个线程会阻塞在那里直到被监视的 fd 中至少有一个可读、可写或者出错才返回。这说明它是“通知你什么时候可以做某件事”而不是“帮你做某件事”。这种定位有个好处你的业务代码始终是顺序执行的一个时刻只处理一个事件不需要考虑并发竞争写起来跟同步代码一样自然。同时因为线程只有一个变量都用不着加锁保护这也是 select 模型最容易写对的地方。适合用 select 模型的项目通常有几个特征连接数量在几百到千把个量级、每个请求的处理量都不大、服务端与客户端有大量空闲时间。外卖系统的长连接推送、中小型聊天室、数据库连接池的简单代理都属于这个范围。如果连接数超过 1024就需要考虑 poll 或 epoll 了select 的集合上限会卡死你。2. select 的核心机制拆解参数、集合与返回值的微妙之处想把 select 用得稳三个关键点必须吃透函数参数的真实含义、fd_set 这个集合的操作方法、以及返回之后 fd_set 会被内核改写成什么样子。任何一点理解偏差都会直接反映在线上诡异 bug 上。2.1 五个参数的作用与边界select 的函数原型是#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);这里最容易犯错的就是第一个参数 nfds。它不是你要监视的 fd 的总数而是所有被监视 fd 中“最大编号加 1”。这个和 fd_set 的实现方式直接相关fd_set 本质上是一个位图每个 fd 编号对应位图中的一位。内核检查时从 0 开始遍历到位图最后一位你告诉它查到哪里它就不用傻乎乎把 1024 位都翻一遍。// 假设要监视 fd 5 和 fd 100 // nfds 传入的值应该是 101而不是 2 int nfds 100 1;readfds、writefds、exceptfds 这三个集合就是你要监听的事件类型。读集合里放你想“读数据”的 fd写集合里放你想“写数据”的 fd异常集合一般不用直接置 NULL。timeout 参数决定 select 的等待行为传 NULL 表示无限期等待直到有 fd 就绪传一个立即超时的结构体可以让 select 变成非阻塞检查传一般的时间值就是限时等待。这一点在心跳检测和超时处理中很常用。2.2 fd_set 的本质一个位图集合fd_set 看起来像一个黑盒官方推荐只用四个宏操作它FD_ZERO(fd_set *set); // 清空整个集合 FD_SET(int fd, fd_set *set); // 将某个 fd 加入集合 FD_CLR(int fd, fd_set *set); // 将某个 fd 从集合移除 FD_ISSET(int fd, fd_set *set); // 检查某个 fd 是否在集合中这四个宏里的跳动很关键。中小系统里 fd 编号几乎不会超过 1024所以 fd_set 就是 1024 位也就是 128 字节。FD_SET 是把位图对应位置 1FD_CLR 是把位置 0FD_ISSET 是查一下某一位是否还是 1。但有一个人人都会踩的坑select 返回后内核会修改传入的 fd_set只保留“有事件就绪”的 fd其他所有位都会被清掉。所以如果你想循环调用 select就必须维护一份“原始集合”的副本每次调用之前重新 FD_SET 一遍。fd_set readfds, allfds; FD_ZERO(allfds); FD_SET(listenfd, allfds); int maxfd listenfd; while (1) { readfds allfds; // 重新拷贝恢复原始集合 ret select(maxfd 1, readfds, NULL, NULL, NULL); // 这里才能安全地用 FD_ISSET 检查 readfds }我最早写这个的时候图省事直接在 select 之后拿着变了形的 readfds 在下一次循环继续用第二次循环直接清空得干干净净所有连接都不再有事件。排查了整整一下午后来打印 fd_set 内容才猜到这层关系。2.3 一边监听一边通信两类 fd 的分工在一个网络服务里至少要面对两类 fd一类是“监听套接字”用于接受新连接另一类是“已连接套接字”用于跟已经建立的客户端通信。select 不区分这两类它眼里都是 fd区别只在于你这个事件就绪后要做什么处理。当监听套接字可读说明有新的客户端调用 connect 等着握手你要去调用 accept当已连接套接字可读说明对方发了数据过来你要去调用 recv。这两类 fd 完全可以放进同一个 readfds 集合里select 一旦返回你遍历 fd 集合时首先判断“这个 fd 是不是监听套接字”是就 accept否则 recv。有一个细节值得注意监听套接字不是连接本身它只有“新连接来了”这一种读事件。你完全可以让主循环把监听 fd 和连接 fd 放在一个集合里统一 select 处理而不需要分开两个线程这也是单线程模型组织代码的核心思路。3. 从零实现一个单线程 select 服务器理论部分聊完接下来直接上工程实现。这里我用 C 语言写一个简洁的 echo 服务器客户端发什么服务端原样回什么。麻雀虽小五脏俱全覆盖了监听、accept、收发、断开连接四大完整环节。3.1 起手式创建监听套接字这一步是所有网络服务的基础注意细节能省下很多调试时间。int listenfd; struct sockaddr_in server_addr; listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket); exit(1); } // 这里必须加上否则服务器重启时端口可能处于 TIME_WAIT 无法立即绑定 int opt 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(8888); if (bind(listenfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); exit(1); } if (listen(listenfd, 128) 0) { perror(listen); exit(1); }SO_REUSEADDR 这个选项建议新手无条件加上。否则你把服务器 kill 掉之后再启动大概率会因为 TIME_WAIT 状态报 bind: Address already in use只能改端口或者等两分钟。加上这个选项属于服务器开发的固定起手操作。3.2 主循环设计accept 还是读数据一视同仁接下来的核心是主循环。维护一个“客户端数组”来跟踪每个连接的 fd、状态、是否被占用。我用一个简单的结构体数组来管理连接数组下标就是“用户槽位”。#define MAX_CLIENTS 1024 #define BUFFER_SIZE 4096 typedef struct { int fd; int in_use; } client_t; client_t clients[MAX_CLIENTS]; void init_clients() { for (int i 0; i MAX_CLIENTS; i) { clients[i].fd -1; clients[i].in_use 0; } }初始化完成后进入 select 主循环fd_set readfds, allfds; int maxfd listenfd; FD_ZERO(allfds); FD_SET(listenfd, allfds); while (1) { readfds allfds; int ret select(maxfd 1, readfds, NULL, NULL, NULL); if (ret 0) { perror(select); continue; } // 1. 有新连接进来 if (FD_ISSET(listenfd, readfds)) { struct sockaddr_in client_addr; socklen_t addrlen sizeof(client_addr); int connfd accept(listenfd, (struct sockaddr *)client_addr, addrlen); if (connfd 0) { perror(accept); continue; } // 找个空槽位保存 int slot -1; for (int i 0; i MAX_CLIENTS; i) { if (!clients[i].in_use) { slot i; break; } } if (slot 0) { clients[slot].fd connfd; clients[slot].in_use 1; FD_SET(connfd, allfds); if (connfd maxfd) { maxfd connfd; } } else { // 连接数已满直接拒绝 close(connfd); } } // 2. 遍历已有客户端读取数据 for (int i 0; i MAX_CLIENTS; i) { int fd clients[i].fd; if (fd 0) continue; if (FD_ISSET(fd, readfds)) { char buffer[BUFFER_SIZE]; ssize_t n recv(fd, buffer, sizeof(buffer), 0); if (n 0) { send(fd, buffer, n, 0); // echo 回写 } else if (n 0) { // 客户端主动关闭 close(fd); clients[i].in_use 0; clients[i].fd -1; FD_CLR(fd, allfds); // 如果它是当前最大 fd需要重新计算 maxfd if (fd maxfd) { maxfd listenfd; for (int j 0; j MAX_CLIENTS; j) { if (clients[j].fd maxfd) { maxfd clients[j].fd; } } } } else { if (errno EINTR || errno EAGAIN) { continue; } // 真正的错误关闭连接 close(fd); clients[i].in_use 0; clients[i].fd -1; FD_CLR(fd, allfds); if (fd maxfd) { maxfd listenfd; for (int j 0; j MAX_CLIENTS; j) { if (clients[j].fd maxfd) { maxfd clients[j].fd; } } } } } } }这段代码里最容易被忽略的就是 maxfd 的动态维护。当某个最大编号的客户端关闭后你没有重新计算 maxfd下次 select 传进去一个过大的 nfds 倒还好只是浪费传进去一个过小的 nfds内核就会漏掉编号更大的 fd导致那个 fd 的事件永远不被处理。这种 bug 不是必现的一出现就非常头痛。3.3 边界情况处理半关闭与返回值的解读recv 的返回值分三种情况每个都值得单独分析n 0正常收到数据。这里要明确recv 只是把内核缓冲区里的数据拷贝到你的 buffer它不关心你这次拷贝的是一个完整的业务包还是半包这个在简单 echo 场景无所谓在真实业务里就得自己定义协议、做好分包。n 0说明对端优雅关闭了连接也就是对方调了 close 或者进程退出。这时候服务端必须主动 close 这个 fd否则会一直占着描述符。很多连接泄漏就是遗忘处理这种情况产生的。n 0出错。但 EINTR被信号打断和 EAGAIN非阻塞模式下暂时没数据不算真正的错误要区分对待。很多新手不处理 EAGAIN在非阻塞模式下 recv 频繁返回 -1还以为是对方断开了直接就把连接关了造成大面积误杀。3.4 通过防火墙的外部 Volatile 调整 recv 模式还有一点值得主动设计一下。我把客户端 fd 默认置为阻塞模式因为 select 已经保证“有数据可读”才调用 recv阻塞模式的 recv 在那一刻必然能读到数据逻辑最简洁。但是如果业务中有“即使没数据也要尝试读一次”的场景就需要把 fd 设为非阻塞。int flags fcntl(connfd, F_GETFL, 0); fcntl(connfd, F_SETFL, flags | O_NONBLOCK);非阻塞模式下 recv 返回 -1 并且 errno 为 EAGAIN 是非常正常的现象就是没数据不代表连接异常。两种模型各有适用场景用 select 返回驱动业务时我更推荐保持阻塞模式代码更直白如果要在事件循环里穿插其他定时任务才考虑非阻塞。4. 实测对比单线程 select 与多线程模型的差异光讲理论不过瘾我把这个 echo 服务器和一个“每连接一线程”的版本做了个简单压测对比。虽然测试环境比较简陋但趋势足以说明问题。4.1 测试环境与压测方法测试机器是普通的四核八线程虚拟机连接数控制在 500每个连接发一批小数据包对比完成全部请求的耗时。多线程版本用 pthread每个连接一个线程服务逻辑完全一样。压测工具我直接用自写的脚本模拟 TCP 连接500 个连接同时建立各有 100 次 request-echo 交互。4.2 结果吞吐不输CPU 和内存差距很大模型500 连接耗时平均 CPU内存占用每连接一线程4.87s78%高线程栈 调度开销单线程 select5.13s24%低仅一份栈空间结果很有意思单线程 select 模型总耗时比多线程模型略慢一点但 CPU 占用率低了一大截。这说明多线程模型大量时间耗在了线程调度和上下文切换上真正业务层面 select 并没有劣势。内存差异就更夸张500 个线程默认栈空间就是 4GB 虚拟内存而单线程只需要一份栈。这背后的原理其实很简单连接多并不代表时刻都有数据要处理大多数连接都是“挂机”状态。多线程把资源白白浪费在等待上单线程 select 把等待成本集中在内核一个 selector 上连接再多也只有一个等待者。4.3 单线程模型的性能天花板按我刚才的测试看单线程 select 性能够用但它有个绕不开的天花板FD_SETSIZE 通常是 1024也就是最多监视 1024 个 fd。这个限制是编译期写死的想突破就得改宏重新编译内核相关的库但这会带来兼容性风险。另一个限制是 select 每次调用都要把完整的 fd_set 从用户态拷贝到内核态fd 多的时候这个拷贝开销也不可忽略。所以 select 最舒服的区间是几十到几百个连接上千连接就建议上 poll超过上万连接就直接 epoll各自有各自的主场。5. 常见问题与排查技巧实录最后把我在实际开发中遇到的典型问题和排查方法总结成一份速查涉及 select 特性的坑和新手常犯的错误每一条都是从真实崩溃中摸出来的。5.1 为什么 select 返回后 fd_set 全变样了前面提过select 返回时内核会把没有就绪的位全部清掉只保留就绪 fd。很多人头一回用都是栽在这里。你把一个 fd_set 重复用第二次循环集合里面已经是空的了所有连接等于从来没被监视。解决办法定义一份 allfds 作为永久集合每次 select 前拷贝到临时集合 readfds。这个拷贝是 fd_set 整体的值传递用 操作符或者 memcpy 都行。5.2 监听套接字就绪了我却半天 accept 不出来这种多半发生在极端连接突发场景accept 返回 EMFILE意思是没有可用文件描述符了。你以为 select 会一直提醒你“有新连接”结果 listenfd 永远处于可读状态主循环死在这里面其他连接也被拖累。解决办法检查 accept 的返回值处理 EMFILE。正常做法是先关闭一个空闲连接腾出 fd 数量或者直接把新连接关掉并记录日志至少不能让主循环原地空转。5.3 recv 返回 -1 但业务还正常我差点把连接全踢了非阻塞模式下这是个经典蛇咬案例。客户端发数据频率低的时候没问题一旦高频发送recv 返回 EAGAIN 的频率也高而你把 EAGAIN 误判成了真实错误咔咔咔把活跃连接全关了。我之前一次误操作直接把线上连接清了三分之一后来加了 errno 的专门判断才解决了。if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { return; // 暂时无数据正常现象 } if (errno EINTR) { return; // 被信号打断重新等 } // 真正错误走这边 close(fd); }5.4 粘包和半包问题select 只管 One Packet 吗select 和 recv 只负责取数据不负责组包。一次 recv 可能读到半个业务包也可能读到两个完整的包这些都是内核缓冲区决定的跟你的业务毫无关系。如果你做的是真实协议而不是 echo 游戏就得在应用层维护一个接收缓冲区把数据先攒起来再按照包头里的长度字段来决定提取多少个完整包。这个缓冲区就是“应用层缓冲区”我给每个连接分配一个动态增长的 buffer简单点就固定大小加指针偏移复杂点可以用环形队列。5.5 最大 fd 编号带来的“隐藏炸弹”每次有一个客户端关闭常规做法只是 close(fd) 然后从集合里清除很少有人去重新算 maxfd。当时我压测到第 800 个连接结束突然发现剩下 200 个连接完全没反应了。排查到最后就是 maxfd 一直保持着一个已关闭 fd 的编号后续 select 等待的区间都和真实的最大活动 fd 不匹配了。写一个 update_maxfd() 函数在每次清理连接之后统一调用。这个函数唯一的 job 就是扫描所有活跃连接算出最大值取 max(listenfd, 最大活跃连接 fd)。别嫌它 O(n)连接数量几百个以内毫无压力。5.6 select 超时返回值在心跳里的妙用timeout 参数经常被忽略但它能做一件事定时检查。你把 timeout 设为 1 秒select 每秒钟返回一次即使没有事件利用返回值等于 0 的时机去挨个检查每个连接的“上次活动时间”。距离当前时间过长的就主动关掉。这比单独开一个定时器线程要轻太多了整个服务仍然是单线程的。struct timeval tv; tv.tv_sec 1; tv.tv_usec 0; int ret select(maxfd 1, readfds, NULL, NULL, tv); if (ret 0) { // 超时执行心跳检查 for (int i 0; i MAX_CLIENTS; i) { if (clients[i].in_use is_timeout(clients[i].last_active)) { close_client(i); } } }这样不但实现了心跳功能还把 select 的等待间隙充分利用了起来相当于给一个单线程程序塞进了一个隐形的定时任务。我个人在这类项目的收尾阶段还会习惯性做一件事情把 select 放到一个独立的 wrapper 函数里外面套一层统计日志里打印每次 select 返回后就绪 fd 的数量和总耗时。这个数据可以帮助快速判断系统是真有空闲还是在忙转。多数时候你会发现select 后真正就绪的 fd 数量极少绝大多数时间就是空等待。这也是单线程模型非常适合这类场景的根本原因——它把等待的压力留给了内核而内核干这事效率极高。如果你现在维护的项目还停留在多线程阻塞模型的泥潭里连接数又没有大到非上 epoll 不可的程度不妨先试一版 select 单线程方案。改造成本通常一个周末就能完成带来的效果大概率会超出预期。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑