惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threatpost
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Engineering at Meta
Engineering at Meta
T
The Blog of Author Tim Ferriss
Recent Announcements
Recent Announcements
G
Google Developers Blog
Google DeepMind News
Google DeepMind News
The Register - Security
The Register - Security
MongoDB | Blog
MongoDB | Blog
U
Unit 42
B
Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
L
LangChain Blog
Stack Overflow Blog
Stack Overflow Blog
P
Privacy International News Feed
L
LINUX DO - 最新话题
博客园_首页
博客园 - Franky
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tor Project blog
V
Visual Studio Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
P
Privacy & Cybersecurity Law Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
K
Kaspersky official blog
C
Cisco Blogs
博客园 - 【当耐特】
阮一峰的网络日志
阮一峰的网络日志
I
Intezer
罗磊的独立博客
MyScale Blog
MyScale Blog
Last Week in AI
Last Week in AI
A
About on SuperTechFans
G
GRAHAM CLULEY
Y
Y Combinator Blog
Microsoft Security Blog
Microsoft Security Blog
GbyAI
GbyAI
T
Threat Research - Cisco Blogs
P
Proofpoint News Feed
D
DataBreaches.Net
The Hacker News
The Hacker News
Spread Privacy
Spread Privacy
AWS News Blog
AWS News Blog
I
InfoQ
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
博客园 - 叶小钗
Project Zero
Project Zero

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证 排序基准测试:用数据说话
【系统架构设计】线程模型:从 thread-per-request 到协程
Liao Tonglang · 2026-04-13 · via 土法炼钢兴趣小组的算法知识备份

2024 年初,某电商平台在大促期间遭遇了一次典型的性能事故:网关层的 Java 服务在并发连接数突破 2 万时,响应延迟从 5ms 飙升到 800ms,最终触发了上游的熔断。事后排查发现,根本原因不是 CPU 或带宽不足——8 核 CPU 的利用率还不到 30%。真正的瓶颈是线程:每个请求独占一个操作系统线程(OS Thread),2 万个线程的栈内存吃掉了 20GB RAM,而频繁的上下文切换(Context Switch)让调度器不堪重负。

这不是个例。从 1999 年 Dan Kegel 提出 C10K 问题(C10K Problem),到今天动辄需要处理百万级并发连接的场景,线程模型始终是高性能服务端架构的核心约束。选错了模型,再强的硬件也救不了你;选对了模型,一台普通机器就能扛住十万级并发。

这篇文章要回答的问题是:thread-per-request、Reactor、Proactor、协程——这些线程模型各自的性能边界在哪里?它们如何约束你的架构决策?


模型本质

thread-per-request(每请求一线程)是最直觉的并发模型:服务端为每一个客户端连接创建一个独立的操作系统线程,该线程负责从接收请求到返回响应的全部生命周期。

// Java thread-per-request 服务器
public class ThreadPerRequestServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while (true) {
            Socket clientSocket = serverSocket.accept();
            // 每个连接创建一个新线程
            new Thread(() -> handleRequest(clientSocket)).start();
        }
    }

    static void handleRequest(Socket socket) {
        try (var in = new BufferedReader(
                new InputStreamReader(socket.getInputStream()));
             var out = new PrintWriter(socket.getOutputStream(), true)) {
            String request = in.readLine();
            // 模拟业务处理(可能包含数据库查询、RPC 调用等阻塞操作)
            Thread.sleep(50);
            out.println("HTTP/1.1 200 OK\r\n\r\nHello");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这个模型有一个巨大的优势:代码是顺序的。开发者不需要关心回调、状态机、Future 链——就像写单线程程序一样,从上到下执行。调试容易,栈追踪完整,异常处理自然。

性能边界

但它的上限非常明确:

指标 典型值 影响
OS 线程栈大小 1MB(Linux 默认) 1 万线程 ≈ 10GB 栈内存
线程创建开销 约 1ms 高频短连接场景成为瓶颈
上下文切换耗时 2-10μs 线程数超过 CPU 核数后成为主要开销
Linux 最大线程数 ulimit -u 和内存限制 通常数千到数万

当并发连接数超过几千时,线程模型的问题不是”慢”,而是”崩”——内存耗尽(OOM),或者大量时间浪费在线程间的切换上,真正用于业务计算的 CPU 时间占比急剧下降。

线程池的改良与局限

线程池(Thread Pool)是对裸线程模型的第一层优化:预先创建固定数量的线程,通过任务队列复用。

// 线程池版本
ExecutorService pool = Executors.newFixedThreadPool(200);
while (true) {
    Socket clientSocket = serverSocket.accept();
    pool.submit(() -> handleRequest(clientSocket));
}

线程池解决了线程创建和销毁的开销,但没有解决核心问题:当线程在等待 I/O 时(数据库查询、网络调用),它仍然被占用着,无法服务其他请求。 如果 200 个线程都在等待一个平均耗时 100ms 的数据库查询,那么系统的最大吞吐量就被锁死在 2000 QPS——无论 CPU 多快、网络多宽。


二、I/O 多路复用:C10K 问题的第一个答案

C10K 问题

1999 年,Dan Kegel 在他的文章 The C10K problem 中提出了一个看似简单的问题:一台普通服务器能否同时处理 1 万个并发连接? 在当时的硬件条件下(单核 CPU、256MB 内存),thread-per-request 模型的答案是”不能”。

C10K 问题的本质不是硬件瓶颈,而是软件模型的瓶颈。解决它的第一步是 I/O 多路复用(I/O Multiplexing):用一个线程监听多个文件描述符(File Descriptor),只有当某个 fd 就绪时才进行实际的 I/O 操作。

select → poll → epoll 的演进

select(1983,BSD 4.2)

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds);

// 最多监听 FD_SETSIZE(通常 1024)个 fd
int ready = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
for (int fd = 0; fd <= max_fd; fd++) {
    if (FD_ISSET(fd, &read_fds)) {
        // 处理就绪的 fd
    }
}

select 的问题:fd 数量上限(FD_SETSIZE = 1024),每次调用需要将整个 fd_set 从用户空间拷贝到内核空间,返回后还需要线性遍历所有 fd 才能找到就绪的——时间复杂度 O(n)。

poll(1986,System V)

poll 去掉了 fd 数量的硬编码限制,用 pollfd 数组替代了 fd_set,但本质没变:每次调用仍然需要线性遍历,仍然需要在用户空间和内核空间之间拷贝整个 fd 集合。

epoll(2002,Linux 2.5.44)

// 创建 epoll 实例
int epfd = epoll_create1(0);

// 注册感兴趣的 fd(只需注册一次)
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

// 等待事件(只返回就绪的 fd)
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout);
for (int i = 0; i < nfds; i++) {
    // events[i].data.fd 就是就绪的 fd,无需遍历
    handle_event(events[i]);
}

