Tokio:为什么异步互斥锁比同步互斥锁更"昂贵"
编辑The feature that the async mutex offers over the blocking mutex is the ability to keep it locked across an
.await
point. This makes the async mutex more expensive than the blocking mutex, so the blocking mutex should be preferred in the cases where it can be used. The primary use case for the async mutex is to provide shared mutable access to IO resources such as a database connection.
异步互斥锁(如 tokio::sync::Mutex
)比标准同步互斥锁(如 std::sync::Mutex
)更"昂贵"的原因涉及到它们的内部实现机制和运行时开销。
内部实现差异
标准同步互斥锁的实现
标准库的 std::sync::Mutex
通常直接映射到操作系统提供的原语上:
-
简单的阻塞机制:
- 使用操作系统提供的底层互斥锁机制
- 当锁被占用时,操作系统会将线程置于睡眠状态
- 当锁释放时,操作系统会唤醒等待的线程
-
轻量级数据结构:
- 只需要跟踪锁的状态(锁定/未锁定)
- 可能包含一个等待线程的队列
// 简化的std::sync::Mutex内部结构示意
struct StdMutex<T> {
// 操作系统级别的互斥锁
os_mutex: OsMutex,
// 受保护的数据
data: UnsafeCell<T>,
}
异步互斥锁的实现
tokio::sync::Mutex
需要支持异步操作,这导致它的实现更加复杂:
-
任务唤醒机制:
- 需要维护等待任务的队列
- 每个等待的任务都有一个
Waker
对象 - 当锁释放时,需要通知异步运行时唤醒下一个任务
-
状态管理:
- 需要跟踪锁的状态
- 需要处理任务在
.await
点挂起和恢复的情况 - 需要确保在任务之间正确传递所有权
-
Future 实现:
.lock()
方法返回一个Future
- 这个
Future
需要实现状态机逻辑
// 简化的tokio::sync::Mutex内部结构示意
struct TokioMutex<T> {
// 保护内部状态的标准互斥锁
state: std::sync::Mutex<MutexState>,
// 受保护的数据
data: UnsafeCell<T>,
}
struct MutexState {
// 锁是否被持有
locked: bool,
// 等待获取锁的任务队列
waiters: VecDeque<Waker>,
}
性能开销的具体原因
1. 内存开销
- 异步互斥锁需要额外的内存来存储等待队列和
Waker
对象 - 每个等待锁的任务都会产生额外的内存分配
2. CPU 开销
- 异步互斥锁在锁定和解锁操作中需要执行更多的代码:
- 管理等待队列
- 创建和处理
Future
- 与异步运行时交互
3. 额外的间接层
- 异步互斥锁通常在内部仍然使用标准互斥锁来保护其状态
- 这意味着有一个额外的间接层,增加了操作的复杂性
4. 运行时集成
- 异步互斥锁需要与异步运行时(如 Tokio)集成
- 这涉及到任务调度、唤醒和上下文切换的额外开销
实际例子:内部实现对比
让我们看一个简化的内部实现对比,以便更好地理解:
标准互斥锁的锁定过程
// 简化的std::sync::Mutex.lock()实现
fn lock(&self) -> LockResult<MutexGuard<T>> {
// 直接调用操作系统API尝试获取锁
self.os_mutex.lock();
// 如果获取失败,当前线程会被操作系统挂起
// 直到锁可用
// 返回守卫对象
Ok(MutexGuard { mutex: self })
}
异步互斥锁的锁定过程
// 简化的tokio::sync::Mutex.lock()实现
fn lock(&self) -> impl Future<Output = MutexGuard<T>> {
// 返回一个实现了Future的对象
async move {
loop {
// 尝试获取锁
{
let mut state = self.state.lock().unwrap();
if !state.locked {
// 锁可用,标记为已锁定
state.locked = true;
return MutexGuard { mutex: self };
}
// 锁不可用,将当前任务添加到等待队列
let waker = futures::task::noop_waker();
state.waiters.push_back(waker);
}
// 挂起当前任务,等待被唤醒
futures::future::poll_fn(|cx| {
// 复杂的唤醒逻辑...
std::task::Poll::Pending
}).await;
// 被唤醒后,再次尝试获取锁
}
}
}
具体性能差异
-
锁竞争场景:
- 在高竞争场景下,异步互斥锁的开销更明显
- 每次锁操作都涉及到更复杂的状态管理和任务调度
-
内存使用:
- 对于大量互斥锁的应用,异步版本会消耗更多内存
- 例如,1000 个互斥锁的应用,异步版本可能会使用比同步版本多几百 KB 的内存
-
CPU 使用:
- 异步互斥锁的锁定/解锁操作通常需要更多的 CPU 指令
- 在频繁锁操作的场景下,这可能导致明显的性能差异
何时值得使用异步互斥锁
尽管异步互斥锁更"昂贵",但在某些场景下它的优势超过了成本:
-
IO 密集型操作:
- 当你需要在持有锁的同时执行异步 IO 操作
- 这种情况下,能够不阻塞线程的好处远大于额外的开销
-
长时间持有锁:
- 当锁需要被持有较长时间
- 异步互斥锁允许其他任务继续在同一线程上执行
-
资源有限的环境:
- 在线程数量有限的环境中
- 避免线程阻塞可以提高整体系统吞吐量
实际代码示例:性能对比
use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::Mutex as TokioMutex;
use std::time::Instant;
#[tokio::main]
async fn main() {
// 测试参数
let iterations = 1_000_000;
let threads = 4;
// 测试标准互斥锁
let std_mutex = Arc::new(StdMutex::new(0));
let start = Instant::now();
let mut handles = vec![];
for _ in 0..threads {
let mutex = Arc::clone(&std_mutex);
handles.push(tokio::spawn(async move {
for _ in 0..iterations/threads {
let mut value = mutex.lock().unwrap();
*value += 1;
}
}));
}
for handle in handles {
handle.await.unwrap();
}
let std_duration = start.elapsed();
println!("标准互斥锁耗时: {:?}", std_duration);
// 测试异步互斥锁
let tokio_mutex = Arc::new(TokioMutex::new(0));
let start = Instant::now();
let mut handles = vec![];
for _ in 0..threads {
let mutex = Arc::clone(&tokio_mutex);
handles.push(tokio::spawn(async move {
for _ in 0..iterations/threads {
let mut value = mutex.lock().await;
*value += 1;
}
}));
}
for handle in handles {
handle.await.unwrap();
}
let tokio_duration = start.elapsed();
println!("异步互斥锁耗时: {:?}", tokio_duration);
println!("性能差异: {:.2}x", tokio_duration.as_secs_f64() / std_duration.as_secs_f64());
}
在这个示例中,你可能会看到异步互斥锁比标准互斥锁慢 1.5-3 倍,具体取决于你的系统和运行环境。
总结
异步互斥锁比同步互斥锁更"昂贵"的主要原因是:
- 更复杂的内部实现,需要管理任务等待队列和唤醒机制
- 与异步运行时的集成带来额外开销
- 通常在内部仍然使用同步互斥锁,增加了一层间接性
- 需要创建和管理 Future 对象
这就是为什么在不需要跨越.await 点持有锁的情况下,推荐使用标准同步互斥锁。只有当你确实需要在持有锁的同时执行异步操作时,异步互斥锁的额外开销才是值得的。
- 0
- 0
-
分享