异步感知原语
如果你浏览tokio
的文档,你会发现它提供了很多类型,这些类型“镜像”了标准库中的那些,但加入了异步的特性:锁、通道、计时器等等。
在异步环境下工作时,你应该优先选择这些异步替代品,而不是它们的同步对应物。
为了理解原因,让我们回顾一下上一章中探讨过的互斥锁Mutex
。
案例研究:Mutex
来看一个简单的例子:
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; async fn run(m: Arc<Mutex<Vec<u64>>){ let guard = m.lock().unwrap(); http_call(&guard).await; println!("Sent {:?} to the server", &guard); // `guard`在此处被释放 } /// 使用`v`作为HTTP请求体 async fn http_call(v: &[u64]){ // [...] } }
std::sync::MutexGuard
与放弃点
这段代码能编译,但存在隐患。
我们在异步上下文中尝试获取std
中的Mutex
锁,然后在.await
(在http_call
上)的放弃点上保持得到的MutexGuard
。
假设有两个任务在单线程运行时上并发执行run
,我们观察到以下调度事件序列:
Task A Task B
|
Acquire lock
Yields to runtime
|
+--------------+
|
Tries to acquire lock
我们遇到了死锁。任务B永远无法获得锁,因为锁目前被任务A持有,而任务A在释放锁之前已经放弃了运行时的控制权,且运行时无法抢占任务B,因此任务A不会再被调度。
tokio::sync::Mutex
通过切换到tokio::sync::Mutex
可以解决这个问题:
#![allow(unused)] fn main() { use std::sync::Arc; use tokio::sync::Mutex; async fn run(m: Arc<Mutex<Vec<u64>>){ let guard = m.lock().await; http_call(&guard).await; println!("Sent {:?} to the server", &guard); // `guard`在此处被释放 } }
现在获取锁是一个异步操作,如果无法继续推进就会让出运行时。回到之前的场景,情况会变成这样:
Task A Task B
|
Acquires the lock
Starts `http_call`
Yields to runtime
|
+--------------+
|
Tries to acquire the lock
Cannot acquire the lock
Yields to runtime
|
+--------------+
|
`http_call` completes
Releases the lock
Yield to runtime
|
+--------------+
|
Acquires the lock
[...]
一切顺利!
多线程并不能拯救你
虽然我们在前面的例子中使用了单线程运行时作为执行环境,但在使用多线程运行时时,同样的风险依然存在。
唯一的区别在于导致死锁所需的并发任务数量:在单线程运行时中,2个就足够;而在多线程运行时,我们则需要N+1
个任务,其中N
是运行时线程的数量。
缺点
拥有异步感知的Mutex
伴随着性能上的损失。
如果你确信锁的竞争并不激烈,并且小心地不在放弃点上持有它,你仍然可以在异步上下文中使用std::sync::Mutex
。
但是,要权衡性能收益与你将承担的活性风险。
其他原语
我们以Mutex
为例,但这同样适用于RwLock
、信号量等。在异步环境中工作时,应优先选择异步感知版本,以最小化潜在问题的风险。