epoll 的三个关键改进:

  1. 注册式而非拷贝式:fd 通过 epoll_ctl 注册到内核的红黑树中,只需注册一次,后续的 epoll_wait 不再需要拷贝整个 fd 集合。
  2. 回调驱动:内核在 fd 状态变化时通过回调函数将就绪的 fd 加入一个链表,epoll_wait 只需返回这个链表——时间复杂度 O(就绪 fd 数量),而非 O(总 fd 数量)。
  3. 无 fd 数量限制:理论上限取决于系统内存。
机制 fd 上限 每次调用开销 就绪通知方式 适用场景
select 1024 O(n) 拷贝 + 遍历 水平触发(LT) 跨平台兼容
poll 无硬限制 O(n) 拷贝 + 遍历 水平触发(LT) fd 数量超过 1024
epoll 系统内存 O(1) 注册,O(k) 返回 LT / 边缘触发(ET) Linux 高并发服务

其他平台的等价物

  • macOS / FreeBSD:kqueue(2000),功能与 epoll 相当,支持多种事件源(fd、信号、定时器等)。
  • Windows:IOCP(I/O Completion Port),但它不是多路复用,而是真正的异步 I/O(后面会讲)。
  • Solaris:/dev/poll,event port。

三、Reactor 模式:事件驱动的架构范式

I/O 多路复用是系统调用级别的机制,Reactor 模式(Reactor Pattern)则是在此之上建立的架构级抽象。Douglas Schmidt 在 1995 年的论文中首次形式化了这一模式。

核心组件

┌─────────────────────────────────────────────────┐
│                   Reactor                        │
│                                                  │
│  ┌───────────┐    ┌──────────────────────────┐  │
│  │ Demulti-  │    │    Event Handlers         │  │
│  │ plexer    │───▶│  ┌─────────┐ ┌─────────┐ │  │
│  │ (epoll/   │    │  │ Accept  │ │  Read   │ │  │
│  │  kqueue)  │    │  │ Handler │ │ Handler │ │  │
│  └───────────┘    │  └─────────┘ └─────────┘ │  │
│                    │  ┌─────────┐ ┌─────────┐ │  │
│                    │  │ Write   │ │ Timeout │ │  │
│                    │  │ Handler │ │ Handler │ │  │
│                    │  └─────────┘ └─────────┘ │  │
│                    └──────────────────────────┘  │
└─────────────────────────────────────────────────┘
  • Demultiplexer(解多路复用器):即 epoll/kqueue/select,负责等待事件。
  • Dispatcher(分发器):将就绪事件分发给对应的 Handler。
  • Event Handler(事件处理器):实际处理 I/O 事件的业务逻辑。

单线程 Reactor

最简形态:一个线程完成所有工作——接受连接、读取数据、处理业务、写回响应。

graph TD
    A[客户端连接到达] --> B[Acceptor 接受连接]
    B --> C[将新 fd 注册到 epoll]
    C --> D{epoll_wait 等待事件}
    D -->|读就绪| E[Handler: 读取请求]
    E --> F[Handler: 业务处理]
    F --> G[Handler: 写回响应]
    G --> D
    D -->|新连接| A
    D -->|写就绪| H[Handler: 继续写入]
    H --> D

Node.js 就是这个模型的典型代表:

// Node.js 单线程事件循环
const http = require('http');

const server = http.createServer(async (req, res) => {
    // 所有请求在同一个线程的事件循环中处理
    // 异步 I/O 不会阻塞事件循环
    const data = await queryDatabase(req.url);
    res.writeHead(200);
    res.end(JSON.stringify(data));
});

server.listen(8080);
// 单线程,但通过 epoll 可以处理数万并发连接

单线程 Reactor 的优势是简单——没有锁、没有竞态条件、没有线程安全问题。但它有一个致命弱点:如果某个 Handler 的业务处理是 CPU 密集型的(比如图片压缩、JSON 序列化大对象),它会阻塞整个事件循环,所有其他连接都会被卡住。

多线程 Reactor(主从 Reactor)

为了解决单线程 Reactor 的瓶颈,业界发展出了多线程变体。最经典的实现是 Netty 所采用的主从 Reactor 模型:

┌──────────────────────────────────────────────────────────┐
│                  Main Reactor Thread                      │
│  ┌──────────────┐                                         │
│  │ Boss Group    │  只负责 accept 新连接                   │
│  │ (1个线程)     │───────────────────┐                     │
│  └──────────────┘                   │                     │
│                                     ▼                     │
│  ┌──────────────────────────────────────────────────────┐ │
│  │              Worker Group(N 个线程)                  │ │
│  │  ┌────────┐  ┌────────┐  ┌────────┐  ┌────────┐     │ │
│  │  │Worker 1│  │Worker 2│  │Worker 3│  │Worker N│     │ │
│  │  │ epoll  │  │ epoll  │  │ epoll  │  │ epoll  │     │ │
│  │  │+ 读写  │  │+ 读写  │  │+ 读写  │  │+ 读写  │     │ │
│  │  └────────┘  └────────┘  └────────┘  └────────┘     │ │
│  └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
  • Boss 线程:运行一个独立的 Reactor,只处理 accept 事件,将新连接分配给 Worker。
  • Worker 线程组:每个 Worker 线程运行自己的 Reactor(各自拥有一个 epoll 实例),负责已注册连接的读写和业务处理。
