学完这篇,你能把 Goroutine、Channel、Context 三件套串成一套能落地的并发写法:知道什么时候该开协程、数据怎么安全传递、超时和取消怎么一路传到底,以及哪些写法会直接埋出泄漏和死锁。
第一步:把 Goroutine 跑起来,并且等它结束
启动协程只有一个动作——在函数调用前加 go:
go func() {
fmt.Println("干活")
}()
但 go 本身不阻塞,主函数一结束,所有协程会被直接掐断。所以必须有个等待手段,标准库给了 sync.WaitGroup:
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait()
用 go run main.go 就能验证输出是否稳定是 3 行。
注意:wg.Add(1) 要写在 go 之前。写进协程内部的话,wg.Wait() 可能在 Add 执行前就返回,导致漏等。另外循环变量在 Go 1.22 之前是共享的,老版本必须用 (i) 传参拷贝,否则三个协程打印的可能是同一个值。
第二步:Channel 怎么用,什么时候关
Channel 是协程之间唯一的推荐通信方式,核心区别在缓冲:
ch := make(chan int) // 无缓冲:发送和接收必须同时就绪
buf := make(chan int, 10) // 缓冲 10:装满才阻塞
无缓冲适合「交接棒」式的同步,缓冲适合「生产者快、消费者慢」的削峰。读取有两种写法:
v := <-ch // 读一个,没数据就阻塞
for v := range ch { // 一直读到 channel 被关闭
fmt.Println(v)
}
注意:range ch 只有在 channel 被 close 后才会退出,忘了关就是永久阻塞,go run 会直接报 all goroutines are asleep - deadlock!。关闭原则是「谁发送谁关闭」,接收方永远不要关;对已关闭的 channel 再发送会 panic,重复关闭也会 panic。
第三步:用 select 做多路复用和超时
select 让一个协程同时等多个 channel:
select {
case v := <-ch:
fmt.Println("收到", v)
case <-time.After(2 * time.Second):
fmt.Println("超时")
case <-ctx.Done():
return ctx.Err()
}
注意:select 里加 default 会变成非阻塞轮询,放在 for 里空转能把 CPU 吃满,除非你确实要做「有就取、没有就走」。另外在长循环里反复调用 time.After 会不断创建定时器,高频场景应改用 time.NewTimer 并复用。
第四步:Context 把取消信号传下去
Context 解决的是「一个请求牵扯多个协程,上游取消要通知所有下游」。三种派生方式对应三种场景:
ctx, cancel := context.WithCancel(context.Background()) // 手动取消
ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // 绝对超时
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(3*time.Second))
ctx = context.WithValue(ctx, "traceID", "abc123") // 传请求域数据
defer cancel()
取消信号是向下传播的:父 ctx 取消,所有子 ctx 的 Done() 都会关闭。所以每个协程只需监听自己拿到的那份:
go func(ctx context.Context) {
select {
case <-ctx.Done():
return
case <-doSomething():
}
}(ctx)
注意:defer cancel() 一个都不能省,否则定时器和挂载的子节点会一直留在内存里,这是最常见的 context 泄漏。Context 必须作为函数的第一个参数 func Do(ctx context.Context, ...),不要塞进 struct 字段。WithValue 只放请求级元数据(traceID、用户 ID),别拿它传业务参数。
第五步:一个完整可跑的并发抓取
把上面几个组件拼起来,效果是「并发跑 3 个任务,任意一个超时或整体超时都立刻退出」:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
results := make(chan string, 3)
var wg sync.WaitGroup
for _, url := range []string{"a.com", "b.com", "c.com"} {
wg.Add(1)
go func(u string) {
defer wg.Done()
select {
case results <- fetch(ctx, u):
case <-ctx.Done():
}
}(url)
}
go func() { wg.Wait(); close(results) }()
for r := range results {
fmt.Println(r)
}
}
这里 results 用带缓冲的 channel,是为了让协程在没人接收时也能把结果放进去并正常退出,避免泄漏。
注意:close(results) 必须等所有发送者退出后再执行,所以单独起一个协程做 wg.Wait()。如果直接在循环里 close,会出现「send on closed channel」panic。
小结
- Goroutine 启动靠
go,结束必须靠 WaitGroup 或 channel 等它,主函数退出会杀掉全部协程。
- Channel 无缓冲用于同步交接,有缓冲用于削峰;关闭原则是「谁发送谁关闭」,接收方不关。
range 读 channel 必须配合 close,否则死锁;select 加 time.After 或 ctx.Done() 做超时。
- Context 从
Background 派生,defer cancel() 不能漏;取消信号向下传播,只传元数据不传业务参数。
- 长跑服务里,凡是启动协程的地方,都要能回答「它什么时候、被谁、以什么方式结束」。