详解 Go 的并发原语 sync.Mutex,通过互斥锁来解决资源并发访问所带来的问题

楔子

Go 最大的亮点就是它在语言层面实现了并发,只需要在函数调用前面加上一个 go 关键字即可开启一个 goroutine(Go 的协程)去并发执行,并且由于 Go 强悍的调度器,更是可以让我们轻轻松松开启成千上万个 goroutine。至于多个 goroutine 之间如何交流,Go 则是引入 Channel,通过 Channel 实现 goroutine 之间的通信,所以入门 Go 的并发非常容易。

不过虽说入门容易,但有时在面对并发时也会感到困惑,比如不知道该用什么并发原语来解决问题;如果多个并发原语都可以解决问题,那么哪一个是最优解呢?是互斥锁、还是 Channel;并发编程不像传统的串行编程,如果程序运行存在着不确定性,那么如何才能让任务按照我们预期的那样执行呢。

  • 在面对并发时,不知道该用什么并发原语来解决问题
  • 如果多个并发原语都可以解决问题,那么哪一个是最优解呢?是互斥锁、还是 Channel
  • 并发编程不像传统的串行编程,如果程序运行存在着不确定性,那么如何才能让任务按照我们预期的那样执行呢
  • 程序出现 panic 或者死锁了,故障要如何排查
  • 如果已知的并发原语都不能解决问题,该怎么办

下面就来一点一点地攻破它。

首先在并发编程中,多个 goroutine 并发操作同一份资源很容易出问题,比如:并发操作计数器可能会导致最终的计数不准确;并发更新用户的账户信息可能会导致用户的账户出现透支、或者秒杀系统出现超卖;并发地往同一个 buffer 里面写数据可能会导致 buffer 中的数据混乱等等,这些都是问题。

而解决这些问题,我们首先能想到的方式就是使用互斥锁,资源在同一时刻只能被一个 goroutine 访问。而在 Go 里面,实现互斥锁的方式是通过 Mutex。

互斥锁的实现机制

互斥锁是并发控制的一个基本手段,是为了避免竞争而建立的一种并发控制机制。在学习它的具体实现原理前,我们要先搞懂一个概念,就是临界区。

在并发编程中,如果程序中的一部分在并发访问的时候会导致意想不到的结果,那么这部分程序需要被保护起来,而这部分被保护起来的程序就叫做临界区。可以说,临界区指的就是对共享资源所执行的一组操作,比如对数据库的访问、对某一个共享数据结构的操作、对一个 I/O 设备的使用、对一个连接池中的连接的调用等等。

如果多个 goroutine 同时访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以使用互斥锁,限定临界区只能同时由一个 goroutine 访问。当某个 goroutine 已经进入临界区时,其它 goroutine 就无法再进入了,如果尝试进入的话会返回失败、或者等待。直到某个进入临界区的 goroutine 退出临界区,这些等待的 goroutine 中的某一个才有机会接着进入该临界区。

通过互斥锁,我们能很好地解决资源竞争问题,互斥锁也被称为排它锁。此外,除了互斥锁(Mutex)之外,还有读写锁(RWMutex),这两者的区别后面再说,总之它们的使用场景是一致的。由于并发地读写共享资源会出现数据竞争(data race)问题,所以需要 Mutex、RWMutex 这样的并发原语来保护。

Mutex 的基本使用方法

在 Go 的标准库中有一个 sync 包,提供了一系列锁相关的并发原语,Mutex 也在里面。除此之外,sync 还提供了一个 Locker 接口,该接口内部定义了两个方法:

type Locker interface{
    Lock()
    Unlock()
}

Mutex、RWMutex 均实现了 Locker 接口,其中 Lock 代表请求锁、Unlock 代表释放锁。但是很明显我们不会直接使用这个 Locker 接口,而是使用具体的并发原语。

所以简单来说,互斥锁 Mutex 就提供两个方法 Lock 和 Unlock:进入临界区之前调用 Lock 方法,退出临界区的时候调用 Unlock 方法。

func (m *Mutex) Lock()
func (m *Mutex) Unlock()

当一个 goroutine 通过调用 Lock 方法获得了这个锁的拥有权后,其它请求锁的 goroutine 就会阻塞在 Lock 方法的调用上,直到锁被释放并且自己获取到了这个锁的拥有权。

我们举个并发场景的栗子,看看不使用锁会出现什么情况。

package main

import "fmt"

var count int64

func main() {
    var ch = make(chan struct{}, 2)
    go func() {
        for i := 0; i < 100000; i++ {
            count++
        }
        ch <- struct{}{}
    }()

    go func() {
        for i := 0; i < 100000; i++ {
            count--
        }
        ch <- struct{}{}
    }()

    <-ch
    <-ch
    close(ch)
    fmt.Println(count)
}

首先我们定义了一个全局变量 count,可以理解为它是一个计数器,然后开启了两个协程,一个对 count 进行 100000 次自增、另一个对 count 进行 100000 次自减。里面的 ch 为 channel,它的作用是负责保证两个 goroutine 执行完毕之后才会打印 count,因此整个逻辑是没有问题的。但是当打印 count 的时候,却发现结果不为 0,并且每次执行的结果都会不一样,理论上 100000 次自增和 100000 次自减会互相抵消,结果应该是 0 才对。

这是因为,count++ 和 count-- 不是一个原子操作,它至少包含几个步骤:读取变量 count 的当前值,对这个值加 1 或减 1,运算之后把结果再保存到 count 中。因为不是原子操作,就可能有并发的问题。比如 goroutine1 和 goroutine2 读取的 count 都是 0,然后 goroutine1 对 count 执行自增,goroutine2 对 count 执行自减,此时运算之后的结果一个是 1、一个是 -1,然后再将结果保存到 count 的时候,也是要么得到 1 要么得到 -1,但很明显结果应该是 0 才对。

以上就是并发访问共享数据的常见错误,这种错误对于有经验的开发人员来说还是比较容易发现的。但很多时候,并发问题隐藏得非常深,即使是有经验的人,也不太容易发现或者 Debug 出来。针对这个问题,Go 提供了一个检测并发访问共享资源是否有问题的工具:race detector,它可以帮助我们自动发现程序有没有 data race 的问题。

