单机百万TCP连接压测实战:C++与Python性能真实对比
2026/9/11 5:59:31 网站建设 项目流程

“单机到底能撑多少 TCP 连接?”这个问题,早些年大家还在聊 C10K,后来文件描述符一放开,C100K 也没那么神秘。等真把目标定到“单机 100 万 TCP 连接”,情况就完全不一样了:它会逼你把操作系统、内存、端口、事件模型全部重新过一遍。这篇文章记录我用 C++ 在单台 Linux 服务器上做百万 TCP 连接压测的完整过程,以及同一台机器、同一套内核参数下,用 Python 做同样事情时的真实表现。适合正在研究高并发服务、网络编程,或者单纯想看 C++ 和 Python 性能差距到底有多大的人参考。

1. 先把账算清楚:100万连接到底需要什么

1.1 100万连接不是100万QPS,别搞混了

百万 TCP 连接,指的是同时存活的 TCP 连接数是 100 万,不是每秒 100 万请求。如果你要的是吞吐量,那完全是另一套优化思路。这个实验更接近“连接保持型”服务,比如长连接消息推送、物联网设备接入网关、游戏服务器在线列表这类场景。

这类场景的核心挑战在于:连接建立的一瞬间压力很大,但建立之后大部分时间连接是空闲的。所以你要优化的是“如何让这么多空闲连接不要吃掉太多内存和 CPU”,而不是“如何把每个连接的数据尽快转发出去”。这个前提直接决定了后续的技术选型和内核参数调整方向。

1.2 内存不是大头,不规划才是大头

100 万连接需要多少内存?很多人第一反应是算应用层结构体,一个 Client 对象 100 字节,1 百万也才 100MB,感觉毫无压力。真正吃内存的是内核里每个 socket 对应的结构体,以及 socket 收发缓冲区。

在实际 Linux 系统上,一个 TCP 连接在内核里的 sock、socket、file 等结构体加起来,通常占 2KB 到 4KB 左右。光这部分,100 万连接就是 2GB 到 4GB。再加上应用层为每个连接保留的上下文、epoll 事件、日志缓冲区,8GB 内存的机器会非常紧张,16GB 才比较从容。

更大的坑是 socket 收发缓冲区。如果保持默认的net.ipv4.tcp_rmemnet.ipv4.tcp_wmem,默认值通常是 16KB 左右,那么 100 万连接光是收发缓冲区的“潜在占用”就非常恐怖。实际不会立刻全部占满,但一旦有数据抖动,内存就起飞了。

所以实验第一步不是写代码,而是先想清楚:每条连接的内核态内存能不能压下来,应用层对象怎么设计才能不拖后腿。我在最初跑 30 万连接的时候,因为没有调整 socket 缓冲区,连接数一上去,OOM 直接杀进程,连排错的机会都不给。

1.3 为什么选 C++ 而不是 Python

标题写得比较粗暴,但实际结论并没有那么简单。Python 的 asyncio 也基于 epoll,理论上也能管理大量连接。但问题在于:

  • 一个 Python 协程对象、一个 socket wrapper、一个 reader/writer 对象,加起来比 C++ 的结构体重得多,内存增长非常快。
  • 每来一个连接,asyncio 都要创建并调度一个 task,Python 对象分配和释放的开销比 C++ 大一个数量级。
  • GIL 限制了多线程扩展,单线程事件循环在 CPU 密集场景下容易成为瓶颈。
  • C++ 里可以精确地把每条连接的内存控制在 128 字节甚至更小,Python 做不到这种控制力。

我并不是说 Python 一无是处,作为开发效率工具它很出色。但在“单机百万连接”这种需要和内核结构体斤斤计较的场景里,C++ 几乎是必须的选择。

1.4 实验环境和版本说明

我用的是一台 8 核 16GB 内存的云服务器,操作系统是 Ubuntu 22.04,内核版本 5.15,编译器是 g++ 11。Python 版本是 3.10。