// Netty 主从 Reactor 配置
EventLoopGroup bossGroup = new NioEventLoopGroup(1);    // 1 个 Boss 线程
EventLoopGroup workerGroup = new NioEventLoopGroup(8);  // 8 个 Worker 线程

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     @Override
     protected void initChannel(SocketChannel ch) {
         ChannelPipeline p = ch.pipeline();
         p.addLast(new HttpServerCodec());
         p.addLast(new HttpObjectAggregator(65536));
         p.addLast(new BusinessHandler());
     }
 });

ChannelFuture f = b.bind(8080).sync();

Reactor 模式的代价

Reactor 模式虽然解决了 C10K 问题,但它引入了一个深层次的架构约束:业务代码不能阻塞事件循环线程。

这意味着所有可能阻塞的操作——数据库查询、文件读写、RPC 调用——都必须是异步的。这不是一个局部的代码改动,而是一个全局性的架构决策:

  • 数据库驱动必须是异步的(传统 JDBC 不行,需要 R2DBC 或异步驱动)
  • 文件 I/O 必须是异步的
  • 所有中间件客户端必须是异步的
  • 业务逻辑的编写方式从”顺序执行”变为”回调 / Promise / async-await”

这就是所谓的函数着色问题(Function Coloring Problem):异步函数和同步函数不能自由组合,一旦某个底层函数是异步的,它的所有调用者都必须是异步的——“异步是传染性的”。


四、Proactor 模式:真正的异步 I/O

Reactor vs Proactor

Reactor 和 Proactor 的核心差异在于 I/O 操作的执行方式:

维度 Reactor Proactor
I/O 操作发起者 应用程序(收到就绪通知后自行读写) 操作系统内核(应用发起请求后由内核完成)
通知内容 “fd 已就绪,你可以读了” “你之前请求的读操作已完成,数据在这里”
数据拷贝 应用调用 read() 时发生 内核在后台完成拷贝
编程模型 同步非阻塞 I/O + 事件循环 真正的异步 I/O

Reactor 是”通知就绪”模型——内核告诉你”数据来了”,你自己去读;Proactor 是”通知完成”模型——内核帮你读完了,直接把数据递给你。

Windows IOCP

Windows 的 I/O Completion Port(IOCP)是最早成熟的 Proactor 实现。它的工作流程:

  1. 应用发起异步 I/O 请求(如 ReadFileEx
  2. 内核在后台执行实际的 I/O 操作
  3. I/O 完成后,内核将结果放入完成队列(Completion Queue)
  4. 工作线程从队列中取出完成事件进行处理
// Windows IOCP 简化流程
HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

// 关联 socket 与 IOCP
CreateIoCompletionPort((HANDLE)socket, iocp, (ULONG_PTR)context, 0);

// 发起异步读
WSARecv(socket, &buffer, 1, &bytesRecvd, &flags, &overlapped, NULL);

// 工作线程等待完成事件
while (true) {
    DWORD bytesTransferred;
    ULONG_PTR key;
    LPOVERLAPPED ov;
    GetQueuedCompletionStatus(iocp, &bytesTransferred, &key, &ov, INFINITE);
    // 数据已经在 buffer 中,直接处理
    processData(key, ov, bytesTransferred);
}

Linux io_uring(2019,Linux 5.1)

Linux 长期以来缺少真正的异步 I/O 机制。aio_read/aio_write(POSIX AIO)几乎没有实际使用价值;Linux 原生的 io_submit(Linux AIO)只支持直接 I/O(Direct I/O),限制极大。

2019 年,Jens Axboe 为 Linux 内核提交了 io_uring,终于为 Linux 带来了可与 IOCP 媲美的异步 I/O 机制。

                io_uring 架构
┌──────────────────────────────────────────┐
│              用户空间                      │
│  ┌────────────────┐  ┌────────────────┐  │
│  │ 提交队列(SQ)  │  │ 完成队列(CQ)  │  │
│  │ Submission     │  │ Completion     │  │
│  │ Queue Ring     │  │ Queue Ring     │  │
│  └───────┬────────┘  └───────▲────────┘  │
│          │                   │            │
├──────────┼───────────────────┼────────────┤
│          ▼                   │            │
│  ┌────────────────────────────────────┐   │
│  │           内核空间                   │   │
│  │  共享内存映射(mmap)                │   │
│  │  SQE → 内核执行 I/O → CQE          │   │
│  └────────────────────────────────────┘   │
│              内核空间                      │
└──────────────────────────────────────────┘

io_uring 的关键设计:

  1. 双环形缓冲区:提交队列(SQ)和完成队列(CQ)通过 mmap 在用户空间和内核空间之间共享,避免了系统调用的开销。
  2. 批量提交:可以一次提交多个 I/O 请求,减少系统调用次数。
  3. 支持所有 I/O 类型:文件读写、网络 I/O、定时器、甚至 openatstatx 等系统调用都可以异步化。
// io_uring 异步读示例
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

// 准备提交队列条目
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, buf_size, 0);
io_uring_sqe_set_data(sqe, user_data);

// 提交请求(可以批量提交多个)
io_uring_submit(&ring);

// 等待完成
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// cqe->res 是读取的字节数,数据已在 buf 中
io_uring_cqe_seen(&ring, cqe);

Proactor 的落地状态

尽管 Proactor 在理论上更优雅(应用不需要自己做 I/O 操作),但在实践中的采用率远低于 Reactor:

  • Windows:IOCP 被广泛使用,.NET 的异步 I/O 底层就是 IOCP。
  • Linux:io_uring 仍然较新,生态还在成熟中。大多数主流框架(Nginx、Netty、Go runtime)仍然基于 epoll 的 Reactor 模型。
  • 跨平台框架:libuv(Node.js 底层)在 Linux 上用 epoll 模拟 Proactor 语义,在 Windows 上用 IOCP。

