揭秘 Go Mutex 实现与应用

320 阅读20分钟

什么是 Mutex

在 Go 语言中,Mutex(互斥锁)是一种用于多线程编程或并发编程中的同步机制,用于确保在同一时刻只有一个 goroutine 可以访问共享资源。

主要特点

  1. 互斥性Mutex的核心特性是互斥性,即保证在任何时刻只有一个 goroutine 可以持有锁并访问被保护的共享资源。这有效地防止了多个 goroutine 同时对共享资源进行读写操作而导致的数据竞争和不一致问题。
  2. 原子性操作:在获取和释放锁的过程中,Mutex的操作是原子性的。这意味着在一个 goroutine 尝试获取锁时,其他 goroutine 不能同时进行获取锁的操作,直到当前持有锁的 goroutine 释放锁。
  3. 不可重入性:Go语言的Mutex只记录了加锁状态,没有记录锁的所有者,所以不支持可重入,自己加的锁别人也可以打开。

基本操作

  1. 加锁(Lock):当一个 goroutine 需要访问被互斥锁保护的共享资源时,它首先需要调用互斥锁的Lock方法。这个方法会阻塞当前线程,直到互斥锁被释放,然后当前线程获得锁并可以继续执行对共享资源的访问操作。
  2. 解锁(Unlock):当一个线程完成对共享资源的访问后,它应该调用互斥锁的Unlock方法来释放锁,以便其他等待的线程可以获得锁并访问共享资源。
var mutex sync.Mutex
func accessSharedResource() {
   mutex.Lock()
   // 访问共享资源的代码
   mutex.Unlock()
}

Mutex 与原子操作

什么原子操作

在前面的文章 《一文搞定原子操作》 介绍过原子操作,原子操作是基于硬件指令实现的一个或者一系列不可中断的操作,相对性能较高,但是应用场景有限,在 Go 语言中只有原子类型才能使用原子操作。

func main() {
    var num int32 = 42
    // 原子存储新值
    atomic.StoreInt32(&num, 100)
    // 原子加载值
    value := atomic.LoadInt32(&num)
}

上面的 atomic.StoreInt32 和 atomic.LoadInt32 都是原子的,但是这两个语句放在一起并不具有原子性,如果需要多条语句具有原子性那就需要 Mutex 。

互斥锁锁的是什么

上面介绍过,Mutex 能保证在任何时刻只有一个 goroutine 可以持有锁并访问被保护的共享资源。所谓受保护的共享资源实际指的是操作共享资源的代码块,这个代码块也就是常说的临界区,临界区有几个特点:

  1. 只有持有锁的 goroutine 才能进入临界区;
  2. 同一时刻只有一个 goroutine 能进入临界区。

互斥锁与原子操作的区别

原子操作和互斥锁均可用于在并发环境中保护共享资源,不过它们在应用场景、实现机制、性能等方面存在一定的差异:

差异点原子操作互斥锁
应用场景适用于对单个变量或简单数据结构的操作,尤其是在高并发场景下需要频繁进行的简单操作。适用于对复杂数据结构或一段代码块的同步,当需要确保一组操作的原子性和一致性时,互斥锁更为合适。例如,对一个链表的插入和删除操作,或者对多个变量的同时修改。
实现机制通过底层硬件指令实现,不需要复杂的同步逻辑。通常基于信号量、原子操作、协程调度等一系列复杂操作实现的。
性能通常性能较高,因为它们直接在硬件层面实现,避免了上下文切换和线程阻塞的开销。相对原子操作性能较低。当多个线程竞争锁时,会导致线程阻塞和唤醒,这会带来一定的开销。

Mutex 实现原理

Mutex 数据结构

type Mutex struct {
    state int32
    sema  uint32
}
  • Mutex.state 是一个复合字段, 它不但能表示等待锁的协程个数,还能表示锁的状态。
  • Mutex.sema 表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。

上图展示Mutex的内存布局:

  • Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
  • Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
  • Starving:表示该Mutex是否处于饥饿状态,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。
  • Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

Mutex 的两种模式

官方文档里的描述:

Mutex 可以处于两种操作模式:正常模式和饥饿模式。