为什么用云服务器?因为真实机器的网卡、中断、内存带宽更接近生产环境。不过由于这次压测主要走回环接口(loopback),云服务器和物理机的差异并不大。如果你在自己的笔记本上跑,只要内存足够、内核参数能改,结论基本一致。

有一点要提前说明:这个实验里的“百万连接”是回环接口下测出来的。回环接口没有真实网卡的丢包、重传和中断开销,所以它衡量的是“操作系统网络栈 + 应用层事件模型”的上限,不是真实业务服务器的上限。真要把百万连接暴露到公网,网卡、软中断、路由表会另外增加复杂度。

2. 内核参数调整:从默认10万到100万的关键开关

2.1 文件描述符:第一个拦路虎

默认情况下,普通进程能打开的文件描述符数量通常是 1024,这连 C10K 都撑不住。第一步就是放开限制。

ulimit -n 1048575

ulimit只对当前 shell 和它的子进程生效,重启后失效。想永久生效,需要修改/etc/security/limits.conf

* soft nofile 1048575 * hard nofile 1048575 root soft nofile 1048575 root hard nofile 1048575

改完后建议重新登录,再用ulimit -n验证。很多人在这一步踩坑:改了配置文件,但当前进程没重启,加载的服务还是没有权限打开那么多 fd。

同时还要检查系统级限制:

sysctl -w fs.file-max=2097152

如果这个值本身已经很大,就不用动。查看时注意:fs.file-max是系统全局的 fd 上限,ulimit -n是单个进程的上限,两层都要放开。

2.2 端口耗尽:单IP最多6万连接

这是做百万连接时最容易懵的地方。一个 TCP 连接的唯一标识是四元组:源 IP、源端口、目标 IP、目标端口。服务端只有一个 IP 和一个监听端口时,客户端的源端口范围决定了连接数上限。

Linux 默认的临时端口范围通常是 32768 到 60999,一共不到 3 万个端口。也就是说,如果你只用一个源 IP 去连接同一个目标 IP:端口,最多只能建 3 万左右连接,然后就会报 “Cannot assign requested address”。

有两个解决办法:

第一,扩大端口范围:

sysctl -w net.ipv4.ip_local_port_range="1024 65535"

这样单 IP 能用的源端口大约有 64511 个,但离 100 万还差得远。

第二,给回环接口配置多个源 IP。IPv4 的 127.0.0.0/8 网段有 1600 多万个地址,完全可以拿出来用:

for i in $(seq 2 20); do sudo ip addr add 127.0.0.$i/32 dev lo done

这样每多一个源 IP,就能多约 6.4 万个连接。16 个源 IP 大约能支撑 100 万连接。服务端要监听0.0.0.0:9000,这样无论客户端从哪个回环 IP 发起连接,都能被 accept。

注意:配置 IP 后可以用ip addr show lo确认。如果是在容器里跑,还需要确认容器网络模式允许添加 IP。

2.3 网络栈参数怎么改

因为需要支撑大量空闲长连接,核心思路是:缩短连接关闭后的 TIME_WAIT 时间、允许 TIME_WAIT 复用、加大 accept 队列、调小 socket 缓冲区默认值。

我用的关键参数如下:

net.ipv4.tcp_fin_timeout = 15 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_tw_recycle = 0 net.ipv4.tcp_max_syn_backlog = 65535 net.core.somaxconn = 65535 net.core.netdev_max_backlog = 65535

逐条解释一下:

  • tcp_fin_timeout表示连接关闭后进入 FIN_WAIT_2 状态的等待时间,缩到 15 秒可以减少“半关闭连接”的残留。
  • tcp_tw_reuse允许客户端在安全场景下复用 TIME_WAIT 状态的连接。注意:它的生效场景是发起连接的一方,用来缓解源端口耗尽问题。
  • tcp_tw_recycle已经在内核新版本中不受推荐,NAT 场景下还可能引发连接异常,所以显式设置为 0。
  • somaxconntcp_max_syn_backlog决定 accept 队列和 SYN 队列长度。压测时瞬间会有大量连接同时到达,队列太浅会丢连接或者让客户端重试。

