你是否听说过C10K 问题?

发布时间:2026/10/8 19:00:01
你是否听说过C10K 问题?
1. 来源这个名词是怎么来的C10K Concurrent 10,000意思是单台服务器同时支撑一万个连接。这个说法来自Dan Kegel 在 1999 年整理的一份文档标题就叫《The C10K problem》。它把当时各种操作系统和编程模型的做法汇总在一起回答一个问题一台机器要同时照看一万个连接为什么这么难分别有哪些办法为什么是 1999 年因为那几年互联网刚普及服务器从服务几百人变成服务几万人。而当时的编程习惯是一个连接配一个线程——这个模型在几百连接时挺好用到了一万就彻底崩了。后来这个目标被攻克又出现了C10M一千万连接但那是内核旁路、用户态协议栈那个层次的事了。C10K 的意义在于它把高并发这件事从概念变成了一个具体的工程目标。2. 原理为什么一万个连接会成为问题关键在于传统模型把连接数和线程数绑在了一起。// 一连接一线程连接数 线程数ServerSocketservernewServerSocket(8080);while(true){Socketsocketserver.accept();// 阻塞等待新连接newThread(()-handle(socket)).start();// 每来一个连接就开一个线程}这段代码很直观也很容易写对。问题出在成本上——每多一个连接就要多付一份固定的开销而这些开销和这个连接是否活跃无关。2.1 逐项拆开看成本项说明线程栈内存默认每个线程 8 MB虚拟内存Linux 上内核对象每个 socket 有接收缓冲区、发送缓冲区、协议控制块上下文切换每个线程被调度一次就要保存/恢复寄存器、刷新 TLB调度器压力可运行线程越多调度决策越慢accept 惊群多个进程抢同一个 listen fd新连接唤醒全部光算栈内存这一项一万个线程 × 8 MB 80 GB虚拟地址空间。这就是为什么 32 位系统在这一关直接出局——用户态地址空间总共只有 3 GB 左右几万个连接连地址空间都不够分跟物理内存有多少毫无关系。64 位系统绕过了地址空间限制但物理内存和上下文切换的开销逃不掉一万个活跃线程每秒上下文切换上万次每次几微秒CPU 大量时间花在换人而不是干活上每个线程的实际内存占用RSS哪怕压到几十 KB加起来也是几百 MB 到几 GB2.2 更隐蔽的一层等待 I/O 的白白占用这是问题的真正核心。// 这个线程 99% 的时间在这里阻塞等待intnin.read(buf);连接大部分时间是空闲的。一个聊天的长连接可能几分钟才来一条消息。但在这几分钟里线程一直占着栈内存线程一直挂在调度器里线程什么活都没干一万个连接里有九千个空闲你依然要养着一万个线程。资源被连接数量而非实际工作量决定——这才是 C10K 的病根。新连接到达新建一个线程来处理连接数 线程数每个线程占栈内存还要参与 CPU 调度连接空闲时资源也不释放线程一直阻塞在等 I/O一万连接 一万线程内存和切换成本线性增长瓶颈不在 CPU 和带宽在线程数量本身2.3 还有一层怎么知道哪个连接有数据了如果不想给每个连接配线程就必然要面对一个新问题一个线程怎么盯着上万个连接早期答案是select/poll// 每次调用把整个 fd 集合从用户态拷进内核// 内核再从头到尾遍历一遍看哪个就绪select(maxfd1,readfds,NULL,NULL,NULL);这是 O(n) 的。一万个连接每次事件循环都要扫一万次即使只有两个连接真的活跃。连接越多无效的扫描工作越多——CPU 被烧在检查有没有事上。这就是 epoll 出现的原因后面细说。2.4 别忘了这些不起眼的限制限制默认值后果单进程 fd 上限1024到一千就Too many open files系统级 fd 上限视发行版同上accept 队列长度128somaxconn高并发下新连接被丢本地端口范围约 2.8 万个主动连接多时不够用很多连接数上不去的故障根因不在代码而在这几个数字上。3. 常见现象撑不住时长什么样C10K 撑不住的时候症状很有辨识度现象说明CPU 不高吞吐也上不去大量线程在阻塞等待不在计算load average 很高CPU 使用率很低典型特征负载算的是可运行 不可中断的进程数vmstat里cs列飙升上下文切换次数异常高内存被吃光OOM线程栈累积unable to create new native thread线程数到达上限新建连接超时accept 队列溢出延迟随连接数急剧恶化尾延迟P99爆炸均值看着还行Too many open filesfd 耗尽大量 TIME_WAIT短连接频繁开关机器一重启就正常跑一段时间又不行连接泄漏或 fd 泄漏最迷惑人的一条是CPU 不高但就是慢。因为瓶颈不是算力是调度和内存——CPU 在忙着切换线程而不是在跑业务代码。4. 如何改进七个层次按收益 / 成本从高到低排。4.1 换 I/O 模型从 select 到 epoll核心这是 C10K 最重要的单项改进。select / pollepoll复杂度每次 O(n) 遍历全部连接每次 O(就绪连接数)fd 集合每次调用都要拷进内核常驻内核增删改时才动上限select 有 fd 数量硬限制只受系统 fd 上限约束epoll 的三个关键设计Linux 2.6 起BSD/macOS 是 kqueueWindows 是 IOCPintepfdepoll_create1(0);// 1. 建一张关注列表epoll_ctl(epfd,EPOLL_CTL_ADD,fd,event);// 2. 增删改红黑树管理O(log n)epoll_ctl(epfd,EPOLL_CTL_MOD,fd,event);// 连接注册一次就留在里面epoll_ctl(epfd,EPOLL_CTL_DEL,fd,event);nepoll_wait(epfd,events,maxevents,-1);// 3. 只返回就绪的连接它为什么快fd 常驻内核——不用每次把上万个 fd 拷来拷去红黑树管理——增删改单个连接是 O(log n)不是重建整个集合就绪链表 回调——哪个连接的网卡收到数据了内核把它挂到就绪链表上epoll_wait直接返回链表里的不遍历未就绪的连接结果一万个空闲连接和一百个空闲连接的等待成本几乎一样。4.2 事件驱动一个线程管上万个连接SelectorselectorSelector.open();serverChannel.register(selector,SelectionKey.OP_ACCEPT);while(true){selector.select();// 阻塞几乎不耗 CPUfor(SelectionKeykey:selector.selectedKeys()){// 只处理就绪的那几个连接// 读数据、处理、写回然后立刻返回循环}}一个线程 一张关注列表里面登记上万个连接调用 epoll_wait阻塞等待不消耗 CPU内核只把就绪的连接报回来不遍历全部连接线程逐个处理就绪连接读或写处理完即走回到 epoll_wait循环往复一万个空闲连接几乎不占 CPU这就是Reactor 模式也是 Nginx、Redis、Netty 的底座。⚠️ 这套模型最致命的坑事件循环线程绝对不能阻塞。你在里面做一次数据库查询、一次文件读取上万个连接全部一起卡住。异步编程难写的恶名根源就在这里。4.3 补上异步编程的易用性协程事件驱动解决了性能但代码被拆成一堆回调难写难调。协程用户态线程解决的是这个体验问题让异步代码写成同步的样子。语言实现Gogoroutine channelJava虚拟线程JDK 21 正式版Kotlin协程Pythonasyncio注意一个常见误解协程不是C10K 的解法。底层用的还是 epoll只是把回调地狱换成了看起来同步的代码。性能来自 epoll易用性来自协程。4.4 减少单连接开销如果暂时不能改模型先抠成本缩小线程栈-Xss512k、ulimit -s用线程池避免来一个起一个但注意池化后连接会排队调 fd 上限ulimit -n 65535还要看fs.file-max这些是续命手段改变不了线性增长的本质。4.5 内核参数调优fs.file-max1000000# 系统级 fd 上限net.core.somaxconn32768# accept 队列长度net.ipv4.tcp_max_syn_backlog8192# 半连接队列net.ipv4.ip_local_port_range1000065000# 可用端口范围net.ipv4.tcp_tw_reuse1# 复用 TIME_WAIT 端口net.core.rmem_max16777216# 收发缓冲区上限net.core.wmem_max16777216SO_REUSEPORT值得单独提它让每个进程/线程各自持有独立的 listen socket由内核做负载均衡从根上消除 accept 惊群。4.6 从架构上减少连接数有时候最有效的办法是别让连接那么多长连接复用HTTP keep-alive、连接池避免频繁开关HTTP/2 多路复用一个连接跑多个请求合并请求把 100 个小请求合成 1 个WebSocket 替代轮询轮询是用连接数换实时性代价很高4.7 减少每次操作的代价连接数降下来之后还能继续抠每个连接每次读写的成本零拷贝sendfile、writev让数据少走几趟批量收发一次系统调用处理多个包网卡多队列 RSS把不同连接散到不同 CPU 核心5. 从 C10K 到 C10MC10K 在 2000 年代中后期基本被解决了epoll 事件驱动 64 位。之后有人提出C10M——单机一千万连接。难度直接跳了几个量级因为内核本身成了瓶颈手段说明内核旁路DPDK网卡数据直接进用户态绕过内核协议栈eBPF / XDP在驱动层做包过滤和转发用户态协议栈自己实现 TCP/IPCPU 亲和性绑定连接和核心绑定避免缓存失效NUMA 感知内存和网卡就近访问但绝大多数业务系统一辈子也到不了 C10M。把 C10K 的三板斧用好——epoll、事件驱动、不阻塞事件循环——就足够支撑绝大多数互联网服务了。6. 六个常见误解误解一C10K 说的是一万个并发请求。是一万个并发连接。一万个空闲长连接远比一万个正在处理的请求容易——前者考验模型后者考验算力和后端依赖。误解二上了 epoll 就能撑一万。epoll 只优化了等待 I/O的成本。如果每个请求都要查三次数据库瓶颈根本不在连接上。误解三换成协程就解决了。性能提升来自 epoll协程改善的是代码可读性。而且协程里阻塞了 IO照样卡住底层线程。误解四加机器就行。单机问题不解决成本就随连接数线性增长。而且单个连接是打不到多台机器上的。误解五连接数上不去一定是代码写得烂。先查ulimit -n、somaxconn、fd 上限。很多故障是一行配置的事。误解六C10K 是老话题过时了。它提出的两个思想——别把连接数和线程数绑在一起、不要用轮询去发现就绪——是今天所有高并发框架的基础。Netty、Nginx、Redis 全建立在这上面。7. 结语C10K 的解法浓缩成一句话让一个线程照看很多个连接并且不要在等待 I/O 的时候占着线程。前半句靠epoll只报告就绪的连接后半句靠事件驱动让出线程而不是阻塞它。而这个问题的病根也浓缩成一句话资源应该由实际工作量决定而不是由连接数量决定。传统模型里一个空闲连接和一个繁忙连接开销完全一样——这就是它撑不住的根本原因。整个 C10K 的演进史就是不断把开销从连接数上解绑的过程。