Go race detector 是基于 Google 的 C/C++ sanitizers 技术实现的,编译器通过探测并监视所有的内存访问,当监控到对共享变量的非同步访问、出现 race 的时候,就会打印出警告信息。这个技术在 Google 内部帮了大忙,探测出了 Chromium 等代码的大量并发问题。Go 1.1 中就引入了这种技术,并且一下子就发现了标准库中的 42 个并发问题,现在 race detector 已经成了 Go 持续集成过程中的一部分。

而 Go race detector 的使用方式也很简单,只需要在编译(compile)、测试(test)或者运行(run)Go 代码的时候,加上 -race 参数即可,如果存在并发问题,会打印输出信息。

这个警告不但会告诉你有并发问题,而且还会告诉你哪个 goroutine 在哪一行对哪个变量有写操作,哪个 goroutine 在哪一行对哪个变量有读操作,就是这些并发的读写访问引起了 data race。比如 goroutine 8 对内存地址 0x00000122dc90 有读操作(第 19 行),goroutine 7 对内存地址 0x00000122dc90 有写操作(第 12 行)。

虽然这个工具使用起来很方便,但因为它的实现方式,只能在真正对实际地址进行读写访问的时候才能探测,所以它并不能在编译的时候发现 data race 问题。而且在运行的时候,只有在触发了 data race 之后,才能检测到,如果碰巧没有触发(比如一个 data race 问题只能在 2 月 14 号零点或者 5 月 20 号零点才出现),那么是检测不出来的。而且把开启了 -race 的程序部署在线上,也比较影响性能。

说了这么多,我们的重点是解决它,而解决方式就是这里 Mutex,它可以轻松地消除掉 data race。我们知道,这里的共享资源是 count 变量,临界区是 count++ 和 count--,只要在进入临界区时获取锁,在离开临界区时释放锁,就能完美地解决 data race 的问题了。

package main

import (
    "fmt"
    "sync"
)

var count int64

func main() {
    var ch = make(chan struct{}, 2)
    var m = new(sync.Mutex)  // 创建互斥锁

    go func() {
        for i := 0; i < 100000; i++ {
            m.Lock()  // 上锁
            count++
            m.Unlock()  // 解锁
        }
        ch <- struct{}{}
    }()

    go func() {
        for i := 0; i < 100000; i++ {
            m.Lock()  // 上锁
            count--
            m.Unlock()  // 解锁
        }
        ch <- struct{}{}
    }()

    <-ch
    <-ch
    close(ch)
    fmt.Println(count)
}

每次在执行 count++ 和 count-- 的时候都会先调用 m.Lock() 去获取锁,而一旦锁已被获取,那么证明别的 goroutine 进入了临界区,当前 goroutine 会阻塞。因此 count++ 和 count-- 不会同时执行,所以最终结果不管执行多少次都是 0。如果加上 -race 参数,也会发现程序没有任何警告。

Go 里面只有值传递,如果你想把 Mutex 传递到其它函数中,那么一定要传递指针。

Mutex 的其它用法

很多情况下,Mutex 会嵌入到其它 struct 中使用,比如:

type Counter struct{
    sync.Mutex
    Count uint64
}

我们不再借助于全局变量,而是自己封装了带锁机制的计数器。

package main

import (
    "fmt"
    "sync"
)

type Counter struct{
    sync.Mutex
    Count uint64
}

func main() {
    var ch = make(chan struct{}, 2)
    var counter = new(Counter)

    go func() {
        for i := 0; i < 100000; i++ {
            counter.Lock()
            counter.Count++
            counter.Unlock()
        }
        ch <- struct{}{}
    }()

    go func() {
        for i := 0; i < 100000; i++ {
            counter.Lock()  
            counter.Count--
            counter.Unlock()
        }
        ch <- struct{}{}
    }()

    <-ch
    <-ch
    close(ch)
    fmt.Println(counter.Count)
}

甚至,我们还可以把获取锁、释放锁、计数加一等逻辑封装成一个方法,不直接对外暴露:

package main

import (
    "fmt"
    "sync"
)

type Counter struct{
    sync.Mutex
    count uint64  // 不对外暴露
}

func (c *Counter) Incr() {
    c.Lock()
    c.count++
    c.Unlock()
}

func (c *Counter) Decr() {
    c.Lock()
    c.count--
    c.Unlock()
}

func (c *Counter) Count() uint64 {
    // 提供一个方法,返回计数器的值,同样需要锁的保护
    c.Lock()
    defer c.Unlock()
    return c.count
}


func main() {
    var ch = make(chan struct{}, 2)
    var counter = new(Counter)

    go func() {
        for i := 0; i < 100000; i++ {
            counter.Incr()
        }
        ch <- struct{}{}
    }()

    go func() {
        for i := 0; i < 100000; i++ {
            counter.Decr()
        }
        ch <- struct{}{}
    }()

    <-ch
    <-ch
    close(ch)
    fmt.Println(counter.Count())
}

如果我们的项目中真的需要实现一个计数器的话,那么就可以采用上面的方式。当然 Mutex 还是比较简单的,但确实是解决并发问题的一种有效手段,像 Docker、k8s 在早期都发现了 data race 问题,并采用 Mutex 进行了修复。

Mutex:源码解密底层实现

Mutex 的用法还是很简单的,进入临界区先 Lock 一下、离开临界区再 Unlock 一下即可,那么 Mutex 的底层实现是不是也很简单呢?答案显然不是的,这背后会涉及到很多的知识点,而且 Go 的 Mutex 也是一步一步演进到现在的。

下面我们就来看看 Mutex 的底层实现以及演进之路,看看它是怎样逐步提升性能以及公平性的,并且在这个过程中我们可以学习如何设计一个并发原语,以及对复杂度、性能、结构设计的权衡也会有新的认识。

Mutex 的架构演进可以大致分为四个阶段,我们分别介绍。

初版的互斥锁

我们先来看看怎么实现一个最简单的互斥锁,在开始之前可以先想一想,如果是你,你会怎么设计呢?

你可能会想到,可以通过一个 flag 变量,标记当前的锁是否被某个 goroutine 持有。如果这个 flag 的值是 1,就代表锁已经被持有,那么其它竞争的 goroutine 只能等待。如果这个 flag 的值是 0,就代表锁当前没有人用,当 goroutine 来获取锁时,可以通过 CAS(compare-and-swap,或者 compare-and-set)将这个 flag 设置为 1,表示锁已经被持有了。