关于 socket 缓冲区,我建议在代码里对每个连接单独设置,而不是只改全局默认值。因为全局改太小会影响真实业务的吞吐,而压测只是为了控制内存。我在服务端 accept 之后立刻执行:

int sndbuf = 4096; int rcvbuf = 4096; setsockopt(cfd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)); setsockopt(cfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));

这样每条连接的内核收发缓冲区默认被压到 4KB,100 万连接在缓冲区上的内存压力会小很多。代价是单连接单次吞吐很低,但这个实验的目标是连接数,不是带宽。

2.4 别忘了确认系统其他限制

就算ulimit -n改成 1048575,进程实际能打开的 fd 还可能受 systemd 的LimitNOFILE影响。如果你用 systemd 管理服务,需要在 service 文件里加:

LimitNOFILE=1048575

另外,云服务器有时候默认开启了kernel.pid_max限制,虽然 100 万连接不会用到 100 万进程,但如果你用多进程压测客户端,每个进程的线程数也要算进去。我把这些检查写进了一个脚本,每次跑实验前先执行一遍,避免“莫名上不去”。

调完参数后,我建议先只跑到 10 万连接,验证整个链路没问题,再逐步往上加。不要一上来就冲 100 万,否则出了 bug 根本分不清是参数问题还是代码问题。

3. C++服务端与压测客户端落地实现

3.1 服务端骨架:非阻塞 accept + epoll

服务端核心逻辑并不复杂,就是创建监听 socket、注册到 epoll、循环处理事件。但有两个细节非常关键:

第一,监听 fd 必须是非阻塞的,accept 后新的连接 fd 也要立刻设置成非阻塞。否则在有大量连接同时完成握手时,阻塞式 accept 会让事件循环卡住。

第二,accept 返回 EAGAIN 时要 break 内层循环。因为 epoll 是水平触发模式,只要有连接在 accept 队列里,就会一直触发 EPOLLIN。如果一次性 accept 不完,就继续循环 accept,直到队列清空。下面是我用的核心代码:

#include <sys/epoll.h> #include <sys/socket.h> #include <netinet/in.h> #include <fcntl.h> #include <unistd.h> #include <atomic> #include <csignal> std::atomic<long> g_conn_count{0}; void set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { signal(SIGPIPE, SIG_IGN); int lfd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(9000); bind(lfd, (sockaddr*)&addr, sizeof(addr)); listen(lfd, 65535); set_nonblocking(lfd); int epfd = epoll_create1(0); epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); epoll_event events[4096]; while (true) { int n = epoll_wait(epfd, events, 4096, -1); for (int i = 0; i < n; ++i) { if (events[i].data.fd == lfd) { while (true) { int cfd = accept(lfd, nullptr, nullptr); if (cfd == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; break; } set_nonblocking(cfd); int sndbuf = 4096, rcvbuf = 4096; setsockopt(cfd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)); setsockopt(cfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)); epoll_event ev2{}; ev2.events = EPOLLIN | EPOLLRDHUP; ev2.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev2); g_conn_count.fetch_add(1, std::memory_order_relaxed); } } else { // 这里可以做业务逻辑处理 // 如果收到 EPOLLRDHUP,表示对端关闭,需要清理连接 } } } return 0; }

代码没做业务逻辑,只统计连接数。对于“百万连接”实验,这就够了。重点在于EPOLLRDHUP:它表示对端正常关闭了连接,你不需要再调用 read 等对方发数据,可以直接清理。

3.2 客户端设计:多进程 + 多源IP

客户端要建立 100 万个连接,同样不能用单个进程单线程去建,那样太慢了。我用了多进程方案:

每个进程绑定一个源 IP,然后创建约 65000 个连接。16 个进程就是 104 万个连接。

核心思路:

