写并发代码,最怕的不是写不出来,而是写出来了、跑起来了、但藏着你看不见的 bug。
一、Channel 的方向约束
Go 的 channel 有三种类型签名:
go
chan int // 双向:能读能写能关
<-chan int // 只读:只能接收
chan<- int // 只写:只能发送和关闭
这不是运行时机制,是编译期契约。运行时它们指向同一个底层结构,性能零损耗。
为什么要自找麻烦加方向?
并发代码的 bug 往往不是"功能错了",而是"角色越界了"------消费者不该写入,生产者不该关别人的 channel。
go
// 不加约束:三个月后你忘了职责,手滑写一行 ch <- x,编译器不拦你
go func(ch chan int) { ... }
// 加了约束:编译器直接报错
go func(ch <-chan int) { ... }
转换规则
go
ch := make(chan int) // 双向
var r <-chan int = ch // ✅ 双向 → 只读(隐式收窄)
var w chan<- int = ch // ✅ 双向 → 只写(隐式收窄)
var ch2 chan int = r // ❌ 只读 → 双向(不可逆,编译错误)
我的建议:所有传入 goroutine 的 channel,都显式标注方向。不是炫技,是为了三个月后的自己。
二、WaitGroup 的正确姿势
sync.WaitGroup 管理的是 goroutine 的生命周期,不是任务的执行顺序。顺序靠 channel,生命周期靠 WaitGroup,两件事别混在一起。
唯一的铁律:wg.Add(1) 必须在 go func() 之前调用。
放在 goroutine 内部,主协程可能在 Add 执行前就跑到 Wait(),直接返回------这是 race condition。
三、实战一:生产者-消费者
设计思路
Producer → [buffered channel] → Consumer × N
关键决策:
- 谁关 channel? 生产者。只有它知道"没有更多数据了"
- 怎么等消费者结束? WaitGroup
- channel 要不要缓冲? 要。解耦生产和消费的速度差
完整实现
go
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// produce 返回只读 channel------调用方只能消费,不能往里塞东西
func produce(tasks []int) <-chan int {
out := make(chan int, 8)
go func() {
defer close(out)
for _, t := range tasks {
out <- t
}
}()
return out
}
// consume 从只读 channel 取任务,处理完自然退出
func consume(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
fmt.Printf("consumer-%d processed job %d\n", id, job)
}
}
func main() {
tasks := make([]int, 30)
for i := range tasks {
tasks[i] = i + 1
}
jobs := produce(tasks)
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go consume(i, jobs, &wg)
}
wg.Wait()
fmt.Println("all done")
}
设计要点
| 设计点 | 原因 |
|---|---|
produce 返回 <-chan int |
防止外部误写入或误关闭 |
close(out) 在生产者内部 |
唯一生产者负责关闭 |
| 5 个消费者共享一个 channel | channel 并发安全,多消费者自动竞争 |
range jobs |
channel 关闭后自动退出,无需额外信号 |
四、实战二:固定 10 个 Worker 打印 100 个元素
题目
用固定 10 个 goroutine,无重复、无遗漏地打印 slice 中的 100 个元素。用容量为 10 的有缓冲 channel 作为任务队列。
核心思路
这是标准的 Worker Pool 模式:
main 把 100 个任务塞进 channel
↓
[buffered channel, cap=10]
↓
10 个 worker 竞争消费,干完一个取下一个
自始至终只有 10 个 goroutine,不是启动 100 个然后限流。
完整实现
go
package main
import (
"fmt"
"sync"
)
func main() {
const size = 100
const workerNum = 10
slice := make([]int, size)
for i := range slice {
slice[i] = i + 1
}
// 容量为 10 的有缓冲 channel 作为任务队列
jobs := make(chan int, workerNum)
// 启动固定 10 个 worker
var wg sync.WaitGroup
for i := range workerNum {
wg.Add(1)
go worker(i, jobs, &wg)
}
// 主协程分发任务
for _, v := range slice {
jobs <- v
}
close(jobs) // 所有任务分发完毕,关闭 channel
// 等待所有 worker 退出
wg.Wait()
fmt.Println("all done")
}
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for num := range jobs {
fmt.Printf("worker-%d: num=%d\n", id, num)
}
}
执行流程拆解
时间线:
1. main 启动 10 个 worker,它们全部阻塞在 <-jobs 等待任务
2. main 开始往 jobs 塞数据
- channel 缓冲区 10,加上 10 个 worker 在读,吞吐很快
- 哪个 worker 先读到是随机的 → 无序打印
3. 100 个元素全部塞完,close(jobs)
4. 每个 worker 的 range 检测到 channel 关闭,退出循环
5. wg.Wait() 等 10 个 worker 全部 Done,结束
为什么这么写?
1. 为什么用 range jobs 而不是 for { select }?
range 在 channel 关闭时自动退出,干净利落。如果用 for + select,你还得自己判断 ok 值,多写一堆代码没有任何好处。
2. 为什么 close(jobs) 放在分发循环之后?
这是"谁生产谁关闭"原则。主协程是生产者,它塞完所有数据后关闭。10 个 worker 是消费者,它们只读不关。
3. 为什么 channel 缓冲区设为 10?
和 worker 数量一致。意味着即使 10 个 worker 都在忙,主协程还能继续塞 10 个任务进缓冲区而不阻塞。这是一个合理的吞吐平衡点。设太大浪费内存,设太小主协程频繁阻塞。
4. 为什么不需要 context 超时?
100 个 fmt.Printf 不会超时。加 context 是防御性编程,在这个场景下是过度设计。如果你的 worker 里跑的是网络请求,那必须加。
五、两种模式对比
| 生产者-消费者 | Worker Pool | |
|---|---|---|
| goroutine 数量 | 固定 N 个 | 固定 N 个 |
| 本质区别 | 强调"生产"和"消费"的角色分离 | 强调"固定人手处理批量任务" |
| channel 语义 | 数据流管道 | 任务队列 |
| 典型场景 | 流式处理、管道串联 | 批量 I/O、并发请求 |
| 关闭时机 | 生产者写完关闭 | 分发者塞完关闭 |
其实 Worker Pool 就是生产者-消费者的一种具体形态。区别在于心智模型:你是在想"数据在流动",还是在想"工人在干活"。
六、写并发代码的纪律
-
谁生产谁关闭。 消费者永远不关 channel。
-
Add在go前面。 没有例外。 -
channel 方向能标就标。 零成本,编译器帮你守门。
-
defer归还资源。 信号量、WaitGroup、锁------全用 defer。 -
固定并发用 Worker Pool,动态限流用信号量。 别搞混。
-
退出前等 goroutine 收尾。 每次写
return之前想一想:还有谁在跑?
并发代码的质量不在于跑没跑通,在于你能不能对着每一行说清楚:谁在什么时候执行,凭什么保证。说不清楚,就是在赌运气。