在正常模式下,等待者按照FIFO(先进先出)的顺序排队,但是被唤醒的等待者不拥有互斥锁,会与新到达的 Goroutine 竞争所有权。新到达的 Goroutine 有优势——它们已经在 CPU 上运行,数量可能很多,因此被唤醒的等待者有很大的机会失去锁。在这种情况下,它将排在等待队列的前面。如果等待者未能在1毫秒内获取到互斥锁,则将互斥锁切换到饥饿模式。

在饥饿模式下,互斥锁的所有权直接从解锁 Goroutine 移交给队列前面的等待者。新到达的 Goroutine 即使看起来未被锁定,也不会尝试获取互斥锁,也不会尝试自旋。相反,它们会将自己排队在等待队列的末尾。如果等待者获得互斥锁的所有权并发现(1)它是队列中的最后一个等待者,或者(2)它等待时间少于1毫秒,则将互斥锁切换回正常模式。 正常模式的性能要优于饥饿模式,因为 Goroutine 可以连续多次获取互斥锁,即使有被阻塞的等待者。饥饿模式很重要,可以防止尾部延迟的病态情况。

Mutex 中的位操作

sync.Mutex 的代码并不多,但是阅读起来比较难懂,原因之一就是源码里有很多位操作的代码:

const ( mutexLocked = 1 << iota  // 00000..001  mutexWoken // 00000..010  mutexStarving // 00000..100  mutexWaiterShift = iota  )

mutexLocked|mutexStarving // 00000..101
old&(mutexLocked|mutexStarving) == mutexLocked // old: 000***..0*1
old&mutexWoken == 0           // old: 000***..*0*
old&mutexStarving == 0  // old: 000***..0**
new |= mutexLocked // 000**..**1
old&(mutexLocked|mutexStarving) != 0 // old: 000***..**1 或者 000***..1** 或者 000***..1*1
old&mutexLocked != 0          // old: 000***..**1
new&mutexWoken == 0             // new: 000***..*0*
new &^= mutexWoken //  new: 000***..*0*
old&(mutexLocked|mutexStarving) == 0 // old: 000***..0*0 既没上锁也没饥饿
old&mutexStarving != 0 // old: 000***..1** 饥饿状态
old>>mutexWaiterShift // old 的高29位,也就是 waiter 的个数
mutexLocked - 1<<mutexWaiterShift // 上锁并将 waiter 个数减 1
old>>mutexWaiterShift == 0 // waiter 的个数为 0
(old - 1<<mutexWaiterShift) | mutexWoken // waiter 个数减 1 并加上唤醒标记

Mutex 中的信号量

Mutex 涉及到阻塞和唤醒,阻塞和唤醒就是基于信号量实现的,信号量主要用于控制对共享资源的访问数量。它是一个整数计数器,表示可用资源的数量。线程或进程可以通过对信号量进行 P 操作(减 1)来申请资源,如果信号量的值小于等于 0,则线程或进程会被阻塞;通过 V 操作(加 1)来释放资源,唤醒一个或多个等待的线程或进程。

对应到 go 的 Mutex 中,有两个操作信号量的函数:

  • 加锁时调用 runtime_SemacquireMutex 进行 P 操作,如果信号量小于等于 0 当前 goroutine 会被阻塞一直等到信号量大于 0,然后自动递减。

  • 解锁时调用 runtime_Semrelease 进行 V 操作,自动递增信号量并通知等待的 goroutine。

runtime_Semreleaseruntime_SemacquireMutex 的源码在 src/runtime/sema.go 中,阅读源码可以发现:

  1. 调用runtime_SemacquireMutex 阻塞的 goroutine 会被放入到一个 FIFO 队列中;
  2. 调用 runtime_Semrelease 时会唤醒 FIFO 队列头部的 goroutine

Mutex 锁的获取

Lock()加锁方法分为两部分:

  1. 第一部分是 fast path,可以理解为快捷通道,如果当前锁没被占用,直接获得锁返回;否则需要进入 slow path
  2. slow path 判断各种条件去竞争锁,主要逻辑都在此处。
// 上锁
func (m *Mutex) Lock() {
    // fastpath:期望当前锁没有被占用,可以快速获取到锁, CAS 修改 state 最后一位的值为1(标记锁是否被占用)
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    // Slow path : 单独抽出来放到一个函数里,方便 fast path 被内联
    m.lockSlow()
}

func (m *Mutex) lockSlow() {
    var waitStartTime int64 // // 记录等待时间
    starving := false // 当前的 goroutine 是否已经饥饿了(如果已经饥饿,就会将 state 的饥饿状态置为 1)
    awoke := false  // 当前的 goroutine 是否被唤醒的
    iter := 0        // 自旋次数
    old := m.state // 保存当前的 state 状态
    
    for {
        /*
        自旋:如果满足如下条件,就会进入 if 语句,然后 continue,不断自旋:
        1. 锁被占用,且不处于饥饿模式(饥饿状态直接去排队,不允许尝试获取锁)
        2. 基于当前自旋的次数,再次自旋有意义 runtime_canSpin(iter)
        
        那么退出自旋的条件也就是:
        1. 锁被释放了,当前处于没被占用状态(说明等到了,该goroutine就会立即去获取锁)
        2. mutex进入了饥饿模式,不自旋了,没意义(饥饿状态会直接把锁交给等待队列队首的goroutine)
        3. 不符合自旋状态(自旋次数太多了,自旋失去了意义)
        
        如下代码是位操作:
        mutexLocked|mutexStarving = 00000...101
        mutexLocked = 00000...001
        如果要满足 old & 00000...101 = 00000...001,需要 old = ...0*1,即状态为:锁被占用,且不处于饥饿状态 
        
        runtime_canSpin(iter) 会根据自旋次数,判断是否可以继续自旋
        */
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
                        
            /*
            如果 
              1. 当前goroutine不是被唤醒的 (awoke=false) 
              2. 锁状态唤醒标志位为0(old&mutexWoken == 0) 
              3. 等待者数量不为0 (old>>mutexWaiterShift != 0  右移三位得到的就是等待者数量)
            
            那么利用CAS,将 state 的唤醒标记置为1,标记自己是被唤醒的 (将state的唤醒标记置为1,说明外面有唤醒着的goroutine,那么在释放锁的时候,就不去等待队列叫号了,毕竟已经有唤醒的了)
            如果有其他 goroutine 已经设置了 state 的唤醒标记位,那么本次就会失败
            */
    
            if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                awoke = true
            }
            
            runtime_doSpin()
          
            // 迭代次数加一
            iter++
          
            // 获取最新的状态
            old = m.state
          
            // 想再次自旋,看看锁释放了没
            continue
        }
    
        // 到这里,说明退出了自旋,当前锁没被占用 或者  系统处于饥饿模式 或者 自旋次数太多导致不符合自旋条件
        
        // new 代表当前goroutine 基于当前状态要设置的新状态
        new := old
    
        // 只要不是饥饿状态,就需要获取锁(饥饿状态直接去排队,不能抢锁)
        if old&mutexStarving == 0 {
            new |= mutexLocked
        }
    
        // 锁被占用 或者 处于饥饿模式下,新增一个等待者
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift
        }
    
        // 当前 goroutine 已经进入饥饿了,且锁还没有释放,需要把 Mutex 的状态改为饥饿状态
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving
        }
    
        // 如果是被唤醒的,把唤醒标志位置0,表示外面没有被唤醒的goroutine了(抢到就获得锁、抢不到就睡眠,把唤醒标志置0)
        if awoke {   
            // 由于是被唤醒的,new 里面的 唤醒标记位一定是 1
            if new&mutexWoken == 0 {
                throw("sync: inconsistent mutex state")
            }

            // a &^ b 的意思就是 清零a中,ab都为1的位,即清除唤醒标记
            new &^= mutexWoken
        }
    
        /*
          利用CAS,将状态设置为新的
          1. 如果是饥饿状态,只增加一个等待者数量
          2. 正常状态,加锁标记置为 1,如果锁已被占用增加一个等待者数量
          3. 如果当前 goroutine 已经饥饿了,将 饥饿标记 置为 1
          4. 如果是被唤醒的,清除唤醒标记
        */
    
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
      
            // 如果改状态之前,锁未被占用 且 处于正常模式,那么就相当于获取到锁了
            if old&(mutexLocked|mutexStarving) == 0 {
                break 
            }
      
            // 到这里说明:1. 之前锁被占用  或者 2.之前是处于饥饿状态 
            // 判断之前是否等待过(是否从队列里唤醒的),之前等待过,再次排队放在队首
            queueLifo := waitStartTime != 0
      
            // 如果之前没等过(新来的),设置等待起始时间当前时间
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
      
            // 之前排过队的老人,放到等待队列队首;新人放到队尾,然后等待获取信号量
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
      
            // 锁被释放,goroutine 被唤醒
      
            // 设置当前 goroutine 饥饿状态,如果之前已经饥饿,或者距离等待开始时间超过了 1ms,也变饥饿
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
      
            // 获取最新的状态
            old = m.state
      
            // 如果 state 饥饿标记为1,说明当前在饥饿模式,饥饿模式下被唤醒,已经获取到锁了;
            // 饥饿状态下,释放锁没有更新等待者数量和饥饿标记,需要获得锁的goroutine去更新状态
            if old&mutexStarving != 0 {

                // 正确性校验:
                // 1. 锁还是锁住的状态(锁已经释放给当前goroutine了,不应该被锁住)
                // 2. 或者有被唤醒的goroutine(饥饿模式下不应该有醒着的goroutine,都应该去乖乖等着)
                // 3. 或者当前goroutine 的等待者数量为0(当前goroutine就是等待者)
                // 这三种情况不应该出现,与预期状态不符
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync: inconsistent mutex state")
                }
        
                // 加锁,减去一个等待者
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
        
                // 如果当前的 goroutine 非饥饿,或者等待者只有一个(也就是只有当前goroutine,等待队列空了),可以取消饥饿状态,进入正常状态
                if !starving || old>>mutexWaiterShift == 1 {
                    delta -= mutexStarving
                }
        
                // 修改状态:
                // 加锁,减去一个等待者: m.state + mutexLocked - 1<<mutexWaiterShift : 
                // 满足非饥饿条件,加锁,减去一个等待者,取消饥饿状态:
                // m.state + mutexLocked - 1<<mutexWaiterShift - mutexStarving: 
                atomic.AddInt32(&m.state, delta)
        
                // 饥饿模式下被唤醒,相当于获得锁了,可以结束
                break
            }
      
            // 之前是处于锁被占用且非饥饿状态,被唤醒,去继续抢锁
            awoke = true
      
            // 新唤醒的,自旋数量置0
            iter = 0
        } else {
            // 修改新状态失败,状态有更新,需要重试
            old = m.state
        }
    }
}

