7.8 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-06-07 14:30 |
Sync 面试题 🔒
概述
本文件涵盖 Go sync 包的 13 道高频面试题,涉及 Mutex、Once、WaitGroup、sync.Map 等同步原语的原理与应用。这些是并发编程中保证数据一致性的核心工具。
关联笔记
- hzh/GolangStar/Go语言进阶/Sync — Sync 包详细讲解
- hzh/GolangStar/Go语言原理/sync.map原理 — sync.Map 三表结构详解
- hzh/GolangStar/Go面试题库/代码面试题 — 同步场景实战代码
正文
Q1:除了 Mutex 还有哪些方式安全读写共享变量? 🟢简单
参考答案
| 方式 | 适用场景 | 性能 |
|---|---|---|
| Channel | Go 推崇的方式,通过通信传递所有权 | 中 |
| 原子操作(atomic) | 简单的整型/指针操作(计数器、状态位) | 最高 |
| Mutex/RWMutex | 保护复杂数据结构或多变量一致性 | 中 |
| sync.Map | 读多写少的并发 Map | 高(特定场景) |
[!tip] 💡 面试技巧 选择策略:"简单计数用 atomic,复杂逻辑用 Mutex,读多写少用 sync.Map,跨 goroutine 通信用 Channel。"
Q2-Q3:原子操作 vs 锁的区别? 🟡中等
[!question] ❓ 思考一下 为什么原子操作比锁快?它们各自能保护什么范围的内容?
参考答案
| 维度 | 原子操作 | 锁 |
|---|---|---|
| 实现层级 | CPU 硬件指令(如 LOCK ADD) | 语言运行时 + 操作系统 |
| 保护范围 | 单个变量的单次读写 | 一段代码块(临界区) |
| 性能 | 极高,无内核介入 | 较低,失败时 goroutine 挂起 |
| 适用场景 | 计数器、标志位 | 多变量一致性、复杂逻辑 |
// 原子操作:高性能计数器
var count int64
atomic.AddInt64(&count, 1)
// 锁:保护多个变量的一致性
mu.Lock()
balance -= amount
transactions = append(transactions, tx)
mu.Unlock()
[!note] 📝 核心考点 原子操作是"微观"的,锁是"宏观"的。原子操作不挂起 goroutine,锁失败时会 park。
Q4:Mutex 底层怎么实现的? 🟡中等
参考答案
Mutex 通过原子操作 + 信号量实现:
type Mutex struct {
state int32 // 锁的状态(锁定/唤醒/饥饿模式等,用二进制位标识)
sema uint32 // 信号量,用于 goroutine 的阻塞和唤醒
}
[!info] 🔗 延伸阅读
- hzh/GolangStar/Go语言原理/gmp调度原理 — GMP 与 Mutex 的关系
Q5:Mutex 有几种模式? 🟡中等
参考答案
两种模式,动态切换:
| 模式 | 特点 | 公平性 |
|---|---|---|
| 正常模式 | 新来的 goroutine 可以和队列头竞争,吞吐量高 | 不公平 |
| 饥饿模式 | 等待超过 1ms 后进入,锁直接交给队头,新来者排尾部 | 绝对公平 |
切换规则:
- 进入饥饿:等待时间 > 1ms
- 退出饥饿:等待队列空 或 等待时间 < 1ms
[!tip] 💡 面试技巧 "Go 在性能和公平之间做了精妙的平衡:正常情况下追求吞吐,发现有人被饿死时就切换到公平模式。"
Q6:自旋的 goroutine 会占用太多资源吗? 🟡中等
参考答案
不会。 Go 的自旋设计非常克制:
- 只在特定条件下触发(CPU 核数 > 1 且排队 goroutine 不多)
- 持续时间极短(几十纳秒)
- 目的是避免更昂贵的上下文切换开销
[!warning] ⚠️ 高频陷阱 自旋是"机会主义的短线优化"——赌锁马上就释放了,与其花大代价挂起+唤醒 goroutine,不如原地稍等一下。
Q7:锁释放后哪个 goroutine 优先获取? 🟡中等
参考答案
取决于当前模式:
| 模式 | 谁先拿到锁 |
|---|---|
| 正常模式 | 队头 goroutine 和新来的自旋 goroutine 竞争,新来的可能插队 |
| 饥饿模式 | 队头 goroutine 直接获得,新来者排尾部 |
Q8:sync.Once 的作用和底层原理? 🟡中等
[!question] ❓ 思考一下 如果两个 goroutine 同时调用 Once.Do(f),f 会被执行几次?如何实现这个保证?
参考答案
作用:确保一个函数在整个程序生命周期内只执行一次,常用于单例初始化。
type Once struct {
done uint32 // 标识位:0=未执行,1=已执行
m Mutex
}
执行流程:
graph TD
A[Once.Do f] --> B{atomic.Load done == 1?}
B -->|是| C[直接返回, 无锁]
B -->|否| D[加锁 doSlow]
D --> E{再次检查 done == 1?}
E -->|是| F[解锁, 返回]
E -->|否| G[执行 f]
G --> H[atomic.Store done = 1]
H --> I[解锁]
[!note] 📝 核心考点 **双重检查(Double-Checked Locking)**是关键:第一次用原子操作快速判断(无锁路径),第二次加锁后再判断防止重复执行。
Q9:WaitGroup 怎样实现协程等待? 🟡中等
参考答案
WaitGroup 本质是原子计数器 + 信号量的协作:
type WaitGroup struct {
noCopy noCopy // vet 静态检查防复制
state atomic.Uint64 // 64位:高32位=计数器, 低32位=等待者数量
sema uint32 // 信号量
}
工作流程:
Add(n):计数器 += nDone():计数器 -= 1Wait():计数器 != 0 则通过信号量挂起当前 goroutine- 最后一个
Done()将计数器归零 → 信号量一次性唤醒所有等待者
[!warning] ⚠️ 高频陷阱 WaitGroup 不能被复制!一旦使用过就不能拷贝,否则会导致状态不一致。
noCopy字段让go vet能在编译期检测这个问题。
Q10:sync.Map 的底层原理? 🟡中等
参考答案
核心思想:空间换时间,通过 read/dirty 双 map 实现读操作的无锁优化。
type Map struct {
mu Mutex // 保护 dirty
read atomic.Value // 实际存储 readOnly
dirty map[interface{}]*entry // 需要加锁访问
misses int // read 未命中次数
}
type readOnly struct {
m map[interface{}]*entry
amended bool // true 表示 dirty 中有 read 没有的数据
}
type entry struct {
p unsafe.Pointer // 指向真正的 value
}
[!info] 🔗 延伸阅读
- hzh/GolangStar/Go语言原理/sync.map原理 — 完整的三表结构图解
Q11:read map 和 dirty map 的关联? 🟢简单
参考答案
| Map | 角色 | 特性 |
|---|---|---|
| read | 只读缓存 | 无锁读取,可能是过期快照 |
| dirty | 最新全集 | 需加锁,包含所有最新数据 |
当 misses == len(dirty) 时,dirty 晋升为新的 read。
Q12:为什么设计 nil 和 expunged 两种删除状态? 🟡中等
参考答案
为了解决 "如何在只读的 read map 上高效删除" 的问题:
- expunged:逻辑删除标记。key 只存在于 read 中时,不能物理删除,标记为 expunged,读操作看到它就直接返回
nil, false - nil:中间状态,用于 dirty 和 read 同步过程中表示 key 正在被删除或迁移
[!tip] 💡 面试技巧 "这是典型的用状态标记换取无锁性能的设计。一次 Delete 不需要加锁复制整个 read map,而是打一个标记,延迟到 dirty 晋升时再做物理删除。"
Q13:sync.Map 适用的场景? 🟢简单
参考答案
读多写少的场景。期望流量在 read map 层被拦截,避免频繁加锁访问 dirty map。
[!warning] ⚠️ 高频陷阱 如果写操作过多,sync.Map 基本等价于一把互斥锁 + map,效率反而比普通 map + Mutex 更低。