俩引用
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{} }
|
Done
是使用最频繁的方法,返回一个通道,一般的做法是监听通道的信号,收到信号说明通道已经关闭,需要退出
- context中携带值是非常少见的,一般在跨程序的API中使用,该值的作用域在结束的时候终结。比如进行http处理签获取header中的Authorization字段
Value
主要用于安全凭证,分布式跟踪ID,操作优先级,退出信号与到期时间等场景,使用时需要谨慎,如果参数与函数核心逻辑相关,依然建议显示传递参数
- 调用
context.Background
或context.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.Background
和Context.TODO
都会返回一个定义好的空emptyCtx
- 当调用派生函数的时候,会产生一个
cancelCtx
结构体保存子context和父context信息
1 2 3 4 5 6 7
| type cancelCtx struct { Context mu sync.Mutex done chan struct{} chidren map[canceler]struct{} 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),锁只有在正常状态才能自旋
- 在一下四种情况,自旋状态终止
-
-
-
-
- 二阶段,饥饿状态,使用信号量同步。如果加锁操作进入信号量同步,那么信号量计数减一,反之加一。当大于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
阻止后面的读操作
- 解锁就是反向操作了,然后依次唤醒所有等待中的读锁,当所有读锁唤醒完之后释放互斥锁
- 所以,写操作的时候效率和互斥锁相当,读的时候效率大大提升