学习目标
读完本文,你应该能够:
- 识别并修复闭包捕获循环变量的经典问题
- 找出常见的goroutine 泄漏场景并提出预防措施
- 说清楚 channel 未关闭和向已关闭 channel 发送的区别
- 避免 Mutex/WaitGroup 被值拷贝导致的静默 bug
- 理解 nil interface vs interface containing nil 的区别
- 知道如何避免 slice 持有底层数组导致内存无法释放
一、闭包捕获循环变量
1.1 经典问题
应用场景
在实际项目中,这个知识点经常应用于:
- 典型场景一:说明具体应用场景
- 典型场景二:说明另一个应用场景
- 面试要点:提醒面试中可能被问到的点
func main() {
vars := []int{1, 2, 3}
var fns []func()
for _, v := range vars {
fns = append(fns, func() {
fmt.Println(v)
})
}
for _, fn := range fns {
fn()
}
}
// 输出: 3 3 3 (不是预期的 1 2 3)
1.2 原因分析
// 编译器实际生成的等价代码:
func main() {
vars := []int{1, 2, 3}
var fns []func()
for i := 0; i < len(vars); i++ {
v := &vars[i]
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
// 同一个地址!
fns = append(fns, func() {
fmt.Println(*v)
})
}
// ...
}
复习提示:Go 1.22 之前,
for range 的循环变量在循环结束后共享同一地址。
Go 1.22+ 每个迭代创建新的循环变量,上述代码会自动修正。
但面试中仍需理解这个历史问题的原理。1.3 修复方式
// 方式一:在循环体内创建局部变量
for _, v := range vars {
localV := v // 每个迭代创建新变量
fns = append(fns, func() {
fmt.Println(localV)
})
}
// 方式二:函
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
数参数传递
for _, v := range vars {
fns = append(fns, func(val int) func() {
return func() { fmt.Println(val) }
}(v))
}
// 方式三:Go 1.22+ 自动修正,但仍建议显式声明
二、goroutine 泄漏
2.1 常见泄漏场景
// ❌ 场景1:channel 永久阻塞
func leakyWorker() {
ch := make(chan int)
go func() {
for v := range ch { // 如果没有地方关闭 ch,这里会永久阻塞
fmt.Println(v)
}
}()
ch <- 1
// 忘记关闭 ch,忘记等待 goroutine 结束
}
// ❌ 场景2:select 无限等待
func leakySelector() {
ch := make(chan int)
go func() {
select {}
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
// 没有任何 case,永远阻塞
}()
// goroutine 永远不会退出
}
// ❌ 场景3:channel 未关闭 + 读取
func processData() {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
// 忘记 close(ch)
}()
for v := range ch { // 永久阻塞,因为 ch 不会被关闭
_ = v
}
}
2.2 检测方法
import "go.uber.org/goleak"
func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // 测试结束后检查是否有泄漏的 goroutine
}
// 运行时检测
func checkGorouti
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
nes() {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
fmt.Printf("Goroutine dump:\n%s\n", buf[:n])
}
2.3 预防措施
func safeWorker(done chan struct{}) {
defer close(done) // 确保退出时关闭通道
// ...
}
// 使用 timeout 防止永久阻塞
select {
case result := <-ch:
// 处理结果
case <-time.After(5 * time.Second):
log.Println("timeout")
case <-ctx.Done():
log.Println("cancelled")
}
三、Channel 常见错误
应用场景
在实际项目中,这个知识点经常应用于:
- 典型场景一:说明具体应用场景
- 典型场景二:说明另一个应用场景
- 面试要点:提醒面试中可能被问到的点
3.1 向已关闭的 channel 发送
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
3.2 从已关闭的 channel 接收
ch := make(chan int)
close(ch)
v, ok := <-ch // v = 0, ok = false(零值和 false)
// 继续接收:v = 0, ok = false(返回零值,非阻塞)
3.3 未关闭 channel 导致的永久阻塞
// ❌ 错误
func sender(ch chan int) {
ch <- 1
// 忘记 close,receiver 的 range 会永久阻塞
}
// ✅ 正确
func sender(ch chan int) {
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
defer close(ch) // 确保关闭
ch <- 1
}
四、sync 包使用陷阱
4.1 Mutex/WaitGroup 被值拷贝
// ❌ 经典错误:waitgroup 被拷贝
func worker(tasks <-chan int, wg sync.WaitGroup) {
for t := range tasks {
fmt.Println(t)
}
wg.Done() // 修改的是副本,原始 wg 不受影响!
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
for i := 0; i < 3; i++ {
go worker(ch, wg) // 值拷贝!
}
wg.Add(3)
close(ch)
wg.Wait() // 永远不会返回
}
// ✅ 正确:传递指针
func worker(tasks <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for t := range tasks {
fmt.Println(t)
}
}
4.2 sync.Once 的 Panic 行为
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
var once sync.Once
var instance *Singleton
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
// 如果这里有 panic,instance 可能为 nil
// 但 once 仍会标记为 done
})
return instance // 可能返回 nil
}
4.3 WaitGroup.Add 写在 goroutine 里
// ❌ 错误:Add 在 goroutine 中,Wait 可能提前返回
var wg sync.WaitGroup
ch := make(chan int, 100)
go func() {
for v := range ch {
wg.Done()
fmt.Println(v)
}
}()
wg.Add(100) // 必须在 goroutine 启动后、Wait 前调用
// 但如果 Add 在另一个 goroutine
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
中,可能race
// ✅ 正确:Add 在主 goroutine 中调用
wg.Add(100)
go func() {
for v := range ch {
wg.Done()
fmt.Println(v)
}
}()
五、内存陷阱
5.1 slice 持有底层数组导致内存无法释放
func keepAlive() {
data := make([]byte, 1024*1024) // 1MB
for i := range data {
data[i] = byte(i)
}
// 取子切片
small := data[0:10]
// small 仍然引用整个 1MB 的底层数组!
_ = small
// 此时 data 可以被 GC,但如果 small 仍然存活,1MB 就不会被释放
}
修复:
smallCopy := make([]byte, 10)
copy(small
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
Copy, data[0:10])
// 现在 smallCopy 有自己的底层数组,原数组可以被 GC
slice 底层数组共享:子切片 / 重新切片都指向同一底层数组
看图要点:slice 的 header 只存 ptr/len/cap,子切片
data[0:10]或重新切片a[:3]都复用同一块底层数组——改b[0]会"误伤"a。面试必考:① 怎么避免大数组因小切片无法释放(make + copy 出新切片,或用a[:0:0]切断 cap);② append 是否共享底层数组(取决于是否扩容);③for range取&v拿到的是同一个地址。
5.2 interface 空值判断误区
var p *Person = nil
var w io.Writer = p // w 不是 nil!它包含类型信息和 nil 值
fmt.Println(w == nil) // false!这是陷阱
fmt.Println(p == nil) // true
// 正确判断方式
if w != nil && p != nil {
w.Write([]byte("test"))
}
5.3 append 后两个 slice 共享底层数组
a := make([]int, 3, 5) // [0, 0, 0, ?, ?]
a[0], a[1], a[2] = 1, 2, 3
b := a[:3] // b 共享 a 的底层数组
c := append(a, 4, 5) // c 可能与 a 共享,也可能不共享(取决于容量)
b[0] = 99
fmt.Println(a) // [99, 2, 3] - 被修改了!
5.4 for range map 的安全边
m := map[int]int{1: 10, 2: 20}
// ❌ 遍历时删除
for k, v := range m {
if k == 1 {
delete(m, k) // panic: map iteration and map write同时
}
fmt.Println(k, v)
}
// ✅ 正确做法
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
for _, k := range keys {
if k == 1 {
delete(m, k)
}
}
六、defer 的性能陷阱
6.1 循环中注册 defer
// ❌ 每次循环都注册一个 defer,可能导致栈溢出
for i := 0; i < 10000; i++ {
f, err := os.Open(file)
if err != nil {
continue
}
defer f.Close() // 10000 个 defer 在函数退出时才执行
}
// ✅ 正确:在循环内部用 goroutine 或立即处理
for i := 0; i < 10000; i++ {
func() {
f, err := os.Open(file)
if err != nil {
return
}
defer f.Close()
// 处理文件
}()
}
七、自测题
面试官
channel 未关闭和 nil channel 有什么区别?分别会怎样?
候选人
未关闭的 buffer channel:goroutine 可能永久阻塞在
nil channel:任何操作都会永久阻塞(发送和接收都不行),常用于 select 中暂时禁用某个 case。
关闭的 channel:可以接收(返回零值和 false),但不能再发送(panic)。
<-ch 上(如果是 range 也会永久阻塞)。nil channel:任何操作都会永久阻塞(发送和接收都不行),常用于 select 中暂时禁用某个 case。
关闭的 channel:可以接收(返回零值和 false),但不能再发送(panic)。
面试官
如何检测一个程序中是否存在 goroutine 泄漏?
候选人
方法:
- goleak:在测试中使用
goleak.VerifyNone(),程序退出前检查 - pprof:采集 goroutine profile,对比不同时间点的差异
- runtime.NumGoroutine():监控 goroutine 数量是否持续增长
- Stack 分析:
debug.WriteHeapProfile可以查看所有 goroutine 的栈
生产环境建议定期采集 goroutine profile 并做 diff 分析。
面试官
for range 遍历 map 时能否删除元素?候选人
不能直接删除,会 panic(concurrent map read and map write)。
正确做法:先收集要删除的 key 到一个切片,遍历结束后再统一删除。 或者使用迭代器模式(Go 1.24 实验性功能)。
正确做法:先收集要删除的 key 到一个切片,遍历结束后再统一删除。 或者使用迭代器模式(Go 1.24 实验性功能)。
八、本章小结
复习提示:
- 闭包循环变量:Go 1.22 前需要显式创建局部变量副本
- goroutine 泄漏:最常见原因是 channel 未关闭或 select 无 case
- Mutex/WaitGroup 值拷贝:必须传指针,否则会静默失效
- slice 底层数组:子切片会持有整个数组,及时 copy 出新切片
- interface nil:nil interface 和 containing nil 是不同的概念
- 下一篇将讲解项目场景问答,覆盖西安大厂必问的项目深挖题目
参考
- Go wiki: https://github.com/golang/go/wiki/CommonMistakes
- Go 1.22 变更:https://go.dev/doc/go1.22#loopvars
- uber goleak:https://github.com/uber-go/goleak