代码中注释很多,再简单总结一下其中的流程:

  1. 乐观态度的自旋:判断是否可以自旋,如果可以自旋,就自旋等待;如果有可能,把唤醒标记位置为1,标记外面有唤醒的 goroutine,释放锁的时候就不会去队列里面唤醒了,毕竟已经有人在等待了;

  2. 修改系统状态:跳出自旋后,每个 goroutine 根据当前系统状态修改系统状态:

    1. 非饥饿状态,想要加锁(如果本来就是加锁状态,将加锁位 设置为 1 相当于不变)
    2. 锁被占用 或者 处于饥饿模式下,新增一个等待者
    3. 当前 goroutine 已经进入饥饿了,且锁还没有释放,需要把 Mutex 的状态改为饥饿状态
    4. 如果当前 goroutine 是被唤醒的,清除系统唤醒标记
  3. 利用 CAS 修改系统状态,同一时刻只有一个 goroutine 能够设置成功,如果设置失败重复上面步骤进行重试,如果设置成功进行下一步操作:

    1. 之前是非上锁的正常状态,设置成功说明本次抢锁成功,可以返回去操作临界区了;
    2. 之前是上锁状态或者饥饿状态,本次只是新增了一个等待者,然后根据是否是新来的,去队列队尾或者队首排队,等待叫号;
  4. 从队列中被叫号唤醒,不一定是获取到锁了:

    1. 当前是饥饿状态,那么一定是获取到锁了,因为饥饿状态只把锁给队列的第一个 goroutine
    2. 非饥饿状态,将自己状态置为唤醒,再去抢锁,重复上述过程

