Go 后端面试复盘:语言、并发与服务实践 · 12. sync、原子操作与 Go 内存模型至13. 内存分配与逃逸分析
前置:Go 系统学习,并完成一个HTTP 工程练习。本页用于面试前查漏补缺,不按章节替代语言课程。
12. sync、原子操作与 Go 内存模型
12.1 什么是数据竞争?
两个 goroutine 并发访问同一内存位置,至少一个是写操作,并且没有正确同步,就存在数据竞争。
数据竞争会让程序行为不可靠。使用:
go test -race ./...
go run -race .race detector 只能发现测试运行过程中实际发生的竞争路径,不能证明程序绝对无竞争。
12.2 sync.Mutex 有哪些注意事项?
- 零值可用。
- 不能复制已经使用过的 Mutex。
- 临界区尽量短。
- 不要持锁执行未知耗时的网络、磁盘或用户回调。
- 加锁和解锁应由同一清晰作用域管理。
- Go 的 Mutex 不可重入,同一 goroutine 再次 Lock 会死锁。
mu.Lock()
defer mu.Unlock()在极热短路径中,是否使用 defer 要以 benchmark 为准;现代编译器已优化很多常见 defer。
12.3 RWMutex 一定比 Mutex 快吗?
不一定。
RWMutex 适合读操作远多于写、读临界区有一定成本且竞争明显的场景。若临界区很短、写操作不少或并发量不高,维护读锁状态的成本可能让它更慢。
必须按真实负载 benchmark。
12.4 sync.WaitGroup 怎么用?
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Add(1)
go func() {
defer wg.Done()
process(job)
}()
}
wg.Wait()关键点:
- 在启动 goroutine 前
Add,避免 goroutine 先 Done。 - 计数不能减为负数。
- WaitGroup 不用于传递错误;可结合错误组或错误 channel。
- 不要在未建立明确阶段边界时一边 Wait 一边 Add;复用前必须等上一轮 Wait 完成。
- 已使用的 WaitGroup 不应被复制。
12.5 sync.Once 有什么用途?
保证函数最多执行一次,常用于延迟初始化:
var once sync.Once
once.Do(initResource)如果函数 panic,该次调用仍被视为已经执行,后续不会重试。需要可重试初始化时要单独设计状态机。
12.6 sync.Cond 适合什么场景?
多个 goroutine 等待某个共享状态变化,且需要 Broadcast 唤醒多个等待者时可使用 Cond。
cond.L.Lock()
for !condition() {
cond.Wait()
}
cond.L.Unlock()必须使用 for 循环重新检查条件,因为被唤醒不代表条件一定仍满足。很多简单场景用 channel 更清晰。
12.7 sync.Pool 是什么?
Pool 是临时对象复用池,用于减少高频短生命周期对象的分配和 GC 压力。
特点:
- 池中对象可能随时被 GC 清理。
- 不能用于连接池或必须可靠保存的对象。
- 取出的对象要重置。
- 放入的对象不应继续被其他地方使用。
- 复用超大对象可能导致内存长期占用,应设置容量上限。
12.8 原子操作适合什么场景?
sync/atomic 适合简单计数器、标志位、指针/配置快照发布等。
var count atomic.Int64
count.Add(1)
v := count.Load()多个字段共同构成不变量时,仅对每个字段分别做原子操作通常不够,应使用锁或不可变快照整体发布。
12.9 atomic.Value 有什么注意事项?
适合发布读多写少的不可变配置快照。
- 第一次 Store 决定具体类型。
- 后续 Store 必须是同一具体类型。
- 不能 Store 无类型 nil。
- Load 之前必须先完成 Store,否则可能得到 nil,需要程序自行保证初始化。
写入后不要再修改快照内部共享对象,否则仍可能发生数据竞争。
12.10 什么是 happens-before?
它描述一个操作的效果保证对另一个操作可见的顺序关系。
常见同步关系:
- mutex 的 Unlock 先行于之后成功获得同一锁的 Lock。
- channel 发送与对应接收之间建立同步关系。
- close channel 先行于接收方观察到关闭。
- goroutine 创建前的求值对新 goroutine 可见。
- 原子操作提供规定的同步保证。
仅仅“实际运行时看起来先执行”不代表内存模型上有可见性保证。
12.11 双重检查锁定怎么写更安全?
复杂双重检查容易因内存可见性和发布半初始化对象出错。优先使用:
sync.Onceatomic.Pointeratomic.Value- 初始化阶段直接构造
不要自己用普通 bool 模拟同步。
13. 内存分配与逃逸分析
13.1 Go 中栈和堆有什么区别?
- 栈:与 goroutine 生命周期和函数调用相关,分配释放快,通常无需 GC 单独追踪对象生命周期。
- 堆:对象可能超出当前调用帧生命周期,由 GC 管理。
对象最终在栈还是堆主要由编译器逃逸分析决定,不是由 new、字面量或开发者直接决定。
13.2 什么是逃逸分析?
编译器分析对象是否可能在当前栈帧结束后仍被访问。无法安全放在栈上时,对象会逃逸到堆。
查看方法:
go build -gcflags="-m=2" ./...常见逃逸原因:
- 返回局部变量指针。
- 局部变量被存入生命周期更长的对象。
- 被闭包捕获并超出函数生命周期。
- 某些接口装箱。
- 对象大小过大,不适合栈。
- 编译器无法证明安全。
13.3 返回局部变量指针安全吗?
安全:
func newUser() *User {
u := User{Name: "CC"}
return &u
}编译器会让 u 逃逸到堆,或采用其他能保证生命周期正确的方式。Go 不会返回悬空栈指针。
13.4 new(T) 一定在堆上吗?
不一定。若指针未逃逸,编译器可以把对象放在栈上。反过来,不使用 new 的局部变量也可能逃逸到堆。
13.5 Go 内存分配器大致如何工作?
面试可用以下层次回答:
- 小对象按 size class 分类。
- 每个 P 有本地缓存,常见小对象分配尽量减少全局锁。
- 本地缓存不足时从中心结构获取 span。
- 更上层从堆管理结构获取页,并按需向操作系统申请或归还内存。
- 大对象走单独路径,按页分配。
概念层级通常描述为:
- mcache:P 的本地缓存。
- mcentral:某 size class 的共享 span 管理。
- mheap:全局堆页管理。
- span:一组连续页,被切分为同尺寸对象槽位。
这些是运行时实现细节,版本可能演进。
13.6 为什么“堆分配次数”比单次分配大小也重要?
大量小对象会增加:
- 分配器工作。
- GC 扫描对象数量。
- 指针追踪成本。
- CPU cache 压力。
优化时不仅看总字节数,还看 allocs/op。常用方法:
- 预分配 slice/map。
- 复用 buffer。
- 避免不必要的接口装箱和临时字符串。
- 批处理。
- 改善数据布局。
13.7 如何预分配?
已知最终长度:
out := make([]Item, len(src))
for i, v := range src {
out[i] = transform(v)
}只知道大概容量:
out := make([]Item, 0, expected)
for _, item := range src {
out = append(out, transform(item))
}map:
m := make(map[string]Item, expected)预分配过大也会浪费内存,应基于实际分布。
13.8 为什么进程 RSS 不一定在 GC 后立即下降?
GC 回收的是 Go 堆中不可达对象,使内存可被运行时再次使用;运行时是否立即把物理页归还操作系统取决于 scavenger、页状态、碎片和操作系统行为。
因此:
- heap in-use 下降不代表 RSS 同步下降。
- RSS 高不一定是泄漏,也可能是运行时保留以便复用。
- 需要结合 heap profile、runtime metrics、RSS、对象数量和时间趋势分析。
继续阅读
- 排错
- 排错
- 排错