前言
最近测试报了个事故,表现是压测的时候,系统吞吐量掉了三分之二,查看内存和cpu,都没有满,搞的我一个头两个大?
原因我就不卖关子了,
- 是因为一批热数据用的是tokio自带的mutex异步锁,在我们的服务里,它的性能表现非常的差。
- 这个服务还要作为SDK提供一个非异步运行时的功能,主要是给一些ui提供服务,这种情况下我们用的std的mutex
所以本文的最终目标是找到tokio mutex的问题,并写一个支持同步异步两种运行时的锁。
tokio的mutex原理
在我的印象中,这种比较基础的包,里面内置的模块性能一般是有保障的,怎么出现如此大规模降性能的情况。
先看一下他的数据结构:
- 一看这个结构就能猜出来它的实现原理,所有future依次同步Mutex进入
Waitlist链表,如果可能拿到数据则执行,否则进入链表等待。
pub struct Mutex<T: ?Sized> {
s: semaphore::Semaphore,
c: UnsafeCell<T>,
}
//Semaphore的结构
pub(crate) struct Semaphore {
waiters: Mutex<Waitlist>,
permits: AtomicUsize,
}
lock过程:里面套了很多层,主要功能在 Semaphore.poll_acquire 函数中,太长了,我这里就不贴了。
unlock过程:在drop的时候Semaphore.add_permits_locked释放锁
基于此,问题已经显而易见了,当锁非常频繁时,tokio mutex 会退化成同步的mutex,并且因为做了非常多的链表,唤醒等操作,可能还不如直接用std mutex。
AsyncMutex
我的服务就是命中了这种情况,在某些时候,会频繁加锁热点数据。导致性能下滑。基于此 我们实现一个快速轻量的锁。主要用原子操作cas机制。
看下锁结构:
- 看到这个结构,也就猜到了工作原理,没错就是饥饿模式+cas
pub struct AsyncMutex<T>{
data:UnsafeCell<T>,
status:Arc<AtomicBool>
}
在同步中使用,直接循环等待就可以了
#[allow(dead_code)]
pub fn synchronize(&self)->AsyncMutexGuard<T>{
let mutex = self.lock();
loop {
if mutex.try_lock() {
let data = mutex.data;
let status = mutex.status.clone();
return AsyncMutexGuard{data,status}
}
}
}
为了在异步中使用,我们来实现一下Future
impl<T> Future for AsyncMutexFut<T>{
type Output = AsyncMutexGuard<T>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.try_lock() {
let data = self.data;
let status = self.status.clone();
return Poll::Ready(AsyncMutexGuard{data,status})
}
cx.waker().wake_by_ref();
Poll::Pending
}
}
锁释放的处理
impl<T> Drop for AsyncMutexGuard<T> {
fn drop(&mut self) {
self.status.store(false,Ordering::Relaxed);
}
}
测试
先和tokio mutex比较一下
- 起四个线程,10个协程,异步加一百万次锁,可以看到差距非常明显。
- tokio mutex用时:
1711ms - wdtools mutex用书:
118ms
#[tokio::test(flavor ="multi_thread", worker_threads = 4)]
pub async fn test_mutex(){
// let am = Arc::new(tokio::sync::Mutex::new(0isize));
let am = Arc::new(Am::new(0isize));
let start = std::time::Instant::now();
let wg = WaitGroup::default();
for _ in 0..10{
wg.defer_args1(|am|async move{
for _ in 0..10_0000 {
let mut lock = am.lock().await;
*(lock.deref_mut()) +=1;
}
},am.clone());
}
wg.wait().await;
// let guard = am.synchronize();
let guard = am.lock().await;
println!("use_time[{}ms]--->{}",start.elapsed().as_millis(),guard.deref());
assert_eq!(*guard.deref(),100_0000isize)
}
当锁操作不频繁的时候,其实两者趋近相同。我们让每次锁1ms,异步锁1w次,tokio mutex用时23258ms,wdtools mutex 用时20174ms
和std mutex比较一下
代码就不贴了,和上面差不多,开十个线程,锁一百万次。大概差了一倍,std用的是系统的锁,还是有保障的。
- std mutex用时:
258ms - wdtools mutex用时:
577ms
如果每次锁不立刻结束,实际两者相差不大,10个线程,每次sleep 1ms,锁1w次。
- std mutex用时:
12752ms - wdtools mutex用时:
12700ms
尾语
因为锁获取用的饥饿模式,开销是很大的。所以对于锁时间很长的情况下,不建议用wdtools mutex。但事情是两面的,对于偏乐观的场景,就很有优势,因为并不需要让出线程。
我们服务里面的mutex也并没有全部替换,只在轻量,异步,乐观的情况下使用。