Mutex 锁的释放

Unlock()解锁方法也分为两部分:

  1. 第一部分是 fast path,可以理解为快捷通道,直接把锁状态位清除,如果此时系统状态恢复到初始状态,说明没有 goroutine 在抢锁等锁,直接返回,否则进入 slow path

  2. slow path 会根据是否为饥饿状态,做出不一样的反应:

    1. 正常状态:唤醒一个 goroutine 去抢锁,等待者数量减一,并将唤醒状态置为 1
    2. 饥饿状态:直接唤醒等待队列队首的 goroutine,锁的所有权直接移交(修改等待者数量、是否取消饥饿标记,由唤醒的 goroutine 去处理)。
func (m *Mutex) Unlock() {
    // Fast path: 把锁标记清除
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // 清除完锁标记,发现还有其他状态,比如等待队列不为空,需要唤醒其他 goroutine
        m.unlockSlow(new)
    }
}

func (m *Mutex) unlockSlow(new int32) {
    /* 状态正确性校验:
        1. 如果解锁一个上锁状态的锁,最后一位则为1,fast path 中 new 已经减去了1, 此时 new 最后一位应当为0
        2. 如果解锁一个未上锁状态的锁,最后一位则为0,fast path 中 new 已经减去了1, 此时 new 最后一位应当为1
          如果 (new+mutexLocked)&mutexLocked == 0,说明 new 当前最后一位是1,那么就是解锁了一个没有上锁的锁,状态有误
    */
    if (new+mutexLocked)&mutexLocked == 0 {
        throw("sync: unlock of unlocked mutex")
    }
  
    // 正常模式,非饥饿,可能需要唤醒队列中的 goroutine,饥饿状态直接移交锁
    if new&mutexStarving == 0 {
        old := new
        for {

            /* 系统运转正常,锁可以正确交接,可以直接返回了:
                1. 没有等待者了 (没有等锁的了,去唤醒谁?)
                2. 有唤醒状态的 goroutine  (自旋状态的 goroutine,将唤醒状态置为1)
                3. 有 goroutine 已经获取了锁 (Unlock方法已经将锁标记置为了0,可能自旋的此时已经抢到了锁)
            */
            if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
      
            // 没有唤醒状态的 goroutine,唤醒一个去抢锁
            // 减去一个等待者,并且将 唤醒标记 置为 1
            new = (old - 1<<mutexWaiterShift) | mutexWoken
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                // 第二个参数为false, 唤醒队首的 goroutine 去抢锁(不一定能抢到)
                runtime_Semrelease(&m.sema, false, 1)
                return
            }
      
            // 上面 CAS 失败,可能由于新增了一个等待者,for 循环重试
            old = m.state
        }
    } else {      
        /*
            1. 第二个参数为 true,直接将锁的所有权,交给等待队列的第一个等待者
            2. 注意,此时没有设置 mutexLocked =1 ,被唤醒的 goroutine 会设置
            3. 虽然没有设置 mutexLocked ,但是饥饿模式下, Mutex 始终被认为是锁住的,都会直接排队等待移交锁
        */
        runtime_Semrelease(&m.sema, true, 1)
    }
}

