你写了安全代码。借用检查器(Borrow Checker)盖了章。没有数据竞争,没有未定义行为,没有段错误。然后你的异步服务开始像钟表一样往生产环境丢延迟尖峰——每200毫秒一次,40毫秒的卡顿,没人能解释。
问题出在互斥锁(Mutex)。不是因为错了,是因为握得太久。
每个Rust异步开发者都会踩一次的坑
Rust的异步故事有个特点:编译器会告诉你代码什么时候不安全。但不会告诉你什么时候慢,或者逻辑上坏了。这些是你的问题。
tokio::sync::Mutex(Tokio异步运行时提供的互斥锁)用来在await点之间保护共享状态。听起来正是你要的。但一旦你握着锁跨过一个.await,你就阻塞了所有需要这个资源的其他任务——整整一个await的时长。一次数据库调用。一个HTTP请求。一次文件读取。100毫秒的绝对静默,其他人全在排队。
作者曾在Tokio的trace日志前坐了三天,想不通为什么负载一上来吞吐量就崩。服务有200个任务在跑,理论上应该并行。实际呢?它们在互斥锁前串成了单行道。每个任务握着锁去等数据库,其他199个干瞪眼。
安全≠正确,编译器不替你思考架构
这是Rust社区最近讨论很多的一个张力。语言把内存安全做成了编译期保证,开发者容易产生一种错觉:能通过编译的代码,性能也不会太差。异步互斥锁就是个反例。
标准库的std::sync::Mutex不能跨await,编译器直接报错。这迫使你重新设计:把锁的作用域缩到最小,在进异步操作前释放。但tokio::sync::Mutex解除了这个限制,代价是把性能陷阱做成了合法操作。
社区里有种声音:这是API设计的问题。锁的接口应该"默认安全"——既指内存安全,也指性能安全。但Tokio的选择是暴露底层能力,把判断留给开发者。换句话说,它给了你足够的绳子,要不要上吊自己决定。
作者提到一个修复模式:用通道(Channel)替代锁。把状态操作发给专门的任务,其他任务发消息请求,避免直接竞争。这增加了代码复杂度,但消除了锁持有时间和异步操作的耦合。
生产环境的教训:延迟比崩溃更难调试
内存不安全会立刻崩溃,你马上知道有事发生。性能劣化是慢性的,像温水煮青蛙。等监控报警,业务已经受损。
那篇讨论里有个细节让人印象深刻:作者最后发现,问题锁保护的只是一个计数器,更新操作不到1微秒。但因为它包裹着一次20毫秒的数据库查询,整个系统的并行度被压到了1。
修复花了10分钟。找问题花了三天。这种比例在异步Rust代码库里并不罕见。
社区正在探索一些方向:静态分析工具检测锁跨越await的情况;运行时监控锁持有时间;或者新的API设计,让"短持有"成为默认。但眼下,这仍是开发者需要自己警惕的盲区。
你的代码能通过编译。借用检查器很满意。问题是,你的用户满意吗?
热门跟贴