01 | Mutex:如何解决资源并发访问问题?

你好,我是鸟窝。

今天是我们Go并发编程实战课的第一讲,我们就直接从解决并发访问这个棘手问题入手。

说起并发访问问题,真是太常见了,比如多个goroutine并发更新同一个资源,像计数器;同时更新用户的账户信息;秒杀系统;往同一个buffer中并发写入数据等等。如果没有互斥控制,就会出现一些异常情况,比如计数器的计数不准确、用户的账户可能出现透支、秒杀系统出现超卖、buffer中的数据混乱,等等,后果都很严重。

这些问题怎么解决呢?对,用互斥锁,那在Go语言里,就是Mutex。

这节课,我会带你详细了解互斥锁的实现机制,以及Go标准库的互斥锁Mutex的基本使用方法。在后面的3节课里,我还会讲解Mutex的具体实现原理、易错场景和一些拓展用法。

好了,我们先来看看互斥锁的实现机制。

互斥锁的实现机制

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

在并发编程中,如果程序中的一部分会被并发访问或修改,那么,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序,就叫做临界区。

可以说,临界区就是一个被共享的资源,或者说是一个整体的一组共享资源,比如对数据库的访问、对某一个共享数据结构的操作、对一个 I/O 设备的使用、对一个连接池中的连接的调用,等等。

如果很多线程同步访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以使用互斥锁,限定临界区只能同时由一个线程持有

当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。

你看,互斥锁就很好地解决了资源竞争问题,有人也把互斥锁叫做排它锁。那在Go 标准库中,它提供了 Mutex 来实现互斥锁这个功能。

根据2019年第一篇全面分析Go并发Bug的论文Understanding Real-World Concurrency Bugs in GoMutex是使用最广泛的同步原语(Synchronization primitives,有人也叫做并发原语。我们在这个课程中根据英文直译优先用同步原语,但是并发原语的指代范围更大,还可以包括任务编排的类型,所以后面我们讲Channel或者扩展类型时也会用并发原语)。关于同步原语,并没有一个严格的定义,你可以把它看作解决并发问题的一个基础的数据结构。

在这门课的前两个模块,我会和你讲互斥锁Mutex、读写锁RWMutex、并发编排WaitGroup、条件变量Cond、Channel等同步原语。所以,在这里,我先和你说一下同步原语的适用场景。

  • 共享资源。并发地读写共享资源,会出现数据竞争(data race)的问题,所以需要Mutex、RWMutex这样的并发原语来保护。
  • 任务编排。需要goroutine按照一定的规律执行,而goroutine之间有相互等待或者依赖的顺序关系,我们常常使用WaitGroup或者Channel来实现。
  • 消息传递。信息交流以及不同的goroutine之间的线程安全的数据交流,常常使用Channel来实现。

今天这一讲,咱们就从公认的使用最广泛的Mutex开始学习吧。是骡子是马咱得拉出来遛遛,看看我们到底可以怎么使用Mutex。

Mutex的基本使用方法

在正式看Mutex用法之前呢,我想先给你交代一件事:Locker接口。

在Go的标准库中,package sync提供了锁相关的一系列同步原语,这个package还定义了一个Locker的接口,Mutex就实现了这个接口。

Locker的接口定义了锁同步原语的方法集:


type Locker interface {
    Lock()
    Unlock()
}

可以看到,Go定义的锁接口的方法集很简单,就是请求锁(Lock)和释放锁(Unlock)这两个方法,秉承了Go语言一贯的简洁风格。

但是,这个接口在实际项目应用得不多,因为我们一般会直接使用具体的同步原语,而不是通过接口。

我们这一讲介绍的Mutex以及后面会介绍的读写锁RWMutex都实现了Locker接口,所以首先我把这个接口介绍了,让你做到心中有数。

下面我们直接看Mutex。

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

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

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

看到这儿,你可能会问,为啥一定要加锁呢?别急,我带你来看一个并发访问场景中不使用锁的例子,看看实现起来会出现什么状况。

在这个例子中,我们创建了10个goroutine,同时不断地对一个变量(count)进行加1操作,每个goroutine负责执行10万次的加1操作,我们期望的最后计数的结果是10 * 100000 = 1000000 (一百万)。

 import (
        "fmt"
        "sync"
    )
    
    func main() {
        var count = 0
        // 使用WaitGroup等待10个goroutine完成
        var wg sync.WaitGroup
        wg.Add(10)
        for i := 0; i < 10; i++ {
            go func() {
                defer wg.Done()
                // 对变量count执行10次加1
                for j := 0; j < 100000; j++ {
                    count++
                }
            }()
        }
        // 等待10个goroutine完成
        wg.Wait()
        fmt.Println(count)
    }

