--- 摄于 2017 年 9 月 藏川线前段
共享状态在任何应用里都是不可避免的问题,而在不同的环境里面,考虑的共享时效性其实是不太一样的,有些允许有稍许延时,有些必须要保证全局一致性。在异步场景下,这个问题更加复杂,因为它不仅涉及时效性,还涉及是否需要用只适用于异步场景的锁的问题。
常规下,我们说的锁,是所谓的同步线程锁,它的主要依赖的是各平台类 FUTEX_WAKE/FUTEX_WAIT 的 syscall,用来通过 syscall 保证线程间共享状态的一致性,对各线程的竞争同步提供保证。当然,这是二度改良版,因为这套 api 在进行 syscall 之前,可以通过 CAS 的原子变换,先在用户态进行锁状态的判断,如果当前并不在锁定状态,那就不需要陷入内核,也就大幅提高了锁的性能。
从 parking_lot 的代码中(pthread.h 也类似),我们可以看到:
fn futex_wait(&self, ts: Option<libc::timespec>) {
...
let r = unsafe {
libc::syscall(
libc::SYS_futex,
&self.futex,
libc::FUTEX_WAIT | libc::FUTEX_PRIVATE_FLAG,
1,
ts_ptr,
)
};
...
}
unsafe fn unpark(self) {
// The thread data may have been freed at this point, but it doesn't
// matter since the syscall will just return EFAULT in that case.
let r = libc::syscall(
libc::SYS_futex,
self.futex,
libc::FUTEX_WAKE | libc::FUTEX_PRIVATE_FLAG,
1,
);
...
}
mutex 和 rwlock 的唯一区别是用 Atomic usize 表达的状态不一样,rwlock 有一个 shared state,而 mutex 是 absolutely exclusive。并且,实现中,它并不需要实现等待队列,它的锁表在 thread_local 数据里 和 全局数据里,它锁的目标是线程。
相对应的异步锁实现,用不太确切的类比来说,从数据库中偷取一个概念,可以这样类比,同步锁锁的是整个表(线程),而异步锁锁的是表中的某一行(Future)。
以 tokio 的实现为例,它的锁实现是基于异步信号量来实现的,并且,没有任何 syscall 的使用,完全是在用户态借助 poll/wake 机制实现的。信号量的定义很简单,如下:
pub(crate) struct Semaphore {
waiters: Mutex<Waitlist>,
/// The current number of available permits in the semaphore.
permits: AtomicUsize,
}
一个请求列表加上一个当前占用数量,但请求列表借助了同步线程锁,用来保证插入请求列表的一致性,请求列表的存在是为了维护公平性(请求排队),在竞争激烈的时候,不至于饿死某些异步任务。
基于信号量实现 mutex 和 rwlock,就比较简单了,他们的主要区别就是:
从定义上来看,这是一个没有什么问题的异步锁实现,整个逻辑也是先 CAS 变换确定当前状态,如果可以获取,则立刻修改状态并返回 guard,如果无法获取,将 waker 信息插入 waitlist,等待当前获取者的释放并唤醒。
我们可以看到,在异步锁的实现中,在 waitlist 上依赖了同步锁,这是一个很小的临界区,并且不得不借助 syscall 实现全局一致的插入和删除,用来实现锁的公平性。那么,在我们自己需要锁的时候,到底什么情况是可以使用同步锁,什么情况是不能使用同步锁呢?
给出一个简单的定义:在异步环境下,如果是简单的计算,状态变化,插入删除等情况,用同步锁其实会更高效,保证临界区足够小,且不会被 block 很久。
Rust 里面有对数据类型有一个相对好的约束,如果没有 Send 的约束,以下代码就有可能被编译过去,然后会发生未定义行为:
use parking_lot::Mutex;
tokio::task::spawn(async move {
let _guard = Mutex::new(()).lock();
tokio::time::interval(::std::time::Duration::from_secs(10)).tick().await
});
目前是会报错:
note: future is not `Send` as this value is used across an await
|
29 | let _guard = Mutex::new(()).lock();
| ------ has type `parking_lot::lock_api::MutexGuard<'_, parking_lot::RawMutex, ()>` which is not `Send`
30 | tokio::time::interval(::std::time::Duration::from_secs(10)).tick().await
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here, with `_guard` maybe used
later
31 | });
| - `_guard` is later dropped here
先 lock 再 await,就意味着,lock 的 guard 可能被放到其他线程执行 drop,而这是一个未定义行为,在 pthread.h 的文档里有一段描述:
If a thread attempts to unlock a mutex that it has not locked or a mutex which is unlocked, undefined behavior results.
而先 await,再 lock 并且在处理完成前不再存在 await/yield 点,就不会存在这种问题。
当然,在异步环境里大量使用线程锁,是会影响异步运行时的性能的,这个一定要注意,基于 tokio 的实现,如果 executor 的所有线程都在竞争同一个线程锁,就意味着,不会有任何其他任务能执行。
我们都是在有限资源上做一些既要 xxx 又要 xxx 的事情,而实际上,如果资源能无限大,问题就不存在了(狗头.jpg
请登录后评论
评论区
加载更多