阶段1 第1课:并发三易错点
阶段 1 第 1 课:并发三易错点
教学目标:纠正 3 个高频并发认知偏差——goroutine panic 的影响范围、channel 关闭语义、context 取消传播。 对应指南:
02-语言特性/02-并发模型.md、03-标准库/04-并发原语.md、03-标准库/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 |
| 关闭已关闭 channel | panic: 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() |
课堂小测(答完即过关)
-
某 goroutine panic 且未 recover,会怎样?
- A. 仅该 goroutine 退出,其他正常
- B. 整个进程崩溃退出,所有 goroutine 终止
- C. runtime 自动忽略
- D. 仅打印日志继续
-
向已关闭的 channel 发送数据会?
- A. 阻塞等待
- B. panic
- C. 返回 false
- D. 静默丢弃
-
调用 cancel() 后,子 goroutine 会怎样?
- A. 被 runtime 强制终止
- B. 收到 Done 信号,但须自己监听并 return 才能退出,否则泄漏
- C. 自动退出
- D. 什么都不发生
答案:1-B, 2-B, 3-B
综合代码作业(阶段 1 验收前置)
实现并发任务池 RunTasks,要求:
- 并发执行
[]func() error全部任务。 - 任一任务返回 error 或 panic 时,取消其他任务(用 context 传播取消)。
- 聚合所有错误返回(不丢错误)。
- 任一 panic 不导致进程崩溃(recover 隔离)。
go test -race通过(无 data race)。
提交时附带:
- 实现代码(含 cancel 传播 + recover 隔离)
- 一个
TestRunTasks用例:3 个任务,其中第 2 个 panic,验证其余被取消且主流程不崩、错误被聚合。
把代码发给我 review,我会按阶段 1 验收标准(panic 行为解释正确 +
-race通过)判定是否过关。