整个逻辑很简单,事实上早期的 Mutex 就是这么设计的,不过在看源码之前我们需要先了解一下什么是 CAS,它非常重要。

假设有一块内存,里面存储的值是 a,但是现在想将其变成 a + b,这个时候需要经历哪几步呢?

  • 将存储的值读取出来,得到 a
  • 将 a 和 b 进行加法运算,得到 a + b
  • 再将计算后的新值 a + b 写回到原来的内存中,也就是将原来的值 a 给更新掉

单线程的话是没有任何问题的,但如果是多个线程同时操作这块内存呢?显然可能会出问题,因此需要通过 CAS 解决这一点。首先它会将内存中原本的值进行备份,运算之后会比较此时内存的值和备份的值,如果一致才进行更新,如果不一致则什么也不做。比如一开始内存的值是 a,备份一份,然后计算完毕之后发现内存的值变成了 a1,前后不一致,说明其它线程已经将这块内存的值给修改了,那么此时就不会再更新了;如果一致,说明没有别的线程修改这个内存的数据,那么此时才会更新。

所以 CAS 的名字很直观,就是先比较、然后再决定是否更新(设置),并且整体是原子性的。CAS 是实现互斥锁和并发原语的基础,我们有必要掌握它。

然后来看看第一版的 Mutex 源代码。

// CAS 操作,当时还没有抽象出 atomic 包
func cas(val *int32, old, new int32) bool
func semacquire(*int32)
func semrelease(*int32)

// 互斥锁的结构,包含两个字段
type Mutex struct {
    key int32  // 锁是否被持有的标识,为 0 表示未被持有,不为 0 表示被持有
    sema int32 // 信号量专用,用以阻塞和唤醒 goroutine
}

// 保证成功在 val 上增加 delta 的值
func xadd(val *int32, delta int32) (new int32) {
    for {
        v := *val
        if cas(val, v, v+delta) {
            return v + delta
        }
    }
    panic("unreached")
}

// 请求锁
func (m *Mutex) Lock() {
    // 标识加 1,如果加 1 后的结果等于 1,表示锁之前未被持有,于是成功获取到锁
    if xadd(&m.key, 1) == 1 { 
        return
    }
    semacquire(&m.sema) // 否则阻塞等待
}

// 释放锁
func (m *Mutex) Unlock() {
    if xadd(&m.key, -1) == 0 { // 将标识减去 1,如果等于 0,则没有其它等待者
        return
    }
    semrelease(&m.sema) // 唤醒其它阻塞的 goroutine
}

所以 Mutex 结构体包含两个字段:

  • 字段 key:是一个 flag,用来标识这个互斥锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个互斥锁已经被持有
  • 字段 sema:是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒

在调用 Lock 请求锁的时候,通过 xadd 方法进行 CAS 操作,并且不成功时会不断循环,直到 CAS 操作执行成功,保证对 key 的加 1 操作完成。如果幸运,锁没有被别的 goroutine 持有(key 为 0),那么 key 再加 1 之后就变成了 1,这个 goroutine 就成功获取了锁。如果锁已经被别的 goroutine 持有了,那么将 key 增加 1 之后显然不等于 1,因此会调用 semacquire 方法,通过请求信号量来将自己休眠。等到持有锁的 goroutine 释放锁的时候,再由持有锁的 goroutine 释放信号量来将自己唤醒。

所以当持有锁的 goroutine 调用 Unlock 释放锁时,它会将 key 减 1,如果结果为 0,那么表示此时没有因等待锁而阻塞 goroutine,于是直接返回;如果不为 0,那么说明还有其它的 goroutine 在等待锁,于是会调用 semrelease 方法,使用信号量唤醒等待的 goroutine 中的一个。

所以到这里我们算是明白了,初版的 Mutex 利用 CAS 原子操作,对 key 这个标志量进行设置,并且 key 不仅仅标识了锁是否被 goroutine 所持有,还记录了当前持有和等待获取锁的 goroutine 的数量。整体没什么难度,但是这里面有一个问题:

Unlock 方法可以被任意的 goroutine 调用,即使是没持有这个互斥锁的 goroutine 也可以进行这个操作。这是因为 Mutex 本身并没有包含持有这把锁的 goroutine 的信息,所以 Unlock 也不会对此进行检查,并且 Mutex 的这个设计一直保持至今。

package main
import "sync"

func main()  {    
    var m sync.Mutex  // 创建互斥锁    
    m.Lock()  // 上锁
    go func() {
        m.Unlock()  // 启动新的 goroutine 去释放锁
    }()
}

加锁和解锁即使不在同一个 goroutine 中也是可以的,但很明显这么做是危险的。假设 goroutine1 在获取锁之后进入了临界区,但是 goroutine2 把锁释放了,可 goroutine1 还认为自己持有锁,于是继续在临界区执行业务操作,从而会产生数据竞争、带来意想不到的结果。所以我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的锁,一定要遵循 "谁申请,谁释放" 的原则。在真实的实践中,我们使用互斥锁的时候,很少在一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。

并且为了避免获取锁之后忘记释放,我们可以使用 defer 关键字,Go 从 1.14 版本开始对 defer 进行了优化,采用更有效的内联方式,取代之前的生成 defer 对象到 defer chain 中,因此 defer 对耗时的影响微乎其微了,在实际工作中可以大胆使用 defer。

package main

import "sync"

type Counter struct {
    sync.Mutex
    count int
}

func (c *Counter) Add() {
    c.Lock()
    defer c.Unlock()

    if c.count < 1000 {
        c.count += 3
        return
    }
    c.count ++
}

通过 defer 便可以确保 Lock 和 Unlock 总是成对出现,不会遗漏。当然,如果临界区只是方法的一部分,那么为了尽快释放锁,建议还是第一时间调用 Unlock,而不是等到方法结束再调用。

初版的 Mutex 实现之后,Go 开发组又对 Mutex 做了一些微调,比如把字段类型变成了 uint32 类型;调用 Unlock 方法会做检查;使用 atomic 包执行原子操作等等,这些小的改动就不细说了。

