By烟花易冷

Golang sync.Mutex 深度剖析(一)
2023-01-02

之前的有小伙伴提到了“当提到并发编程、多线程编程时,往往都离不开锁这一概念”,对此我深有同感。当对应场景发生时,我们可能会使用 singleflight、sync.Once 等高级封装简化代码编写,提高工作的效率,而这两个类库底层都是调用 sync.Mutex 包实现。目前网络上有不少关于这个包的讲解博文,但大多数趋于讲述怎么调用,在这里我把我学习 sync.Mutex  包的过程以及心得体会整理分享出来,带领大家探究一下它是如何实现的吧。

一、首先看看这个包的作用是什么

       包如其名,这个包是 Go 语言中对锁的封装,是一个非常简单且“暴力”的互斥锁。当一个 goroutine 对某一个“片段”上锁后,其他 goroutine 想要再次上锁时就只能“阻塞”住乖乖等它释放。

二、基本组成

        Mutex 结构体包含了 state、sema 两个成员;state 表示锁当前的状态,是一个复合属性,可以粗暴把他看做一个由 0/1 组成的二进制 bitmap。从右边数起,第一位表示当前这个锁是不是处于上锁状态(0:没有锁定 1:已被锁定), 第二位标识着在等待的 goroutine 是否已被唤醒(0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中);第三位标识是否有 goroutine 处于饥饿状态。如果一个等待的 goroutine 超过1ms没有获取锁,那么它将会把锁转变为饥饿模式,饥饿模式下竞争的表现会变动稍微不同,这里后续将会详细讲解。剩余的29位则作为一个简单的计数器,表示当前正在等待的 goroutine 个数。sema 则表示信号量,(是的就是你想象中的那个进程间通信的信号量),用于协程之间的协调。

同时,它实现了 sync.Locker 这个接口,拥有 Lock(上锁)、Unlock(解锁) 能力。

// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
type Mutex struct {
   state int32
   sema  uint32
}
 
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
   Lock()
   Unlock()
}

三、包的发展历程

        辩证法教会我们,要用发展的眼光看待问题,编程语言也不例外。sync.Mutex 这个包从诞生至今经历过 4 次重大的变革,变得越来越“快”,变得越来越合理“合理”。这里借用一张网上的图,很好的解释了这  4 步发展历程。

      在第一个阶段,Mutex 基于信号量实现了简单的互斥锁,用一句很俗套的话来说,就是“该有的都有了”,上层封装也非常简单,这里摘录一段源码如下:

type Mutex struct {
    key int32;  // 0是没上锁,大于 0 是已上锁
    sema int32;  // 朴朴素素的信号量
}
 
// xadd 一个具有原子性的加法,当初还没有 atomic 包呢
func xadd(val *int32, delta int32) (new int32) {
    for {
        v := *val;
        if cas(val, v, v+delta) {
            return v+delta;
        }
    }
    panic("unreached")
}
 
// Lock 上锁操作
// m.key 变量 +1;如果锁已经被别人上了,则会阻塞等待唤起(多个协程排着队)
func (m *Mutex) Lock() {
    if xadd(&m.key, 1) == 1 {
        // changed from 0 to 1; we hold lock
        return;
    }
    semacquire(&m.sema);
}
 
// Unlock 解锁操作
// 很简单的 -1,如果减了之后 m.key 这个变量不是 0,代表着还有其他 goroutine 在等着用锁,释放个信号量去唤起
func (m *Mutex) Unlock() {
    if xadd(&m.key, -1) == 0 {
        // changed from 1 to 0; no contention
        return;
    }
    semrelease(&m.sema);
}

       咋看是不是很有我们大学上“操作系统”课时,写出来的作品?用一个变量表示有没有上锁,上锁时通过 acquire 获取信号量,解锁时 release,简洁,实用!

       肯定有小伙伴发现了,Mutex 的解锁有一个神奇之处,在设计上它允许任何协程去执行 Unlock 操作,并不关心是不是上锁的和解锁的是不是同一个协程。这么设计有好有坏,在使用上还是需要关注一下这一点的。

       此外单纯用系统的信号量实现的锁,唤醒 goroutine 的机制是按排队顺序,谁在前面就先唤醒谁。看着没啥问题,但是这其中却存在着一个 CPU 上下文切换的过程,不一定是排着队的那个 goroutine 就是被唤起效率就是最高的(你想想看,如果这时候正好有个 goroutine 正在上锁,就这么分毫之差内还得去阻塞排队,万一这个 goroutine 内的操作极其简单,上下文切换的性能损耗说不定比这个 goroutine 本身的还大 ),因此执行在高并发的情况下还是会有不容忽视的损耗,这也就来到了第二版 Mutex。

       第二、第三版 Mutex 整体比较类似,整体思路都是被唤醒后,和新进来的 goroutine 再一起抢一轮锁,具体请听下回分解。

参考资料:

  • mutex 源码:https://github.com/golang/go/blob/d90e7cbac65c5792ce312ee82fbe03a5dfc98c6f/src/pkg/sync/mutex.go
  • cas 的汇编语言源码:https://github.com/golang/go/blob/weekly.2011-01-20/src/pkg/sync/asm_amd64.s
  • 信号量相关 C 语言源码:https://github.com/golang/go/blob/d90e7cbac65c5792ce312ee82fbe03a5dfc98c6f/src/pkg/runtime/sema.c
  • sync_runtime_canSpin源码:https://github.com/golang/go/blob/846dce9d05f19a1f53465e62a304dea21b99f910/src/runtime/proc.go#L5580