Go 并发陷阱与内存陷阱全攻略(面试必问坑点)

2022-02-11T10:00:00+08:00 | 8分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

学习目标

读完本文,你应该能够:

  • 识别并修复闭包捕获循环变量的经典问题
  • 找出常见的goroutine 泄漏场景并提出预防措施
  • 说清楚 channel 未关闭和向已关闭 channel 发送的区别
  • 避免 Mutex/WaitGroup 被值拷贝导致的静默 bug
  • 理解 nil interface vs interface containing nil 的区别
  • 知道如何避免 slice 持有底层数组导致内存无法释放
Go 陷阱与易错点并发陷阱同步原语陷阱内存/类型陷阱闭包循环变量捕获goroutine 泄漏channel 使用错误Mutex 值拷贝Once panic 行为WaitGroup.Addslice 底层数组interface nil 误区map 遍历删除

一、闭包捕获循环变量

1.1 经典问题

应用场景

在实际项目中,这个知识点经常应用于:

  1. 典型场景一:说明具体应用场景
  2. 典型场景二:说明另一个应用场景
  3. 面试要点:提醒面试中可能被问到的点
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 常见错误

应用场景

在实际项目中,这个知识点经常应用于:

  1. 典型场景一:说明具体应用场景
  2. 典型场景二:说明另一个应用场景
  3. 面试要点:提醒面试中可能被问到的点

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 底层数组共享:子切片 / 重新切片都指向同一底层数组

data 底层数组(1MB,8 格示意)01234567
<rect x="40" y="270" width="420" height="48" rx="8"/>
<text x="250" y="299" text-anchor="middle" fill="#1a1a1a" font-size="11.5" stroke="none">small := data[0:10] ptr→&amp;data[0] len=10 cap=1MB</text>
<rect x="40" y="335" width="420" height="48" rx="8"/>
<text x="250" y="364" text-anchor="middle" fill="#1a1a1a" font-size="11.5" stroke="none">b := a[:3] ▶ 同样指向 data 底层数组</text>
<rect x="520" y="40" width="440" height="200" rx="12" stroke-width="3"/>
<text x="740" y="66" text-anchor="middle" fill="#B03030" font-size="13.5" font-weight="700" stroke="none">smallCopy 独立底层数组(copy 出来)</text>
<rect x="535" y="90" width="38" height="60" rx="4"/>
<rect x="578" y="90" width="38" height="60" rx="4"/>
<rect x="621" y="90" width="38" height="60" rx="4"/>
<rect x="664" y="90" width="38" height="60" rx="4"/>
<rect x="707" y="90" width="38" height="60" rx="4"/>
<rect x="750" y="90" width="38" height="60" rx="4"/>
<rect x="793" y="90" width="38" height="60" rx="4"/>
<rect x="836" y="90" width="38" height="60" rx="4"/>
<rect x="879" y="90" width="38" height="60" rx="4"/>
<rect x="922" y="90" width="38" height="60" rx="4"/>
<rect x="520" y="270" width="440" height="48" rx="8"/>
<text x="740" y="299" text-anchor="middle" fill="#1a1a1a" font-size="11.5" stroke="none">copy(smallCopy, data[0:10]) 自己的 10 字节</text>
small / b 的 ptr 都指向 data[0]copy 拷贝数据(虚线)b[0]=99 改的是 data 底层数组的 cell0 → a[0] 也变 99(共享!)原 1MB 数组不再被引用 → 可被 GC 回收

看图要点: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 可能永久阻塞在 <-ch 上(如果是 range 也会永久阻塞)。
nil channel:任何操作都会永久阻塞(发送和接收都不行),常用于 select 中暂时禁用某个 case。
关闭的 channel:可以接收(返回零值和 false),但不能再发送(panic)。
面试官
如何检测一个程序中是否存在 goroutine 泄漏?
候选人

方法:

  1. goleak:在测试中使用 goleak.VerifyNone(),程序退出前检查
  2. pprof:采集 goroutine profile,对比不同时间点的差异
  3. runtime.NumGoroutine():监控 goroutine 数量是否持续增长
  4. Stack 分析:debug.WriteHeapProfile 可以查看所有 goroutine 的栈
    生产环境建议定期采集 goroutine profile 并做 diff 分析。
面试官
for range 遍历 map 时能否删除元素?
候选人
不能直接删除,会 panic(concurrent map read and map write)。
正确做法:先收集要删除的 key 到一个切片,遍历结束后再统一删除。 或者使用迭代器模式(Go 1.24 实验性功能)。

八、本章小结

复习提示:
  • 闭包循环变量:Go 1.22 前需要显式创建局部变量副本
  • goroutine 泄漏:最常见原因是 channel 未关闭或 select 无 case
  • Mutex/WaitGroup 值拷贝:必须传指针,否则会静默失效
  • slice 底层数组:子切片会持有整个数组,及时 copy 出新切片
  • interface nil:nil interface 和 containing nil 是不同的概念
  • 下一篇将讲解项目场景问答,覆盖西安大厂必问的项目深挖题目

参考

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!