unlockSlow 流程:

应用场景

保护共享资源

在并发编程中,多个 goroutine 可能同时访问和修改同一个共享资源。为了确保数据的一致性和正确性,需要使用互斥锁来保护共享资源。例如,Map 并发读写时会出现 panic,我们可以使用 Mutex 来保护 Map 防止出现 panic。

package main

import (
    "fmt"
    "sync"
)

type SafeMap struct {
    m    map[string]int
    lock sync.Mutex
}

func NewSafeMap() *SafeMap {
    return &SafeMap{
        m: make(map[string]int),
    }
}

func (sm *SafeMap) Put(key string, value int) {
    sm.lock.Lock()
    sm.m[key] = value
    sm.lock.Unlock()
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.lock.Lock()
    value, ok := sm.m[key]
    sm.lock.Unlock()
    return value, ok
}

func main() {
    safeMap := NewSafeMap()

    // 模拟并发写入
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            if id % 2 == 0 {
                safeMap.Put(fmt.Sprintf("key%d", id), id)
            } else {
                // 读取数据
                value, ok := safeMap.Get("key5")
                if ok {
                    fmt.Println("Value for key5:", value)
                }
            }
            
        }(i)
    }
    wg.Wait()
}

同步 goroutine

在某些情况下,需要确保多个 goroutine 按照特定的顺序执行。可以使用互斥锁来实现这种同步。例如,一个 goroutine 需要等待另一个 goroutine 完成某个任务后才能继续执行。可以使用互斥锁来确保两个 goroutine 之间的同步。

package main

import (
   "fmt"
   "sync"
)

var mutex sync.Mutex
var done bool

func worker1() {
   // 执行一些任务
   fmt.Println("Worker 1 is working...")
   mutex.Lock()
   done = true
   mutex.Unlock()
}