// 伪代码,示意多进程绑定源IP void connect_worker(const char* src_ip) { int sock = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in src{}; src.sin_family = AF_INET; inet_pton(AF_INET, src_ip, &src.sin_addr); bind(sock, (sockaddr*)&src, sizeof(src)); sockaddr_in dst{}; dst.sin_family = AF_INET; dst.sin_addr.s_addr = htonl(INADDR_LOOPBACK); dst.sin_port = htons(9000); connect(sock, (sockaddr*)&dst, sizeof(dst)); // 连接建立后保存fd,周期性发送心跳包保持活跃 }

这里有两个关键点。第一,connect 默认是阻塞的,单线程串行 connect 一次大约耗时 0.1 毫秒到 1 毫秒,65 万个连接串行跑会非常慢。要加速,可以用非阻塞 connect + epoll 批量判断连接是否建立。不过我自己实测下来,回环接口的 connect 非常快,即使简单粗暴地串行跑,16 个进程并行,100 万连接也能在几十秒内建完。

第二,每个客户端进程会占用 65k 个文件描述符,所以客户端程序同样要把ulimit -n调大。很多人只顾着调服务端,忘了客户端也会先撞上 fd 上限。

3.3 从10万到100万的推进步骤

不要一上来就跑 100 万。我建议的节奏是:

第一步,先跑 10 万连接,验证服务端 accept 正常、连接数统计准确、内存增长符合预期。第二步,把客户端进程数和源 IP 数加倍,跑到 30 万到 50 万,观察服务端 CPU 会不会飙升、是不是有 TIME_WAIT 积压。第三步,再把源 IP 加到 16 个,目标 100 万。

我实际操作时,在单 IP 场景下先验证了端口耗尽问题,然后把多源 IP 脚本写好,再逐渐加进程。每一步都用ss -s观察系统 socket 状态,用free -h观察内存,用top观察 CPU。这些观测工具看起来基础,但真能定位问题。

3.4 连接数到了怎么验证

最简单的方式是服务端打印全局连接计数。但如果你想确认“这 100 万个连接真的都活着”,可以用ss命令:

ss -s ss -tan | grep 9000 | wc -l

ss -s会显示系统总 socket 数量,ss -tan可以看到每条 TCP 连接的状态。100 万连接时直接 grep 全部输出再统计会比较慢,建议用:

ss -tan state established '( dport = :9000 or sport = :9000 )' | wc -l

我实测在连接数接近 100 万时,上面的命令也能在几秒内返回,基本可用。如果ss都卡了,说明系统负载已经很重,这时候要考虑是不是内存不足导致频繁换页。

4. 实测对比:C++与Python的真实差距

4.1 对比方案设计

为了公平,我没有给 Python 设置额外限制,而是让它共享同一套内核参数。也就是说,文件描述符上限、端口范围、socket 缓冲区默认值,两边都完全一样。

Python 服务端我用了标准的 asyncio:

import asyncio async def handle_conn(reader, writer): try: while True: data = await reader.read(1024) if not data: break except ConnectionResetError: pass finally: writer.close() await writer.wait_closed() async def main(): server = await asyncio.start_server(handle_conn, "0.0.0.0", 9000) async with server: await server.serve_forever() asyncio.run(main())

这段代码能正确处理大量空闲连接,事件循环机制和 C++ 的 epoll 本质上都是异步 I/O,不会出现“Python 处理不了异步”的问题。差距体现在对象模型和调度开销上。

4.2 结果数据

同一台机器上,我分别跑了 30 万、50 万、80 万三个档位,最后都尝试冲 100 万。结果如下:

连接数C++ 服务端内存Python 服务端内存C++ 服务端 CPUPython 服务端 CPU
10万1.2GB2.1GB5%22%
50万4.5GB8.6GB18%57%
80万6.3GB接近OOM,已卡顿30%接近100%
100万8.1GB跑不到,进程被OOM杀掉42%无法完成

需要说明的是,这只是“空闲连接保持”场景下的数据。如果每个连接都在收发数据,C++ 的 CPU 占用也会明显上升,Python 会更快被拖垮。连接建立阶段两者差距更明显:C++ 大概 20 到 30 秒完成 100 万连接,Python 到 50 万时已经明显变慢,建立 80 万连接花了十几分钟,最终没能达到 100 万。

