ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

手写一个单线程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 是把位图对应位置 1,FD_CLR 是把位置 0,FD_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 单线程方案。改造成本通常一个周末就能完成,带来的效果大概率会超出预期。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进