然后我们仔细观察一下初版的 Mutex 实现,会发现它有一个问题:请求锁的 goroutine 会排队等待获取互斥锁,虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用 CPU 时间片的 goroutine 的话,就不需要做上下文的切换了,在高并发的情况下会有更好的性能。那 Go 的作者们是如何解决的呢?

第二版的互斥锁(给新来的 goroutine 一个机会)

Go 开发者在 2011 年 6 月 30 日的 commit 中对 Mutex 做了一次大的调整,调整后的 Mutex 实现如下:

type Mutex struct{
    state int32
    sema  uint32
}
const(
    mutexLocked = 1 << iota  // 持有锁的标记
    mutexWoken               // 唤醒标记
    mutexWaiterShift = iota  // 用于计算阻塞等待的 waiter 数量
)  

虽然 Mutex 结构体还是包含两个字段,但是第一个字段已经改成了 state,它的含义也不一样了。state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)表示这个锁是否被持有,第二位表示是否有已经被唤醒、正在竞争锁的 goroutine,剩余的位数表示等待此锁(需要被唤醒)的 goroutine 数(waiter 数)。所以,state 这一个字段被分成了三部分,代表三个数据。

  • m.state & mutexLocked 的结果可以判断锁是否被持有,结果为 1 表示锁已被持有,结果为 0 表示锁未被持有
  • m.state & mutexWoken 的结果可以判断是否有被唤醒的 goroutine,结果为 2、或者说结果不为 0 表示有被唤醒的 goroutine,结果为 0 表示没有
  • m.state >> mutexWaiterShift 则表示等待的 waiter 数量

负责请求锁的 Lock 方法也变得复杂了,复杂之处不仅仅在于对字段 state 的操作难以理解,而且代码逻辑也变得相当复杂。

func (m *Mutex) Lock() {
    /* 快分支:通过 CAS 检测 state 字段,如果没有 goroutine 获得锁、也没有等待获取锁的 goroutine
     * 那么此时 state 的值为 0,当前的 goroutine 就很幸运,可以直接获得锁
     * 所以将 state 设置为 mutexLocked 之后返回即可
     */
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    /* 但如果 state 的值不为 0,那么不好意思,就需要通过循环不断进行检查
     * 我们知道,如果想要获取锁的 goroutine 没有机会获取到锁,就会进行休眠
     * 然后已经获得锁的 goroutine 在释放锁之后会将其唤醒,只是它并不能像先前一样直接获取到锁
     * 之前是先被唤醒就能先获取锁,但现在不一样了,它还要和其它正在请求锁的 goroutine 进行竞争
     * 这会给后来请求锁的 goroutine 一个机会
     * 这也让正在执行的 goroutine 有更多的机会获取到锁,在一定程度上提高了程序的性能
     */
    
    // awoke 表示该 goroutine 是新来的 goroutine,还是休眠之后被唤醒的 goroutine
    // 在第一次进入循环时肯定是新来的 goroutine,所以 awoke 为 false
    // 当获取不到锁进入休眠、然后再被唤醒时,那么它就变成了被唤醒的 goroutine
    // 显然该 goroutine 醒来第一件事就是将 awoke 设置为 true,此后就一直为 true
    awoke := false  
    for {
        old := m.state  // 获取旧状态
        /* 返回新状态,此时最后一位会变成 1,表示加锁
         * 但如果之前就是 1,则表示锁已经是被获取的状态,那么 new 和 old 相同
         * 但如果之前不是 1,那么这里的 new 就是加锁的新状态
         * 最后会通过 CAS 将 new 写回 state
         */
        new := old | mutexLocked 
    /* 如果 if 条件成立,说明之前的 state 的最后一位已经是 1,说明锁已经被获取了
     * 那么将等待者的数量加 1,但我们说 state 是从第 3 位开始表示等待者的数量
     * 所以 new = old + 1<<mutexWaiterShift,要将 1 左移两位之后再和 old 进行相加
     */
        if old & mutexLocked != 0 {
            new = old + 1<<mutexWaiterShift 
        }
        if awoke{
            /* 此 goroutine 是被唤醒的,接下来要么获取锁,要么获取不到继续进入休眠
             * 但是不管是哪种,新状态都要清除唤醒标志
             * Go 的 ^ 表示取反操作,因此下面等价于 new = new & (^mutexWoken)
             * mutexWoken 等于 2,也就是 0b10,所以相当于 new = new & ^0b10
             * 显然这是在将第 2 位变成 0,清除唤醒标志
             */
            new &^=mutexWoken
        }
        
        // 通过 CAS 将新状态写回去(当前 goroutine 也可以是新来的 goroutine)
        if atomic.CompareAndSwapInt32(&m.state, old, new) { 
            // 如果 if 条件成立,说明之前锁没有被人获取,那么该 goroutine 就会获取到锁
            // 直接 break
            if old&mutexLocked == 0 { 
                break
            }
            /* 走到这里,说明锁已经被别的 goroutine 获取,之前的 state 的最后一位已经是 1
             * 那么上面将 new 写回 state 的 CAS 操作
             * 则只是相当于在原 state 的基础上清除了 mutexWoken 标志或者增加了一个 waiter
             */
            // 请求信号量,同时进入进行休眠
            runtime.Semacquire(&m.sema)
            // 唤醒之后,它就变成被唤醒的 goroutine,然后进行下一轮循环
            awoke = true
        }
    }
}

请求锁的 goroutine 有两类,一类是新来的请求锁的 goroutine,另一类是被唤醒之后请求锁的 goroutine。锁的状态也有两种:加锁和未加锁,用一张表格整理一下 goroutine 不同来源不同状态下的处理逻辑。

看完了获取锁,再来看看如何释放锁。

