Skip to content

Go 后端面试复盘:语言、并发与服务实践 · 12. sync、原子操作与 Go 内存模型至13. 内存分配与逃逸分析 ​

返回分篇目录

前置:Go 系统学习,并完成一个HTTP 工程练习。本页用于面试前查漏补缺,不按章节替代语言课程。

12. sync、原子操作与 Go 内存模型 ​

12.1 什么是数据竞争? ​

两个 goroutine 并发访问同一内存位置,至少一个是写操作,并且没有正确同步,就存在数据竞争。

数据竞争会让程序行为不可靠。使用:

bash
go test -race ./...
go run -race .

race detector 只能发现测试运行过程中实际发生的竞争路径,不能证明程序绝对无竞争。

12.2 sync.Mutex 有哪些注意事项? ​

  • 零值可用。
  • 不能复制已经使用过的 Mutex。
  • 临界区尽量短。
  • 不要持锁执行未知耗时的网络、磁盘或用户回调。
  • 加锁和解锁应由同一清晰作用域管理。
  • Go 的 Mutex 不可重入,同一 goroutine 再次 Lock 会死锁。
go
mu.Lock()
defer mu.Unlock()

在极热短路径中,是否使用 defer 要以 benchmark 为准;现代编译器已优化很多常见 defer。

12.3 RWMutex 一定比 Mutex 快吗? ​

不一定。

RWMutex 适合读操作远多于写、读临界区有一定成本且竞争明显的场景。若临界区很短、写操作不少或并发量不高,维护读锁状态的成本可能让它更慢。

必须按真实负载 benchmark。

12.4 sync.WaitGroup 怎么用? ​

go
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 有什么用途? ​

保证函数最多执行一次,常用于延迟初始化:

go
var once sync.Once
once.Do(initResource)

如果函数 panic,该次调用仍被视为已经执行,后续不会重试。需要可重试初始化时要单独设计状态机。

12.6 sync.Cond 适合什么场景? ​

多个 goroutine 等待某个共享状态变化,且需要 Broadcast 唤醒多个等待者时可使用 Cond。

go
cond.L.Lock()
for !condition() {
	cond.Wait()
}
cond.L.Unlock()

必须使用 for 循环重新检查条件,因为被唤醒不代表条件一定仍满足。很多简单场景用 channel 更清晰。

12.7 sync.Pool 是什么? ​

Pool 是临时对象复用池,用于减少高频短生命周期对象的分配和 GC 压力。

特点:

  • 池中对象可能随时被 GC 清理。
  • 不能用于连接池或必须可靠保存的对象。
  • 取出的对象要重置。
  • 放入的对象不应继续被其他地方使用。
  • 复用超大对象可能导致内存长期占用,应设置容量上限。

12.8 原子操作适合什么场景? ​

sync/atomic 适合简单计数器、标志位、指针/配置快照发布等。

go
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.Once
  • atomic.Pointer
  • atomic.Value
  • 初始化阶段直接构造

不要自己用普通 bool 模拟同步。


13. 内存分配与逃逸分析 ​

13.1 Go 中栈和堆有什么区别? ​

  • 栈:与 goroutine 生命周期和函数调用相关,分配释放快,通常无需 GC 单独追踪对象生命周期。
  • 堆:对象可能超出当前调用帧生命周期,由 GC 管理。

对象最终在栈还是堆主要由编译器逃逸分析决定,不是由 new、字面量或开发者直接决定。

13.2 什么是逃逸分析? ​

编译器分析对象是否可能在当前栈帧结束后仍被访问。无法安全放在栈上时,对象会逃逸到堆。

查看方法:

bash
go build -gcflags="-m=2" ./...

常见逃逸原因:

  • 返回局部变量指针。
  • 局部变量被存入生命周期更长的对象。
  • 被闭包捕获并超出函数生命周期。
  • 某些接口装箱。
  • 对象大小过大,不适合栈。
  • 编译器无法证明安全。

13.3 返回局部变量指针安全吗? ​

安全:

go
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 如何预分配? ​

已知最终长度:

go
out := make([]Item, len(src))
for i, v := range src {
	out[i] = transform(v)
}

只知道大概容量:

go
out := make([]Item, 0, expected)
for _, item := range src {
	out = append(out, transform(item))
}

map:

go
m := make(map[string]Item, expected)

预分配过大也会浪费内存,应基于实际分布。

13.8 为什么进程 RSS 不一定在 GC 后立即下降? ​

GC 回收的是 Go 堆中不可达对象,使内存可被运行时再次使用;运行时是否立即把物理页归还操作系统取决于 scavenger、页状态、碎片和操作系统行为。

因此:

  • heap in-use 下降不代表 RSS 同步下降。
  • RSS 高不一定是泄漏,也可能是运行时保留以便复用。
  • 需要结合 heap profile、runtime metrics、RSS、对象数量和时间趋势分析。

继续阅读