--- id: backend.go.errors-concurrency title: 错误处理和并发:goroutine、channel、context type: lesson domain: backend stack: [go] tags: [Go, 错误处理, goroutine, channel, context, 并发] level: intermediate prerequisites: [backend.go.basics-types] est_hours: 4 related: [backend.go.scenario.batch-third-party-calls, backend.go.interview] week: [1, 3] order: 2 --- # 错误处理和并发:goroutine、channel、context ## 解释说明 **错误处理** - 错误是普通的值。用 `fmt.Errorf("读取配置: %w", err)` **包装**错误,保留原始错误并加上下文;调用方用 `errors.Is(err, ErrNotFound)` 判断是不是某个错误,用 `errors.As(err, &target)` 取出某种类型的错误。 - 只在真正不可恢复的情况下用 `panic`(例如程序启动时配置缺失);HTTP 服务里用中间件 `recover`,防止一个请求的 panic 让整个进程退出。 **并发** - **goroutine**:`go f()` 启动一个轻量级“线程”(初始栈几 KB,由 Go 运行时调度到系统线程上),可以轻松开成千上万个。 - **channel**:goroutine 之间传递数据的管道。无缓冲 channel 收发双方要同时就绪;有缓冲 channel 满了才阻塞发送。“不要通过共享内存来通信,而要通过通信来共享内存。” - **select**:同时等待多个 channel,哪个先就绪处理哪个;常用来实现超时和取消。 - **sync**:`WaitGroup` 等待一组 goroutine 结束;`Mutex` / `RWMutex` 保护共享数据;`Once` 只执行一次。 - **context**:在调用链上传递**取消信号、超时和请求范围的值**。HTTP 请求进来时创建,往下传给数据库查询、外部调用;客户端断开或超时,下游全部及时停止。约定:`ctx` 作为函数的第一个参数。 ## 案例 - 一个接口要同时查用户信息、订单列表、优惠券三个下游服务:三个 goroutine 并发查询,整体用 `context.WithTimeout` 限制 2 秒,任一失败就取消其他的(`errgroup` 的典型用法)。 - 消息消费者:从队列取消息、交给固定数量的 worker 处理(worker pool),服务关闭时通过 `context` 通知 worker 处理完手上的消息再退出。 ## 代码示例 一个带超时和取消的 worker pool: ```go package main import ( "context" "errors" "fmt" "sync" "time" ) var ErrInvalid = errors.New("无效的任务") func process(ctx context.Context, n int) (int, error) { if n < 0 { return 0, fmt.Errorf("处理 %d: %w", n, ErrInvalid) // 包装错误 } select { case <-time.After(time.Duration(n) * 10 * time.Millisecond): // 模拟耗时操作 return n * n, nil case <-ctx.Done(): // 超时或被取消 return 0, ctx.Err() } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) defer cancel() jobs := make(chan int) type result struct { n, v int err error } results := make(chan result) var wg sync.WaitGroup for w := 0; w < 3; w++ { // 3 个 worker wg.Add(1) go func() { defer wg.Done() for n := range jobs { // jobs 关闭后循环结束 v, err := process(ctx, n) results <- result{n, v, err} } }() } go func() { for _, n := range []int{1, 2, -1, 3, 50} { jobs <- n } close(jobs) // 发送方负责关闭 }() go func() { wg.Wait(); close(results) }() // 全部 worker 结束后关闭结果通道 for r := range results { switch { case errors.Is(r.err, ErrInvalid): fmt.Println(r.n, "参数错误:", r.err) case errors.Is(r.err, context.DeadlineExceeded): fmt.Println(r.n, "超时") default: fmt.Println(r.n, "=>", r.v) } } } ``` 运行:`go run main.go`,再用 `go run -race main.go` 检查数据竞争。完整的生产写法(并发上限、单次超时、部分失败)见 [examples/batch-fetch](examples/batch-fetch/)。 ## 易错点 - **goroutine 泄漏**:goroutine 阻塞在没人接收的 channel 上永远不退出。每个 goroutine 都要想清楚“它什么时候结束”,配合 `context` 或关闭 channel。 - **向已关闭的 channel 发送会 panic**;关闭的责任在发送方,多个发送方时用 `WaitGroup` 等全部结束再关闭。 - **循环变量**:Go 1.22 起每次循环都是新变量,旧版本里在 goroutine 中直接用循环变量会全部拿到最后一个值。 - **map 不是并发安全的**:多个 goroutine 同时读写会直接崩溃(fatal error),用 `Mutex` 或 `sync.Map`。 - **把 context 存进结构体**:context 应该随调用传递,不要长期保存。 - **吞掉错误**:`_ = f()` 要有理由;记录日志和返回错误二选一,不要两个都做(会重复打印)。 ## 面试常问 见 [Go 面试题](interview.md):GMP 调度、channel 的阻塞规则、context 的用途、如何发现数据竞争。