go 怎么等待所有的协程完成_深挖 Go 系列之深入理解 mutex

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

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

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

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

文章目录

  • 0x01 为什么需要锁

    • 非原子操作

    • 内存重排

  • 0x02 mutex 如何实现原子操作

  • 0x03 mutex 如何避免内存重排

  • 0x04 Go 对 mutex 的优化点

    • 不一直让 cpu 空转等待锁

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

    • 避免过多无效的协程唤起

0x01 为什么需要锁

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

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

非原子操作

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

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

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

0275eb56d0ce6a8f9eaa9bae480895b2.png
自增操作

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

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

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

内存重排

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

  • 语言编译时

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

  • 系统执行时

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

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

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

4631daaf162067885a3f471dcf3756d3.png
cpu的缓存与主存

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

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

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

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

0x02 mutex 如何实现原子操作

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

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

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

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

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

Cas的实现如下:

// 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 节[3] 引用。

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[4],来让线程睡眠直至条件满足后唤醒。

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

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

避免过多无效的协程唤起

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

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

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

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

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

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

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

参考资料

[1]

MESI协议: https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE

[2]

Understanding memory reordering: https://www.internalpointers.com/post/understanding-memory-ordering

[3]

POSIX多线程程序设计-3.4节: https://my.oschina.net/chuqq/blog/3022854

[4]

futex: https://zh.wikipedia.org/wiki/Futex

推荐阅读

  • 深挖Go系列之如何在函数内修改指针


喜欢本文的朋友,欢迎关注“Go语言中文网”:

21f5ef0cd9b81af6e2363ad3151e1efe.png

Go语言中文网启用微信学习交流群,欢迎加微信:274768166,投稿亦欢迎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值