文章目录
Dig101: dig more, simplified more and know more
sync.Mutex
是Go
实现的互斥锁,提供了基本的同步操作,使用很方便。
不过,你是否好奇过,Go
是如何实现的Mutex
,又是为什么要这样实现?
今天跟随几个问题,我们一起探索下Mutex
背后的设计。
(不用担心,不会有大段的源码分析出现在本文😳)
0x01 为什么需要锁
锁当然是为了保证同步操作,或者应该这样问:没有锁的时候,为什么会有不同步?
这里导致不同步的原因主要有两点:
非原子操作
如果一个线程的内存操作的中间状态(比如只完成一半),可以被另一个线程获取到,那么其操作就是非原子操作。
或者说其操作不是不可再分的。
比如自增操作i++
, i
的读取、修改、写入(RMW
),其底层cpu和内存实际交互了两次,自然没法保证操作过程的原子性。
那直接读取操作是否就能保证原子性呢?
也不一定,比如一般操作系统操作内存的最小粒度是一个机器字machine word
(32位系统是4B
,64位系统是8B
)
内存对齐的情况下,在32位系统上读取一个int64
(8B
)就不可能是原子的,更别说如果内存是不对齐的了。
内存重排
我们写的代码顺序与实际执行的顺序可能并不一致。这是因为有内存重排的存在。他会发生在两个地方:
- 语言编译时
语言为了一些优化考虑,可能会在编译期间重排代码顺序。
- 系统执行时
这个完全取决于系统底层指令的实现,主要是为了更好利用那些原本要浪费掉的指令周期,提升程序的执行速度。
比如cpu和主存(main memory
)访问延时是很高的。多核时代,每个cpu核为了加速访问,就增加了各自与主存交互的缓存(cache
)。
如果缓存中有就无需再和主存交互了。如下图所示:
但有多份缓存,就需要有缓存一致(cache coherency
)协议保证他们的结果一致。(具体可以了解MESI协议)。
这种情况下,如果多个cpu核同时修改或读取同一份数据,且这份数据在各自的缓存中,为避免缓存间不一致,就不可避免有的cpu核要等待其他cpu核先操作数据。
所以在这些cpu核等待期间,他可以先执行其他内存指令。
详细可以参考 Understanding memory reordering 这篇文章。
0x02 mutex如何实现原子操作
利用atomic
提供的AddInt32
和CompareAndSwapInt32
函数,其底层使用了系统架构提供的LOCK
前缀指令。
该指令会设置处理器的LOCK
信号。
这个信号会使总线锁定,阻止其他处理器接管总线访问内存,直到使用LOCK
前缀指令执行结束,这会使这条指令的执行变为原子操作。
在多处理器环境下,设置LOCK
信号能保证某个处理器对共享内存的独占使用。
具体指令参见:runtime/internal/atomic/asm_amd64.s
如Cas
的实现如下:
1 | // bool Cas(int32 *val, int32 old, int32 new) |
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_canSpin
和runtime_doSpin
(源码见runtime/proc.go
)
用信号量实现睡眠的协程唤起
上边说了不会一直空转cpu,那4次空转之后锁还没好,怎么办?
在多线程场景里一般就会基于信号量(semaphore
)实现 futex
,来让线程睡眠直至条件满足后唤醒。
Go也用信号量实现了类似futex
的wake
和sleep
. 只不过他们管理的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