五、绿色线程与协程:回归顺序编程

Reactor 模式解决了 C10K 问题,但付出了代价——程序员必须用回调或 Promise 链来表达原本顺序的逻辑。协程(Coroutine)和绿色线程(Green Thread)的目标是:在保持事件驱动的高并发能力的同时,恢复顺序编程的体验。

核心思想

协程是用户空间的轻量级执行单元,由语言运行时(Runtime)而非操作系统内核调度:

特征 OS 线程 协程 / 绿色线程
调度者 内核调度器 用户态运行时
栈大小 1MB(固定) 2KB-8KB(可增长)
创建开销 ~1ms ~0.3μs
上下文切换 2-10μs(进入内核) ~0.1-0.2μs(用户态)
并发数量 数千到数万 数十万到百万
抢占方式 时间片抢占 协作式 / 部分抢占

协程的本质是将”等待 I/O”从”阻塞 OS 线程”变成”挂起协程”。当协程发起 I/O 时,运行时将其挂起,把底层 OS 线程让给其他协程。I/O 完成后,运行时将协程恢复到某个可用的 OS 线程上继续执行。

从程序员的角度看,代码仍然是顺序的——但底层实际上在做事件驱动的多路复用。


六、Go goroutine 与 GMP 调度器

Go 语言的 goroutine 是协程模型在工程领域最成功的实现之一。它的设计目标很明确:让并发像函数调用一样简单。

GMP 模型

Go 运行时的调度器基于 GMP 模型:

graph TB
    subgraph "Go 调度器 - GMP 模型"
        G1[G: goroutine 1] --> LRQ1
        G2[G: goroutine 2] --> LRQ1
        G3[G: goroutine 3] --> LRQ1
        G4[G: goroutine 4] --> LRQ2
        G5[G: goroutine 5] --> LRQ2
        G6[G: goroutine 6] --> GRQ

        subgraph P1["P (逻辑处理器 1)"]
            LRQ1[本地运行队列]
        end

        subgraph P2["P (逻辑处理器 2)"]
            LRQ2[本地运行队列]
        end

        GRQ[全局运行队列]

        P1 --> M1[M: OS 线程 1]
        P2 --> M2[M: OS 线程 2]
        M3[M: OS 线程 3<br/>阻塞在系统调用] -.-> P3_detach[P 被解绑<br/>交给其他 M]
    end
  • G(Goroutine):用户态的协程,初始栈仅 2KB,可动态增长到 1GB。
  • M(Machine):操作系统线程,真正执行代码的载体。
  • P(Processor):逻辑处理器,连接 G 和 M 的桥梁。P 的数量默认等于 CPU 核数(GOMAXPROCS)。每个 P 持有一个本地运行队列(Local Run Queue)。

调度规则:

  1. 新创建的 goroutine 优先放入当前 P 的本地队列。
  2. 本地队列满 时,将一半 goroutine 移入全局队列。
  3. M 寻找工作 的优先级:本地队列 → 全局队列 → 从其他 P 偷取(Work Stealing)→ 从网络轮询器(Netpoller)获取。
  4. goroutine 发起系统调用 时,当前 M 会阻塞。此时 P 会与该 M 解绑,寻找或创建新的 M 来继续执行队列中的其他 goroutine。
  5. goroutine 发起网络 I/O 时不会阻塞 M,而是将 fd 注册到 Netpoller(基于 epoll),goroutine 被挂起,M 去执行其他 goroutine。

Go 并发服务器

package main

import (
    "fmt"
    "net"
    "time"
)

func handleConn(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 4096)
    for {
        // 看起来是阻塞调用,实际上 Go runtime 会自动挂起 goroutine
        n, err := conn.Read(buf)
        if err != nil {
            return
        }
        // 模拟业务处理
        time.Sleep(10 * time.Millisecond)
        conn.Write(buf[:n])
    }
}

func main() {
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        panic(err)
    }
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        // 每个连接一个 goroutine——但不是 OS 线程
        go handleConn(conn)
    }
}

这段代码和 thread-per-request 几乎一样简单,但性能完全不同:

  • 10 万个并发连接仅需约 200MB 栈内存(vs thread-per-request 的 100GB)
  • 底层通过 epoll + goroutine 调度实现事件驱动
  • 程序员写的是顺序代码,没有回调,没有 Promise 链

goroutine 的抢占机制

Go 1.14 引入了基于信号的异步抢占(Asynchronous Preemption)。在此之前,goroutine 只在特定点(函数调用、channel 操作等)让出执行权,一个纯计算的 goroutine 可能长时间占用 M。

// Go 1.14 之前,这个 goroutine 会饿死其他 goroutine
go func() {
    for {
        // 纯计算,没有调度点
        x++
    }
}()

// Go 1.14 之后,runtime 通过 SIGURG 信号强制抢占

七、Java 21 Virtual Thread(Project Loom)

Java 21 正式发布了虚拟线程(Virtual Thread),这是 Project Loom 多年努力的成果。它的目标与 Go goroutine 类似:让每个并发任务拥有自己的线程,但不再受 OS 线程的开销限制。

传统线程 vs 虚拟线程

// 传统平台线程——受 OS 限制
ExecutorService traditional = Executors.newFixedThreadPool(200);

// 虚拟线程——每个任务一个虚拟线程
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();

// 用法完全相同
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // 每个任务在自己的虚拟线程中运行
            // 阻塞 I/O 会自动挂起虚拟线程,释放底层平台线程
            var result = httpClient.send(request, BodyHandlers.ofString());
            processResult(result);
            return null;
        });
    }
}

虚拟线程的实现原理

