Sync 面试题

1. 除了 mutex 以外还有那些方式安全读写共享变量?

还有信号量、通道(Channel)、原子操作(atomic)等方式可以安全读写共享变量。

2. Go 语言是如何实现原子操作的?

Go 语言依赖底层 CPU 硬件提供的原子指令.

具体来说,Go 的 sync/atomic 包中的函数,在编译时会被编译器识别,并直接转换成对应目标硬件平台的单条原子指令。

3. 聊聊原子操作和锁的区别?

原子操作是 CPU 硬件层面的“微观”机制,它保证对单个数据的单次读改写操作是绝对不可分割的,性能极高

锁(如 mutex)是软件层面的“宏观”机制,它通过互斥访问来保护一段代码或数据结构,适用于更复杂的场景,但性能较原子操作低

4. Go 语言互斥锁 mutex 底层是怎么实现的?

mutex 底层是通过原子操作加信号量来实现的

互斥锁对应的底层结构是 sync.Mutex 结构体

1
2
3
4
type Mutex struct {
state int32
sema uint32
}

state 代表锁的状态,0 表示未锁定,1 表示锁定,2 表示有等待的 goroutine。sema 是一个信号量,用于阻塞和唤醒等待的 goroutine。

5. Mutex 有几种模式?

Mutex 有两种模式:正常模式和饥饿模式。

  • 正常模式:默认模式。

    • 特点:
      • 新来的 goroutine 可以参与抢锁
      • 等待队列中的 goroutine 不一定先拿到锁
    • 优点:
      • 吞吐量高
    • 缺点:
      • 可能饿死
  • 饥饿模式:如果某 goroutine 等待超过 1ms,mutex 会进入饥饿模式。

    • 此时:锁直接交给等待队列头部 goroutine
    • 新 goroutine 不会自旋,必须排到队尾

6. 在 Mutex 上自旋的 goroutine 会占用太多资源吗?

并不会,因为:

  1. 有严格的次数和时间限制,通常指持续几十纳秒
  2. 自旋仅在特定条件下才会发生

7. Mutex 已经被一个 Goroutine 获取了,其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex?

正常模式下,锁分配是“不公平”的,当锁释放时,等待队列中第一个 goroutine 会被唤醒,但不一定能拿到锁,可能被后面新来的 goroutine 抢先获取。

饥饿模式下,锁分配是“公平”的,等待队列中第一个 goroutine 会被唤醒并直接获取锁。

8. sync.Once 的作用是什么,讲讲它的底层实现原理?

sync.once 的作用是确保一个函数在程序生命周期内,无论在多少个 goroutine 中被调用,都只会被执行一次。
它常用于单例对象的初始化或一些只需要执行一次的全局配置加载

sync.0nce 保证代码段只执行 1 次的原理主要是其内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了

Once其实是一个结构体

1
2
3
type Once struct {
m Mutex
done uint32

9. WaitGroup 是怎样实现协程等待?

WaitGroup 实现等待,本质上是一个原子计数器和一个信号量的协作。

调用 Add 会增加计数值,Done 会减计数值。而 Wait 方法会检查这个计数器,如果不为零,就利用信号量将当前 goroutine 高效地挂起。直到最后一个 Done 调用将计数器清零,它就会通过这个信号量,一次性唤醒所有在 Wait 处等待的goroutine,从而实现等待目的。

waitGroup 结构体定义如下:

1
2
3
4
5
6
7
8
9
type WaitGroup struct {
noCopy noCopy // 用于 vet 工具检查是否被复制

// 64 位的值:高 32 位是计数器,低 32 位是等待的 goroutine 数量
state atomic.Uint64

// 用于等待者休眠的信号量
sema uint32
}

10. 讲讲 sync.Map 的底层原理?

sync.Map 的底层核心是“空间换时间”,通过两个 Map(readdirty)的冗余结构,实现“读写分离”最终达到针对特定场景的“读”操作无锁优化。

它的 read 是一个只读的 map,提供无锁的并发读取,速度极快。
写操作则会先操作一个加了锁的、可读写的dirty map。当 dirty map 的数据积累到一定程度,或者 read map中没有某个 key 时,sync.Map 会将 dirty map 里的数据“晋升”并覆盖掉旧的 read map,完成一次数据同步。

sync.Map 的结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Map struct {
// read 是一个原子值,存储一个只读的 map
read atomic.Value // readOnly

// dirty 是一个加锁保护的可读写 map
dirty map[any]any

// 计数器,记录在从 read 中读取数据的时候,没有命中的次数
// 当 misses 值等于 dirty 长度时,dirty 提升为 read
misses int

// 保护 dirty map 的互斥锁
mu Mutex
}

11. read map 和 dirty map 之间有什么关联?

read map 是 dirty map 的一个不完全、且可能是过期的只读快照

dirty map 则包含了所有的最新数据

12. 为什么要设计 nil 和 expunged 两种删除状态?

nil 和 expunged 本质上是在区分:

状态 含义
nil 逻辑删除,可恢复
expunged 物理删除,不可直接恢复

这是 sync.Map 为了实现:

  • 无锁读
  • 延迟删除
  • 降低锁竞争
  • 减少 map 重建
  • 提高高并发性能

而设计的“双阶段删除机制”。

13. sync.Map 适用的场景

适合读多写少的场景


参考资料: