5.1 除了 mutex 以外还有那些方式安全读写共享变量?
除了Mutex,主要还有信号量、通道(Channel),原子操作(atomic)这几种方式。
信号量的实现其实跟mutex差不多,实现起来也很方便,主要通过信号量计数来保证。chanenl是Go最推崇的方式,它通过通信来传递数据所有权,从根源上避免竞争,更适合复杂的业务逻辑;而原子操作则针对最简单的整型或指针等进行无锁操作,性能最高,常用于实现计数器或状态位。选择哪种,完全取决于数据结构的复杂度和业务的读写模型。
5.2 Go 语言是如何实现原子操作的?
Go语言实现原子操作,其根本是依赖底层CPU硬件提供的原子指令,而不是通过操作系统或更上层的锁机制。
具体来说,Go的sync/atomic包中的函数,在编译时会被编译器识别,并直接转换成对应目标硬件平台(如x86、ARM)的单条原子机器指令。例如,在x86架构上,atomic.AddInt64这类操作会对应到像LOCK; ADD这样的指令。前面的LOCK前缀是关键,它会锁住总线或缓存行,确保后续的ADD指令在执行期间,其他CPU核心不能访问这块内存,从而保证了整个操作的原子性。
// atomic.AddInt64(&cnt,1)
TEXT ·AddInt64(SB),NOSPLIT,$0-16
MOVQ ptr+0(FP), AX // AX = cnt变量的内存地址
MOVQ delta+8(FP), CX // CX = 增量,这里就是1
LOCK
XADDQ CX, (AX) // 核心原子指令
MOVQ CX, ret+16(FP) // 返回新值
RET
MOVQ ptr+0(FP), AX :拿栈上的地址&cnt存入 AX。不碰 cnt 内存,此时 CPU 核心 B 完全可以读写 cnt,没关系!这条 MOVQ 根本没访问 cnt。
MOVQ delta+8(FP), CX : 把 1 放到 CX。同样不碰 cnt 内存
LOCK XADDQ CX, (AX) :到这一步!才开始访问(AX)也就是 cnt 内存,LOCK 生效,独占缓存行。 此时其他 CPU 核想要访问 cnt,就会被阻塞,等这条指令完成。
// 普通cnt++,拆成 3 条独立指令,中间可以被调度打断
MOVQ (AX), BX // ①读内存cnt到BX
INCQ BX // ②寄存器+1
MOVQ BX, (AX) // ③写回内存
MOVQ (AX), BX: 访问内存地址AX,把内存里 cnt 的值读到 CPU 内部寄存器 BX。这一步会访问主存 / 缓存。
INCQ BX:只操作寄存器 BX,完全不碰内存。
MOVQ BX, (AX):把 BX 的值,写回到 AX 指向的内存地址(写回 cnt 内存)。
举例子:初始cnt = 0,两个 CPU 核同时执行这三段代码:
核 1:MOVQ (AX),BX →读到 BX=0
CPU 切换,此时核 2 执行 MOVQ (AX),BX,它也读到 BX=0
核 1:INCQ BX →BX=1
核 1:MOVQ BX,(AX) →写回内存 cnt=1
核 2:INCQ BX →BX=1
核 2:MOVQ BX,(AX) →写回内存 cnt=1
预期结果是 2,实际结果只有 1,更新丢失!
5.3 聊聊原子操作和锁的区别?
原子操作和锁最核心的区别在于它们的实现层级和保护范围。
原子操作是CPU硬件层面的”微观”机制,它保证对单个数据(通常是整型或指针)的单次读改写操作是绝对不可分割的,性能极高,因为它不涉及操作系统内核的介入和goroutine的挂起。
锁则是操作系统或语言运行时提供的”宏观”机制,它保护的是一个代码块(临界区),而不仅仅是单个变量。当获取锁失败时,它会让goroutine休眠,而不是空耗CPU。虽然锁的开销远大于原子操作,但它能保护一段复杂的、涉及多个变量的业务逻辑。
所以,对于简单的计数器或标志位更新,用原子操作追求极致性能;而只要需要保护一段逻辑或多个变量的一致性,就必须用锁。
func sync_4() {
var mu sync.Mutex
var a, b int
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(gorID int) {
defer wg.Done()
for j := 0; j < 3; j++ {
fmt.Printf("【协程%d】准备调用mu.Lock(),尝试抢锁\n", gorID)
// 重点:如果锁被别人持有,当前goroutine就在这一行阻塞、进入休眠
mu.Lock()
fmt.Printf("【协程%d】✅成功拿到锁,进入临界区\n", gorID)
// 临界区开始
a++
fmt.Printf("【协程%d】 a = %d\n", gorID, a)
time.Sleep(1 * time.Second) // 持有锁1秒,拉长等待时间,方便观察
b = a * 2
fmt.Printf("【协程%d】 b = a*2 = %d\n", gorID, b)
if b > 100 {
b = 0
fmt.Printf("【协程%d】b超过100,重置b=%d\n", gorID, b)
}
// 临界区结束
fmt.Printf("【协程%d】准备释放锁\n", gorID)
mu.Unlock()
fmt.Printf("【协程%d】🔓已释放锁\n\n", gorID)
time.Sleep(200 * time.Millisecond)
}
}(i)
}
wg.Wait()
fmt.Println("全部协程结束,最终 a=", a, " b=", b)
}
Building .【协程0】准备调用mu.Lock(),尝试抢锁
【协程0】✅成功拿到锁,进入临界区
【协程0】 a = 1
【协程1】准备调用mu.Lock(),尝试抢锁
【协程0】 b = a*2 = 2
【协程0】准备释放锁
【协程0】🔓已释放锁
【协程1】✅成功拿到锁,进入临界区
【协程1】 a = 2
【协程0】准备调用mu.Lock(),尝试抢锁
【协程1】 b = a*2 = 4
【协程1】准备释放锁
【协程1】🔓已释放锁
【协程0】✅成功拿到锁,进入临界区
【协程0】 a = 3
【协程1】准备调用mu.Lock(),尝试抢锁
【协程0】 b = a*2 = 6
【协程0】准备释放锁
【协程0】🔓已释放锁
【协程1】✅成功拿到锁,进入临界区
【协程1】 a = 4
【协程0】准备调用mu.Lock(),尝试抢锁
【协程1】 b = a*2 = 8
【协程1】准备释放锁
【协程1】🔓已释放锁
【协程0】✅成功拿到锁,进入临界区
【协程0】 a = 5
【协程1】准备调用mu.Lock(),尝试抢锁
【协程0】 b = a*2 = 10
【协程0】准备释放锁
【协程0】🔓已释放锁
【协程1】✅成功拿到锁,进入临界区
【协程1】 a = 6
【协程1】 b = a*2 = 12
【协程1】准备释放锁
【协程1】🔓已释放锁
全部协程结束,最终 a= 6 b= 12
Go Mutex 底层依赖 runtime 信号量sema:
抢锁成功:直接继续运行
抢锁失败:把当前 G 放入锁的等待队列,G 休眠,让出 M(CPU),不再占用 CPU,不会循环重试。
5.4 Go语言互斥锁mutex底层是怎么实现的?
mutex底层是通过原子操作加信号量来实现的,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒
分析
互斥锁对应的是底层结构是sync.Mutex结构体
type Mutex struct {
state int32
sema uint32
}
state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式
state 是一个 int32(32 位整数),把一个 int32 的二进制,切分成 4 段,每一段 bit 各自存不同标记:

sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒

调用mu.Lock():
先用 atomic CAS 原子操作修改state,尝试把mutexLocked置 1;成功直接拿到锁。
CAS 失败(锁被别人拿着):正常模式下,短暂自旋几次,反复 CAS 尝试抢锁。如果自旋还是拿不到,state 的waiterCount 计数 + 1(原子修改 state 高位),然后调用sema信号量,即调用runtime_Semacquire(&mu.sema),取到sema地址,然后做哈希hash 找到 semroot 分片,创建 sudog,把当前 G 打包,sudog 放进等待队列,G 休眠,让出 M(goroutine 休眠,进入等待队列),到这里,协程阻塞,不再占用 CPU
调用mu.Unlock():
原子 CAS 修改 state,清除 mutexLocked 标记。
读取 state 里的 waiterCount,看有没有排队等待的协程,读取 state 里 waiterCount,如果等于 0,直接结束,没人等锁。如果有等待协程,通过sema信号量唤醒队列头部一个 goroutine,调用runtime_Semrelease(&mu.sema),取到sema地址,然后做哈希hash 找到 semroot 分片,在 sudog 队列取出队首 G,唤醒这个 goroutine,被唤醒的 goroutine 起来之后,继续 CAS 抢锁。
5.5 Mutex 有几种模式?
Go的Mutex主要有两种模式:正常模式(Normal Mode)和饥饿模式(Starvation Mode)。
正常模式:这是默认模式,讲究的是性能。新请求锁的goroutine会和等待队列头部的goroutine竞争,新来的goroutine有几次”自旋”的机会,如果在此期间锁被释放,它就可以直接抢到锁。这种方式吞吐量高,但可能会导致队列头部的goroutine等待很久,即”不公平”。
饥饿模式:当一个goroutine在等待队列中等待超过1毫сан(1ms)后,Mutex就会切换到此模式,讲究的是公平。在此模式下,锁的所有权会直接从解锁的goroutine移交给等待队列的头部,新来的goroutine不会自旋,必须排到队尾。这样可以确保队列中的等待者不会被”饿死”。
当等待队列为空,或者一个goroutine拿到锁时发现它的等待时间小于1ms,饥饿模式就会结束,切换回正常模式。这两种模式的动态切换,是Go在性能和公平性之间做的精妙平衡。
5.6 在Mutex上自旋的goroutine 会占用太多资源吗
并不会,因为Go的自旋设计得非常”克制”和”智能”。
首先,自旋不是无休止的空转,它有严格的次数和时间限制,通常只持续几十纳秒。其次,自旋仅仅在特定条件下才会发生,比如CPU核数大于1,并且当前机器不算繁忙(没有太多goroutine在排队)。它是在赌,与其付出”goroutine挂起和唤醒”这种涉及内核调度的巨大代价,不如原地”稍等一下”,因为锁可能马上就释放了。
所以,这种自旋是一种机会主义的短线优化,目的是用极小的CPU开销去避免一次昂贵的上下文切换,在锁竞争不激烈、占用时间极短的场景下,它反而是节省了资源。
5.7 Mutex 已经被一个 Goroutine 获取了, 其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex 呢?
取决于Mutex当前处于正常模式还是饥饿模式。
在正常模式下,锁的分配是”不公平”的。当锁被释放时,等待队列中的第一个goroutine会被唤醒,但它不一定能拿到锁。它需要和那些此刻刚刚到达、正在自旋的新goroutine进行竞争。新来的goroutine因为正在CPU上运行,很有可能”插队”成功,直接抢到锁。这种策略的优点是吞吐量高,但缺点是可能导致等待队列中的goroutine被饿死。
而一旦Mutex进入饥饿模式,锁的分配就变得”绝对公平”。锁被释放后,会直接移交给等待队列的队头goroutine,任何新来的goroutine都不会参与竞争,必须乖乖排到队尾。
5.8 sync.Once 的作用是什么,讲讲它的底层实现原理?
sync.Once的作用是确保一个函数在程序生命周期内,无论在多少个goroutine中被调用,都只会被执行一次。它常用于单例对象的初始化或一些只需要执行一次的全局配置加载
sync.Once保证代码段只执行1次的原理主要是其内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了
分析
Once其实是一个结构体
type Once struct {
done uint32 // 标识位
m Mutex
}
核心依赖一个uint32的done标志位和一个互斥锁Mutex,
func (o *Once) Do(f func()) {
// 【第一次检查:无锁,原子读】
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f) // done=0,进入慢路径
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock() // 加互斥锁
defer o.m.Unlock()
// 【第二次检查:拿到锁之后,再检查一遍done】
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f() // 执行函数,只会跑一次
}
}
当Once.Do(f)首次被调用时:
它首先会通过原子操作(atomic.LoadUint32)快速检查done标志位。如果done为1,说明初始化已完成,直接返回,这个路径完全无锁,开销极小。
如果done为0,说明可能是第一次调用,这时它会进入一个慢路径(doSlow)。
在慢路径里,它会先加锁,然后再次检查done标志位。这个”双重检查”(Double-Checked Locking)是关键,它防止了在多个goroutine同时进入慢路径时,函数f被重复执行。
如果此时done仍然为0,那么当前goroutine就会执行传入的函数f。执行完毕后,它会通过原子操作(atomic.StoreUint32)将done标志位置为1,最后解锁。
之后任何再调用Do的goroutine,都会在第一步的原子Load操作时发现done为1而直接返回。整个过程结合了原子操作的速度和互斥锁的安全性,高效且线程安全地实现了”仅执行一次”的保证
场景:多个 goroutine 同时调用 Once.Do,此时 done=0
G1、G2 同时执行 Once.Do,第一重检查 atomic.Load 看到 done=0
G1、G2 一起进入 doSlow()o.m.Lock() 互斥锁:G1 抢到锁,G2 卡在 Lock 阻塞休眠
G1 拿到锁,第二重检查 done == 0,执行 f (),然后把 done 置 1,释放锁
G2 被唤醒,拿到锁,执行第二重检查,发现 done=1 → 直接退出,不会再执行 f ()
5.9 WaiGroup 是怎样实现协程等待?
WaitGroup实现等待,本质上是一个原子计数器和一个信号量的协作。
调用Add会增加计数值,Done会减计数值。而Wait方法会检查这个计数器,如果不为零,就利用信号量将当前goroutine高效地挂起。直到最后一个Done调用将计数器清零,它就会通过这个信号量,一次性唤醒所有在Wait处等待的goroutine,从而实现等待目的。
分析:
waitgroup的结构定义:
type WaitGroup struct {
noCopy noCopy // 用于vet工具检查是否被复制
// 64位的值:高32位是计数器,低32位是等待的goroutine数量。
// 通过原子操作访问,保存了状态和等待者数量。
state atomic.Uint64
// 用于等待者休眠的信号量。
sema uint32
}
noCopy: 这是一个特殊的字段,用于静态分析工具(go vet)在编译时检查WaitGroup实例是否被复制。WaitGroup被复制后会导致状态不一致,可能引发程序错误,因此该字段的存在旨在防止此类问题的发生。
state: 这是WaitGroup的核心,一个64位的无符号整型,通过sync/atomic包进行原子操作,以保证并发安全。这个64位的空间被巧妙地分成了两部分:
高32位: 作为计数器(counter),记录了需要等待的 goroutine 的数量。
低32位: 作为等待者计数器(waiter count),记录了调用Wait()方法后被阻塞的 goroutine 的数量。
sema: 这是一个信号量,用于实现 goroutine 的阻塞和唤醒。当主 goroutine 调用Wait()方法且计数器不为零时,它会通过这个信号量进入休眠状态。当所有子 goroutine 完成任务后,会通过这个信号量来唤醒等待的主 goroutine。
5.10 讲讲sync.Map的底层原理
sync.Map的底层核心是”空间换时间”,通过两个Map(read和dirty)的冗余结构,实现”读写分离”,最终达到针对特定场景的”读”操作无锁优化。
它的read是一个只读的map,提供无锁的并发读取,速度极快。写操作则会先操作一个加了锁的、可读写的dirty map。当dirty map的数据积累到一定程度,或者read map中没有某个key时,sync.Map会将dirty map里的数据”晋升”并覆盖掉旧的read map,完成一次数据同步。
分析:
sync.Map的结构定义
type Map struct {
mu Mutex // 用于保护dirty字段的锁
read atomic.Value // 只读字段,其实际的数据类型是一个readOnly结构
dirty map[interface{}]*entry //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素以及新加入的元素
misses int // 计数器,记录在从read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read
}
read字段的类型是atomic.Value,但是在使用中里面其实存储的是readOnly结构,readOnly结构定义如下:
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
m map[interface{}]*entry // key为任意可比较类型,value为entry指针
amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中都存在
}
调用 sync.Map 的 Load 方法查询键时,会优先不加锁访问 read 映射查找目标 key。如果在 read 中找到目标数据,直接返回结果,查询结束。如果 read 中没有找到该 key,则会加锁,到 dirty 映射中继续查找。
整个查询过程不会读取 amended 标记,无论 amended 是 true 还是 false,只要 read 查找失败,就会进入 dirty 查询逻辑。amended 并不控制查询的分支走向,它仅用来标记 dirty 中是否包含 read 快照不存在的键,服务于 read 快照升级的判断逻辑。
amended 用来标记 dirty 是否包含 read 快照中不存在的键。当amended 为 true时,代表 dirty 存在 read 里没有的 key,read 快照已经过时;否则当amended 为 false时,代表 dirty 内所有 key 在 read 中都能找到,没有新增的键。
amended 的作用和 miss 计数器绑定。当查询时,每次在 read 查找 key 失败,需要加锁访问 dirty 时,则miss 计数就加一。又当 miss 计数大于或等于 dirty 的键数量时,则会触发快照升级,将 dirty 直接提升为新的 read,通过原子操作替换 read 指针,同时把 dirty 置空nil、清空 miss 计数。升级完成后新 read 的 amended 会置为 false。
entry这个结构:
type entry struct {
p unsafe.Pointer // p指向真正的value所在的地址
}