虚拟线程建立在 Continuation(延续)之上。当虚拟线程遇到阻塞操作时:

  1. JVM 捕获当前虚拟线程的执行栈(Continuation)
  2. 将虚拟线程从底层平台线程(Carrier Thread)上卸载(unmount)
  3. 平台线程被释放,可以去执行其他虚拟线程
  4. 阻塞操作完成后,虚拟线程被重新挂载(mount)到某个可用的平台线程上继续执行
// 虚拟线程服务器示例
public class VirtualThreadServer {
    public static void main(String[] args) throws IOException {
        var server = ServerSocket(8080);

        // 用虚拟线程处理每个连接
        while (true) {
            Socket socket = server.accept();
            Thread.startVirtualThread(() -> handleRequest(socket));
        }
    }

    static void handleRequest(Socket socket) {
        try (var in = socket.getInputStream();
             var out = socket.getOutputStream()) {
            byte[] buf = new byte[4096];
            int n = in.read(buf);            // 阻塞调用——但只挂起虚拟线程
            Thread.sleep(Duration.ofMillis(50)); // sleep 也只挂起虚拟线程
            out.write(processRequest(buf, n));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

虚拟线程的陷阱:Pinning

虚拟线程有一个已知的问题:synchronized 块和 native 方法会导致载体线程被钉住(Pinned),此时虚拟线程无法从载体线程上卸载,退化为占用 OS 线程的行为。

// 错误:synchronized 会 pin 载体线程
synchronized (lock) {
    // 如果这里发生阻塞 I/O,载体线程也会被阻塞
    database.query("SELECT ...");
}

// 正确:使用 ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    database.query("SELECT ...");
} finally {
    lock.unlock();
}

JDK 团队正在逐步修复这个问题(JDK 24 已经解决了部分 synchronized 的 pinning 问题),但在使用虚拟线程时仍需注意。


八、Rust async/await 与 Tokio

Rust 对异步编程的设计哲学与 Go 和 Java 截然不同:零成本抽象(Zero-Cost Abstraction),不要你不用的东西(You don’t pay for what you don’t use)。

Future:惰性的异步计算

在 Rust 中,async fn 返回一个 Future,这个 Future 是惰性的——仅仅创建它不会执行任何代码,必须被 executor 轮询(poll)才会推进。

use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("0.0.0.0:8080").await?;

    loop {
        let (socket, _) = listener.accept().await?;
        // 每个连接 spawn 一个 task(类似 goroutine)
        tokio::spawn(async move {
            handle_connection(socket).await;
        });
    }
}

async fn handle_connection(mut socket: TcpStream) {
    let mut buf = [0u8; 4096];
    loop {
        let n = match socket.read(&mut buf).await {
            Ok(0) => return,    // 连接关闭
            Ok(n) => n,
            Err(_) => return,
        };
        // 业务处理
        let response = process(&buf[..n]);
        if socket.write_all(&response).await.is_err() {
            return;
        }
    }
}

fn process(data: &[u8]) -> Vec<u8> {
    // CPU 密集型处理
    data.to_vec()
}

Rust 异步的独特之处

  1. 没有内置运行时:Go 和 Java 的运行时都内置了调度器,Rust 把运行时选择权交给开发者。Tokio 是最流行的选择,但还有 async-std、smol、glommio 等。

  2. 编译期状态机:Rust 的 async/await 在编译时将异步函数转换为状态机(State Machine),不需要堆上分配协程栈。

// 编译器将这段代码:
async fn example() {
    let a = read_file("a.txt").await;
    let b = read_file("b.txt").await;
    println!("{}{}", a, b);
}

// 转换为类似这样的状态机:
enum ExampleStateMachine {
    State0 { future_a: ReadFileFuture },
    State1 { a: String, future_b: ReadFileFuture },
    Complete,
}

impl Future for ExampleStateMachine {
    type Output = ();
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        // 根据当前状态驱动执行
        match self.state {
            State0 { ref mut future_a } => {
                match future_a.poll(cx) {
                    Poll::Ready(a) => { /* 转到 State1 */ }
                    Poll::Pending => Poll::Pending,
                }
            }
            // ...
        }
    }
}
  1. 没有隐式的调度点:在 Go 中,几乎所有 I/O 操作都会自动触发调度;在 Rust 中,只有显式的 .await 才是让出点。这给了开发者更精确的控制,但也意味着更多的心智负担。

Tokio 运行时架构

Tokio 的默认配置使用多线程调度器,内部也采用 Work Stealing 策略:

// Tokio 运行时配置
let runtime = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(8)           // 8 个 worker 线程
    .enable_io()                 // 启用 I/O 驱动(epoll/kqueue)
    .enable_time()               // 启用定时器
    .build()
    .unwrap();

// 或使用单线程运行时(适用于嵌入式或资源受限场景)
let runtime = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();

九、三种语言的等价服务器对比

为了直观比较 Go、Java、Rust 三种语言处理并发的方式,下面是一个功能等价的 echo 服务器实现:

Go 版本

package main

import (
    "io"
    "log"
    "net"
)

func main() {
    ln, err := net.Listen("tcp", ":9000")
    if err != nil {
        log.Fatal(err)
    }
    defer ln.Close()
    log.Println("Echo server listening on :9000")

    for {
        conn, err := ln.Accept()
        if err != nil {
            log.Println("Accept error:", err)
            continue
        }
        go func(c net.Conn) {
            defer c.Close()
            io.Copy(c, c) // echo: 读到什么写回什么
        }(conn)
    }
}

Java 21 Virtual Thread 版本

import java.io.*;
import java.net.*;

public class EchoServer {
    public static void main(String[] args) throws IOException {
        try (var server = new ServerSocket(9000)) {
            System.out.println("Echo server listening on :9000");
            while (true) {
                Socket socket = server.accept();
                Thread.startVirtualThread(() -> {
                    try (socket;
                         var in = socket.getInputStream();
                         var out = socket.getOutputStream()) {
                        in.transferTo(out); // echo
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                });
            }
        }
    }
}

Rust + Tokio 版本

use tokio::io;
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:9000").await?;
    println!("Echo server listening on :9000");

