Go 并发 sync 包面试题,覆盖 Mutex / RWMutex / WaitGroup / Pool / Once 等并发原语。
开篇碎碎念
整理自 Go-Interview 仓库的 Go并发 模块,重点是 goroutine / channel / sync 包 / GC 这四大块。每道题尽量附上「答案」和「代码示例」,建议按顺序阅读,先建立心智模型,再回头刷面试题。
适合人群:刚学完 GMP 模型、准备 Go 高级岗位面试的同学。
go并发编程
Mutex几种状态
- mutexLocked 表示互斥锁的锁定状态
- mutexWoken 唤醒锁
- mutexStarving 当前互斥锁进入饥饿状态
- mutexWaiterShift 统计阻塞在这个互斥锁上的goroutine的数目
互斥锁无冲突是最简单的情况了,有冲突时,首先进行自旋,因为Mutex保护的代码段都很短
经过短暂的自旋就可以获得,如果自旋等待无果,就只好通过信号量让当前goroutine进入Gwaitting状态
Mutex的正常模式和饥饿模式
- 正常模式(非公平锁)
- 正常模式下,所有等待的goroutine按照FIFO(先进先出)的顺序等待
- 唤醒的goroutine不会直接拥有锁而是会和新进来的请求goroutine竞争锁
- 新请求的goroutine更容易抢占,因为它正在CPU上执行,所以刚刚唤醒的goroutine很大可能会竞争失败
- 在这种情况下这个被唤醒的goroutine会被加入到等待队列的前面
- 饥饿模式(公平锁)
- 为了解决等待goroutine队列的长尾问题,饥饿模式下
- 直接由unlock把锁交给等待队列中第一个goroutine(队头),同时,饥饿模式下
- 新进来的goroutine不会进行抢锁,也不会进入自旋状态,会直接进入等待队列的尾部
- 这样很好的解决了老的goroutine一直抢不到锁的情况
- 饥饿模式的触发条件
- 当一个goroutine等待时间超过1毫秒时,或者当前队列剩下一个goroutine的时候,Mutex切换到饥饿模式
- 总结
- 对于两种模式,正常模式下性能是最好的,因为goroutine可以连续多次获取锁
- 饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平的一个平衡模式
Mutex允许自旋的条件
- 锁已被占用,且锁不处于饥饿模式
- 积累的自旋次数小于最大自旋次数
- CPU核数>1
- 有空闲的P
- 当前的goroutine所挂载的P下,本地待运行队列为空
RWMutex实现
- 通过记录readerCount读锁的数量来进行限制,当有一个写锁的时候,会将读锁的数量设置为负数1<<30
- 目的是让新进来的读锁等待之前的写锁释放通知读锁
- 同样当有写锁进行抢占时,也会等待之前的读锁都释放完毕,才进行后续的操作
- 而等写锁释放完成之后,会将值重新加上1<<30,并通知刚才新进入的读锁(rw.readerSem)两者互相限制
RWMutex注意事项
- RWMutex是个单写多读锁,可以加多个读锁或者一个写锁
- 读锁占用的情况下会阻止写,不会阻止读,多个goroutine可以同时获取读锁
- 写锁会阻止其它goroutine(无论读写锁)进来,整个锁由该goroutine独占
- 适用于读多写少的场景
- RWMutex的零值是一个未锁定状态的互斥锁
- RWMutex在首次使用之后就不能再被拷贝
- RWMutex的读锁或写锁在未锁定的状态下进行解锁都会引发panic
- RWMutex的一个写锁去锁定临界区的共享资源,如果临界区的资源已被(读锁或写锁)锁定,这个写锁的goroutine会被阻塞直到解锁
- RWMutex的读锁不要用于递归调用,容易产生死锁
- RWMutex的锁定状态与特定的goroutine没有关联,一个goroutine可以RLock(Lock),另外一个goroutine可以RUnlock(Unlock)
- 写锁被解锁后,所有因操作锁定读锁的goroutine会被唤醒,并都可以成功锁定读锁
- 读锁被解锁后,在没有其它读锁锁定的情况下,所有因操作锁定写锁而被阻塞的goroutine中,其中等待时间最长的goroutine会被唤醒
cond是什么
- Cond实现了一种条件变量,可以使用在多个reader等待共享资源ready的场景,(如果只有一读一写,一个锁或者channel就搞定了)
- 每个Cond都会关联一个Lock(*sync.Mutex or *sync.RWMutex), 当修改条件或者调用Wait方法时,必须加锁以保护condition
- 案例:
go
var (
c = sync.Cond{L: &sync.Mutex{}} // 条件变量,多个reader等待共享资源ready的场景
maxNum = 15
)
func main(){
for i := 0; i < maxNum; i++{
go func(i int) {
c.L.Lock()
defer c.L.Unlock()
// Wait会释放c.L锁,并挂起调用者的goroutine,之后恢复执行
c.Wait() // Wait会在返回时对c.L加锁
// 除非被Broadcast或Signal唤醒,否则Wait不会返回
fmt.Println("goroutine:", i)
time.Sleep(time.Millisecond * 100)
}(i)
}
c.L.Lock()
maxNum = 16
c.L.Unlock()
c.Broadcast() // 广播,所有等待的goroutine都会执行
//c.Signal() // 单播,从所有等待的goroutine中随机找一个去执行
time.Sleep(time.Second * 2)
}Broadcast和Signal的区别
- Broadcast会唤醒所有等待c的goroutine,调用Broadcast的时候,可以加锁也可以不加锁
- Signal只唤醒一个等待c的goroutine,调用Signal的时候,可以加锁也可以不加锁
Cond中Wait使用
- Wait会自动释放c.L锁,并挂起调用者的goroutine,之后恢复执行
- Wait会在返回时对c.L加锁
- 除非被Broadcast或Signal唤醒,否则Wait不会返回
- 由于Wait第一次恢复是,c.L并没有加锁,所以当Wait返回时,调用者通常不能假设条件为真
- 简单来说,只要想使用condition就必须加锁
WaitGroup用法
- 一个WaitGroup对象可以等待一组协程结束,使用方法是
- main协程通过调用 wg.add(delta int) 来设置worker协程的个数,然后创建worker协程
- worker协程结束以后,都要调用wg.Done()
- main协程调用wg.Wait()而被block, 知道所有worker协程全部执行结束后返回
- 如果不确定要创建的worker协程数量,就不要一次性wg.add(),而是在每个创建worker协程之前调用一次wg.add(1)
- 首次使用后不得复制wg
WaitGroup实现原理
- WaitGroup主要维护了两个计数器,一个请求计数器v,一个等待计数器w
- 二者组成了一个64bit的值,请求计数器占高32bit,等待计数器占低32bit
- 每次Add执行,请求计数器 v+1, 每次Done,等待计数器 w-1
- 当v为0时,通过信号量唤醒Wait
什么是sync.Once
- Once可以用来执行且仅仅执行一次的动作,常常用于单例对象的初始化场景
- Once常常用来初始化单例资源,或者并发访问只需要初始化一次的共享资源
- 或者在测试的时候初始化一次测试资源
- sync.Once只暴露了一个方法Do,你可以多次调用Do方法
- 但是只有第一次调用Do方法时f参数才会执行,这里的f是一个无参数无返回值的函数
什么操作叫原子操作
- 原子操作即是进程过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中
- CPU绝不会再去进行其它针对值的操作,为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成
- 原子操作是无锁的,常常直接通过CPU指令直接实现,
- 事实上,其它同步技术的实现常常依赖于原子操作
原子操作和锁的区别
- 原子操作由底层硬件支持,而锁由操作系统调度器实现
- 锁应当用来保护一段逻辑,对于一个变量更新的保护
- 原子操作通常执行上会更有效率,并且更能利用计算机多核资源
- 如果要更新的是一个复合对象,则应当使用automic.Value封装好的实现
sync.Pool有什么用
- 对于很多需要重复分配、回收内存的地方,sync.Pool是一个很好的选择
- 频繁的分配回收内存会给GC带来一定负担,严重的时候会引起CPU的毛刺
- 而sync.Pool可以将暂时不使用的对象缓存起来,待下次需要的时候直接使用
- 不用再次经过内存分配,复用对象的内存,减轻GC的压力,提升系统性能
- 案例
go
func main(){
var p sync.Pool
var a = "jdfakljfdalfaskdj阿加加加金灯送福卡萨发几块爱神的箭快疯了金阿奎"
p.Put(a)
ret := p.Get()
fmt.Println(ret)Go Runtime
1. goroutine定义
x86asm
golang在语言级别支持协程,称之为goroutine; golang标准库提供的所有系统调用操作(包括所有同步I/O操作)
都会让出CPU给其它goroutine, 这让goroutine的切换管理不依赖于系统的线程和进程,也不依赖于CPU的核心数量
而是交给Golang的运行时统一调度2. GMP指的是什么
markdown
* G(goroutine),我们所说的协程,用户级的轻量级线程,每个goroutine对象中的sched保存着其上下文信息
* M(machine),对内核级线程的封装,数量对应真实的CPU数量(真正干活的对象)
* P(processor),即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过GOMAXPROCS来设置,默认是CPU核心数量3. 1.0之前GM调度模型
css
调度器把不同的G分配到M上,不同的G在不同的M并发运行时,都需要向系统申请资源,比如堆栈内存,因为资源是全局的
就会因为资源竞争造成很多性能损耗,为了解决这一问题go从1.1版本引入,在运行时系统的时候加入p对象,
让P去管理G对象,M想要运行G,必须先绑定P,然后才能运行P下面的G对象
GM调度存在的问题:
1. 单一全局互斥锁(Sched.Lock)和集中状态存储
2. groutine传递问题(M经常在M之间传递可运行的goroutine)
3. 每个M做内存缓存,导致内存占用过高,数据局部性较差
4. 频繁的syscall调用, 导致严重的线程阻塞/解锁,家具额外的性能损耗_4. GMP调度流程
css
1. 每个p有个局部队列,局部队列放的是待执行的goroutine,当M绑定的P的局部队列满了以后就会把G放到全局队列
2. 每个P和一个M绑定,M是真正执行P中goroutine的实体,M从绑定的P的局部队列中获取G来执行
3. 当M绑定的P的局部队列为空时,M就会从全局队列获取到本地队列来执行,当全局队列也为空时,
M就会从其它P队列偷取G来执行,这种从其它P偷的方式称之为 work stealing
4. 当G因系统调用(syscall)阻塞时会阻塞M,此时P会和M解绑即hand off,并寻找新的M,如果没有空闲的M就会新建一个M
5. 当G因channel或者network I/O操作阻塞时,不会阻塞M,M会寻找其它runable的G,当阻塞的G恢复后重新进入runable进入P队列等待执行5. GMP中的work stealing机制
css
先获取p本地队列,如果为空时,去全局队列里取G运行,如果全局队列为空时从netpoll和事件池里拿
如果在拿不到从其它P队列里偷6. GMP中的handoff机制
css
当本地线程M因为G进行的系统调用阻塞时,会释放绑定的P,把P转移给其它空闲的M执行7. 协作式的抢占式调度
markdown
在1.14之前,程序只能依靠goroutine主动让出CPU资源才能触发调用,这种方式存在问题有:
1. goroutine长时间占用线程,造成其它goroutine的饥饿
2. 垃圾回收需要暂停整个程序,最长可能几分钟,导致整个程序无法工作8. 基于信号的抢占式调度
css
1. 在任何情况下,go运行时并行执行的goroutine数量要小于等于p的数量
2. 为了提高性能,p的数量肯定不是越小越好,官方给出默认是CPU的核心数量
3. 如果设置过小的,当M绑定的P执行的G执行系统调用阻塞,导致M也阻塞时,GO的调度器是迟钝的,他有可能什么都不做
它有可能什么都不做,知道M阻塞了相当长时间以后,才会发现一个P/M被syscall阻塞了,然后才会用空闲的M来抢这个P
所以P也不建议设置太小,通过sysmon监控实现的抢占式调度,最快在20us,最慢在10-20ms才会发现一个M持有P并阻塞了,
而操作系统1ms可以完成几十次系统调度
所以P适当的比CPU核心数多一些最好9. GMP调度过程中存在哪些阻塞
markdown
1. I/O, select
2. block on syscall
3. channel
4. 等待所
5. runtime.Gosched()10. sysmon有什么用
markdown
sysmon也叫监控线程,变动的周期性检查,好处
1. 释放超过5分钟的span物理内存
2. 如果超过两分钟没有垃圾回收,强制执行
3. 将长时间未处理的netpoll加入到全局队列
4. 向长时间运行的g任务发出抢占调度(超过10ms的g,会进行retake)
5. 收回因syscall阻塞的P