func (m *Mutex) Unlock() {
    /* 去掉锁标志,因为 state 的最后一位表示锁是否被持有,因此减去 1 即可,这一步是原子操作
     * 注意:锁标志去掉了并不代表就已经万事大吉,还有很多额外的工作要做
     * 首先要检测锁是不是已经处于被释放的状态,如果锁已经被释放了,那么这里再释放就应该报错
     * 另外,最重要的一步,我们说 goroutine 在获取不到锁的时候会请求信号量、进入休眠
     * 但是这些休眠的 goroutine 没办法靠自己的力量唤醒,那么谁来让它们醒来呢?
     * 没错,显然是此时释放锁的 goroutine
     */
    new := atomic.AddInt32(&m.state, -mutexLocked)  // 虽然有返回值,但是 m.state 也会被修改
    
    // new+mutexLocked 显然等于之前的 state,如果和 mutexLocked 与运算结果为 0
    // 说明之前 state 的最后一位就是 0,也就是本来就没有加锁
    if (new+mutexLocked)&mutexLocked == 0 { 
        // 没有加锁的情况下释放锁会报错
        panic("sync: unlock of unlocked mutex")
    }
    // 这里使用 old 将 new 保存起来,但是 old 已经是锁释放后的状态了
    old := new
    // 下面就是唤醒逻辑了
    for {
        /* old>>mutexWaiterShift 表示 waiter 的数量
         * 而 old 的最后一位已经为 0,所以 old&(mutexLocked|mutexWoken) 表示是否有被唤醒的 goroutine
         * 如果 old>>mutexWaiterShift == 0 为真,那么表示没有 waiter,直接返回即可
         * 如果 old>>mutexWaiterShift == 0 为假,表示有 waiter、即等待被唤醒的 goroutine
         * 但如果 old&(mutexLocked|mutexWoken) != 0 也为真,说明已经有 goroutine 醒来了
         * 那么直接把烂摊子交给其它的 goroutine 即可,当前的 goroutine 也可以返回了
         */
        if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0 {
            return
        }
        /* 但 if 逻辑不满足的话,就代表:当前既有等待的 waiter,而且还一个都没有醒
         * 那么当前的 goroutine 就不能直接返回了
         * 而是需要先唤醒其它 goroutine,找其中一个来接自己的班,然后才能返回
         */
        
        // (old - 1<<mutexWaiterShift) 表示将 waiter 的数量减一,因为肯定会有一个 goroutine 获取锁
        // 然后按位或 mutexWoken,也就是将第二位设置为 1,表示有 goroutine 被唤醒
        new = (old - 1<<mutexWaiterShift) | mutexWoken
        // 通过 CAS 操作将 new 写回 state
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 释放信号量,此时会随机唤醒一个 goroutine
            runtime.Semrelease(&m.sema)
            return
        }
        // 如果 CAS 写回失败时,那么将 m.state 赋值为 old 继续下一轮循环
        old = m.state
    }
}

通过以上的检查、判断和设置,我们就可以安全地将一把互斥锁释放了。相对于初版的设计,这次的改动主要就是,新来的 goroutine 也有机会先获取到锁,甚至一个 goroutine 可能连续获取到锁,打破了先来先得的逻辑,但也增加了代码的复杂度。

虽然这一版的 Mutex 已经给新来请求锁的 goroutine 一些机会,让它参与竞争,没有空闲的锁或者竞争失败才加入到等待队列中,但是其实还可以进一步优化。

第三版的互斥锁(多给几次机会)

在 2015 年 2 月的改动中,如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁,它们就会通过自旋(spin,通过循环不断尝试,spin 的逻辑是在 runtime 中实现的)的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑,相当于多给几次机会。

func (m *Mutex) Lock() {
    // 这里和第二版的逻辑一样
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    // 是否是休眠之后被唤醒的 goroutine,刚进来的话显示是新来的 goroutine,因此 awoke 为 false
    awoke := false
    // 自旋次数
    iter := 0
    // 不管是新来的请求锁的goroutine,还是被唤醒的 goroutine,都不断尝试请求锁
    for { 
        old := m.state            // 先保存当前 state
        new := old | mutexLocked  // 新 state 设置加锁标志
        // 如果锁没有被释放,那么按照之前的版本,该 goroutine 就要进入休眠了
        if old&mutexLocked != 0 { 
            // 但是这里会判断是否还可以自旋,如果能自旋
            if runtime_canSpin(iter) { 
                /* 如果是新来的 goroutine,并且没有处于等待被唤醒的 goroutine、
                 * 并且 waiter 不为 0、并且通过 CAS 操作将 state 第二位设置为 1 成功
                 */
                if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
                    atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
                    // 将自身伪装成被唤醒的 goroutine,因为没有其它唤醒 goroutine 与之竞争    
                    awoke = true
                }
                // 自旋
                runtime_doSpin()
                // 自旋次数加 1
                iter++
                // 再次循环、查看锁是否被释放
                continue 
            }
            /* 为什么要有自旋,因为对于临界区代码执行时间短的场景来说,这是一个非常好的优化
             * 如果临界区的代码耗时很短,锁很快就能释放
             * 而抢夺锁的 goroutine 不用通过休眠唤醒的方式等待调度,直接 spin 几次,可能就获得了锁。
             */
            // 如果自旋达到一定次数之后,锁还是没有释放,那么停止自旋,等待者的数量加 1
            new = old + 1<<mutexWaiterShift
        }
        // 唤醒状态
        if awoke { 
            if new&mutexWoken == 0 {
                panic("sync: inconsistent mutex state")
            }
            new &^= mutexWoken // 新状态清除唤醒标记
        }
        
        // 通过 CAS 操作将 new 写回 state
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 旧状态锁已释放,新状态成功持有了锁,直接跳出循环即可
            if old&mutexLocked == 0 { 
                break
            }
            runtime_Semacquire(&m.sema) // 阻塞等待
            awoke = true                // 表示被唤醒
            iter = 0                    // 自旋次数重置为 0
        }
    }
}

经过几次优化,Mutex 的代码越来越复杂,应对高并发争抢锁的场景也更加公平。但是还有问题,因为新来的 goroutine 也参与竞争,有可能每次都会被新来的 goroutine 抢到获取锁的机会,在极端情况下,等待中的 goroutine 可能会一直获取不到锁,这就是饥饿问题。先前版本的 Mutex 遇到的也是同样的困境,"悲惨" 的 goroutine 总是得不到锁,Mutex 不能容忍这种事情发生。所以 2016 年 Go 1.9 中的 Mutex 增加了饥饿模式,让锁变得更公平,不公平的等待时间限制在 1 毫秒,并且修复了一个大 Bug:总是把唤醒的 goroutine 放在等待队列的尾部,会导致更加不公平的等待时间。

