从0到180万连接:一个Rust/Tokio网关的真实扩展之路

那个不戏剧性的崩溃时刻

系统停止线性扩展的那一刻一点都不戏剧。

CPU没爆满,内存也没耗尽,没有任何东西崩溃。但大概在42万并发连接左右,延迟曲线开始向上弯曲,再也回不来了。吞吐量变平了,尾部延迟也变宽了。从数学角度看我们还有余量,但Tokio调度器不这么认为。

那个转折点逼我们面对一个现实。当事情"够快"的时候,很容易忽略一个事实:极限并发不是性能问题,而是调度问题。

接下来要讲的不是什么Rust成功故事。这是一篇关于基于Tokio的网关跨过几十万连接进入百万级后如何表现的后记——在这个领域,调度器机制、背压和可观测性比原始效率更重要。

Rust Tokio网关扩展概念图

我们对Rust/Tokio并发的三个错误假设

我们做了三个假设,后来证明都是错的。

第一,只要是异步的,任务就会随核心数线性扩展。第二,只要避免阻塞调用,协作调度基本"自己就能工作"。第三,空闲连接很便宜。

真正重要的心智模型是这样的:

  • 线程是固定的、稀缺的,由操作系统抢占式调度
  • 任务是廉价的、大量的,由运行时协作式调度
  • 只有当任务自愿让出时,进度才会发生

到处用异步不会消除竞争,只会重新分配它。当任务停止频繁让出——因为长轮询、超大的缓冲区、或者意外的CPU工作——调度器就无法强制公平。在规模上,这变成了饥饿。

我们首先撞到的不是吞吐量墙,是公平性墙。

Tokio网关架构:它到底在做什么

我们讨论的系统是一个无状态的TCP/WebSocket网关。它终止连接,执行轻量级认证,然后通过持久链路把分帧消息转发给下游服务。大多数连接在大多数时候是空闲的,突发流量来自扇出事件和重连风暴。

核心循环看起来简单,但运行时结构比代码本身更重要。

[内核接收队列]
       |
  [接收循环]
       |
  [连接任务]
       |
 [IO状态机]
       |
 [下游连接池]

在运行时,这映射到一个多线程Tokio执行器:

[Tokio 运行时]
  |--------|--------|
[工作线程0][工作线程1][工作线程2]
   |   |       |         |
 [任务][任务] [任务][任务] [任务]

每个连接只拥有一个任务。没有每个消息生成。没有后台助手。任务在IO上挂起,在就绪时唤醒。正是这种纪律让180万连接成为可能。

Tokio调度器和运行时调优实战

开箱即用的Tokio工作窃取调度器既激进又乐观。它假设任务是短命且协作的。在高连接数下,这些假设会衰减。

我们的第一个调优杠杆是运行时线程数。让线程数匹配核心数是个错误。我们发现用比物理核心更少的运行时线程能获得更好的公平性,减少了跨核心窃取的开销。

let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(12) // 16核心机器
    .enable_io()
    .enable_time()
    .build()?;

第二个杠杆是任务行为。我们审计了每个异步函数,寻找隐式循环和长轮询。任何可能运行超过约200微秒而不await的东西都被重构了。实际上,这意味着激进地把工作分解成让出点。

第三个杠杆是接收背压。以超过调度器公平挂起能力的速度接受连接是自找麻烦。

loop {
    let (sock, _) = listener.accept().await?;
    semaphore.acquire().await?;
    tokio::spawn(handle_conn(sock, permit));
}

信号量不是关于限制总连接数。它是关于在峰值期间平滑调度器负载。仅这一项就把我们的线性扩展点推过了70万连接。

Tokio调度器调优面板

180万连接的现实

“180万连接"不意味着180万个活跃请求。

在我们的测量中:

  • 约150万连接是空闲的WebSocket,只有心跳
  • 约25万是间歇性活跃的
  • 约5万维持稳定的消息流

在一台16核心、128GB的机器上,内存成了主要约束,CPU反而不是。每个连接包括缓冲区和任务状态平均约48KB,在180万连接下大约是86GB常驻内存,还没算分配器开销。

我们从没跨过65%的CPU利用率。调度器延迟成了限制因素——这是测量为就绪后到首次poll的时间。

在约190万连接时,在churn事件期间尾部唤醒延迟超过了40毫秒。那是我们的天花板。

可观测性:Rust网关的扩展诊断工具

我们不是通过基准测试发现极限的。我们通过调度器信号发现的。

最重要的三个指标:

  • 任务poll延迟(从IO就绪到poll的时间)
  • 每个工作线程的可运行队列深度
  • 连接churn率(每秒接受数+关闭数)

饱和期间的示例日志行:

sched_poll_delay_p99=37ms runnable_tasks=182k accepts=9k/s closes=8.7k/s

在CPU饱和之前,poll延迟就先飙升了,那是早期预警。内存压力后来才出现,表现为分配器停顿而不是OOM。

第一个退化的不是吞吐量,是公平性。

可观测性指标面板

故障模式和病态情况

生产中有两种故障模式很重要。

调度器饥饿。一小部分行为不端的任务——通常是慢速下游写操作没有适当背压——会独占工作线程。检测来自不对称的可运行队列,缓解措施是严格的写超时和拆分IO路径。

慢客户端也会造成问题。读得慢的客户端导致缓冲区增长和延迟让出,在规模上几千个慢消费者会扭曲所有人的内存使用。我们给每个连接的出站缓冲区设置了上限,在背压时强制执行丢包语义。

这两种故障看起来都不像崩溃。都像"一切还在工作,只是更糟了”。

故障模式对比

权衡和非目标

这种架构在CPU密集型的每连接逻辑下很脆弱。Tokio的协作模型假设IO占主导。如果你的工作负载把计算和IO混合,像JVM这样的抢占式运行时可能表现得更可预测。

Go的调度器虽然每个任务效率较低,但在处理病态公平性案例时需要更少的调优。Java的Loom以内存为代价简化了其中一些问题。

当任务诚实的时候Tokio大放异彩。当它们不诚实的时候,它会惩罚你。

Tokio vs Go调度器对比

最后的想法

我们没有得到什么英雄般的系统,最终只是得到了一个极限为我们理解的系统。

这算不上什么Rust或async的胜利,更像是一个和调度器协商出来的休战——它会完全按照你告诉它做的去做,而不做任何你假设的。

老调度员有句话:你告诉它做什么,它就做什么,你没说的它不做。


常见问题

180万连接需要多少内存?

在我们的环境中,每个连接平均约48KB,包括TCP缓冲区、任务状态和应用缓冲区。180万连接大约需要86GB内存,这还没算分配器开销。内存通常在达到CPU极限之前先成为瓶颈。

为什么不把线程数设置为核心数?

Tokio的工作窃取调度器在跨核心窃取任务时有开销。用比物理核心更少的线程可以减少这种开销,提高缓存局部性,从而改善整体公平性。在16核心机器上,我们发现12个工作线程表现最好。

Tokio和Go的调度器有什么区别?

Go的调度器是抢占式的,会在函数调用时插入检查点。Tokio完全依赖协作式调度——任务必须主动让出。这意味着Tokio可以更高效,但行为不端的任务能独占工作线程。Go会强制公平,Tokio需要你显式地设计它。