记一次tokio中异步mutex问题排查,以及如何写一个更快,更轻量的异步mutex

2,636 阅读3分钟

前言

最近测试报了个事故,表现是压测的时候,系统吞吐量掉了三分之二,查看内存和cpu,都没有满,搞的我一个头两个大?

原因我就不卖关子了,

  1. 是因为一批热数据用的是tokio自带的mutex异步锁,在我们的服务里,它的性能表现非常的差。
  2. 这个服务还要作为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机制。

github代码传送门

看下锁结构:

  • 看到这个结构,也就猜到了工作原理,没错就是饥饿模式+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也并没有全部替换,只在轻量,异步,乐观的情况下使用。