另外我们看到代码中存在着一个快分支,就是对某个特殊条件先行判断,如果满足的话能直接返回,从而省去不少流程,这种情况我们称之为快分支;快分支不满足的话,就会走通用流程,我们称之为慢分支。而在 2018 年 Go 开发者将快分支和慢分支拆分成了独立的方法,以便内联、提高效率。

此外在 2019 年也做了一个 Mutex 的优化,虽然没有对 Mutex 进行改动,但是对于唤醒之后持有锁的 goroutine,调度器有更高的优先级去执行。比如 goroutine 1、2、3,当 goroutine 2 获得锁之后,调度器优先调度 goroutine 2 去执行,显然这已经是非常细致的优化了。

第四版的互斥锁(解决饥饿)

我们说第三版本的互斥锁会带来饥饿问题,就是某个等待的 goroutine 始终得不到获取锁的机会。而 Mutex 表示 goroutine 一个也不能落下,不会让某个 goroutine 永远没有机会获取锁,不抛弃不放弃是它的宗旨,而且它也尽可能地让等待时间更长的 goroutine 更有机会获取到锁。

type Mutex struct {
    state int32
    sema  uint32
}

const (
    mutexLocked = 1 << iota
    mutexWoken
    mutexStarving         
    mutexWaiterShift      = iota
    starvationThresholdNs = 1e6 
)

此时 state 中又分出了一个位,作为饥饿标记,因此:

  • state 第一个位表示锁是否被获取
  • state 第二个位表示是否有已经被唤醒的 goroutine
  • state 第三个位表示 Mutex 是否进入饥饿模式
  • state 第四个位以及之后的所有的位表示 waiter 的数量

此外我们看到常量还多了一个 starvationThresholdNs,表示饥饿模式的最大等待时间(1 毫秒)。这意味着一旦有等待者等待的时间超过了这个阈值,Mutex 的处理就有可能进入饥饿模式(否则就是正常模式),从而优先让等待者先获得锁。

通过加入饥饿模式,可以避免把机会全都留给新来的 goroutine,保证了请求锁的 goroutine 获取锁的公平性,对于我们使用锁的业务代码来说,不会有业务一直等待锁而不被处理。

然后看一下第四版的底层实现,会有点复杂,需要多花点时间来理解。

func (m *Mutex) Lock() {
    // 快分支:上来就能获取锁,没有竞争、直接返回,无需额外的判断
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    // 慢分支、或者说通用分支:内部涉及到自旋、竞争等逻辑,可以看到在第四版的互斥锁中,快分支和慢分支放在了单独的方法中
    m.lockSlow()
}

func (m *Mutex) lockSlow() {
    /* 正常模式下,waiter 都是进入 "先入先出队列"
     * 被唤醒的 waiter 并不会直接持有锁,而是要和新来的 goroutine 进行竞争
     * 但新来的 goroutine 有先天的优势,它们正在 CPU 中运行,可能它们的数量还不少
     * 所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁,这时它会被插入到队列的前面
     * 如果 waiter 获取不到锁的时间超过阈值 1 毫秒,那么这个 Mutex 就进入到了饥饿模式
     * 在饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter
     * 新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin,而是会乖乖地加入到等待队列的尾部
     * 如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:
         1. 此 waiter 已经是队列中的最后一个 waiter ,没有其它的等待锁的 goroutine 了
         2. 此 waiter 的等待时间小于 1 毫秒
     * 正常模式拥有更好的性能,因为即使有等待抢锁的 waiter,goroutine 也可以连续多次获取到锁
     * 饥饿模式是对公平性和性能的一种平衡,它避免了某些 goroutine 长时间的等待锁在饥饿模式下,优先对待的是那些一直在等待的 waiter。
     */
    var waitStartTime int64  // 请求锁的初始时间
    starving := false        // 标记是否处于饥饿模式(有 goroutine 等待时间超过阈值)
    awoke := false           // 是否有被唤醒的 goroutine
    iter := 0                // 自旋次数
    old := m.state           // 当前锁的状态

    for {
        /* 这里的 if 逻辑和之前是类似的,只不过加了一个不能是饥饿状态的逻辑
         * 它会对正常状态抢夺锁的 goroutine 尝试 spin
         * 和以前的目的一样,就是在临界区耗时很短的情况下提高性能
         */
        if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
            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 := old
        // if 条件成立,说明 state 的第三位为 0,锁处于非饥饿模式,或者说正常模式
        // 那么直接加锁即可,后续 CAS 如果成功就可能获取到锁
        if old&mutexStarving == 0 {
            new |= mutexLocked  
        }
        // 如果锁已经被持有或者锁处于饥饿状态,那么最好的归宿就是等待,所以 waiter 的数量加 1
        if old&(mutexLocked|mutexStarving) != 0 {
            new += 1 << mutexWaiterShift 
        }
        // 如果此 goroutine 等待时间超过阈值,并且锁还被持有,那么,我们需要把此 Mutex 设置为饥饿模式
        if starving && old&mutexLocked != 0 {
            new |= mutexStarving
        }
        if awoke {
            // 新状态清除唤醒标记,因为不管是获得了锁还是进入休眠,都需要清除 mutexWoken 标记。
            if new&mutexWoken == 0 {
                throw("sync: inconsistent mutex state")
            }
            new &^= mutexWoken
        }
        // 使用 CAS 设置 state
        if atomic.CompareAndSwapInt32(&m.state, old, new) {
            // 原来锁的状态已释放,并且不是饥饿状态,正常请求到了锁,返回
            if old&(mutexLocked|mutexStarving) == 0 {
                break 
            }
            // 下面就要处理饥饿模式了
            // 判断是否第一次加入到 waiter 队列,如果以前就在队列里面,加入到队列头
            queueLifo := waitStartTime != 0
            if waitStartTime == 0 {
                waitStartTime = runtime_nanotime()
            }
            // 将此 waiter 加入到队列进行等待,如果是首次,加入到队尾,先进先出
            // 如果不是首次,那么加入到队首,这样等待最久的 goroutine 优先能够获取到锁
            // 然后此 goroutine 会进行休眠
            runtime_SemacquireMutex(&m.sema, queueLifo, 1)
            // 唤醒之后检查锁是否还处于饥饿模式,注意:执行这一句的时候,它已经被唤醒了
            starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
            old = m.state
            // 如果锁已经处于饥饿状态,直接抢到锁,返回
            if old&mutexStarving != 0 {
                if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync:inconsistent mutex state")
                }
                // 有点绕,加锁并且将 waiter 数减1
                delta := int32(mutexLocked - 1<<mutexWaiterShift)
                // 设置标志,在没有其它的 waiter 或者此 goroutine 等待还没超过 1 毫秒,则会将 Mutex 转为正常状态
                if !starving || old>>mutexWaiterShift == 1 {
                    // 最后一个 waiter 或者已经不饥饿了,清除饥饿标记
                    delta -= mutexStarving 
                }
                atomic.AddInt32(&m.state, delta)
                break
            }
            awoke = true
            iter = 0
        } else {
            old = m.state
        }
    }
}

