单机百万TCP连接压测实战:C++与Python性能真实对比

发布时间:2026/9/11 5:59:47
单机百万TCP连接压测实战:C++与Python性能真实对比 “单机到底能撑多少 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_rmem和net.ipv4.tcp_wmem默认值通常是 16KB 左右那么 100 万连接光是收发缓冲区的“潜在占用”就非常恐怖。实际不会立刻全部占满但一旦有数据抖动内存就起飞了。所以实验第一步不是写代码而是先想清楚每条连接的内核态内存能不能压下来应用层对象怎么设计才能不拖后腿。我在最初跑 30 万连接的时候因为没有调整 socket 缓冲区连接数一上去OOM 直接杀进程连排错的机会都不给。1.3 为什么选 C 而不是 Python标题写得比较粗暴但实际结论并没有那么简单。Python 的 asyncio 也基于 epoll理论上也能管理大量连接。但问题在于一个 Python 协程对象、一个 socket wrapper、一个 reader/writer 对象加起来比 C 的结构体重得多内存增长非常快。每来一个连接asyncio 都要创建并调度一个 taskPython 对象分配和释放的开销比 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-max2097152如果这个值本身已经很大就不用动。查看时注意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_range1024 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。somaxconn和tcp_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));这样每条连接的内核收发缓冲区默认被压到 4KB100 万连接在缓冲区上的内存压力会小很多。代价是单连接单次吞吐很低但这个实验的目标是连接数不是带宽。2.4 别忘了确认系统其他限制就算ulimit -n改成 1048575进程实际能打开的 fd 还可能受 systemd 的LimitNOFILE影响。如果你用 systemd 管理服务需要在 service 文件里加LimitNOFILE1048575另外云服务器有时候默认开启了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::atomiclong 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 -lss -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 服务端我用了标准的 asyncioimport 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 服务端 CPU10万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_rangeaccept: too many open files进程 fd 上限不够ulimit -n 1048575service 文件加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 closeCPU 100% 但连接数没涨客户端 connect 重试风暴或者服务端 accept 队列太浅调大somaxconn和tcp_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/sockstatsockets: 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 泄漏。所以事件处理一定要完整EPOLLIN、EPOLLRDHUP、EPOLLERR、EPOLLHUP都要有对应处理。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 上观察软中断占比可能需要配置 RPSReceive Packet Steering和 RFSReceive Flow Steering让不同连接分散到不同 CPU 核心。另外真实公网网络下连接建立速度会比回环慢很多SYN 队列、超时重传、丢包退避这些参数也需要逐个调。如果打算用这篇文章的方案做原型验证我建议把它定位成“操作系统和内核参数的上限验证”。真实业务接入还需要考虑安全防护、连接鉴权、心跳超时等模块。6.3 最后分享一点个人经验跑通 100 万连接之后我最大的感受不是“C 真快”而是“系统参数的理解比代码本身更重要”。再快的代码撞上文件描述符限制、端口范围、内存水位也只能干瞪眼。如果你也想复现这个实验我的建议是先跑 10 万连接把工具链、参数修改、状态检查全部理顺再往 30 万、50 万、100 万逐步推。每一步都要记录内存、CPU、连接建立耗时出现异常立刻停下排查。还有压测结束后记得把内核参数恢复原样别让开发环境的其他服务被这些激进参数影响。这不是什么高深技巧但能帮你省掉很多不必要的麻烦。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询