在这段代码中,我们使用sync.WaitGroup来等待所有的goroutine执行完毕后,再输出最终的结果。sync.WaitGroup这个同步原语我会在后面的课程中具体介绍,现在你只需要知道,我们使用它来控制等待一组goroutine全部做完任务。

但是,每次运行,你都可能得到不同的结果,基本上不会得到理想中的一百万的结果。

这是为什么呢?

其实,这是因为,count++ 不是一个原子操作,它至少包含几个步骤,比如读取变量count的当前值,对这个值加1,把结果再保存到count中。因为不是原子操作,就可能有并发的问题。

比如,10个goroutine同时读取到count的值为9527,接着各自按照自己的逻辑加1,值变成了9528,然后把这个结果再写回到count变量。但是,实际上,此时我们增加的总数应该是10才对,这里却只增加了1,好多计数都被“吞”掉了。这是并发访问共享数据的常见错误。

 // count++操作的汇编代码
    MOVQ    "".count(SB), AX
    LEAQ    1(AX), CX
    MOVQ    CX, "".count(SB)

这个问题,有经验的开发人员还是比较容易发现的,但是,很多时候,并发问题隐藏得非常深,即使是有经验的人,也不太容易发现或者Debug出来。

针对这个问题,Go提供了一个检测并发访问共享资源是否有问题的工具: race detector,它可以帮助我们自动发现程序有没有data race的问题。

Go race detector是基于Google的 C/C++ sanitizers 技术实现的,编译器通过探测所有的内存访问,加入代码能监视对这些内存地址的访问(读还是写)。在代码运行的时候,race detector就能监控到对共享变量的非同步访问,出现race的时候,就会打印出警告信息。

这个技术在Google内部帮了大忙,探测出了Chromium等代码的大量并发问题。Go 1.1中就引入了这种技术,并且一下子就发现了标准库中的42个并发问题。现在,race detector已经成了Go持续集成过程中的一部分。

我们来看看这个工具怎么用。

在编译(compile)、测试(test)或者运行(run)Go代码的时候,加上race参数,就有可能发现并发问题。比如在上面的例子中,我们可以加上race参数运行,检测一下是不是有并发问题。如果你go run -race counter.go,就会输出警告信息。

这个警告不但会告诉你有并发问题,而且还会告诉你哪个goroutine在哪一行对哪个变量有写操作,同时,哪个goroutine在哪一行对哪个变量有读操作,就是这些并发的读写访问,引起了data race。

例子中的goroutine 10对内存地址0x00c000126010有读的操作(counter.go文件第16行),同时,goroutine 7对内存地址0x00c000126010有写的操作(counter.go文件第16行)。而且还可能有多个goroutine在同时进行读写,所以,警告信息可能会很长。

虽然这个工具使用起来很方便,但是,因为它的实现方式,只能通过真正对实际地址进行读写访问的时候才能探测,所以它并不能在编译的时候发现data race的问题。而且,在运行的时候,只有在触发了data race之后,才能检测到,如果碰巧没有触发(比如一个data race问题只能在2月14号零点或者11月11号零点才出现),是检测不出来的。

而且,把开启了race的程序部署在线上,还是比较影响性能的。运行 go tool compile -race -S counter.go,可以查看计数器例子的代码,重点关注一下count++前后的编译后的代码:

0x002a 00042 (counter.go:13)    CALL    runtime.racefuncenter(SB)
       ......
        0x0061 00097 (counter.go:14)    JMP     173
        0x0063 00099 (counter.go:15)    MOVQ    AX, "".j+8(SP)
        0x0068 00104 (counter.go:16)    PCDATA  $0, $1
        0x0068 00104 (counter.go:16)    MOVQ    "".&count+128(SP), AX
        0x0070 00112 (counter.go:16)    PCDATA  $0, $0
        0x0070 00112 (counter.go:16)    MOVQ    AX, (SP)
        0x0074 00116 (counter.go:16)    CALL    runtime.raceread(SB)
        0x0079 00121 (counter.go:16)    PCDATA  $0, $1
        0x0079 00121 (counter.go:16)    MOVQ    "".&count+128(SP), AX
        0x0081 00129 (counter.go:16)    MOVQ    (AX), CX
        0x0084 00132 (counter.go:16)    MOVQ    CX, ""..autotmp_8+16(SP)
        0x0089 00137 (counter.go:16)    PCDATA  $0, $0
        0x0089 00137 (counter.go:16)    MOVQ    AX, (SP)
        0x008d 00141 (counter.go:16)    CALL    runtime.racewrite(SB)
        0x0092 00146 (counter.go:16)    MOVQ    ""..autotmp_8+16(SP), AX
       ......
        0x00b6 00182 (counter.go:18)    CALL    runtime.deferreturn(SB)
        0x00bb 00187 (counter.go:18)    CALL    runtime.racefuncexit(SB)
        0x00c0 00192 (counter.go:18)    MOVQ    104(SP), BP
        0x00c5 00197 (counter.go:18)    ADDQ    $112, SP

