一、goroutine 有多轻
go func() {
fmt.Println("running")
}()
go 关键字启动一个 goroutine,初始栈只有 2KB 左右(可动态增长),所以起几万个也不稀奇。但「轻」不等于「免费」:每个 goroutine 都要调度、占内存,必须有明确的退出路径,否则就是泄漏。
主函数 main 返回时,所有 goroutine 立即终止,不会等待。
二、channel:无缓冲 vs 有缓冲
ch := make(chan int) // 无缓冲
ch := make(chan int, 10) // 缓冲 10
- 无缓冲:发送和接收必须同时就绪,否则阻塞。天然起到「同步握手」的作用。
- 有缓冲:缓冲区没满就能发,没空就读不到。适合「生产者快、消费者慢」的削峰。
ch <- 1 // 发送
v := <-ch // 接收
v, ok := <-ch // ok 为 false 表示 channel 已关闭且无数据
close(ch) // 关闭(只能由发送方关)
四条铁律:
- 向已关闭的 channel 发送 → panic。
- 重复关闭 → panic。
- 从已关闭的 channel 接收 → 立即返回零值,
ok为 false。 - 只由发送方关闭,接收方不要关。
三、select:多路等待
select {
case v := <-ch1:
fmt.Println("ch1", v)
case ch2 <- 42:
fmt.Println("sent to ch2")
case <-time.After(3 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("no ready channel")
}
- 多个 case 同时就绪时随机选一个(避免饥饿)。
default让 select 不阻塞,实现「非阻塞尝试」。time.After做超时是最常见的用法,但注意它在高频率循环里会堆积定时器,长期运行应改用time.NewTimer并复用。
四、WaitGroup:等一组任务
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
work(n)
}(i)
}
wg.Wait()
要点:
Add必须在启动 goroutine 之前调用,放在 goroutine 里可能Wait已经返回。- 循环变量要作为参数传入(Go 1.22 之前共享变量,会全部拿到最后一个值;1.22 起每次迭代新变量,但显式传参仍然更清晰)。
defer wg.Done()保证 panic 时也能减计数。
五、context:取消与超时
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-ctx.Done():
return ctx.Err() // context deadline exceeded
case res := <-resultCh:
return res
}
约定:
ctx作为函数的第一个参数传递。- 不要把它存进结构体。
cancel()必须调用(defer cancel()),否则会泄漏定时器和子 context。- 下游函数要周期性检查
ctx.Done(),否则取消信号传不下去。
context.WithValue 只放请求级的元数据(trace id、用户身份),不要放业务参数。
六、数据竞争
多个 goroutine 同时读写同一个变量且没有同步,就是数据竞争(race)。表现可能是「偶尔算错」「偶尔 panic」,极难复现。
go test -race ./...
go run -race main.go
-race 检测器会在运行时报告竞争位置(两个 goroutine 的调用栈)。并发代码必须跑一次 -race,这是最划算的投入。
保护共享数据的两种方式:
var mu sync.Mutex
mu.Lock(); counter++; mu.Unlock()
// 或原子操作
atomic.AddInt64(&counter, 1)
七、三个常见错误
1. goroutine 泄漏
// 消费者提前退出,生产者永远阻塞在发送上
ch := make(chan int)
go func() { for i := 0; ; i++ { ch <- i } }()
解决:用带缓冲的 channel + ctx 控制,或确保消费端始终读到关闭。
2. 循环里起 goroutine 没限流
一万个任务起一万个 goroutine,可能把下游打挂。用信号量模式限流:
sem := make(chan struct{}, 10)
for _, t := range tasks {
sem <- struct{}{}
go func(t Task) {
defer func() { <-sem }()
handle(t)
}(t)
}
3. 用 channel 做锁
channel 能做同步,但简单互斥用 sync.Mutex 更清晰、更快。别为了「Go 风格」把什么都塞进 channel。
小结
goroutine 要能退出;无缓冲 channel 是同步握手,有缓冲是削峰;只由发送方 close;Add 在启动前调用;cancel 必须 defer;并发代码必须跑 -race。