func (m *Mutex) Unlock() {
    // 释放锁的逻辑比较少,我们主要看慢分支 unlockSlow 方法即可
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        m.unlockSlow(new)
    }
}

func (m *Mutex) unlockSlow(new int32) {
    // 对锁不能二次释放
    if (new+mutexLocked)&mutexLocked == 0 {
        throw("sync: unlock of unlocked mutex")
    }
    // 如果 Mutex 处于正常状态
    if new&mutexStarving == 0 {
        old := new
        for {
            // 如果没有 waiter,或者已经有醒来的 waiter 在获取锁,那么释放就好
            if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
                return
            }
            // 否则,waiter 数减 1,mutexWoken 标志设置上,通过 CAS 更新 state 的值
            new = (old - 1<<mutexWaiterShift) | mutexWoken
            if atomic.CompareAndSwapInt32(&m.state, old, new) {
                runtime_Semrelease(&m.sema, false, 1)
                return
            }
            old = m.state
        }
    } else {
        // 如果 Mutex 处于饥饿状态,直接唤醒等待队列中的 waiter
        runtime_Semrelease(&m.sema, true, 1)
    }
}

所以 Mutex 并不是一步到胃,生来就是现在这个样子,其设计也是从简单设计到复杂处理逐渐演变的。初版的 Mutex 设计非常简洁,充分展示了 Go 创始者的简单、简洁的设计哲学。但随着大家的使用,逐渐暴露出一些缺陷,为了弥补这些缺陷,Mutex 不得不越来越复杂。

但是不管怎么复杂,Mutex 对外的接口始终没有发生变化,依然向下兼容。此外我们也能看到,为了一个程序 20% 的特性,可能需要添加 80% 的代码,这也是程序越来越复杂的原因。所以最开始的时候,如果能够有一个清晰而且易于扩展的设计,未来增加新特性时,也会更加方便。


问题来了,等待同一个 Mutex 的 goroutine 最多能有多少?是否能满足现实的需求。

首先 state 的前三位分别表示:锁是否已被持有、是否有被唤醒的 goroutine、锁是否进入饥饿模式,然后再除去最高位,那么还剩下 28 个位。所以等待同一个 Mutex 的 goroutine 最多可以有 2 的 28 次幂减一个,而一个 goroutine 最小是 2kb。

>>> 2 ** 28 - 1
268435455
>>> 268435455 * 2 / 1024 / 1024
511.99999809265137
>>> 

按照最低标准 2kb,需要你的内存至少达到 512 个 G,才可能导致 goroutine 不够用。而且这些 goroutine 还可以动态伸缩,实际上肯定会超过 512 个 G。因此如果不是写恶意代码,那么是完全够用的,即便想故意这么做,也不是那么容易的,首先得弄一台内存至少五六百 G 的机器。

Mutex:4 种易错场景大盘点

在了解完 Mutex 的架构演进之后,现在我们已经清楚 Mutex 的实现细节了。当前 Mutex 的实现貌似非常复杂,其实主要还是针对饥饿模式和公平性问题,做了一些额外处理。但是 Mutex 使用起来还是非常简单的,在最开始我们就体验过了,毕竟它只有 Lock 和 Unlock 两个方法,使用起来还能复杂到哪里去?

正常使用 Mutex 时确实是这样的,很简单,基本不会有什么错误,即使出现错误,也是在一些复杂的场景中,比如跨函数调用 Mutex 或者在重构、修补 Bug 时误操作。但如果是刚接触 Mutex、或者使用 Mutex 时没有注意,也会出现一些 Bug,比如说忘记释放锁、重入锁、复制已使用了的 Mutex 等情况。那么下面就一起来看看使用 Mutex 常犯的几个错误。

Lock/Unlock 没有成对出现

Lock/Unlock 没有成对出现,就意味着会出现死锁的情况,或者是因为 Unlock 一个未加锁的 Mutex 而导致 panic。如果是缺少 Unlock,那么意味着锁被获取之后就不会被释放了,其它的 goroutine 永远都没机会获取到锁。

package main

import (
    "sync"
    "time"
)

func main() {
    var m sync.Mutex

    go func() {
        m.Lock()
    }()
    // 确保子协程内部的代码先执行,简单 sleep 一下
    time.Sleep(100)
    m.Lock()
}

此时会引发 panic:fatal error: all goroutines are asleep - deadlock,因为子协程已经获取锁了,而主协程也在获取锁,所以主协程会阻塞在 m.Lock() 这一步。然后当子协程执行完毕之后,就只剩下主协程,而主协程如果想往下走,那么必须有子协程进行 Unlock。但是现在没有子协程了,所以主协程想往下走是不可能的,因此就死锁了。

如果是缺少 Lock,那么就会对一个未加锁的 Mutex 进行 Unlock,此时会引发 panic。

package main

import (
    "sync"
)

func main() {
    var m sync.Mutex
    m.Unlock()
}

此时会出现:fatal error: sync: unlock of unlocked mutex,在之前的源码中我们就已经见过了,不能对一个没有 Lock 的 Mutex 执行 Unlock 操作。

拷贝了一个 Mutex

一般出现这种情况,都是在将 Mutex 作为函数参数传递的时候没有传递指针,而是直接把值本身传递了。因为 Go 只有值传递,不管传值还是传指针,都是拷贝一份。如果直接传 Mutex 本身,那么会拷贝一份,两者不是同一个 Mutex 了。

