Files
cs-note/hzh/GolangStar/Go面试题库/Sync面试题.md
T

263 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go, golang, interview, sync-questions]
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 挂起 |
| 适用场景 | 计数器、标志位 | 多变量一致性、复杂逻辑 |
```go
// 原子操作:高性能计数器
var count int64
atomic.AddInt64(&count, 1)
// 锁:保护多个变量的一致性
mu.Lock()
balance -= amount
transactions = append(transactions, tx)
mu.Unlock()
```
> [!note] 📝 核心考点
> 原子操作是"微观"的,锁是"宏观"的。原子操作不挂起 goroutine,锁失败时会 park。
---
### Q4:Mutex 底层怎么实现的? 🟡中等
## 参考答案
Mutex 通过**原子操作 + 信号量**实现:
```go
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 的自旋设计非常克制:
1. 只在特定条件下触发(CPU 核数 > 1 且排队 goroutine 不多)
2. 持续时间极短(几十纳秒)
3. 目的是避免更昂贵的上下文切换开销
> [!warning] ⚠️ 高频陷阱
> 自旋是"机会主义的短线优化"——赌锁马上就释放了,与其花大代价挂起+唤醒 goroutine,不如原地稍等一下。
---
### Q7:锁释放后哪个 goroutine 优先获取? 🟡中等
## 参考答案
取决于当前模式:
| 模式 | 谁先拿到锁 |
|------|-----------|
| 正常模式 | 队头 goroutine **和新来的自旋 goroutine 竞争**,新来的可能插队 |
| 饥饿模式 | **队头 goroutine 直接获得**,新来者排尾部 |
---
### Q8:sync.Once 的作用和底层原理? 🟡中等
> [!question] ❓ 思考一下
> 如果两个 goroutine 同时调用 Once.Do(f),f 会被执行几次?如何实现这个保证?
## 参考答案
**作用**:确保一个函数在整个程序生命周期内只执行一次,常用于单例初始化。
```go
type Once struct {
done uint32 // 标识位:0=未执行,1=已执行
m Mutex
}
```
**执行流程:**
```mermaid
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 本质是**原子计数器 + 信号量的协作**:
```go
type WaitGroup struct {
noCopy noCopy // vet 静态检查防复制
state atomic.Uint64 // 64位:高32位=计数器, 低32位=等待者数量
sema uint32 // 信号量
}
```
**工作流程:**
1. `Add(n)`:计数器 += n
2. `Done()`:计数器 -= 1
3. `Wait()`:计数器 != 0 则通过信号量挂起当前 goroutine
4. 最后一个 `Done()` 将计数器归零 → 信号量一次性唤醒所有等待者
> [!warning] ⚠️ 高频陷阱
> WaitGroup **不能被复制**!一旦使用过就不能拷贝,否则会导致状态不一致。`noCopy` 字段让 `go vet` 能在编译期检测这个问题。
---
### Q10:sync.Map 的底层原理? 🟡中等
## 参考答案
核心思想:**空间换时间**,通过 read/dirty 双 map 实现读操作的无锁优化。
```go
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 更低。
## 关联笔记
- [[hzh/GolangStar/Go语言进阶/Sync]]
- [[hzh/GolangStar/Go语言原理/sync.map原理]]
- [[hzh/GolangStar/Go面试题库/代码面试题]]