1. 来源:这个名词是怎么来的
C10K = Concurrent 10,000,意思是"单台服务器同时支撑一万个连接"。
这个说法来自Dan Kegel 在 1999 年整理的一份文档,标题就叫《The C10K problem》。它把当时各种操作系统和编程模型的做法汇总在一起,回答一个问题:
一台机器要同时照看一万个连接,为什么这么难?分别有哪些办法?
为什么是 1999 年?因为那几年互联网刚普及,服务器从"服务几百人"变成"服务几万人"。而当时的编程习惯是一个连接配一个线程——这个模型在几百连接时挺好用,到了一万就彻底崩了。
后来这个目标被攻克,又出现了C10M(一千万连接),但那是内核旁路、用户态协议栈那个层次的事了。C10K 的意义在于它把"高并发"这件事从概念变成了一个具体的工程目标。
2. 原理:为什么一万个连接会成为问题
关键在于:传统模型把"连接数"和"线程数"绑在了一起。
// 一连接一线程:连接数 = 线程数ServerSocketserver=newServerSocket(8080);while(true){Socketsocket=server.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 到几 GB
2.2 更隐蔽的一层:等待 I/O 的白白占用
这是问题的真正核心。
// 这个线程 99% 的时间在这里阻塞等待intn=in.read(buf);连接大部分时间是空闲的。一个聊天的长连接,可能几分钟才来一条消息。但在这几分钟里:
- 线程一直占着栈内存
- 线程一直挂在调度器里
- 线程什么活都没干
一万个连接里有九千个空闲,你依然要养着一万个线程。资源被"连接数量"而非"实际工作量"决定——这才是 C10K 的病根。
2.3 还有一层:怎么知道哪个连接有数据了
如果不想给每个连接配线程,就必然要面对一个新问题:一个线程怎么盯着上万个连接?
早期答案是select/poll:
// 每次调用:把整个 fd 集合从用户态拷进内核,// 内核再从头到尾遍历一遍,看哪个就绪select(maxfd+1,&readfds,NULL,NULL,NULL);这是 O(n) 的。一万个连接,每次事件循环都要扫一万次,即使只有两个连接真的活跃。连接越多,无效的扫描工作越多——CPU 被烧在"检查有没有事"上。
这就是 epoll 出现的原因,后面细说。
2.4 别忘了这些不起眼的限制
| 限制 | 默认值 | 后果 |
|---|---|---|
| 单进程 fd 上限 | 1024 | 到一千就Too many open files |
| 系统级 fd 上限 | 视发行版 | 同上 |
| accept 队列长度 | 128(somaxconn) | 高并发下新连接被丢 |
| 本地端口范围 | 约 2.8 万个 | 主动连接多时不够用 |
很多"连接数上不去"的故障,根因不在代码,而在这几个数字上。
3. 常见现象:撑不住时长什么样
C10K 撑不住的时候,症状很有辨识度:
| 现象 | 说明 |
|---|---|
| CPU 不高,吞吐也上不去 | 大量线程在阻塞等待,不在计算 |
| load average 很高,CPU 使用率很低 | 典型特征:负载算的是"可运行 + 不可中断"的进程数 |
vmstat里cs列飙升 | 上下文切换次数异常高 |
| 内存被吃光,OOM | 线程栈累积 |
unable to create new native thread | 线程数到达上限 |
| 新建连接超时 | accept 队列溢出 |
| 延迟随连接数急剧恶化 | 尾延迟(P99)爆炸,均值看着还行 |
Too many open files | fd 耗尽 |
| 大量 TIME_WAIT | 短连接频繁开关 |
| 机器一重启就正常,跑一段时间又不行 | 连接泄漏或 fd 泄漏 |
最迷惑人的一条是"CPU 不高但就是慢"。因为瓶颈不是算力,是调度和内存——CPU 在忙着切换线程,而不是在跑业务代码。
4. 如何改进:七个层次
按"收益 / 成本"从高到低排。
4.1 换 I/O 模型:从 select 到 epoll(核心)
这是 C10K 最重要的单项改进。
| select / poll | epoll | |
|---|---|---|
| 复杂度 | 每次 O(n) 遍历全部连接 | 每次 O(就绪连接数) |
| fd 集合 | 每次调用都要拷进内核 | 常驻内核,增删改时才动 |
| 上限 | select 有 fd 数量硬限制 | 只受系统 fd 上限约束 |
epoll 的三个关键设计(Linux 2.6 起;BSD/macOS 是 kqueue,Windows 是 IOCP):
intepfd=epoll_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);n=epoll_wait(epfd,events,maxevents,-1);// 3. 只返回"就绪"的连接它为什么快:
- fd 常驻内核——不用每次把上万个 fd 拷来拷去
- 红黑树管理——增删改单个连接是 O(log n),不是重建整个集合
- 就绪链表 + 回调——哪个连接的网卡收到数据了,内核把它挂到就绪链表上;
epoll_wait直接返回链表里的,不遍历未就绪的连接
结果:一万个空闲连接和一百个空闲连接的等待成本几乎一样。
4.2 事件驱动:一个线程管上万个连接
Selectorselector=Selector.open();serverChannel.register(selector,SelectionKey.OP_ACCEPT);while(true){selector.select();// 阻塞,几乎不耗 CPUfor(SelectionKeykey:selector.selectedKeys()){// 只处理"就绪"的那几个连接// 读数据、处理、写回,然后立刻返回循环}}这就是Reactor 模式,也是 Nginx、Redis、Netty 的底座。
⚠️ 这套模型最致命的坑:事件循环线程绝对不能阻塞。你在里面做一次数据库查询、一次文件读取,上万个连接全部一起卡住。异步编程"难写"的恶名,根源就在这里。
4.3 补上异步编程的易用性:协程
事件驱动解决了性能,但代码被拆成一堆回调,难写难调。
协程(用户态线程)解决的是这个体验问题:让异步代码写成同步的样子。
| 语言 | 实现 |
|---|---|
| Go | goroutine + channel |
| Java | 虚拟线程(JDK 21 正式版) |
| Kotlin | 协程 |
| Python | asyncio |
注意一个常见误解:协程不是C10K 的解法。底层用的还是 epoll,只是把"回调地狱"换成了"看起来同步"的代码。性能来自 epoll,易用性来自协程。
4.4 减少单连接开销
如果暂时不能改模型,先抠成本:
- 缩小线程栈:
-Xss512k、ulimit -s - 用线程池:避免"来一个起一个",但注意池化后连接会排队
- 调 fd 上限:
ulimit -n 65535(还要看fs.file-max)
这些是"续命"手段,改变不了线性增长的本质。
4.5 内核参数调优
fs.file-max=1000000# 系统级 fd 上限net.core.somaxconn=32768# accept 队列长度net.ipv4.tcp_max_syn_backlog=8192# 半连接队列net.ipv4.ip_local_port_range=1000065000# 可用端口范围net.ipv4.tcp_tw_reuse=1# 复用 TIME_WAIT 端口net.core.rmem_max=16777216# 收发缓冲区上限net.core.wmem_max=16777216SO_REUSEPORT值得单独提:它让每个进程/线程各自持有独立的 listen socket,由内核做负载均衡,从根上消除 accept 惊群。
4.6 从架构上减少连接数
有时候最有效的办法是别让连接那么多:
- 长连接复用:HTTP keep-alive、连接池,避免频繁开关
- HTTP/2 多路复用:一个连接跑多个请求
- 合并请求:把 100 个小请求合成 1 个
- WebSocket 替代轮询:轮询是"用连接数换实时性",代价很高
4.7 减少每次操作的代价
连接数降下来之后,还能继续抠"每个连接每次读写"的成本:
- 零拷贝:
sendfile、writev让数据少走几趟 - 批量收发:一次系统调用处理多个包
- 网卡多队列 + RSS:把不同连接散到不同 CPU 核心
5. 从 C10K 到 C10M
C10K 在 2000 年代中后期基本被解决了(epoll + 事件驱动 + 64 位)。之后有人提出C10M——单机一千万连接。
难度直接跳了几个量级,因为内核本身成了瓶颈:
| 手段 | 说明 |
|---|---|
| 内核旁路 | DPDK:网卡数据直接进用户态,绕过内核协议栈 |
| eBPF / XDP | 在驱动层做包过滤和转发 |
| 用户态协议栈 | 自己实现 TCP/IP |
| CPU 亲和性绑定 | 连接和核心绑定,避免缓存失效 |
| 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 的演进史,就是不断把"开销"从"连接数"上解绑的过程。