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

【系统架构设计百科】线程模型:从 thread-per-request 到协程

文章导航

分类入口
architecture
标签入口
#threading#Reactor#Proactor#goroutine#virtual-thread#async#C10K

目录

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:简单直接的起点

模型本质

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 高并发服务

其他平台的等价物


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

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

核心组件

┌─────────────────────────────────────────────────┐
│                   Reactor                        │
│                                                  │
│  ┌───────────┐    ┌──────────────────────────┐  │
│  │ Demulti-  │    │    Event Handlers         │  │
│  │ plexer    │───▶│  ┌─────────┐ ┌─────────┐ │  │
│  │ (epoll/   │    │  │ Accept  │ │  Read   │ │  │
│  │  kqueue)  │    │  │ Handler │ │ Handler │ │  │
│  └───────────┘    │  └─────────┘ └─────────┘ │  │
│                    │  ┌─────────┐ ┌─────────┐ │  │
│                    │  │ Write   │ │ Timeout │ │  │
│                    │  │ Handler │ │ Handler │ │  │
│                    │  └─────────┘ └─────────┘ │  │
│                    └──────────────────────────┘  │
└─────────────────────────────────────────────────┘

单线程 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  │     │ │
│  │  │+ 读写  │  │+ 读写  │  │+ 读写  │  │+ 读写  │     │ │
│  │  └────────┘  └────────┘  └────────┘  └────────┘     │ │
│  └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
// 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 调用——都必须是异步的。这不是一个局部的代码改动,而是一个全局性的架构决策:

这就是所谓的函数着色问题(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:


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

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

调度规则:

  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 几乎一样简单,但性能完全不同:

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):

// 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 应用
多进程 + 事件驱动 反向代理、负载均衡器 需要进程间共享大量状态

一个现实中的架构选择

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

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


十五、总结

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

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

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


上一篇:吞吐量优化

下一篇:零拷贝


参考资料

论文与书籍

技术文档与规范

技术博客与演讲

工具与框架

同主题继续阅读

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

2026-04-13 · architecture

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

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

2026-04-13 · architecture

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

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

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。


By .