别让channel拖慢你的服务
线上系统突然卡住,CPU飙高,日志里没报错,排查半天发现是goroutine堆积。这种情况在用Go写并发程序时太常见了。很多人知道用channel通信,但用法不对,反而埋下隐患。
比如有个定时任务要上报数据,每秒启动一个goroutine往channel发消息。如果下游处理慢,channel没缓冲,上游就会卡住,越积越多,内存直接爆掉。
ch := make(chan int) // 无缓冲channel
go func() {
for {
ch <- 1 // 一旦没人收,这里就卡死
}
}()这种写法在测试时没问题,一上生产就出事。换成带缓冲的channel能缓解,但治标不治本。
用select防死锁
想避免阻塞,可以加default分支。这样send失败不会卡住,而是立刻走下一步。
ch := make(chan int, 10)
go func() {
for {
select {
case ch <- 1:
// 发送成功
default:
// 队列满了,跳过或记录丢弃
}
}
}()这招在流量突增时特别管用。就像快递柜满了,你不该一直堵在门口等空位,而是把包裹放驿站另想办法。
记得关channel,也得会判断是否已关
close(channel)谁都会,但什么时候关、谁来关有讲究。多个生产者往一个channel发数据,随便一个关了,其他再发就会panic。
标准做法是:由唯一的发送方关闭channel,接收方只负责读。或者用sync.WaitGroup协调多个生产者,等都完成了再关。
ch := make(chan string, 5)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- fmt.Sprintf("task %d", id)
}(i)
}
go func() {
wg.Wait()
close(ch)
}()接收的时候也要注意,从已关闭的channel还能读数据,但无法判断是真有数据还是channel已经关了。可以用逗号-ok模式:
for {
data, ok := <-ch
if !ok {
break // channel已关闭,退出
}
fmt.Println(data)
}nil channel永远阻塞
有时候你想暂停某个channel的接收,可以直接把它设为nil。对nil channel的读写操作都会永久阻塞,select会自动跳过它。
var ch chan int
ch = make(chan int)
go func() { ch <- 1 }()
select {
case <-ch:
ch = nil // 下次循环这个case不会再触发
case <-time.After(time.Second):
fmt.Println("timeout")
}这特性适合做一次性触发逻辑,比如初始化完成通知,之后不再关心。
别滥用无缓冲channel
无缓冲channel强调同步,两边必须同时准备好才能通信。理想很美好,现实很骨感。网络请求、数据库延迟都会导致接收方来不及响应。
建议默认用带缓冲channel,容量根据业务压力测试调整。像是API接口的请求队列,设个100~1000的缓冲,能扛住短时间峰值。
真正需要强同步的场景其实不多,比如主协程等子协程启动完成,这时候无缓冲刚好合适。