news 2026/10/21 3:04:17

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
select实现单线程高并发:原理剖析与HTTP服务器实战

你还记不记得第一次用 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 对应位是否为 1,select 返回后用它判断谁就绪了。

位图的优点是紧凑、处理快;缺点是大小写死。FD_SETSIZE 在 Linux 上默认是 1024,也就是说 select 最多能监听 1024 个 fd。想突破这个值得重新编译内核或者调配置,极其麻烦。对玩具 Demo 够用,对生产场景就是天花板。

2.2 一次 select 调用的完整旅程

一次 select 调用看起来只是一句函数,背后实际是五步:

  1. 用户态把 fd_set 复制进内核空间。
  2. 内核遍历 readfds、writefds、exceptfds 覆盖范围内的所有 fd,挨个问协议栈"你现在可读吗 / 可写吗 / 有异常吗"。
  3. 只要有一个 fd 就绪,函数立刻返回;否则内核让当前进程睡眠,等待事件或超时。
  4. 内核把更新过的 fd_set 复制回用户空间。注意,select 返回后的 fd_set 只保留就绪位,没就绪的会被清零。
  5. 用户程序拿到新 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. 单线程服务器怎么搭骨架:连接表、状态机、非阻塞 IO

3.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\n,rbuf 很快就会填满。我这版代码在 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 轮询。只有真的遇到 EAGAIN,write 状态才会在下一轮被选入 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_SETSIZE,select 根本没法监听它,直接 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 坑 2:fd_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 还停在 900,select 多扫描几百个无意义位,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,你会发现事件分发逻辑几乎不用动。等到你真正用单线程模型扛住几百个活生生的并发连接,对"高并发"这三个字的理解,就跟以前完全不一样了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 3:03:54

jnetpcap源码包深度解析:从编译到自定义协议扩展

简介&#xff1a;jnetpcap-src-1.4.r1425-1.zip 是 Java 网络封包捕获与分析库 jnetpcap 的特定版本源码包&#xff0c;面向需要深入底层网络数据包处理的 Java 开发者、安全分析人员及网络监控工具构建者。jnetpcap 封装了开源库 libpcap&#xff0c;编译后可生成 libjnetpcap…

作者头像 李华
网站建设 2026/10/21 3:03:26

TCP/IP协议栈核心原理与网络故障排查实战指南

有次我在给某线上服务排查一个间歇性超时的怪问题&#xff0c;抓了一下午包&#xff0c;最后发现表面正常的连接里&#xff0c;TCP重传率已经高得离谱。那一瞬间我突然意识到&#xff0c;懂协议栈和不太懂协议栈的人&#xff0c;处理网络问题的速度真的差着一个数量级。TCP/IP协…

作者头像 李华
网站建设 2026/10/21 3:03:09

CE6.3中文版:从内存扫描到AOB注入的逆向调试实战

简介&#xff1a;CE6.3中文版是专为游戏数据修改与程序调试设计的Cheat Engine中文版本&#xff0c;面向游戏爱好者、逆向学习者和程序调试人员&#xff0c;解决内存扫描、数值修改、脚本定制等需求&#xff0c;从新手入门到高级定制均能派上用场。压缩包共包含78个文件&#x…

作者头像 李华
网站建设 2026/10/21 3:03:09

基于YOLOv8的泰勒锥检测实战:从数据集构建到Web实时监控

泰勒锥检测&#xff0c;严格说是电喷雾、静电纺丝这类实验里最折磨人的环节之一&#xff1a;液体在高压电场下从喷头末端拉出一个圆锥形结构&#xff0c;这个锥形稳不稳定、锥尖有没有抖动、有没有从稳定锥变成滴状模式&#xff0c;直接决定后面射流和液滴的质量。过去课题组里…

作者头像 李华
网站建设 2026/10/21 3:02:01

基于Web的校园失物招领系统:Spring Boot+Vue全栈设计与实现

不知道你有没有想过&#xff0c;一所万人规模的学校里&#xff0c;每天到底会产生多少件遗失物品。从我接触过的几个高校后勤项目来看&#xff0c;光是学生餐厅、图书馆和操场这三个地方&#xff0c;一天捡到的水杯、校园卡、耳机、钥匙就有几十件。这些物品大部分被送到失物招…

作者头像 李华
网站建设 2026/10/21 3:01:51

K8s AI自愈能力系统性验证:故障注入与实操指南

先说结论&#xff1a;容器化应用在K8s里跑得越久&#xff0c;你就越会发现&#xff0c;K8s原生的健康检查机制只是最低限度的兜底&#xff0c;真正能让业务在故障中“活下来”的&#xff0c;往往是一套成体系的自愈机制。而当团队把AI塞进自愈决策链路之后&#xff0c;新的问题…

作者头像 李华