func worker2() {
   mutex.Lock()
   for!done {
       mutex.Unlock()
       // 等待一段时间后再次尝试获取锁
       // 这里可以使用更高效的等待机制,如条件变量
       mutex.Lock()
   }
   fmt.Println("Worker 2 can start working...")
   mutex.Unlock()
}

func main() {
   go worker1()
   go worker2()
   // 等待两个 goroutine 完成
   select {}
}

避免数据竞争

在并发编程中,如果多个 goroutine 同时访问和修改同一个变量,可能会导致数据竞争。使用互斥锁可以避免这种数据竞争。例如,一个 goroutine 正在读取一个变量的值,而另一个 goroutine 正在修改这个变量的值。使用互斥锁可以确保在读取和修改操作之间不会被其他 goroutine 干扰。

package main

import (
   "fmt"
   "sync"
)

var data int
var mutex sync.Mutex

func reader() {
   mutex.Lock()
   fmt.Println("Data:", data)
   mutex.Unlock()
}

func writer() {
   mutex.Lock()
   data++
   mutex.Unlock()
}

func main() {
   var wg sync.WaitGroup
   for i := 0; i < 100; i++ {
       wg.Add(1)
       go func() {
           defer wg.Done()
           reader()
       }()
       wg.Add(1)
       go func() {
           defer wg.Done()
           writer()
       }()
   }
   wg.Wait()
}

注意事项

Lock/Unlock 不成对出现

如果对互斥锁的加锁(Lock)和解锁(Unlock)操作不成对进行,就会引起程序异常:

  1. 多次加锁而未解锁可能导致程序“卡死”(也被称为死锁)。
  2. 尝试解锁一个未加锁的互斥锁可能引发程序崩溃(panic)。

避免死锁

在使用互斥锁时,要注意避免死锁的发生。死锁是指两个或多个 goroutine 相互等待对方释放锁,导致程序无法继续执行的情况。为了避免死锁,可以按照固定的顺序获取多个锁,或者使用超时机制来避免无限期地等待锁。

package main

import (
   "fmt"
   "sync"
)

var mutex1 sync.Mutex
var mutex2 sync.Mutex

func worker1() {
   mutex1.Lock()
   fmt.Println("Worker 1 acquired mutex1")
   mutex2.Lock()
   fmt.Println("Worker 1 acquired mutex2")
   // 执行一些任务
   mutex2.Unlock()
   fmt.Println("Worker 1 released mutex2")
   mutex1.Unlock()
   fmt.Println("Worker 1 released mutex1")
}

func worker2() {
   mutex2.Lock()
   fmt.Println("Worker 2 acquired mutex2")
   mutex1.Lock()
   fmt.Println("Worker 2 acquired mutex1")
   // 执行一些任务
   mutex1.Unlock()
   fmt.Println("Worker 2 released mutex1")
   mutex2.Unlock()
   fmt.Println("Worker 2 released mutex2")
}

func main() {
   go worker1()
   go worker2()
   // 等待两个 goroutine 完成
   select {}
}

在上面的代码中,worker1 先获取 mutex1,然后获取 mutex2;而worker2先获取mutex2,然后获取mutex1。如果两个 goroutine 同时执行,就会发生死锁。为了避免死锁,可以按照固定的顺序获取锁,例如先获取mutex1,再获取mutex2

避免过度使用锁

虽然互斥锁可以确保在同一时刻只有一个 goroutine 能够访问共享资源,但过度使用锁可能会导致性能下降。在设计并发程序时,应该尽量减少对共享资源的访问,以降低锁的竞争程度。

例如:在 sync.Pool 中为每个 P 都分匹配一个 poolLocal,这样访问 poolLocal 时就不用加锁。

注意锁的粒度

锁的粒度指的就是临界区的大小,临界区的代码都是串行执行的。如果锁的粒度太大,可能会导致并发度降低;如果锁的粒度太小,可能会增加锁的管理开销。在设计并发程序时,应该根据实际情况选择合适的锁粒度。

例如:如果一个共享资源只在一个函数内部被访问和修改,可以考虑使用局部变量来代替共享变量,而不是使用互斥锁来保护整个共享资源。

总结

Go Mutex 是一种非常有用的同步机制,可以确保在并发编程中共享资源的安全访问。了解其实现原理和应用场景,可以帮助我们更好地设计和实现并发程序。同时,在使用互斥锁时,要注意避免死锁、过度使用锁和选择合适的锁粒度等问题,以提高程序的性能和可靠性。