【Go笔记】并发控制

俩引用

Data races are among the most common and hardest to debug types of bugs in concurrent systems.
如果你不知道如何退出一个协程,那么就不要创建这个协程

context

为什么

  • 引入context之后就可以使用规范的退出方式<-ctx.Done()
  • 具有级联关系,退出可以具有传递性,把整个调用链上的协程全部退出了

怎么用

  • context.Context是一个接口,提供了4中方法
1
2
3
4
5
6
type Context interface {
Deadline() (deadline time.Time, ok bool) // 还有多久到期、是否到期
Done <- chan struct{} // 搞完了返回
Err() error // 返回退出的原因
Value(key interface{}) interface{} // 返回Context携带的指定值
}
  • Done是使用最频繁的方法,返回一个通道,一般的做法是监听通道的信号,收到信号说明通道已经关闭,需要退出
  • context中携带值是非常少见的,一般在跨程序的API中使用,该值的作用域在结束的时候终结。比如进行http处理签获取header中的Authorization字段
  • Value主要用于安全凭证,分布式跟踪ID,操作优先级,退出信号与到期时间等场景,使用时需要谨慎,如果参数与函数核心逻辑相关,依然建议显示传递参数
  • 调用context.Backgroundcontext.TODO返回最简单的context实现,一般作为根对象存在,需要派生出新的context以使用其功能。配套函数如下
1
2
3
4
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithTimeOut(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
  • 返回了一个CancelFunc函数,子context在两种情况下会退出,一种是调用该函数,另一种是父context退出的时候,子context也会全部退出
  • 剩余两种退出方法相比第一种,分别多了超时时间和超时时刻退出,这些情况下子context也会退出

context原理

  • 很大程度利用了通道在close的时候会通知所有监听它的协程这一特性
  • 每个派生出的子协程都会创建一个新的退出通道,组织好context之间的关系就可以实现继承链退出的传递
  • Context.BackgroundContext.TODO都会返回一个定义好的空emptyCtx
  • 当调用派生函数的时候,会产生一个cancelCtx结构体保存子context和父context信息
1
2
3
4
5
6
7
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{} // 每个context都有一个done通道
chidren map[canceler]struct{} // 保存当前context之后派生的子context信息
err error
}
  • 使用cancel方法会关闭自身的通道,并遍历当前children哈希表,调用当前所有子context的退出函数,然后产生连锁反应

数据争用检查

  • 数据争用时指两个协程同时访问相同的内存空间,并且至少有一个在进行写操作的情况
  • 注意for _, k := range a里面的k是一个地址不变的内存空间,但是值会变;如果将k的地址在每次循环传给一个协程,那么协程在执行的时候读取的数据很有可能有遗漏和重复
  • Go提供检查工具race来检查数据争用问题
  • 竞争检测的成本因程序而异,内存的使用量可能增加510倍,执行时间可能增加220倍,且会为当前每个defer和recover语句额外分配8字节,在goroutine退出之前都不会被回收
  • race工具借助了ThreadSanitizer,本来是为了给C++用了,但是也通过CGO形式被Go所用
  • 使用矢量时钟技术来观察时间之间的happened-before关系,分布式系统中大量用于检测时间的因果关系

原子锁

  • count++这样的操作是非原子性的,需要使用原子锁
  • 编译器在编译时通过调整指令顺序来优化运行,因此指令执行顺序可能与代码中的显示的不同
  • 需要一种机制解决并发访问时数据冲突以及内存操作混乱的问题,提供一种原子性的操作,通常需要硬件支持,比如X86指令集中的LOCK指令,对应Go中的sync/atomic包,里面有原子操作,比如atomic.AddInt64(&count, 1), atomic.StoreInt64(&flag, 1)等等
  • 原子操作是底层最基本的同步保证,可以构建起许多同步原语,比如自旋锁,信号量,互斥锁等

互斥锁

  • 操作系统的锁接口提供了终止与唤醒的机制,避免频繁自旋造成的CPU资源浪费。操作系统会构建锁的等待队列,以便之后被依次唤醒
  • Go在协程的基础上实现了比传统操作系统级别的锁更加轻量的互斥锁sync.Mutex,同一时刻只有一个获取锁的协程继续运行

互斥锁实现原理

1
2
3
4
type Mutex struct {
state int32 // 以位图的形式存储锁的状态
sema uint32 // 实现的信号量
}
  • 状态中包含了是否为锁定状态、正在等待被锁唤醒的协程数量、两个和饥饿模式有关的状态
  • 饥饿模式 - 解决某一个协程可能长时间无法获取锁的问题。饥饿模式下,unlock会唤醒最先申请加速的协程

锁定

  • 一阶段,使用原子操作快速抢占锁,如果成功就返回,失败就调用lockSlow方法。慢路径方法正常情况下会自旋尝试抢占锁一段时间(抢占主体为goroutine),锁只有在正常状态才能自旋
  • 在一下四种情况,自旋状态终止
    • 单核CPU
    • P小于或等于1
    • P在局部队列上有其他G等待运行
    • 自旋次数超过阈值
  • 二阶段,饥饿状态,使用信号量同步。如果加锁操作进入信号量同步,那么信号量计数减一,反之加一。当大于0的时候,意味着有其他协程执行解锁操作,加锁协程可拿到锁直接退出。等于0时,说明加锁协程需要进入休眠状态
  • 三阶段,所有锁的信息会根据锁的地址存储在全局semtable哈希表中,哈希桶的链表还被构造成treap方便查找。注意在访问该哈希表的时候,也有可能有数据竞争问题,此处的锁在自旋获取失败后会直接调用操作系统级别的锁。锁被放置到全局的等待队列中并等待被唤醒,顺序为FIFO

释放

  • 如果既没有进入饥饿状态和唤醒状态,也没有多个协程因为抢占锁而堵塞,那就直接修改状态后退出,否则进入慢路径调用unlockSlow方法
  • 判断是否重复释放
  • 如果处于饥饿状态,进入信号量同步阶段,寻找哈希表中的等待队列,按照FIFO唤醒
  • 如果不是饥饿状态且当前mutexWoken已经重置,说明其他协程准备从正常状态退出,这样说明有其他协程会唤醒等待协程,那我就直接退出就好
  • 如果唤醒了等待队列中的协程,就将唤醒的协程放入当前写成所在P的runnext中就可以了

读写锁及其原理

  • 读锁必须能读到上次写入的值,写锁比如要等所有读锁释放之后才写入
1
2
3
4
5
6
7
type RWMutex struct {
w Mutex
writerSem uint32 // 信号量,写锁等到读锁完成
readerSem uint32 // 信号量,读锁等待写锁完成
readerCount int32 // 执行写操作的数量
readerWait int32 // 写操作等待的读操作的数量
}
  • 读锁通过原子操作将readerCount加一,如果大于等于0就返回,小于0就说明有写入操作,使用信号量进入等待状态
  • 读锁解锁的时候如果没有写锁就直接readCount减一然后退出,如果有的话就判断是不是最后一个释放的读锁,是的话就需要增加信号量然后唤醒写锁
  • 申请写锁的时候要先获取互斥锁,然后readCount减掉rwmutexMaxReaders阻止后面的读操作
  • 解锁就是反向操作了,然后依次唤醒所有等待中的读锁,当所有读锁唤醒完之后释放互斥锁
  • 所以,写操作的时候效率和互斥锁相当,读的时候效率大大提升

【Go笔记】并发控制
https://study.0x535a.cn/go-note/go-context-lock/
Author
Stephen Zeng
Posted on
September 2, 2025
Licensed under