注意:哪怕只是只读查询 dirty,也必须加锁。
dirty 是普通 Go map,map 扩容时会迁移桶,同一个 key 哈希对应的桶位置会发生改变。如果不加锁,一个协程只读查找 dirty 的 key,另一个协程同时执行 Store 触发 map 扩容,读写并发冲突,会破坏哈希表结构,造成 panic。所以哪怕只是对 dirty 做只读查询,也需要加锁,避免查询过程中发生扩容、桶迁移。
read 里的 map 不会新增 key,不会触发 map 扩容,只会修改 entry 内部的值,所以不需要锁。
5.11 read map和dirty map之间有什么关联?
它们之间是”只读缓存”和”最新全集”的关联。
read map是dirty map的一个不完全、且可能是过期的只读快照。dirty map则包含了所有的最新数据。
具体来说,read map中的所有数据,在dirty map里一定存在。一个key如果在read map里,那它的value要么就是最终值,要么就是一个特殊指针,指向dirty map里对应的条目。而dirty map里有,read map里却可能没有,因为dirty是最新、最全的。
当dirty map积累了足够多的新数据后,它会”晋升”为新的read map,旧的read map则被废弃。这个过程,就完成了”缓存”的更新。
调用 Delete 删除 key 时,会把 entry 的 p 原子置为 nil,完成软删除。这个时候 key 对应的 entry 还保留在 read 映射中。 等到后续需要新建 dirty(dirty 为 nil,准备从 read 拷贝数据到 dirty)的时候,会遍历 read 里所有 entry。遇到 p 等于 nil 的 entry,会通过 CAS 把它修改为 expunged,完成彻底擦除。expunged 状态的 entry 不会复制到 dirty 中。 简单说:Delete 负责把 entry 标记为软删除 nil;新建 dirty 这个动作,决定是否把 nil 升级为 expunged 彻底删除。
5.12 为什么要设计nil和expunged两种删除状态?
设计nil和expunged这两个状态,是为了解决在sync.Map的”读写分离”架构下,如何高效、无锁地处理”删除”操作。
因为read map本身是只读的,我们不能直接从中删除一个key。所以,当用户调用Delete时,如果这个key只存在于read map中,系统并不会真的删除它,而是将它的值标记为一个特殊的”已删除”状态,这个状态就是expunged。后续的读操作如果看到这个expunged标记,就知道这个key其实已经不存在了,直接返回nil, false。
而nil则是一个中间状态,主要用于dirty map和read map的同步过程,表示这个key正在被删除或迁移。
简单来说,这两个状态就像是在只读的read map上打的”逻辑删除”补丁。它避免了因为一次Delete操作就引发加锁和map的整体复制,把真正的物理删除延迟到了dirty map”晋升”为read map的那一刻,是典型的用状态标记来换取无锁性能的设计。
5.13 sync.Map 适用的场景?
sync.Map适合读多写少的场景,而不是和写多读少的场景。
因为我们期望将更多的流量在read map这一层进行拦截,从而避免加锁访问dirty map 对于更新,删除,读取,read map可以尽量通过一些原子操作,让整个操作变得无锁化,这样就可以避免进一步加锁访问dirty map。倘若写操作过多,sync.Map 基本等价于一把互斥锁 + map,其读写效率会大大下降