返回文章列表

同一个玩具协议,Rust 写出了五种服务器

193 字·1 分钟阅读
Rust异步编程并发模型服务器设计

很多人学 Rust 异步,第一站就是 tokio 的 #[tokio::main]。一上来被 .await 包围,能跑,但说不清为什么非要异步。

Eli Bendersky 的《Concurrent Servers: Part 7 - Rust》好就好在,它不教你"怎么用 tokio",而是把"为什么需要 tokio"从头演了一遍。

它用了一个很妙的教学手法。整篇文章只服务一个几乎弱智的玩具协议:客户端连上来,服务器发一个 *,客户端发 ^ 进入消息态,服务器把收到的每个字节加一吐回去,发 $ 退出。就这么点逻辑,被五种不同的服务器实现反复套用。业务代码一行没动,变的全是外面那层"怎么同时伺候多个连接"的壳。

这一手是关键。你看到的性能和行为差异,纯粹来自并发模型本身,没有业务噪音掺和。看懂这层,再看自己项目里的并发代码,会清楚很多。

第一层:顺序基线,故意写得最蠢

最开始的实现没有任何并发。主循环 accept 一个连接,调用 serve_connection,这个函数不返回,下一个连接就永远接不进来。一次只能服务一个客户端。

它是后面所有方案的对照物。没有它,你没法判断"并发到底解决了什么"。

第二层:一线程一客户端,线程会失控

接下来把每个连接丢给一个新线程:thread::Builder::new().spawn(...)。这里有个细节,作者把 spawn 返回的 JoinHandle 直接丢弃了,等于把线程 detach 掉,因为主循环永不退出,不需要 join。

能并发了,代价是线程数没有上限。压测一来,线程直接 runaway。这不是理论风险,是真实服务器上线就会撞上的墙。

第三层:线程池,用有界队列做背压

固定 256 个 worker 线程,靠一个共享 channel 派活。真正讲究的地方在背压:队列容量设成和 worker 数一样,也是 256。队列满了,主循环就阻塞在 tx.send 上,不再 accept 新连接。

注意,这不是拒绝连接,是主动限流。压力顶不上去,线程也不会爆炸。还有一处容易被忽略:它用的是 crossbeam_channel 的有界 mpmc 通道,因为标准库只有稳定的 mpsc,mpmc 还在 nightly。这暴露了 Rust std 的一个刻意选择,很多基础设施它不给你,留给生态 crate。

第四层:异步加 tokio,卖点就是"长得像同步"

#[tokio::main]、async TcpListener、tokio::spawn。作者想让你盯住一件事:异步版 serve_connection_async 和同步版几乎一字不差,只多了 .await 和 tokio::net::TcpStream 的类型差别。

这就是 async/await 的全部价值。它让异步代码长成同步代码的样子,底下 mio 去调操作系统的 epoll。你要是只记一句,就记这句:异步不是更快,是更省线程。

第五层:异步加 Redis,真实世界的麻烦来了

最后一版引入两个生产环境才会有的麻烦:长耗时的计算,和跨任务共享的状态。

这里有三个真功夫,值得单独拎出来。

函数染色。 Rust 里同步函数和异步函数不互通,你只能把函数劈成两份:serve_connection 和 serve_connection_async。比 JavaScript 还狠,JS 至少能在 async 里 await 同步函数,Rust 不行,async 上下文里调同步阻塞函数会直接卡死 executor。文章里这就是染色问题的活标本。

async 里阻塞是毒药。 素性测试 isprime(n, delay=true) 里用了 std::thread::sleep,如果直接在 async task 里调,会占住一个 executor 线程,把整个事件循环拖垮。tokio 官方给的解法是 spawn_blocking,或者丢到独立线程池再用 channel 回传。作者点出了这个坑,但示例里的素性测试仍然在 async 里同步计算,他承认这不对,是个刻意简化。你抄代码时别照抄这一处。

Send/Sync 和 clone 技巧。 AppState { redis_connection } 派生 Clone,内部是 MultiplexedConnection,已经内部同步好了。每个 task clone() 一份,开销极低。这比 Arc<Mutex<>> 更地道,是 Rust 异步里共享状态的常见姿势。配合 get_multiplexed_async_connection 单连接多路复用,避免了每个连接开一条 Redis 链接。顺带说,连 Redis 客户端库都得提供同步和异步两套构造器,这是染色的另一面。

这篇文章有三处软肋,得说清楚

一是没有任何基准数字。作者说 tokio 因性能优势流行,但全文没给一条吞吐或延迟曲线。严谨的工程文章这里该补 wrk 或 ab 的压测。

二是 delay 模拟太糙。sleep(n 毫秒) 只是 CPU 工作的蹩脚替身,既不建模缓存命中,也不模拟真实的素性计算开销。

三是 Redis 那版仍在 async 里同步算 isprime,和前面"别在 async 里阻塞"的教训自相矛盾。要么是简化,要么是疏漏,你引用时要讲明白。

对你写 Rust 工程结构的一点启发

抛开并发模型不谈,这篇文章的工程结构本身就值得抄。serve_connection 和 serve_connection_async 收在一个 lib crate 里,每种并发策略各占一个 bin crate。业务逻辑下沉成库,并发策略各是一个可执行体,这种切分比任何一个并发模型都更能决定你项目的可维护性。

如果你正打算写 Rust 工程结构,这条叙事弧,基线、线程池、异步、把计算卸载出去,是现成的教学骨架。Eli 的原文把坑都踩了一遍,你只需要把骨架借来,填上自己的例子。