文章

阶段1 第1课:并发三易错点

阶段 1 第 1 课:并发三易错点

教学目标:纠正 3 个高频并发认知偏差——goroutine panic 的影响范围、channel 关闭语义、context 取消传播。 对应指南:02-语言特性/02-并发模型.md03-标准库/04-并发原语.md03-标准库/10-运行时调试.md


易错点 1:goroutine 的 panic 会终止整个进程

结论(先行)

未 recover 的 panic 会终止整个进程,所有 goroutine 一起被强杀。Go 里没有「线程级异常隔离」,进程是最小隔离单位。

原理

  • 每个 goroutine 有独立调用栈,但共享同一个进程地址空间
  • panic 沿调用栈上抛,没人 recover 就到 goroutine 入口 → runtime 触发 fatal error → 进程 exit
  • 进程退出时所有 goroutine 被强制终止(不是优雅退出)。

反例(会崩)

func main() {
    go func() {
        panic("boom") // 子 goroutine panic,没 recover
    }()
    time.Sleep(time.Second)
    fmt.Println("never printed") // 永远打不到,进程在 panic 时已 exit
}

运行结果:

panic: boom
[stack trace...]
exit status 2

正解:recover 隔离

recover 只在 defer 函数体内直接调用才有效。

func safeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("goroutine panic recovered: %v", r) // 记录,别静默吞
            }
        }()
        fn()
    }()
}

关键结论

说法正误
goroutine panic 只影响自己❌ 错,进程全死
recover 能在任意函数里捕获 panic❌ 必须在 defer 内
panic 往往代表 bug,应告警而非静默✅ 对

易错点 2:channel 关闭语义

结论(先行)

close 是发送方的责任;向已关闭 channel 发送 / 重复 close 都会 panic;从已关闭 channel 接收立即返回零值(ok=false);向 nil channel 收发会永久阻塞。

行为速查表

操作结果
向已关闭 channel 发送panic: send on closed channel
关闭已关闭 channelpanic: close of closed channel
从已关闭 channel 接收立即返回零值,ok=false(不 panic)
向 nil channel 发送/接收永久阻塞
多发送方各自 close必 double-close panic

反例

ch := make(chan int)
go func() { ch <- 1 }()
close(ch)
ch <- 2 // panic: send on closed channel

正解:接收方用 range / ok

// 推荐:range 在 channel 关闭后自动退出
for v := range ch {
    fmt.Println(v)
}

// 或显式判断
v, ok := <-ch
if !ok {
    // 已关闭,无更多数据
}

多发送方如何安全关闭

不能各自 close(会 double-close)。用 sync.Once 或单独的 done channel + WaitGroup 协调。

var once sync.Once
closeCh := func() { once.Do(func() { close(ch) }) } // 谁先完成谁关,只关一次

关键结论

  • 接收方绝不 close。
  • for v := range ch 是最安全的接收方式。

易错点 3:context 取消传播

结论(先行)

cancel 是信号不是强杀:goroutine 必须自己监听 ctx.Done()return,否则泄漏。cancel 必须 defer 调用,否则 timer 泄漏。父子 context 是树,cancel 父 → 所有子都收得到。

原理

  • context.WithCancel/WithTimeout/WithDeadline 返回派生 context,形成树。
  • 调 cancel → 关闭该 ctx 的 Done() channel → 所有子 ctx 同步感知。
  • goroutine 靠 select { case <-ctx.Done(): return } 主动退出。Go 不会强制终止 goroutine。

反例(goroutine 泄漏)

func doWork(ctx context.Context) {
    go func() {
        for {
            work() // 没监听 ctx.Done(),cancel 后永远跑 → 泄漏
        }
    }()
}

正解

func worker(ctx context.Context) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("canceled:", ctx.Err())
                return // 必须 return,否则泄漏
            default:
                work()
                time.Sleep(10 * time.Millisecond)
            }
        }
    }()
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 必须 defer,释放 timer
worker(ctx)

关键结论

说法正误
cancel 会强杀子 goroutine❌ 只是发信号
不 return 也会退出❌ 会泄漏
cancel 可以不 defer❌ timer 泄漏
传 nil context 无所谓❌ 用 Background()/TODO()

课堂小测(答完即过关)

  1. 某 goroutine panic 且未 recover,会怎样?

    • A. 仅该 goroutine 退出,其他正常
    • B. 整个进程崩溃退出,所有 goroutine 终止
    • C. runtime 自动忽略
    • D. 仅打印日志继续
  2. 向已关闭的 channel 发送数据会?

    • A. 阻塞等待
    • B. panic
    • C. 返回 false
    • D. 静默丢弃
  3. 调用 cancel() 后,子 goroutine 会怎样?

    • A. 被 runtime 强制终止
    • B. 收到 Done 信号,但须自己监听并 return 才能退出,否则泄漏
    • C. 自动退出
    • D. 什么都不发生

答案:1-B, 2-B, 3-B


综合代码作业(阶段 1 验收前置)

实现并发任务池 RunTasks,要求:

  1. 并发执行 []func() error 全部任务。
  2. 任一任务返回 error 或 panic 时,取消其他任务(用 context 传播取消)。
  3. 聚合所有错误返回(不丢错误)。
  4. 任一 panic 不导致进程崩溃(recover 隔离)。
  5. go test -race 通过(无 data race)。

提交时附带:

  • 实现代码(含 cancel 传播 + recover 隔离)
  • 一个 TestRunTasks 用例:3 个任务,其中第 2 个 panic,验证其余被取消且主流程不崩、错误被聚合。

把代码发给我 review,我会按阶段 1 验收标准(panic 行为解释正确 + -race 通过)判定是否过关。