Linux 高性能网络:NtyCo 协程实现百万 TCP 并发连接 NtyCo 这样的协程库究竟能不能在客户端和服务端之间维持百万级 TCP 连接协程在高并发服务端中能带来什么好处Linux 内核需要怎样调优出现 OOM 时又应该从哪里定位和优化本文以一次 NtyCo 百万连接实验为主线从虚拟机准备、文件描述符和监听队列调优开始完整记录服务端与客户端实现、OOM 排查、单连接内存优化以及 TCP 四元组和多端口背后的原理。这里的目标是验证“同时保持大量 TCP 连接”的能力不是百万 QPS也不是严格的吞吐量基准测试。实验中的激进参数以低流量、长连接场景为目标不能直接照搬到生产环境。一、实验环境与 Linux 内核调优1. 实验环境开发平台均为 Ubuntu Linux 虚拟机。先将 NtyCo 克隆到本地服务端分配 4 核 CPU、8 GB 内存三台客户端各分配 1 核 CPU、2 GB 内存也就是一共准备四台虚拟机。服务端配置如下客户端配置如下每台客户端使用相同规格为了让实验可以复现建议同时记录内核版本和 NtyCo 提交版本uname -a git -C NtyCo rev-parse HEAD以下是我的终端输出内容lazywinubuntu:~/doublelearn$ uname -a Linux ubuntu 5.15.0-139-generic #149~20.04.1-Ubuntu SMP Wed Apr 16 08:29:56 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux lazywinubuntu:~/doublelearn$ git -C NtyCo rev-parse HEAD 72ab5fd04f0c228f464f160aaa521bb791b34aa52. 提高进程可用的文件描述符上限服务端每accept一个连接都会得到一个新的 socket 文件描述符。以百万连接为目标时单个服务端进程除了约 100 万个已连接 socket还要保留 40 个监听 socket、epoll、eventfd 和日志等文件描述符。因此首先要提高进程的nofile上限。Ubuntu 登录会话通常通过/etc/security/limits.conf配置资源限制sudo vim /etc/security/limits.conf在文件末尾添加* soft nofile 1048576 * hard nofile 1048576 root soft nofile 1048576 root hard nofile 1048576其中*表示普通用户但不覆盖root所以这里单独为root配置。soft是当前会话实际使用的软限制hard是用户能够提高到的硬限制软限制不能超过硬限制。该配置通过 PAM 对新登录会话生效修改后需要重新登录如果程序由 systemd 启动还需要在服务单元中配置LimitNOFILE。重新登录后验证ulimit -n cat /proc/sys/fs/nr_open sysctl fs.file-maxnr_open是单进程可设置的文件描述符上限fs.file-max是系统级文件句柄上限。三台客户端每台计划创建约 33.3 万个连接因此客户端也要执行同样的检查。1048576对服务端百万连接只是勉强够用实际仍要为其他描述符留出余量。3. 正确认识半连接队列和全连接队列调用listen网络API后 TCP 建连阶段主要涉及两个队列半连接队列服务端收到 SYN 并回复 SYNACK 后连接处于SYN_RECV等待客户端最后一个 ACK。全连接队列也叫 accept 队列三次握手已经完成但应用程序还没有调用accept取走连接。在 Linux 2.2 之后listen(fd, backlog)的backlog主要控制全连接队列最终会受到net.core.somaxconn的限制半连接队列主要由net.ipv4.tcp_max_syn_backlog管理。可以先查看当前值再按实验需要临时调整sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog ​ sudo sysctl -w net.core.somaxconn65535 sudo sysctl -w net.ipv4.tcp_max_syn_backlog65535应用代码中的listen(fd, backlog)也要与之匹配。本文服务端将LISTEN_BACKLOG设置为65535因此 accept 队列上限可以近似理解为全连接队列上限 min(应用层 backlog, net.core.somaxconn)需要注意队列参数解决的是突发建连时“来不及 accept”的问题并不决定已经建立并被应用接收的稳态连接总数。 启用 SYN Cookie 后半连接队列溢出时的处理语义还会发生变化因此不能只靠放大一个参数解决所有建连问题。4. 扩大客户端临时端口范围Linux 默认的客户端临时端口范围通常是32768~60999大约 2.8 万个端口。单个客户端 IP 连接同一个服务端IP:Port时每条 TCP 连接的四元组必须唯一因此可用临时端口数量会成为上限之一。本次实验让客户端轮流连接服务端的 40 个端口并在实验环境中扩大临时端口范围# 1. 扩大客户端临时端口范围 sudo sysctl -w net.ipv4.ip_local_port_range1024 65535 ​ # 2. 仅在反复建立、关闭连接的压测中考虑开启 TIME_WAIT 复用 sudo sysctl -w net.ipv4.tcp_tw_reuse1验证是否生效cat /proc/sys/net/ipv4/ip_local_port_range cat /proc/sys/net/ipv4/tcp_tw_reuse扩大临时端口范围可以降低建连时出现connect failed: Cannot assign requested address的概率。实际使用时还要检查net.ipv4.ip_local_reserved_ports避免临时端口与本机业务端口冲突。tcp_tw_reuse只影响主动关闭后处于TIME_WAIT的连接并且只有在协议判断安全时才会复用。它不会增加同时处于ESTABLISHED状态的连接容量也不能简单理解为“无条件跳过 2MSL”。本次以长时间保持连接为主这个参数并不是建立百万长连接的必要条件只有频繁重跑、产生大量短连接时才可能有帮助。系统侧准备完成后下面开始搭建服务端和客户端。二、NtyCo 百万连接压测实战1. 服务端实现先安装htop用来观察压测期间的 CPU、内存和 Swapsudo apt update sudo apt install -y htop服务端代码nty_multi_client_server.c完整如下。它会监听2000~2039共 40 个端口并为每个已连接 socket 创建一个处理 Echo 逻辑的协程#include nty_coroutine.h ​ #include arpa/inet.h #include errno.h #include netinet/in.h #include stdint.h #include stdio.h #include stdlib.h #include string.h #include sys/socket.h ​ #define SERVER_IP 192.168.139.130 #define SERVER_PORT 2000 #define MAX_PORT 40 #define LISTEN_BACKLOG 65535 #define TARGET_CONNECTIONS 1000000ULL ​ static uint64_t total_accepted 0; static uint64_t active_connections 0; static uint64_t start_time 0; static uint64_t last_k_time 0; ​ static int send_all(int fd, const char *buf, size_t len) { size_t sent 0; ​ while (sent len) { ssize_t n nty_send(fd, buf sent, len - sent, MSG_NOSIGNAL); if (n 0) { sent (size_t)n; continue; } if (n 0 errno EINTR) { continue; } return -1; } ​ return 0; } ​ static void client_reader(void *arg) { int fd *(int *)arg; free(arg); ​ char buf[4096]; for (;;) { ssize_t n nty_recv(fd, buf, sizeof(buf), 0); if (n 0) { if (send_all(fd, buf, (size_t)n) 0) { break; } } else if (n 0) { break; } else if (errno ! EINTR) { break; } } ​ nty_close(fd); if (active_connections 0) { active_connections--; } } ​ static void server_listener(void *arg) { unsigned short port *(unsigned short *)arg; free(arg); ​ int fd nty_socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(nty_socket); return; } ​ int reuse 1; if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)) 0) { perror(setsockopt SO_REUSEADDR); nty_close(fd); return; } ​ struct sockaddr_in local; memset(local, 0, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(port); ​ if (inet_pton(AF_INET, SERVER_IP, local.sin_addr) ! 1) { fprintf(stderr, invalid server ip: %s\n, SERVER_IP); nty_close(fd); return; } ​ if (bind(fd, (struct sockaddr *)local, sizeof(local)) 0) { perror(bind); nty_close(fd); return; } ​ if (listen(fd, LISTEN_BACKLOG) 0) { perror(listen); nty_close(fd); return; } ​ printf(Server listening on %s:%u\n, SERVER_IP, port); ​ for (;;) { if (active_connections TARGET_CONNECTIONS) { break; } ​ struct sockaddr_in remote; socklen_t len sizeof(remote); ​ int cli_fd nty_accept(fd, (struct sockaddr *)remote, len); if (cli_fd 0) { if (errno EMFILE || errno ENFILE) { fprintf(stderr, file descriptor limit reached; check nofile settings\n); break; } if (errno ! EINTR) { perror(nty_accept); } continue; } ​ /* 其他监听协程可能在本协程挂起期间已经达到全局目标。 */ if (active_connections TARGET_CONNECTIONS) { nty_close(cli_fd); break; } ​ int *pfd malloc(sizeof(*pfd)); if (pfd NULL) { perror(malloc client fd); nty_close(cli_fd); continue; } *pfd cli_fd; ​ nty_coroutine *co NULL; if (nty_coroutine_create(co, client_reader, pfd) ! 0) { fprintf(stderr, create client coroutine failed\n); free(pfd); nty_close(cli_fd); continue; } ​ total_accepted; active_connections; if (total_accepted % 1000 0) { uint64_t now nty_coroutine_usec_now(); uint64_t total_us now - start_time; uint64_t k_us now - last_k_time; ​ printf([STATS] total accepted: %llu, active clients: %llu, latest 1000 accepts cost: %.3f ms, total cost: %.3f ms\n, (unsigned long long)total_accepted, (unsigned long long)active_connections, k_us / 1000.0, total_us / 1000.0); last_k_time now; } } ​ nty_close(fd); } ​ int main(void) { for (int i 0; i MAX_PORT; i) { unsigned short *port malloc(sizeof(*port)); if (port NULL) { perror(malloc listener port); return EXIT_FAILURE; } *port (unsigned short)(SERVER_PORT i); ​ nty_coroutine *co NULL; if (nty_coroutine_create(co, server_listener, port) ! 0) { fprintf(stderr, create listener coroutine failed: %u\n, *port); free(port); return EXIT_FAILURE; } } ​ start_time nty_coroutine_usec_now(); last_k_time start_time; nty_schedule_run(); ​ return 0; }这份代码的核心结构并不复杂main创建 40 个监听协程每个协程绑定一个独立端口监听协程通过nty_accept接收连接再为每个连接创建一个client_reader协程。服务端把活跃连接目标限制为 100 万条三台客户端分别创建333334、333333和333333条合计正好 100 万条避免服务端停止监听后客户端因继续建连失败而退出。client_reader没有数据时会让出执行权有数据时完成读取和 Echo然后继续等待。底层调度器通过 epoll 等事件机制管理就绪连接因此不需要为每个连接创建一个系统线程。这里还修正了几个容易在小规模测试中被忽略的问题nty_send可能短写所以需要循环发送malloc或协程创建失败时必须关闭刚刚接收的 socket40 个监听协程共用的统计起点只在main中初始化一次避免每个监听协程重复重置时间。将源码保存为sample/nty_multi_client_server.c并在项目根目录的Makefile中添加下面的编译目标。命令行前必须使用 Tab而不是空格nty_multi_client_server : $(OBJS_DIR)/nty_socket.o $(OBJS_DIR)/nty_coroutine.o $(OBJS_DIR)/nty_epoll.o $(OBJS_DIR)/nty_schedule.o $(SAMPLE_DIR)/nty_multi_client_server.c $(CC) -o $(BIN_DIR)/$ $^ $(FLAG)然后在 NtyCo 目录下依次执行make # 生成 libntyco.a 静态库 make nty_multi_client_server # 编译到 bin 目录 ./bin/nty_multi_client_server启动后可以看到 40 个端口进入监听状态可以采用下面的命令同时观察内存和端口数量watch -n 1 free -h; echo ---; ss -tan | grep -E :20[0-3][0-9] | wc -l其中free -h显示内存与 Swapss -tan列出 TCP socketgrep匹配2000~2039端口wc -l统计行数。不过这个写法会把LISTEN和ESTABLISHED混在一起而且每秒扫描近百万条连接本身也有不小开销。更严谨的做法是降低采样频率并只统计已建立连接watch -n 5 free -h; echo --- ESTABLISHED ---; ss -Htan state established ( sport :2000 and sport :2039 ) | wc -l此时看到的40并不是 40 条已建立连接而是 40 个处于LISTEN状态的监听 socket2. 客户端实现三台客户端分别运行同一个压测程序。程序允许通过第三个参数指定本机的目标连接数单台上限为 40 万条并在服务端的 40 个端口之间轮询。下面是整理后的完整版本#define _GNU_SOURCE ​ #include arpa/inet.h #include errno.h #include fcntl.h #include netinet/in.h #include stdint.h #include stdio.h #include stdlib.h #include string.h #include sys/epoll.h #include sys/socket.h #include time.h #include unistd.h ​ #define MAX_BUFFER 128 #define MAX_EVENTS 8192 #define MAX_PORT 40 #define MAX_CONNECTIONS 400000 #define CONNECT_INTERVAL_US 500 ​ 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 double diff_ms(const struct timespec *begin, const struct timespec *end) { return (end-tv_sec - begin-tv_sec) * 1000.0 (end-tv_nsec - begin-tv_nsec) / 1000000.0; } ​ static int send_all_blocking(int fd, const char *buf, size_t len) { size_t sent 0; ​ while (sent len) { ssize_t n send(fd, buf sent, len - sent, MSG_NOSIGNAL); if (n 0) { sent (size_t)n; } else if (n 0 errno EINTR) { continue; } else { return -1; } } ​ return 0; } ​ static void close_client(int fd, int *active) { close(fd); if (*active 0) { (*active)--; } } ​ static void handle_events(struct epoll_event *events, int nfds, int *active) { for (int i 0; i nfds; i) { int clientfd events[i].data.fd; uint32_t revents events[i].events; int closed 0; ​ if (revents (EPOLLERR | EPOLLHUP)) { close_client(clientfd, active); continue; } ​ if (revents EPOLLIN) { for (;;) { char buffer[MAX_BUFFER]; ssize_t n recv(clientfd, buffer, sizeof(buffer), 0); ​ if (n 0) { continue; } if (n 0) { close_client(clientfd, active); closed 1; break; } if (errno EINTR) { continue; } if (errno EAGAIN || errno EWOULDBLOCK) { break; } ​ fprintf(stderr, recv fd%d failed: %s\n, clientfd, strerror(errno)); close_client(clientfd, active); closed 1; break; } } ​ if (!closed (revents EPOLLRDHUP)) { close_client(clientfd, active); } } } ​ int main(int argc, char **argv) { if (argc ! 4) { fprintf(stderr, Usage: %s ip port connections\n, argv[0]); return EXIT_FAILURE; } ​ const char *ip argv[1]; char *port_end NULL; errno 0; long parsed_port strtol(argv[2], port_end, 10); if (errno ! 0 || port_end argv[2] || *port_end ! \0 || parsed_port 1 || parsed_port UINT16_MAX - (MAX_PORT - 1)) { fprintf(stderr, invalid base port: %s\n, argv[2]); return EXIT_FAILURE; } int base_port (int)parsed_port; ​ char *count_end NULL; errno 0; long parsed_connections strtol(argv[3], count_end, 10); if (errno ! 0 || count_end argv[3] || *count_end ! \0 || parsed_connections 1 || parsed_connections MAX_CONNECTIONS) { fprintf(stderr, invalid connection count: %s (max %d)\n, argv[3], MAX_CONNECTIONS); return EXIT_FAILURE; } uint64_t target_connections (uint64_t)parsed_connections; ​ struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; if (inet_pton(AF_INET, ip, addr.sin_addr) ! 1) { fprintf(stderr, invalid server ip: %s\n, ip); return EXIT_FAILURE; } ​ int epoll_fd epoll_create1(EPOLL_CLOEXEC); if (epoll_fd 0) { perror(epoll_create1); return EXIT_FAILURE; } ​ struct epoll_event *events calloc(MAX_EVENTS, sizeof(*events)); if (events NULL) { perror(calloc events); close(epoll_fd); return EXIT_FAILURE; } ​ uint64_t total_created 0; int active 0; int index 0; struct timespec last_time; clock_gettime(CLOCK_MONOTONIC, last_time); ​ while (total_created target_connections) { int sockfd socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (sockfd 0) { perror(socket); break; } ​ addr.sin_port htons((uint16_t)(base_port index)); index (index 1) % MAX_PORT; ​ /* 本程序以连接密度为目标connect 仍然是串行阻塞式。 */ if (connect(sockfd, (struct sockaddr *)addr, sizeof(addr)) 0) { fprintf(stderr, connect failed: %s\n, strerror(errno)); close(sockfd); break; } ​ char buffer[MAX_BUFFER]; int len snprintf(buffer, sizeof(buffer), Hello Server: client -- %llu\n, (unsigned long long)total_created); if (len 0 || (size_t)len sizeof(buffer)) { fprintf(stderr, message formatting failed\n); close(sockfd); break; } if (send_all_blocking(sockfd, buffer, (size_t)len) 0) { perror(send); close(sockfd); break; } ​ if (set_nonblock(sockfd) 0) { perror(set_nonblock); close(sockfd); break; } ​ struct epoll_event ev; memset(ev, 0, sizeof(ev)); ev.data.fd sockfd; ev.events EPOLLIN | EPOLLRDHUP; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, ev) 0) { perror(epoll_ctl add); close(sockfd); break; } ​ total_created; active; ​ if (total_created % 1000 0) { struct timespec now; clock_gettime(CLOCK_MONOTONIC, now); printf(created: %llu, active: %d, latest 1000 cost: %.3f ms\n, (unsigned long long)total_created, active, diff_ms(last_time, now)); last_time now; ​ int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, 0); if (nfds 0) { handle_events(events, nfds, active); } else if (nfds 0 errno ! EINTR) { perror(epoll_wait); break; } } ​ usleep(CONNECT_INTERVAL_US); } ​ if (total_created ! target_connections) { fprintf(stderr, connection creation stopped early: created%llu, active%d\n, (unsigned long long)total_created, active); free(events); close(epoll_fd); return EXIT_FAILURE; } ​ printf(target reached: created%llu, active%d\n, (unsigned long long)total_created, active); ​ /* 达到目标后继续处理读事件让连接保持存活。 */ while (active 0) { int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds 0) { handle_events(events, nfds, active); } else if (nfds 0 errno ! EINTR) { perror(epoll_wait); break; } } ​ free(events); close(epoll_fd); return 0; }这份客户端仍然保留了原实验的核心逻辑通过命令行获取服务端 IP、起始端口和本机目标连接数在2000~2039之间轮询连接成功后发送一条测试消息再把 socket 交给 epoll 等待 Echo 响应。每创建 1000 条连接打印一次耗时每轮建连后usleep(500)主动降低建连速度避免瞬间把监听队列打满。同时代码修正了原版本的几个问题原版本在事件处理中误用了“最近创建的sockfd”所有事件操作都应该针对该事件对应的clientfdepoll_wait的maxevents不能超过事件数组容量连接完成后不应持续监听几乎总是就绪的EPOLLOUT否则会形成忙循环和发送风暴EPOLLIN、错误和关闭事件也要分别处理。这里的connect是串行阻塞式调用usleep(500)还给每轮建连耗时设置了至少 0.5 毫秒的下限也就是给单客户端的理论建连速率设置了上限。所以这段代码适合生成和保持大量连接不适合作为异步建连吞吐基准。客户端主动连接也不需要在连接完成后设置SO_REUSEADDR它不能让两个并存连接复用相同四元组。编译后运行gcc -O2 -Wall -Wextra -o client client.c ​ # 客户端 1 ./client 192.168.139.130 2000 333334 ​ # 客户端 2 ./client 192.168.139.130 2000 333333 ​ # 客户端 3 ./client 192.168.139.130 2000 333333前三个参数依次是服务端 IP、起始端口和本机目标连接数需要根据实际环境调整。三台客户端同时启动后服务端会持续打印连接统计三台的目标连接数之和必须与服务端的 100 万目标一致。3. 首轮压测与 OOM 定位首轮压测中第一条统计显示从服务端开始计时到累计接收 1000 条连接约为 260 秒其中还包含等待三台客户端启动和人工操作的时间不能当作纯建连耗时。后续相邻批次逐渐稳定在每 1000 条约 0.6~0.7 秒。这个现象只能说明本次环境下的观测结果不能直接得出“协程一定比 Reactor 快”的结论NtyCo 本身也是基于 epoll 和事件循环调度协程而且客户端的串行connect、usleep(500)、日志打印和虚拟机调度都会影响结果。当累计accept数增长到约 73.4 万时服务端进程退出终端重新回到 Shell仅凭这张截图还不能断定退出原因。继续查看内核环形缓冲区中的近期日志sudo dmesg | tail -20 ​ # systemd 系统也可以这样查询 sudo journalctl -k -b | grep -Ei out of memory|killed process输出中包含[ 4120.679707] Out of memory: Killed process 7959 (nty_multi_clien) total-vm:4076324kB, anon-rss:3537628kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:7996kB, oom_score_adj:0这时可以确认内核触发了 OOM Killer并终止了 PID 7959 对应的服务端进程而不是“线程”。total-vm表示进程的虚拟地址空间约 3.89 GiBanon-rss表示进程实际驻留的匿名物理内存约 3.37 GiB。不过不能仅根据这两项就断言进程“吃了 7 GB”。内核 socket 缓冲、slab、页表、其他进程和虚拟机自身的可用内存都会参与系统级 OOM部分开销也不会计入该进程的anon-rss。更完整的观察方式是同时记录free -h cat /proc/net/sockstat ss -s grep -E VmSize|VmRSS|RssAnon /proc/服务端PID/status sudo slabtop -o4. 降低单连接内存开销解决这类 OOM思路仍然是“开源节流”一方面增加可用资源或合理放宽全局预算另一方面降低每条连接的用户态和内核态开销。4.1 缩小业务缓冲区和协程保存栈服务端的client_reader中有一个 4096 字节局部缓冲区char buf[4096];在本文核对的 NtyCo 源码版本中默认启用了_USE_UCONTEXT调度器使用一块共享执行栈协程让出时再把实际使用的栈片段复制到该协程自己的堆内存中。因此头文件里的NTY_CO_MAX_STACKSIZE128KB是共享执行栈和单次保存栈的上限并不等于每个协程一创建就常驻分配 128 KB。复现实验时应保存文首命令输出的 commit SHA避免上游代码变化后套用错误结论。但是buf[4096]在nty_recv挂起时仍然位于有效栈帧中会随保存栈一起复制。将它缩小到 1024 字节确实可以明显降低大量空闲协程的总内存char buf[1024];本次实验还把NtyCo/core/nty_coroutine.h中的栈上限改成了 4 KB#define NTY_CO_MAX_STACKSIZE (4 * 1024) // 本次低流量 TCP 实验值这是一个非常激进的实验值不是通用配置。如果实际调用链使用的栈超过 4 KBNtyCo 保存栈时可能触发断言或产生栈空间不足。更稳妥的做法是先缩小大块局部数组再从 16 KB 等相对保守的值开始压测测出峰值栈使用量后保留安全余量。4.2 分清tcp_rmem、tcp_wmem和tcp_mem先查看调整前的 TCP 内存参数sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_mem这三组参数的含义并不相同tcp_rmemTCP 接收缓冲自动调优的min/default/max单位是字节。tcp_wmemTCP 发送缓冲自动调优的min/default/max单位也是字节。tcp_mem整个系统所有 TCP socket 的全局min/pressure/max单位是内存页不是字节更不是“每条连接的缓冲区大小”。tcp_rmem和tcp_wmem的三个值分别是保证下限、初始值和自动调优上限不代表每条连接建立时会立即分配固定的全部空间。tcp_mem则由内核根据机器内存在启动时计算超过pressure后会进入内存压力模式。可以临时采用下面这组参数sudo sysctl -w \ net.ipv4.tcp_rmem4096 4096 4096 \ net.ipv4.tcp_wmem4096 16384 4194304 \ net.ipv4.tcp_mem262144 393216 524288这组配置要拆开理解把tcp_rmem的上限锁到 4 KB是为了压低低流量连接的接收缓冲增长空间但会明显牺牲真实业务的吞吐能力tcp_wmem基本保持原值tcp_mem的单位是页如果系统页大小为 4 KB那么三档大约对应 1 GiB、1.5 GiB 和 2 GiB它实际上是放宽全局 TCP 内存预算而不是继续“节流”。因此这不是一组通用的生产参数。正式调整前应先保存原值分阶段修改并同时观察吞吐、重传、延迟、sockstat和 OOM 情况。页面大小可以通过下面的命令确认getconf PAGESIZE修改后再次检查如果只是临时实验重启后参数会恢复。需要持久化时建议新建独立配置文件内容必须与已经验证过的临时配置保持一致sudo vim /etc/sysctl.d/99-million-connections.conf写入net.ipv4.tcp_rmem 4096 4096 4096 net.ipv4.tcp_wmem 4096 16384 4194304 net.ipv4.tcp_mem 262144 393216 524288加载配置sudo sysctl -p /etc/sysctl.d/99-million-connections.conf也可以继续使用/etc/sysctl.conf但独立文件更容易回滚和区分实验参数。有时为了“腾内存”会看到下面这种做法sudo sync sudo sysctl -w vm.drop_caches3它只能回收干净页缓存以及可回收的 dentry、inode不能释放进程匿名 RSS、协程保存栈或 TCP socket 缓冲因此不能从根因上解决本文的 OOM。它还会改变缓存冷热状态干扰压测结果不建议把它作为正式调优步骤。5. 再次压测与实验结果完成用户态和内核参数调整后再次启动nty_multi_client_server40 个端口进入监听状态。重新压测前通过htop观察 CPU、内存和 Swap服务端日志显示调优后从服务端开始计时到累计接收首批 1000 条连接约为 5.9 秒这个数字同样可能包含客户端启动时间。之后在累计连接数较低时相邻批次大多维持在每 1000 条约 0.6~0.8 秒随着连接数继续上升内存压力、虚拟机调度和客户端生成速度都会影响后段耗时。最终截图显示服务端的累计accept数达到 98 万距离百万目标还差 2 万在累计 95 万以后每 1000 次accept已经上升到约 2~4.6 秒说明系统正在接近资源边界还要注意原实验代码中的日志计数器只增不减因此截图只能直接证明累计接收了 98 万条连接不能证明当时 98 万条连接全部在线。是否同时保持了接近 98 万条连接需要结合ss的ESTABLISHED统计确认。截图对应的原实验服务端没有正常退出条件上文整理后的代码虽然会在活跃连接达到 100 万后停止继续接入但仍会保留已有连接。因此在 98 万时终端回到 Shell 也不是正常的“任务完成”。究竟是再次 OOM、触发EMFILE、协程栈断言、客户端停止还是被手动终止必须结合服务端退出码、dmesg、客户端错误日志和 fd 数量判断。三、TCP 四元组与多端口连接原理一条 TCP 连接由四个要素唯一确定(源 IP, 源端口, 目的 IP, 目的端口)源 IP 和源端口对应客户端目的 IP 和目的端口对应服务端。四元组中只要有一个要素不同就是另一条 TCP 连接。这也解释了为什么服务端的一个监听端口可以接收大量连接。服务端accept后得到的是一个新的已连接 socket许多已连接 socket 可以拥有相同的服务端 IP 和服务端端口只要客户端 IP 或客户端端口不同四元组仍然唯一。因此“一个服务端端口最多只有约 6.5 万条连接”并不成立这个限制只会在客户端 IP 和目的端点都固定时出现。在本次实验中客户端临时端口范围约为1024~65535粗略按 6.4 万个可用端口计算服务端监听 40 个目的端口。单个客户端 IP 的理论四元组空间约为6.4 万个源端口 × 40 个目的端口 ≈ 256 万条连接三台客户端拥有三个不同的源 IP粗略的组合空间还可以再乘以 3约为 768 万。真实可用数量需要扣除保留端口、已经占用的端口和系统资源限制。虽然客户端程序允许单台最多创建 40 万条连接但本次三台实际配置为333334 333333 333333 1000000与服务端验收目标一致。因此四元组空间不是这次实验最先遇到的瓶颈服务端内存反而先触发了 OOM。同一个客户端源端口也可以用于不同的远端四元组。例如客户端可以用相同的本地端口连接 Redis、MySQL、KV 服务等不同的IP:Port也可以连接同一台服务器的不同端口。内核查找 TCP 连接时依据的是完整四元组而不是只看某一个端口。所以多端口策略的准确作用是当客户端 IP 数量较少时通过增加目的端口数量扩展可用四元组空间。它并不是突破“服务端单端口物理上限”的唯一方案更不会消除 fd、内存、CPU、监听队列和网络设备等实际限制。四、从百万连接到可复现实验边界、指标与结论1. 百万连接不等于百万 QPS本文实战内容的是“百万条连接同时保持”但现有日志只记录了累计accept数最终是否同时在线仍要以连接状态统计为准。绝大多数连接建立后处于空闲等待状态只有最初发送和 Echo 一次。这类实验验证的是连接密度不代表每秒可以处理百万请求也不代表吞吐量和延迟已经满足生产要求。如果真让接近百万条连接同时收发数据CPU、网卡带宽、软中断、内核缓冲、业务序列化和锁竞争都会迅速成为新的瓶颈。因此高并发连接数、QPS、吞吐量和尾延迟必须分开测试。2. 协程解决了什么又没有解决什么NtyCo 的优势是用接近阻塞式的代码组织连接逻辑同时在recv等待数据时挂起协程让单个调度线程复用 epoll 事件循环。它避免了“每连接一个系统线程”带来的线程栈和上下文切换成本也让业务控制流更直观。但协程不会消除以下资源开销瓶颈常见现象重点检查主要处理方向文件描述符Too many open files、EMFILEulimit -n、nr_open、fs.file-max提高进程与系统上限并留出余量四元组空间Cannot assign requested address临时端口范围、客户端 IP、目的端口增加源 IP 或目的端口避开保留端口监听队列突发建连超时、重传somaxconn、tcp_max_syn_backlog、backlog放大队列并提高 accept 速度内存OOM、Swap 上升、后段明显变慢RSS、sockstat、slab、TCP 内存降低单连接开销或增加可用内存可以用一个粗略公式先做容量预算可承载连接数 ≈ 可用于连接的内存 ÷ 单连接综合开销这里的“单连接综合开销”包括用户态连接对象、协程控制块、保存栈、业务缓冲区、文件描述符相关结构、内核 socket、TCP 缓冲和页表等。最可靠的方法不是套用固定数字而是在 10 万、30 万、50 万、80 万等阶段实测增量内存再推算下一阶段是否安全。3. 怎样判断“百万连接实验成功”只看到累计接受连接数达到 100 万还不够。一次更严谨的百万连接实验至少应该同时满足服务端确实存在约 100 万条ESTABLISHED连接而不是把LISTEN、TIME_WAIT等状态混在一起。连接能够稳定保持 5~10 分钟服务端没有 OOM、断言退出或大量重传。随机抽取连接进行 Echo确认连接不是只有内核状态、业务协程却已经失效。同时记录服务端 RSS、系统可用内存、TCP 内存、fd 数、CPU 和连接状态分布。保存内核版本、NtyCo 提交、sysctl、客户端数量和每台客户端的连接目标保证下一次可以复现。本文这次实验的累计接受连接数停在 98 万因此更准确的结论是在 4 核 8 GB 的服务端虚拟机上通过提高 fd 上限、扩展四元组空间、缩小协程有效保存栈并调整 TCP 内存参数NtyCo 单进程已经完成了接近百万次长连接接入但距离“同时稳定保持百万连接”的验收目标还差活跃连接数确认、退出原因定位和稳定性验证。多端口解决四元组组合空间问题fd 调优解决句柄上限降低协程与缓冲区开销解决内存瓶颈。三者缺一不可但真正进入生产环境后还必须重新验证吞吐、延迟、重传和故障恢复能力。参考资料NtyCo 源码仓库Linux listen(2) 手册backlog、somaxconn 与半连接队列Linux 内核 IP Sysctl 文档tcp_rmem、tcp_wmem、tcp_mem 与 tcp_tw_reuseLinux limits.conf(5) 手册