Dig101-Go之深入理解mutex

文章目录

  1. 1. 0x01 为什么需要锁
    1. 1.1. 非原子操作
    2. 1.2. 内存重排
  2. 2. 0x02 mutex如何实现原子操作
  3. 3. 0x03 mutex如何避免内存重排
  4. 4. 0x04 Go对mutex的优化点
    1. 4.1. 不一直让cpu空转等待锁
    2. 4.2. 用信号量实现睡眠的协程唤起
    3. 4.3. 避免过多无效的协程唤起

Dig101: dig more, simplified more and know more

sync.MutexGo实现的互斥锁,提供了基本的同步操作,使用很方便。

不过,你是否好奇过,Go是如何实现的Mutex,又是为什么要这样实现?

今天跟随几个问题,我们一起探索下Mutex背后的设计。

(不用担心,不会有大段的源码分析出现在本文😳)

0x01 为什么需要锁

锁当然是为了保证同步操作,或者应该这样问:没有锁的时候,为什么会有不同步?

这里导致不同步的原因主要有两点:

非原子操作

如果一个线程的内存操作的中间状态(比如只完成一半),可以被另一个线程获取到,那么其操作就是非原子操作。

或者说其操作不是不可再分的。

比如自增操作i++, i的读取、修改、写入(RMW),其底层cpu和内存实际交互了两次,自然没法保证操作过程的原子性。

自增操作

那直接读取操作是否就能保证原子性呢?

也不一定,比如一般操作系统操作内存的最小粒度是一个机器字machine word(32位系统是4B,64位系统是8B)

内存对齐的情况下,在32位系统上读取一个int64(8B)就不可能是原子的,更别说如果内存是不对齐的了。

内存重排

我们写的代码顺序与实际执行的顺序可能并不一致。这是因为有内存重排的存在。他会发生在两个地方:

  • 语言编译时

语言为了一些优化考虑,可能会在编译期间重排代码顺序。

  • 系统执行时

这个完全取决于系统底层指令的实现,主要是为了更好利用那些原本要浪费掉的指令周期,提升程序的执行速度。

比如cpu和主存(main memory)访问延时是很高的。多核时代,每个cpu核为了加速访问,就增加了各自与主存交互的缓存(cache)。

如果缓存中有就无需再和主存交互了。如下图所示:

cpu的缓存与主存

但有多份缓存,就需要有缓存一致(cache coherency)协议保证他们的结果一致。(具体可以了解MESI协议)。

这种情况下,如果多个cpu核同时修改或读取同一份数据,且这份数据在各自的缓存中,为避免缓存间不一致,就不可避免有的cpu核要等待其他cpu核先操作数据。

所以在这些cpu核等待期间,他可以先执行其他内存指令。

详细可以参考 Understanding memory reordering 这篇文章。

0x02 mutex如何实现原子操作

利用atomic提供的AddInt32CompareAndSwapInt32函数,其底层使用了系统架构提供的LOCK前缀指令。

该指令会设置处理器的LOCK信号。

这个信号会使总线锁定,阻止其他处理器接管总线访问内存,直到使用LOCK前缀指令执行结束,这会使这条指令的执行变为原子操作。

在多处理器环境下,设置LOCK信号能保证某个处理器对共享内存的独占使用。

具体指令参见:runtime/internal/atomic/asm_amd64.s

Cas的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// bool Cas(int32 *val, int32 old, int32 new)
// Atomically:
// if(*val == old){
// *val = new;
// return 1;
// } else
// return 0;
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17
MOVQ ptr+0(FP), BX
MOVL old+8(FP), AX
MOVL new+12(FP), CX
LOCK
CMPXCHGL CX, 0(BX)
SETEQ ret+16(FP)
RET

0x03 mutex如何避免内存重排

上边提到的LOCK指令及XCHG等指令,会引入内存屏障(memory barrier

内存屏障是强制处理器按照可预知的方式访问内存的CPU指令。

内存屏障的工作方式类似路障:内存屏障之前的指令保证先于内存屏障之后的指令执行。

更多关于内存屏障可以看看这篇文章中对 POSIX多线程程序设计-3.4节 引用。

0x04 Go对mutex的优化点

有了底层架构指令支持,如果Go直接基于atomic.CAS+cpu时钟周期阻塞,就可实现常见的自旋锁(spinLock):

未获取锁前阻塞线程,每次等待一些cpu时钟周期,直到锁可用就好了。

但Go自己还是做了一些优化,一般情况下会优于常见的多线程互斥锁。

主要优化点是:

不一直让cpu空转等待锁

一直自旋最大的问题就是浪费cpu时钟周期,会占用一些cpu,除非锁能很快获取到的。

所以Go在多核不会影响其他Goroutine调度时,会最多自旋4次,且每次空转30个cpu时钟周期。以期能短期内获取锁。

详见runtime_canSpinruntime_doSpin (源码见runtime/proc.go)

用信号量实现睡眠的协程唤起

上边说了不会一直空转cpu,那4次空转之后锁还没好,怎么办?

在多线程场景里一般就会基于信号量(semaphore)实现 futex,来让线程睡眠直至条件满足后唤醒。

Go也用信号量实现了类似futexwakesleep. 只不过他们管理的Go自己调度的协程而非线程。

这样好处是,让协程等待的代价要比线程更低,而且协程上下文切换也更快一些。

避免过多无效的协程唤起

如果一个锁有多个协程在抢,且已经有被挂起的协程在等待唤起。

这种情况下,当锁释放,等待被唤起的协程被唤醒时,他会和其他已经在cpu上运行的协程一起去抢锁,自然很容易失败而导致继续挂起。

为缓解这种极端情况的延迟。Go增加饥饿模式(starvation mode):

正常情况按先进先出唤醒抢锁;如果有协程获取锁失败被挂起超过1ms,就将其放到队首,并转为饥饿模式。

这种模式下,下次唤醒就直接把锁交给handoff)队首等待已久的协程。

了解这些,再去看mutex的源码可能会更好理解一些。

最后推荐一个talk,是英文版的,里边有很详细的关于mutex的讨论。

如有疑问,请文末留言交流或邮件:newbvirgil@gmail.com 本文链接 : https://newbmiao.github.io/2020/07/01/dig101-golang-understanding-mutex.html