    loop {
        let (mut socket, _) = listener.accept().await?;
        tokio::spawn(async move {
            let (mut reader, mut writer) = socket.split();
            if let Err(e) = io::copy(&mut reader, &mut writer).await {
                eprintln!("Error: {}", e);
            }
        });
    }
}

对比分析

维度 Go goroutine Java Virtual Thread Rust async + Tokio
并发单元栈 2KB 初始,动态增长 按需分配(Continuation) 无栈(编译期状态机)
内存开销 / 百万并发 ~2GB ~1-2GB(取决于栈深度) ~200-400MB
调度方式 M:N + Work Stealing M:N(ForkJoinPool) M:N + Work Stealing
抢占 信号抢占(Go 1.14+) JVM safepoint 无(协作式 .await)
运行时大小 内置(~5MB 二进制) JVM(~200MB) 可选(Tokio ~100KB)
学习曲线 低(兼容现有代码) 高(生命周期 + Pin)
生态兼容性 全部标准库天然支持 大部分库兼容 需要 async 版本的库
适用场景 通用后端服务 企业级应用、现有 Java 系统 系统编程、极致性能

十、线程模型对架构的约束

线程模型不是一个可以后期更换的实现细节——它是一个架构级决策,会深刻影响整个系统的设计。

约束一:技术栈的传染性

选择 Reactor 模式意味着整个技术栈必须是异步的:

线程模型选择
    │
    ├── thread-per-request
    │   ├── 数据库驱动:JDBC ✓(阻塞式)
    │   ├── HTTP 客户端:Apache HttpClient ✓(阻塞式)
    │   ├── 日志框架:Logback ✓(同步写入无所谓)
    │   └── ORM:Hibernate ✓
    │
    ├── Reactor(如 Spring WebFlux)
    │   ├── 数据库驱动:R2DBC(必须异步)
    │   ├── HTTP 客户端:WebClient(必须异步)
    │   ├── 日志框架:异步 Appender
    │   └── ORM:没有成熟方案,手写 SQL
    │
    └── 协程(Go / Virtual Thread)
        ├── 数据库驱动:标准库即可(阻塞调用自动挂起)
        ├── HTTP 客户端:标准库即可
        ├── 日志框架:标准库即可
        └── ORM:GORM / Hibernate ✓

约束二:错误处理和调试

// thread-per-request:完整的栈追踪
Exception in thread "pool-1-thread-3" java.lang.NullPointerException
    at com.example.UserService.getUser(UserService.java:42)
    at com.example.OrderHandler.handle(OrderHandler.java:28)
    at com.example.Server.processRequest(Server.java:15)

// Reactor:栈追踪几乎无用
Exception in thread "reactor-http-nio-3" java.lang.NullPointerException
    at com.example.UserService.lambda$getUser$0(UserService.java:42)
    at reactor.core.publisher.Mono.subscribe(Mono.java:4400)
    at reactor.core.publisher.Mono.block(Mono.java:1707)
    // 看不出是谁调用了 getUser

协程模型在这方面取得了折中:Go 的 goroutine 栈追踪相对完整;Java Virtual Thread 完全保留了传统的栈追踪;Rust 的 async 栈追踪正在改善中(需要 RUST_BACKTRACE=1#[track_caller])。

约束三:背压与流量控制

线程模型决定了系统如何自然地实现背压(Backpressure):

  • thread-per-request + 线程池:天然的背压——线程池满了,新请求排队或被拒绝。简单有效。
  • Reactor:需要显式实现背压(Reactive Streams 规范),否则快生产者会压垮慢消费者。
  • 协程:通过 channel 缓冲区大小控制(Go)或 Semaphore(Tokio)实现。
// Go 中通过带缓冲的 channel 实现背压
sem := make(chan struct{}, 1000) // 最多 1000 个并发请求

for {
    conn, _ := listener.Accept()
    sem <- struct{}{} // 超过 1000 会阻塞 accept
    go func(c net.Conn) {
        defer func() { <-sem }()
        handleConn(c)
    }(conn)
}

约束四:CPU 密集型工作的隔离

所有非抢占式模型(单线程 Reactor、协作式协程)都面临同一个问题:CPU 密集型任务会饿死其他任务。不同模型的解决方案:

模型 CPU 密集型任务处理策略
Node.js(单线程 Reactor) Worker Threads 或子进程
Go goroutine 自动抢占(Go 1.14+),但仍建议拆分到独立 goroutine
Tokio(Rust) tokio::task::spawn_blocking() 转移到专用线程池
Virtual Thread(Java) 自然支持,ForkJoinPool 会创建补偿线程
// Tokio 中隔离 CPU 密集型任务
async fn handle_request(data: Vec<u8>) -> Vec<u8> {
    // 将 CPU 密集型工作转移到阻塞线程池
    tokio::task::spawn_blocking(move || {
        compress(&data)
    })
    .await
    .unwrap()
}

十一、工程案例分析

案例一:Nginx 的多进程 + 事件驱动架构

Nginx 的线程模型是理解事件驱动架构的绝佳范本。

                  Nginx 架构
┌─────────────────────────────────────────┐
│            Master 进程                    │
│  - 读取配置                              │
│  - 管理 Worker 进程                      │
│  - 不处理任何请求                         │
└──────────────┬──────────────────────────┘
               │ fork
    ┌──────────┼──────────────┐
    ▼          ▼              ▼
┌────────┐ ┌────────┐    ┌────────┐
│Worker 1│ │Worker 2│    │Worker N│
│单线程   │ │单线程   │    │单线程   │
│epoll    │ │epoll    │    │epoll    │
│事件循环  │ │事件循环  │    │事件循环  │
│处理数千  │ │处理数千  │    │处理数千  │
│并发连接  │ │并发连接  │    │并发连接  │
└────────┘ └────────┘    └────────┘

关键设计决策:

  1. 多进程而非多线程:避免了锁的复杂性。进程之间通过共享内存通信(如共享的监听 socket)。
  2. 每个 Worker 单线程事件循环:基于 epoll(Linux)/ kqueue(FreeBSD)。一个 Worker 可以处理数千个并发连接。
  3. 无阻塞操作:所有 I/O 都是非阻塞的。上游连接(upstream)、文件读取、日志写入全部异步化。当需要读取静态文件时,Nginx 可以使用 sendfile 系统调用实现零拷贝(Zero Copy),或在支持的平台上使用线程池进行异步文件 I/O。

Nginx 在一台 8 核机器上通常启动 8 个 Worker 进程,每个 Worker 通过 accept_mutexSO_REUSEPORT 竞争新连接。这个架构使 Nginx 能够在一台普通服务器上轻松处理 10 万以上的并发连接。

案例二:Node.js 的单线程事件循环

Node.js 将单线程 Reactor 模型推向了极致。它的核心是 libuv 提供的事件循环:

libuv 事件循环依次执行 timers → pending callbacks → idle/prepare → poll(阻塞等待 I/O 事件)→ check(setImmediate)→ close callbacks,然后回到 timers 开始下一轮。整个过程在单线程中完成,I/O 密集型场景下表现优异,但 CPU 密集型任务会直接阻塞事件循环。解决方案:

// 使用 Worker Threads 处理 CPU 密集型任务
const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
    const http = require('http');
    const server = http.createServer((req, res) => {
        if (req.url === '/heavy') {
            // CPU 密集型任务交给 Worker Thread
            const worker = new Worker(__filename);
            worker.on('message', (result) => {
                res.end(JSON.stringify(result));
            });
        } else {
            res.end('Hello');
        }
    });
    server.listen(8080);
} else {
    // Worker Thread 中执行 CPU 密集型计算
    const result = heavyComputation();
    parentPort.postMessage(result);
}

