Linux 高性能网络:NtyCo 协程实现百万 TCP 并发连接

发布时间:2026/10/24 6:45:57
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

以下是我的终端输出内容:

lazywin@ubuntu:~/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 lazywin@ubuntu:~/doublelearn$ git -C NtyCo rev-parse HEAD 72ab5fd04f0c228f464f160aaa521bb791b34aa5

2. 提高进程可用的文件描述符上限

服务端每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-max

nr_open是单进程可设置的文件描述符上限,fs.file-max是系统级文件句柄上限。三台客户端每台计划创建约 33.3 万个连接,因此客户端也要执行同样的检查。1048576对服务端百万连接只是勉强够用,实际仍要为其他描述符留出余量。

3. 正确认识半连接队列和全连接队列

调用listen网络API后 TCP 建连阶段主要涉及两个队列:

  • 半连接队列:服务端收到 SYN 并回复 SYN+ACK 后,连接处于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.somaxconn=65535 sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535

应用代码中的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_range="1024 65535" ​ # 2. 仅在反复建立、关闭连接的压测中考虑开启 TIME_WAIT 复用 sudo sysctl -w net.ipv4.tcp_tw_reuse=1

验证是否生效:

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、内存和 Swap:

sudo 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或协程创建失败时必须关闭刚刚接收的 socket;40 个监听协程共用的统计起点只在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显示内存与 Swap,ss -tan列出 TCP socket,grep匹配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状态的监听 socket:

2. 客户端实现

三台客户端分别运行同一个压测程序。程序允许通过第三个参数指定本机的目标连接数,单台上限为 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”,所有事件操作都应该针对该事件对应的clientfd;epoll_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 GiB;anon-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 -o

4. 降低单连接内存开销

解决这类 OOM,思路仍然是“开源节流”:一方面增加可用资源或合理放宽全局预算,另一方面降低每条连接的用户态和内核态开销。

4.1 缩小业务缓冲区和协程保存栈

服务端的client_reader中有一个 4096 字节局部缓冲区:

char buf[4096];

在本文核对的 NtyCo 源码版本中,默认启用了_USE_UCONTEXT:调度器使用一块共享执行栈;协程让出时,再把实际使用的栈片段复制到该协程自己的堆内存中。因此,头文件里的NTY_CO_MAX_STACKSIZE=128KB是共享执行栈和单次保存栈的上限,并不等于每个协程一创建就常驻分配 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 KB,NtyCo 保存栈时可能触发断言或产生栈空间不足。更稳妥的做法是先缩小大块局部数组,再从 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_rmem:TCP 接收缓冲自动调优的min/default/max,单位是字节。

  • tcp_wmem:TCP 发送缓冲自动调优的min/default/max,单位也是字节。

  • tcp_mem:整个系统所有 TCP socket 的全局min/pressure/max,单位是内存页,不是字节,更不是“每条连接的缓冲区大小”。

tcp_rmem和tcp_wmem的三个值分别是保证下限、初始值和自动调优上限,不代表每条连接建立时会立即分配固定的全部空间。tcp_mem则由内核根据机器内存在启动时计算,超过pressure后会进入内存压力模式。

可以临时采用下面这组参数:

sudo sysctl -w \ net.ipv4.tcp_rmem="4096 4096 4096" \ net.ipv4.tcp_wmem="4096 16384 4194304" \ net.ipv4.tcp_mem="262144 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_caches=3

它只能回收干净页缓存以及可回收的 dentry、inode,不能释放进程匿名 RSS、协程保存栈或 TCP socket 缓冲,因此不能从根因上解决本文的 OOM。它还会改变缓存冷热状态,干扰压测结果,不建议把它作为正式调优步骤。

5. 再次压测与实验结果

完成用户态和内核参数调整后,再次启动nty_multi_client_server,40 个端口进入监听状态。重新压测前,通过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 万还不够。一次更严谨的百万连接实验,至少应该同时满足:

  1. 服务端确实存在约 100 万条ESTABLISHED连接,而不是把LISTEN、TIME_WAIT等状态混在一起。

  2. 连接能够稳定保持 5~10 分钟,服务端没有 OOM、断言退出或大量重传。

  3. 随机抽取连接进行 Echo,确认连接不是只有内核状态、业务协程却已经失效。

  4. 同时记录服务端 RSS、系统可用内存、TCP 内存、fd 数、CPU 和连接状态分布。

  5. 保存内核版本、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_reuse

  • Linux limits.conf(5) 手册