竞态&内存逃逸

1. 竞态 (Race Condition)

1.1 什么是竞态?

竞态(Data Race)是指在程序中,同一块内存同时被多个 Goroutine 访问,且至少有一个是写入操作

当发生竞态时,程序的行为是不可预测的。轻则数据错乱,重则导致程序崩溃(Panic)。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
var count int

func add() {
count++ // 这里的 ++ 不是原子操作,分为:读取、计算、写入
}

func main() {
// 启动两个协程同时修改 count
go add()
go add()
// ...等待协程结束
}

在这个例子中,如果两个协程同时读取到 count 为 0,然后各自加 1 写入,最终结果可能是 1 而不是 2。

1.2 如何检测竞态?

Go 官方提供了一个非常强大的工具。在编译或运行时,添加 -race 标志即可启用竞态检测器。

1
2
3
4
5
6
7
8
# 运行测试
go test -race ./...

# 编译运行
go run -race main.go

# 构建生产包(不建议在生产环境开启,因为会影响性能)
go build -race main.go

当检测到竞态时,控制台会输出详细的 Warning,指出是哪两个 Goroutine 在哪一行代码发生了冲突。

1.3 解决方案

解决竞态的核心思想是:同一时间,只能有一个协程操作共享资源。

方案 A:互斥锁 (sync.Mutex)

最通用的方式。在操作共享资源前加锁,操作完解锁。

1
2
3
4
5
6
7
8
var mu sync.Mutex
var count int

func add() {
mu.Lock() //以此为界,进入临界区
count++
mu.Unlock() // 退出临界区
}

方案 B:读写锁 (sync.RWMutex)

如果读多写少,使用 Mutex 会导致读操作也被串行化,效率低下。RWMutex 允许多个协程同时读,但写操作是排他的。

  • RLock() / RUnlock():读锁。
  • Lock() / Unlock():写锁。

方案 C:原子操作 (sync/atomic)

对于简单的计数器、标志位,使用原子操作性能更好,不需要昂贵的锁开销。

1
atomic.AddInt64(&count, 1)

方案 D:Channel 通信

遵循 Go 的哲学:“不要通过共享内存来通信,而要通过通信来共享内存。” 通过 Channel 将数据的所有权传递给处理协程。


2. 内存逃逸分析 (Escape Analysis)

2.1 什么是逃逸分析?

在 C/C++ 中,开发者需要手动决定变量分配在栈(Stack)还是堆(Heap)。但在 Go 中,这个决定由编译器自动完成。

逃逸分析就是编译器在编译阶段对代码进行分析,决定变量应该分配在栈上还是堆上的过程。

  • 栈 (Stack):分配/释放速度极快(指令级),函数返回即回收,无需 GC 介入。
  • 堆 (Heap):分配速度较慢,需要 GC 进行垃圾回收,容易产生碎片。

核心原则:如果一个变量在函数返回后仍被引用(即逃出了函数的作用域),它就必须分配在堆上;否则,优先分配在栈上。

2.2 为什么要懂逃逸分析?

  • 减轻 GC 压力:栈上的变量不需要 GC 回收。减少堆内存分配,就是减少 GC 的工作量,提升程序性能。

  • 我们可以通过编译指令查看逃逸情况:

1
2
3
go build -gcflags "-m -l" main.go
# -m: 输出优化/逃逸策略
# -l: 禁用内联(方便观察)

2.3 常见的逃逸场景 (重点)

场景 1:指针逃逸

这是最典型的场景。函数返回了局部变量的指针,导致该变量在函数结束后依然被外部引用。

1
2
3
4
func NewStudent() *Student {
s := Student{Name: "Tom"}
return &s // 局部变量 s 逃逸到堆上
}

解析:Go 编译器发现 s 的地址被返回了,为了保证外部能访问到,必须将其分配到堆上。

场景 2:动态类型逃逸 (interface{})

当变量被赋值给 interface{} 类型时,编译器往往很难确定其具体类型和大小,或者调用方法时涉及到反射,通常会逃逸。最常见的例子就是 fmt.Println

1
2
3
4
func main() {
num := 10
fmt.Println(num) // num 逃逸
}

解析:fmt.Println 的参数类型是 ...interface{},内部使用了反射,导致 num 逃逸。

场景 3:栈空间不足

当创建一个对象过大,超过了 Goroutine 栈的预留空间(通常较小,虽可动态扩容但有上限),编译器会直接将其分配到堆上。

1
2
3
4
func main() {
s := make([]int, 10000, 10000) // 可能不逃逸
big := make([]int, 10000000) // 空间太大,直接逃逸到堆
}

场景 4:闭包引用对象

闭包(Closure)中引用的外部变量,如果闭包本身的生命周期超过了该变量的作用域,那么该变量会逃逸。

1
2
3
4
5
6
7
func Adder() func() int {
sum := 0
return func() int {
sum++ // sum 必须在堆上,因为 Adder 返回后这个闭包还在用它
return sum
}
}

场景 5:切片长度动态变化

如果切片的长度或容量在编译期无法确定(由变量控制),或者底层数组过大,也容易发生逃逸。

1
2
length := 10
s := make([]int, length) // 动态分配,容易逃逸

3. 总结

在面试中,遇到这两个问题可以这样总结:

  1. 竞态

    • 本质是并发读写同一内存
    • 开发时用 -race 检查。
    • 生产代码用 sync.MutexRWMutexChannel 解决,遵循“通信共享内存”原则。
  2. 逃逸分析

    • 本质是编译器决定内存分配位置(堆 or 栈)。
    • 快且无需 GC,慢且增加 GC 负担。
    • 口诀:指针返回必逃逸,接口传参易逃逸,闭包引用不仅存,大栈装不下也得逃。