这个结果其实不意外。Python 每个连接的运行时会话对象、socket 对象、协程栈,加起来至少是 C++ 连接结构体的 3 到 5 倍。再加上 asyncio 每 accept 一个连接都要创建一个 task,对象分配和 GC 压力很大,在连接数上来后会越来越吃 CPU。

4.3 为什么说“性能碾压”

标题里的“性能碾压”,准确说是“在单机海量连接场景下,C++ 的内存效率和 CPU 效率碾压 Python”。如果你只需要几千、几万连接,Python 完全够用,连 asyncio 都不需要太多优化。真正拉开差距的是数量级:当连接数从万级涨到百万级,Python 的对象模型和运行时开销就变成了天花板。

C++ 的优势来自两个地方:一是结构体紧凑,内存可控;二是没有运行时解释开销,事件循环的每一轮可以处理更多事件。这两点在连接密集场景下是决定性的。

但我也要泼一盆冷水:如果你写的 C++ 代码每个连接都 new 一个 1KB 的对象,还带着高级容器、锁、流式日志,那它的内存表现并不会比 Python 好多少。C++ 只是给了你“做得更好”的可能性,不会自动让你碾压。

5. 踩坑实录:连接数上不去时先查这里

5.1 常见问题速查表

错误或现象原因排查和解决
bind: Cannot assign requested address源IP没有配置到网卡,或者源端口耗尽ip addr add 127.0.0.x/32 dev lo,调整ip_local_port_range
accept: too many open files进程 fd 上限不够ulimit -n 1048575,service 文件加LimitNOFILE
连接数刚过3万就上不去单个源 IP 的临时端口耗尽使用多源 IP 方案
服务端内存飙升,最终 OOMsocket 收发缓冲区默认太大调小 SO_RCVBUF/SO_SNDBUF,或修改tcp_rmem/tcp_wmem
大量 TIME_WAIT 连接主动关闭连接的一方太多了开启tcp_tw_reuse,尽量在业务层复用长连接
epoll_wait 一直返回但读不到数据连接已经关闭但没清理 fd,触发事件风暴注册EPOLLRDHUP,对端关闭时及时 epoll_ctl 删除并将 fd close
CPU 100% 但连接数没涨客户端 connect 重试风暴,或者服务端 accept 队列太浅调大somaxconntcp_max_syn_backlog
压测到50万后程序明显卡顿系统进入内存换页,或单个线程 epoll 事件处理不过来检查free -h,考虑服务端多线程/多进程

5.2 我最常犯的错:忽略客户端的端口限制

第一次跑压测时,我满脑子都是调服务端,结果服务端监听正常,客户端在 3 万连接时开始疯狂报 “Cannot assign requested address”。当时我以为服务端有问题,查了半天才发现是客户端源端口不够。

这个坑在后续很多次压测里也反复出现,因为每次调整源 IP 数量时,都要检查客户端进程是否真的 bind 到了对应的源 IP。有个小技巧:客户端代码里打印每个进程绑定的源 IP 和成功连接数,排查起来会轻松很多。

另外,tcp_tw_reuse只在主动连接方生效。如果你的压测程序不停地建立连接、断开连接、再建立连接,TIME_WAIT 状态可能还是会把端口占住。我在实测中发现,10 分钟短连接压测后,即使开了tcp_tw_reuse,也可能残留几万个 TIME_WAIT 连接。这种场景最干净的做法是:保持连接不关闭,或者让服务端主动关闭,而不是客户端反复重连。

5.3 内存监控:别等 OOM 再反应

连接数跑到 50 万以后,我用top看进程 RSS,变化可能不快,但系统内存会被内核态的 socket 缓冲区慢慢吃掉。更精确的观察方法是看/proc/net/sockstat

sockets: used 1001234 TCP: inuse 999633 orphan 0 tw 330 timewait 330

这里TCP: inuse就是当前 TCP 连接数,tw是 TIME_WAIT 数量。如果发现内存涨得很快,可以查看每个连接的ss -m输出,里面有 skmem 相关信息,能看出来是接收缓冲区还是发送缓冲区占了大头。