package main

import (
    "fmt"
    "sync"
)

var count int

// 这里应该声明为 *sync.Mutex,因为锁要接收相应的指针才对
// 如果你使用的是 Goland 这种智能的编辑器,那么会给你飘黄,提示你应该声明为 *sync.Mutex
func add(m sync.Mutex) {
    for i := 0; i < 100000; i++ {
        m.Lock()
        count++
        m.Unlock()
    }
}
func sub(m sync.Mutex) {
    for i := 0; i < 100000; i++ {
        m.Lock()
        count--
        m.Unlock()
    }
}

func main() {
    var m sync.Mutex
    var ch = make(chan struct{}, 2)
    go func() {
        add(m)
        ch <- struct{}{}
    }()
    go func() {
        sub(m)
        ch <- struct{}{}
    }()

    <-ch
    <-ch
    fmt.Println(count)
}

打印出的 count 并不符合我们的预期,原因就是 add 和 sub 里面的锁不是同一把锁,而给不同的锁进行加锁、解锁和没有锁是等价的。

如果在声明的时候,声明为 *sync.Mutex,传递的时候传递指针,那么不管执行多少次,打印的 count 都是 0,因为此时操作的是同一把锁。

重入

下面来讨论一下重入这个问题,不过我们需要先解释一下什么叫可重入锁。

当一个线程获取锁时,如果没有其它线程拥有这个锁,那么这个线程就成功获取到锁了。之后如果其它线程再请求这个锁,就会处于阻塞等待的状态。但如果是本身拥有这把锁的线程再请求这把锁的话,那么就不会阻塞了,而是成功返回,所以叫可重入锁(也叫做递归锁)。只要你拥有这把锁,你可以无限地获取,比如通过递归实现一些算法,调用者不会阻塞或者死锁。

了解了可重入锁的概念,我们再来看 Mutex 使用的错误场景。划重点:Mutex 不是可重入锁。因为如果一把锁是可重入锁,那么它就必须记录当前持有锁的是哪一个 goroutine,然后再区别对待:请求锁的是别的 goroutine,那么阻塞;请求锁的是当前已经持有该锁的 goroutine,那么返回。但是在 Mutex 的实现中,我们并没有看到它有记录当前获得锁的 goroutine,所以无法计算重入条件。

package main

import (
    "sync"
)

func main() {
    var m sync.Mutex
    m.Lock()
    m.Lock()
}

上面这段代码会报错,因为 Mutex 不是可重入锁,它分不清谁是谁,只要锁被获取了,那么再次获取就会阻塞。所以第二次执行 m.Lock() 的时候会阻塞住,但是没有人 Unlock,所以死锁了。

package main

import (
    "sync"
)

func release(m *sync.Mutex) {
    m.Unlock()
}

func main() {
    var m sync.Mutex
    m.Lock()
    go release(&m)
    m.Lock()
}

此时不会报错,因为子协程把锁释放了,因此第二个 m.Lock() 将不再阻塞,而是继续往下执行,而下面没有内容了,显然程序结束。所以从这里我们可以看出,任何一个 goroutine 都可以对锁 Unlock,即使锁不是在当前的 goroutine 中创建的。再举个栗子:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var m sync.Mutex

    go func() {
        m.Lock()
    }()
    time.Sleep(100)
    go func() {
        time.Sleep(time.Second * 3)
        fmt.Println("要释放锁了......")
        m.Unlock()
    }()
    m.Lock()
    /*
    要释放锁了......
    */
}

第一个子协程在获取锁之后没有释放锁,所以主协程获取锁阻塞,但此时不会出现死锁,因为还有一个子协程在执行。第二个子协程 sleep 3 秒之后会释放锁,所以程序正常结束。如果第二个子协程执行完毕时,锁还是没有被释放,那么最终程序依旧会 panic,但要等待第二个子协程执行完毕之后才会 panic。

总结:Mutex 不是可重入锁,即便是持有者也不能连续获取,同理也不能连续释放。另外对于同一把锁而言,它的 Lock 和 Unlock 要成对出现,如果 Lock 之后不进行 Unlock,尽管程序可以继续执行,但如果其它 goroutine 要是也对该锁进行 Lock 的话,那么就永远处于阻塞了。

除了获取锁和释放锁要成对出现之外,获取锁和释放锁的 goroutine 应该也是同一个,因为鲁迅曾说过:"自己拉的💩,自己擦干净",所以不要有 goroutine1 获取的锁交给 goroutine2 去释放这种情况出现。

死锁

最后我们来说一下死锁,进程(或线程、goroutine)在执行过程中,因争夺共享资源而处于一种互相等待的状态,如果没有外部干涉,它们都将无法继续执行,此时我们称系统处于死锁状态或系统产生了死锁。

package main

import (
    "sync"
)

func main() {
    var m sync.Mutex
    var ch  = make(chan int)
    
    m.Lock()
    go func() {
        m.Lock()
        ch <- 1
    }()

    <- ch
    m.Unlock()
}

比如:我们在主协程中获取了锁,然后开启子协程,同时主协程会阻塞在 <- ch 这一步。主协程如果想往下执行,那么子协程必须要往 channel 里面发送消息。但子协程如果想发送消息,那么先要获取锁,而子协程想获取锁必须要主协程释放锁,但主协程释放锁需要子协程先往 channel 里面发送消息。如此一来,就产生了死结,主协程和子协程都无法继续执行,于是就产生了死锁。

以上就是 Mutex 的一些易错点,可能你会觉得这些错误太低级了吧,真的有人会犯吗?答案是当然有,如果项目比较小的话还感觉不到,但对于一个大工程而言,加锁、解锁一不小心很容易就出问题,Docker、Kubernetes、gRPC、etcd 这些知名开源项目都犯过相应的错误。尤其是 gRPC,在 issue 795 时修复了一个 bug,你猜 bug 是啥,原来是开发者不小心将 Unlock 写成了 Lock。


 

本文转载自:

  • 极客时间,鸟窝《Go 并发》
posted @ 2019-12-29 15:01  古明地盆  阅读(4074)  评论(0编辑  收藏  举报