在编译的代码中,增加了runtime.racefuncenter、runtime.raceread、runtime.racewrite、runtime.racefuncexit等检测data race的方法。通过这些插入的指令,Go race detector工具就能够成功地检测出data race问题了。

总结一下,通过在编译的时候插入一些指令,在运行时通过这些插入的指令检测并发读写从而发现data race问题,就是这个工具的实现机制。

既然这个例子存在data race问题,我们就要想办法来解决它。这个时候,我们这节课的主角Mutex就要登场了,它可以轻松地消除掉data race。

具体怎么做呢?下面,我就结合这个例子,来具体给你讲一讲Mutex的基本用法。

我们知道,这里的共享资源是count变量,临界区是count++,只要在临界区前面获取锁,在离开临界区的时候释放锁,就能完美地解决data race的问题了。

package main


    import (
        "fmt"
        "sync"
    )


    func main() {
        // 互斥锁保护计数器
        var mu sync.Mutex
        // 计数器的值
        var count = 0
        
        // 辅助变量,用来确认所有的goroutine都完成
        var wg sync.WaitGroup
        wg.Add(10)

        // 启动10个gourontine
        for i := 0; i < 10; i++ {
            go func() {
                defer wg.Done()
                // 累加10万次
                for j := 0; j < 100000; j++ {
                    mu.Lock()
                    count++
                    mu.Unlock()
                }
            }()
        }
        wg.Wait()
        fmt.Println(count)
    }

如果你再运行一下程序,就会发现,data race警告没有了,系统干脆地输出了1000000:

怎么样,使用Mutex是不是非常高效?效果很惊喜。

这里有一点需要注意:Mutex的零值是还没有goroutine等待的未加锁的状态,所以你不需要额外的初始化,直接声明变量(如 var mu sync.Mutex)即可。

那Mutex还有哪些用法呢?

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

type Counter struct {
    mu    sync.Mutex
    Count uint64
}

在初始化嵌入的struct时,也不必初始化这个Mutex字段,不会因为没有初始化出现空指针或者是无法获取到锁的情况。

有时候,我们还可以采用嵌入字段的方式。通过嵌入字段,你可以在这个struct上直接调用Lock/Unlock方法。

func main() {
    var counter Counter
    var wg sync.WaitGroup
    wg.Add(10)
    for i := 0; i < 10; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < 100000; j++ {
                counter.Lock()
                counter.Count++
                counter.Unlock()
            }
        }()
    }
    wg.Wait()
    fmt.Println(counter.Count)
}


type Counter struct {
    sync.Mutex
    Count uint64
}

如果嵌入的struct有多个字段,我们一般会把Mutex放在要控制的字段上面,然后使用空格把字段分隔开来。即使你不这样做,代码也可以正常编译,只不过,用这种风格去写的话,逻辑会更清晰,也更易于维护。

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

func main() {
    // 封装好的计数器
    var counter Counter

    var wg sync.WaitGroup
    wg.Add(10)

    // 启动10个goroutine
    for i := 0; i < 10; i++ {
        go func() {
            defer wg.Done()
            // 执行10万次累加
            for j := 0; j < 100000; j++ {
                counter.Incr() // 受到锁保护的方法
            }
        }()
    }
    wg.Wait()
    fmt.Println(counter.Count())
}

// 线程安全的计数器类型
type Counter struct {
    CounterType int
    Name        string

    mu    sync.Mutex
    count uint64
}

// 加1的方法,内部使用互斥锁保护
func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

// 得到计数器的值,也需要锁保护
func (c *Counter) Count() uint64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

总结

这节课,我介绍了并发问题的背景知识、标准库中Mutex的使用和最佳实践、通过race detector工具发现计数器程序的问题以及修复方法。相信你已经大致了解了Mutex这个同步原语。

在项目开发的初始阶段,我们可能并没有仔细地考虑资源的并发问题,因为在初始阶段,我们还不确定这个资源是否被共享。经过更加深入的设计,或者新功能的增加、代码的完善,这个时候,我们就需要考虑共享资源的并发问题了。当然,如果你能在初始阶段预见到资源会被共享并发访问就更好了。