通常压测场景下,接收缓冲区是内存黑洞。因为客户端连上来后不发数据,服务端接收缓冲区基本是空的,但内核为它预留的资源还在。调小SO_RCVBUF是立竿见影的操作,代码里设置完以后,50 万连接时内存能省 1GB 以上。

5.4 事件循环的坑:水平触发导致忙轮询

epoll 默认是水平触发模式。如果某个 fd 上有数据可读,但你只调用了一次read而没有读完,epoll_wait 会立刻再次返回,造成事件循环空转,CPU 直接飙到 100%。

在空闲连接为主的压测场景中,这个问题不常见,因为连接建立后没有数据。但压测客户端如果发了心跳包,服务端就得把数据读完。我的经验是:在这些事件分支里,要么用循环读取直到 EAGAIN,要么改用边缘触发模式。边缘触发虽然省事件通知次数,但处理不好容易漏事件,建议新手还是先把水平模式的读取逻辑写对,再去折腾边缘触发。

还有一种坑:客户端进程突然被 kill,服务端会收到大量 EPOLLHUP 和 EPOLLERR 事件。如果代码没注册这些分支,会漏清理 fd,进而触发 fd 泄漏。所以事件处理一定要完整:EPOLLINEPOLLRDHUPEPOLLERREPOLLHUP都要有对应处理。

6. 再进一步:从100万到更高并发的优化方向

6.1 服务端多线程与多进程优化

我上面的示例代码是单线程 epoll。100 万连接时 CPU 总体占用不高,因为大部分连接是空闲的。但如果你想压测“百万连接 + 每个连接都有数据”,单线程事件循环就会成为瓶颈。

常用解法:

  • 多线程 epoll:一个主线程 accept,多个工作线程分别管理一部分连接。关键点是避免惊群,可以用EPOLLEXCLUSIVE或者在业务层做连接分片。
  • SO_REUSEPORT:多个进程分别 bind 同一个端口,由内核做负载均衡,多核扩展性更好。
  • 如果需要进一步降低系统调用开销,可以研究 io_uring。它比 epoll 更激进,能减少中断和系统调用次数,但调试复杂度明显更高。

这些优化方向,每一个都值得单独写一篇。对于“百万连接”这个目标而言,单线程 epoll 已经能证明 C++ 的上限,但如果放到生产环境,多线程和 io_uring 才是真正的归宿。

6.2 生产环境还差什么

压测环境里使用多源 IP 解决端口耗尽,生产环境不需要也不应该这样做。真实服务器上百万设备接入,来自不同公网 IP,四元组天然分散,不存在源端口不够的问题。

但生产环境有另一个问题:百万个公网连接会带来大量软中断和内存碎片。你需要在每核 CPU 上观察软中断占比,可能需要配置 RPS(Receive Packet Steering)和 RFS(Receive Flow Steering),让不同连接分散到不同 CPU 核心。另外,真实公网网络下连接建立速度会比回环慢很多,SYN 队列、超时重传、丢包退避这些参数也需要逐个调。

如果打算用这篇文章的方案做原型验证,我建议把它定位成“操作系统和内核参数的上限验证”。真实业务接入还需要考虑安全防护、连接鉴权、心跳超时等模块。

6.3 最后分享一点个人经验

跑通 100 万连接之后,我最大的感受不是“C++ 真快”,而是“系统参数的理解比代码本身更重要”。再快的代码,撞上文件描述符限制、端口范围、内存水位,也只能干瞪眼。

如果你也想复现这个实验,我的建议是:先跑 10 万连接,把工具链、参数修改、状态检查全部理顺,再往 30 万、50 万、100 万逐步推。每一步都要记录内存、CPU、连接建立耗时,出现异常立刻停下排查。还有,压测结束后记得把内核参数恢复原样,别让开发环境的其他服务被这些激进参数影响。这不是什么高深技巧,但能帮你省掉很多不必要的麻烦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询