263 lines
7.8 KiB
Markdown
263 lines
7.8 KiB
Markdown
---
|
||
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面试题库/代码面试题]]
|