案例三:Netty 在百万级连接推送系统中的实践

某即时通信平台使用 Netty 构建了一个百万级长连接推送系统。架构如下:

硬件配置:8 核 CPU,32GB 内存,千兆网卡。

Netty 配置

// Boss Group:1 个线程处理 accept
EventLoopGroup bossGroup = new EpollEventLoopGroup(1);
// Worker Group:8 个线程处理读写
EventLoopGroup workerGroup = new EpollEventLoopGroup(8);

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(EpollServerSocketChannel.class)
 .option(ChannelOption.SO_BACKLOG, 65535)
 .childOption(ChannelOption.SO_KEEPALIVE, true)
 .childOption(ChannelOption.TCP_NODELAY, true)
 .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     @Override
     protected void initChannel(SocketChannel ch) {
         ch.pipeline()
           .addLast(new IdleStateHandler(300, 0, 0))
           .addLast(new ProtobufDecoder())
           .addLast(new ProtobufEncoder())
           .addLast(new PushMessageHandler());
     }
 });

关键优化点

优化措施 效果
使用 EpollEventLoopGroup 替代 NioEventLoopGroup 避免 JDK NIO 的 epoll bug,减少一层抽象
PooledByteBufAllocator 池化内存分配 减少 GC 压力,百万连接下尤为关键
IdleStateHandler 心跳检测 及时清理死连接,释放资源
消息合并与批量刷新 减少系统调用次数(writeAndFlushwrite + 定时 flush
SO_REUSEPORT(Linux 3.9+) 多个 Worker 线程可以同时 accept,避免惊群效应

结果:单机支持 120 万个长连接,消息推送延迟 P99 < 50ms,内存占用约 20GB(每个连接约 17KB,包含接收缓冲区和协议状态)。


十二、内存开销量化对比

下面通过一个受控实验,量化不同线程模型在高并发场景下的内存占用。场景:维持 10 万个空闲 TCP 连接(idle connections),测量服务端进程的内存开销。

实现方式 语言/框架 内存占用(10万连接) 单连接内存 备注
thread-per-request Java(Platform Thread) ~100GB ~1MB 受 OS 线程栈限制
线程池(200线程) Java + NIO ~800MB ~8KB 连接数受线程池大小限制
Reactor Netty(NIO) ~1.5GB ~15KB 包含 ByteBuf 和 Pipeline
goroutine-per-conn Go 1.22 ~600MB ~6KB 2KB 栈 + 运行时开销
Virtual Thread Java 21 ~1.2GB ~12KB Continuation + 栈帧
async task Rust + Tokio ~400MB ~4KB 编译期状态机,无独立栈
epoll 直接编程 C(手写事件循环) ~200MB ~2KB 仅 fd 和用户态缓冲区

注意:以上数据为近似值,实际数据会因操作系统版本、JDK 版本、缓冲区配置等因素有所变化。关键结论是:从 OS 线程的 MB 级到协程的 KB 级,大约有两个数量级的差距。


十三、从 C10K 到 C10M

C10K 问题在 2000 年代初期就已经被 epoll + Reactor 解决了。现在更前沿的问题是 C10M——单机千万级并发连接。

C10M 的瓶颈已经不在线程模型,而在更底层的系统设计:

1. 内核旁路(Kernel Bypass)

DPDK(Data Plane Development Kit) 直接在用户空间处理网络包,绕过内核协议栈,通过轮询替代中断。代价是需要独占 CPU 核心,且应用必须自己实现 TCP/IP 协议栈。

2. 零拷贝(Zero Copy)

sendfilesplicemmap 等机制可以将数据直接从磁盘传输到网卡,不经过用户空间缓冲区。这将在下一篇文章中详细讨论。

3. CPU 亲和性(CPU Affinity)

将特定的 Worker 线程绑定到特定的 CPU 核心,减少缓存失效和跨 NUMA 节点的内存访问。

C10K → C10M 技术演进总览

graph LR
    subgraph "C10K 时代"
        A[thread-per-request] -->|内存瓶颈| B[I/O 多路复用]
        B --> C[Reactor 模式]
        C --> D[非阻塞 I/O]
    end

    subgraph "C100K 时代"
        D --> E[协程 / 绿色线程]
        E --> F[零拷贝]
        F --> G[内存池化]
    end

    subgraph "C1M 时代"
        G --> H[SO_REUSEPORT]
        H --> I[io_uring]
        I --> J[连接状态压缩]
    end

    subgraph "C10M 时代"
        J --> K[内核旁路 DPDK]
        K --> L[用户态协议栈]
        L --> M[CPU 亲和性 + NUMA]
    end

十四、线程模型选择决策框架

面对具体的工程场景,如何选择线程模型?以下是一个实用的决策框架:

决策树

  1. 并发连接数 < 1000?
    • 是 → thread-per-request + 线程池。够用,且开发体验最好。
    • 否 → 继续。
  2. 团队技术栈以 Java 为主?
    • 是 → Java 21 Virtual Thread。最小的迁移成本,大部分现有代码不用改。
    • 否 → 继续。
  3. 需要极致的内存效率和延迟控制?
    • 是 → Rust + Tokio。编译期开销最低,但学习曲线最陡。
    • 否 → 继续。
  4. 通用后端服务,团队接受新语言?
    • 是 → Go。goroutine 的简单性和 Go 的工具链是通用场景的最优解。
    • 否 → 继续。
  5. 现有 Java/Spring 生态,不想全面切换?
    • 是 → Spring WebFlux + Reactor,或者升级到 Java 21 用 Virtual Thread。
    • 否 → 根据具体场景评估。

各模型的甜蜜区

线程模型 最适合的场景 不适合的场景
thread-per-request 内部服务、并发量可控的 CRUD 应用 高并发网关、长连接推送
Reactor(Netty/WebFlux) 高并发网关、代理层、中间件 复杂业务逻辑多的应用服务
Go goroutine 通用后端服务、微服务、CLI 工具 对延迟有亚微秒级要求的场景
Java Virtual Thread 企业级应用现代化、现有 Java 系统升级 全新系统且团队不绑定 Java
Rust async 系统编程、嵌入式网络、CDN 边缘节点 快速原型、CRUD 应用
多进程 + 事件驱动 反向代理、负载均衡器 需要进程间共享大量状态

一个现实中的架构选择

假设要设计一个电商平台的后端架构:

  • API 网关层:Go(goroutine 天然适合高并发代理)或 Nginx(反向代理 + 负载均衡)。
  • 业务服务层:Java 21 + Virtual Thread(复杂业务逻辑 + 丰富的 Java 生态)。
  • 实时推送服务:Netty Reactor(百万级长连接,纯 I/O 密集)。
  • 数据处理管道:Go(并发管道天然适合流式数据处理)。

不同层使用不同的线程模型,因为每一层面对的并发特征不同。


十五、总结

线程模型的演进是一部”用更少的资源做更多的事”的历史。从 thread-per-request 的 1 连接 = 1MB 栈内存,到协程的 1 连接 = 几 KB,再到 DPDK 的完全绕过内核——每一步都在挑战”系统能同时处理多少个连接”这个看似简单的问题。

但技术选择从来不是比较谁”更先进”。thread-per-request 在并发量可控的场景下仍然是最好的选择——简单、可调试、整个生态都为它优化过。Reactor 在代理和网关场景有不可替代的优势。Go 的 goroutine 在通用后端服务中提供了并发性能和开发体验的最佳平衡。Java Virtual Thread 为数量庞大的 Java 系统提供了一条低成本的升级路径。Rust 的零成本抽象在极端性能要求下无可匹敌。

最重要的判断标准只有一个:你的系统实际面对的并发负载是什么? 不要为了”10 万并发”的想象去选择复杂的线程模型,也不要在真的需要 10 万并发时固守 thread-per-request。用数据驱动决策,用压测验证假设。


上一篇:吞吐量优化

下一篇:零拷贝


参考资料

论文与书籍

  • Dan Kegel, The C10K problem, 1999, http://www.kegel.com/c10k.html
  • Douglas C. Schmidt, Reactor: An Object Behavioral Pattern for Demultiplexing and Dispatching Handles for Synchronous Events, Pattern Languages of Program Design, 1995
  • Douglas C. Schmidt, Proactor: An Object Behavioral Pattern for Demultiplexing and Dispatching Handlers for Asynchronous Events, Pattern Languages of Program Design, 1996
  • Robert Love, Linux Kernel Development, 3rd Edition, Addison-Wesley, 2010
  • Brendan Gregg, Systems Performance: Enterprise and the Cloud, 2nd Edition, Addison-Wesley, 2020

技术文档与规范

技术博客与演讲

  • Bob Nystrom, What Color is Your Function?, 2015
  • Ron Pressler, Project Loom: Modern Scalable Concurrency for the Java Platform, QCon 2020
  • Bryan Cantrill, The Summer of Rust, 2020
  • Nginx, Inside NGINX: How We Designed for Performance & Scale, nginx.com
  • Linux manual pages: epoll(7), io_uring(7), select(2), poll(2)

工具与框架

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计】消息队列架构:异步解耦的设计与陷阱

在分布式系统中,服务之间的直接同步调用会导致强耦合、级联故障和性能瓶颈。消息队列(Message Queue)作为异步通信的核心基础设施,在现代架构中承担着解耦、削峰、容错等关键职责。然而,引入消息队列并非没有代价——投递语义的选择、顺序性保证、消费者组再平衡、幂等消费等问题,每一个都隐藏着工程陷阱。本文将从原理到实践…

2026-04-13 · architecture

【系统架构设计】什么是软件架构:从代码结构到系统决策

软件架构的定义至少有三种主流理解,每种指向不同的关注点。本文对比 Booch、Fowler、Johnson 三家定义,分析架构决策的不可逆性特征,拆解 Krutchten 4+1 视图模型,回答一个核心问题:架构和设计的边界到底在哪里。

2026-04-13 · architecture

【系统架构设计】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。