意识到共享资源的并发访问的早晚不重要,重要的是,一旦你意识到这个问题,你就要及时通过互斥锁等手段去解决。

比如Docker issue 37583355173282630696等、kubernetes issue 7236171617等,都是后来发现的data race而采用互斥锁Mutex进行修复的。

思考题

你已经知道,如果Mutex已经被一个goroutine获取了锁,其它等待中的goroutine们只能一直等待。那么,等这个锁释放后,等待中的goroutine中哪一个会优先获取Mutex呢?

欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

精选留言

  • Gojustforfun

    2020-10-12 18:07:32

    等待的goroutine们是以FIFO排队的
    1)当Mutex处于正常模式时,若此时没有新goroutine与队头goroutine竞争,则队头goroutine获得。若有新goroutine竞争大概率新goroutine获得。
    2)当队头goroutine竞争锁失败1ms后,它会将Mutex调整为饥饿模式。进入饥饿模式后,锁的所有权会直接从解锁goroutine移交给队头goroutine,此时新来的goroutine直接放入队尾。

    3)当一个goroutine获取锁后,如果发现自己满足下列条件中的任何一个#1它是队列中最后一个#2它等待锁的时间少于1ms,则将锁切换回正常模式

    以上简略翻译自https://golang.org/src/sync/mutex.go 中注释Mutex fairness.
  • 💎A

    2020-10-12 22:40:20

    看了下作者的发量,果断入手
  • 我来也

    2020-10-12 22:39:43

    课后思考题:
    分享两篇文章吧(我还没看完😂)

    鸟叔的:sync.mutex 源代码分析
    https://colobu.com/2018/12/18/dive-into-sync-mutex/

    golang源码阅读-sync.Mutex
    https://studygolang.com/articles/17017
  • Panmax

    2020-10-18 17:36:39

    原文中关于 race detector 的介绍有两句话前后矛盾,老师可否解释一下:

    前边说:在编译(compile)、测试(test)或者运行(run)Go 代码的时候,加上 race 参数,就有可能发现并发问题。

    后边却又说:因为它的实现方式,只能通过真正对实际地址进行读写访问的时候才能探测,所以它并不能在编译的时候发现 data race 的问题。

    所以结论是 race detector 并不能在编译阶段发现并发问题?那么前边那句是不是就没必要提了,不然容易让大家误会。
    作者回复

    那借这个问题解答一下吧。编译的时候不能发现data race,但是编译的时候可以开启race参数,这样编译后的程序在运行时就可以data race问题了。你看到很仔细,希望这个解答能把这段解释清楚。
    另外,绝对不要把带race参数编译的程序部署到线上。

    2020-10-18 21:36:14

  • pedro

    2020-10-13 09:11:18

    大家都已经解答了,就不重复了。这里给一些不熟悉 go 需要的同学补充一下,go 语言查看汇编代码命令:
    go tool compile -S file.go
    对于文中 counter 的例子可以过度优化一下,那就是获取计数的 Count 函数其实可以通过读写锁,也就是 RWMutex 来优化一下。
    作者回复

    你居然“剧透”第五讲的内容������

    2020-10-13 09:19:00

  • Remember九离

    2020-10-12 23:57:31

    这个课程我想一直跟着走,不单单只是看,我想在吸收课程精华的同时,也进行独立的思考,然后输出,总结,加深理解。因此创建了一个仓库,欢迎大家在学习的同时一起做到手中有码,项目地址:https://github.com/wuqinqiang/Go_Concurrency
  • 骁勇善战

    2021-06-13 16:31:20

    老师,为什么读也要加锁呢?
    作者回复

    说来话长。
    1.mutex保护的临界区。如果读的时候不加锁,可能会造成不一致的后果,比如部分变量被修改了。
    2.如果临界区比较简单,比如一个int64读写,也可能在一些cpu架构下有可见性问题,导致别的goroutine对变量的写读goroutine看不到

    2021-06-15 23:17:22

  • Daiver

    2020-10-12 18:05:45

    go的goroutine 调度中,内部维护了队列,goroutine在抢占锁的时候,会自旋一段时间,如果抢占失败,这个goroutine会被放到一个FIFO队列中,一般来说,锁释放时,会优先唤醒队头的goroutine,即队头的goroutine优先获得Mutex。
  • Panda

    2020-10-20 14:36:52

    推荐一个工具 Chronos - A static race detector for the go language
    https://github.com/amit-davidson/Chronos
  • ZY

    2020-10-17 15:41:16

    有两种情况
    1. 如果当前有协程进入自旋模式,当前协程会成功获取到锁
    2. 如果没有协程进入自选模式,释放锁的协程会释放的信号量会成功唤醒等待队列中的协程,该卸程会成功获取到锁,并且把等待计数器减1.

    老师:在饥饿模式下,信号量唤醒的协程成功获取到锁之后,该Mutex的模式会改变吗?
    作者回复

    进入自旋不一定会获取到锁。
    饥饿模式不一定改变,看文章。只有等待时间小于阈值或者无等待者时才会改变模式

    2020-10-17 19:08:51

  • 一代咩神

    2021-01-14 18:21:03

    能否解答一下,为什么获取计数器的值也需要加锁?
    作者回复

    否则获取的时候可能不能得到刚增加的值

    2021-01-15 23:05:41

  • geek_arong2048

    2021-06-17 22:48:33

    // 得到计数器的值,也需要锁保护
    func (c *Counter) Count() uint64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
    }
    老师,在这里读取count值时有必要加锁吗?我理解的是这类简单的读取应该不太需要增加锁,当需要进行读取并修改时才需要进行加锁操作,因为"读取"这个动作自身是原子的,而"读取-修改"这个动作是非原子的
  • chapin

    2020-10-13 17:40:56

    个人希望🐤 窝大佬可以多分析一些源码。
    作者回复

    明天一讲剖析mutex

    2020-10-13 20:04:40

  • 无名无姓

    2021-10-10 08:43:31

    请问老师goroutine里面自旋怎么理解
    作者回复

    一直尝试做获取锁,而不是让渡cpu给其他goroutine

    2021-10-11 13:18:59

  • 初学者

    2021-05-27 14:07:40

    老师好,“多个goroutine并发更新同一个资源”,这里的同一个资源的条件是不是应该是堆栈分析后分配到堆上的变量,因为堆上是线程共享的,如果是栈上的变量的话,因为是线程独有的就不会出现并发更新的问题,望老师解答下
    作者回复

    2021-05-28 06:54:57

  • Singin in the Rain

    2023-04-04 14:11:04

    在Go 1.20之后的版本,执行命令『go tool compile -race -S counter.go』会报如下的错误:
    could not import fmt (file not found)
    could not import sync (file not found)
    导致错误的原因是:在Go 1.20之前,标准库被安装到$GOROOT/pkg/$GOOS_$GOARCH。从Go 1.20开始,标准库被构建和缓存但没有安装。可以通过设置GODEBUG=installgoroot=all,然后编译重新安装Go运行环境,恢复$GOROOT/pkg/$GOOS_$GOARCH的使用,但是改动太大。可以通过如下命令解决这个问题:
    不带race参数:go build -gcflags=-S counter.go 1> normal.txt 2>&1
    带race参数:go build -race -gcflags=-S counter.go 1> race.txt 2>&1
    参考链接:
    https://github.com/golang/go/issues/58629
    https://pkg.go.dev/cmd/go
    https://go.dev/doc/asm
    作者回复

    👍🏻

    2023-04-19 08:59:11

  • 无名氏

    2020-10-22 22:29:55

    每篇文章多品几遍
  • 2021-11-01 16:31:06

    再用boltDb数据库时候 加上-race就会报错 fatal error: checkptr: converted pointer straddles multiple allocations 去掉就不报错了,代码里只有一句创建表,报的这个错也不是 data race错 是指针转换问题 咋弄啊
    作者回复

    -race -d=checkptr=0

    2021-12-18 14:05:36

  • 面向工资编程

    2021-09-03 13:14:14

    main 函数里面调用的 wg.Wait() 不是已经能够确保后面的读取 fmt.Println(counter.Count()) 操作和写入 counter.Incr() 这两个 goroutine 的前后顺序了么(确定是先 Incr 完成之后再读取)?为啥读取还要加锁?
  • 2021-09-02 10:20:51

    用了一个内存库 github.com/boltdb/bolt,在用-race 运行的时候,里边一个函数的时候报错 panic类型的,fatal error: checkptr: converted pointer straddles multiple allocations 关键的两行报错
    D:/GO/src/runtime/checkptr.go:20 +0xc9 fp=0xc00029f9e8 sp=0xc00029f9b8 p
    c=0x554b89
    github.com/boltdb/bolt.(*freelist).write(0xc0004e5290, 0xc000508000, 0xc00050800
    0, 0x0)
    百度不出来所以然 老师这东西为啥报错啊 不带race运行好好的
    作者回复

    看看是不是你数据有并发的问题?如果确定不是,那就是bolt的bug了,不过可能性比较小

    2021-09-02 21:32:40