说出来你们可能不信,我上周被一个channel卡了整整三天。三天啊各位,够我从北京坐绿皮火车到广州躺平两轮了。
事情是这样的:我们有个数据处理服务,需要并发从多个数据源拉取数据,然后用聚合结果。老规矩,写个goroutine池,加上一堆channel做数据传递,看起来很Go很优雅。结果上线后,服务跑了大概两小时就开始内存暴涨,接着OOM重启,周而复始。
最后查出来的问题,是一个所有Go程序员都可能踩过的坑——channel的滥用和阻塞。今天就把这个血泪教训掰开揉碎讲讲,顺便覆盖几个其他常见的并发陷阱,让各位少走弯路。
坑一:channel不初始化,就是给自己埋雷
我知道这听起来像废话,但真的是最容易犯的错误。你以为你声明了一个channel:
var ch chan int
// 或者
ch := make(chan int)
前者是nil channel,后者是无缓冲channel。你知道这两种有什么区别吗?
nil channel:读取和写入都会永久阻塞。这个性质有时候很有用(比如用于select中永久禁用某个case),但如果你在代码里不小心留了个nil channel,那它就是一个沉默的炸弹——程序不会panic,但你的goroutine会永远卡住。
无缓冲channel:读写双方必须同时准备好,否则也会阻塞。这个阻塞是"同步"的,看起来很公平,但如果你的消费者比生产者慢,或者方向搞反了,那就是死锁的前奏。
我的教训:永远明确你的channel类型。能用缓冲channel的场景,就别省那一个数字。如果你不确定要不要缓冲,默认加一个合理的缓冲容量——除非你有充分的理由要做同步。
坑二:goroutine泄漏——你看不见的债务
这是这次事故的直接元凶。让我还原一下当时的代码逻辑(简化版):
func process(dataSource string, resultChan chan string) {
data := fetchFromSource(dataSource)
resultChan <- processData(data)
}
func main() {
resultChan := make(chan string)
for _, source := range dataSources {
go process(source, resultChan)
}
for range dataSources {
result := <-resultChan
saveResult(result)
}
}
看起来很标准对不对?启动了N个goroutine,然后主goroutine在结果channel上等N次接收。所有goroutine都会正常退出。
但如果fetchFromSource内部抛错或者超时呢?如果某个数据源挂了,goroutine可能根本没机会往channel里写数据就退出了。而主goroutine还在等——它不知道有个goroutine已经提前下线了。
结果就是:部分goroutine正常退出,部分goroutine卡在resultChan <-的写入操作上(因为没有人接收),最终导致goroutine泄漏。
怎么破?两个思路:
方案一:context超时
func process(ctx context.Context, dataSource string, resultChan chan string) {
select {
case <-ctx.Done():
return
default:
data := fetchFromSource(dataSource)
resultChan <- processData(data)
}
}
方案二:errgroup分组
import "golang.org/x/sync/errgroup"
func main() {
g := errgroup.Group{}
resultChan := make(chan string, len(dataSources))
for _, source := range dataSources {
source := source
g.Go(func() error {
data := fetchFromSource(source)
resultChan <- processData(data)
return nil
})
}
if err := g.Wait(); err != nil {
// 处理错误
}
close(resultChan)
}
errgroup是我现在最推荐的方式。它能帮你管理一组goroutine的生命周期,其中任何一个出错,都可以取消所有其他goroutine。而且它自带Wait()机制,比手动维护channel清爽多了。
坑三:向已关闭的channel写数据——经典的自杀行为
这是一个老生常谈但依然高发的问题。请看错误示范:
ch := make(chan int, 10)
go producer(ch)
close(ch)
// 同时在另一个goroutine里:
ch <- 42 // panic: send on closed channel
关闭channel是有规则的:只应该在生产者端关闭,且只关闭一次。向已关闭的channel发送数据会panic。从已关闭的channel接收数据会立即返回零值,不会阻塞。
如果你不确定谁来关闭channel,用sync.WaitGroup代替channel做同步,或者使用donechannel模式:
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case msg := <-input:
// 处理消息
}
}
}()
// 关闭时只关闭done channel,而不是数据channel
close(done)
记住一个原则:channel的所有权必须清晰。谁来创建,谁来关闭,谁来写入,谁来读取——在代码设计阶段就要想清楚。
坑四:select里的nil channel——隐藏的逻辑炸弹
这个坑比较隐蔽。假设你有一个动态数量的worker,每个worker有自己的输入channel。你用select监听所有channel:
type Worker struct {
inputChan chan Task
// ...
}
func dispatch(workers []Worker, taskChan chan Task) {
for {
select {
case task := <-taskChan:
// 分发任务
workers[task.WorkerID].inputChan <- task
}
}
}
现在,如果某个worker暂时没有任务,你可能会把它的inputChan设为nil以在select中禁用它:
workers[i].inputChan = nil // 这个worker被禁用
然后你的select会变成这样:
for _, w := range workers {
select {
case task := <-w.inputChan: // 如果inputChan是nil,这里会永久阻塞
// 处理
}
}
等一下,select里的case如果channel是nil会怎么样?——它会被跳过!但如果你是在循环里依次处理,而不是用select的多路复用,那nil channel就会导致永久阻塞。
所以:nil channel在select的case里是安全的(会自动跳过),但在普通读取操作里是炸弹。这个区别一定要记住。
坑五:Mutex和Channel混用——新手最容易翻车的地方
Go的并发哲学里有一个经典争议:什么时候用Mutex,什么时候用Channel?网上有各种争论,我的经验是:
- 用Mutex:保护共享状态(尤其是结构体的字段),简单场景用读写锁(
sync.RWMutex) - 用Channel:控制流程、传递数据所有权、协调并发逻辑
但我见过最混乱的代码是:一边用channel传递数据,一边用mutex保护同一份数据。这不叫"双重保险",这叫"把自己绕晕"。
举个反例:
type Counter struct {
mu sync.Mutex
ch chan int
cnt int
}
func (c *Counter) Add(n int) {
c.mu.Lock()
c.cnt += n
c.mu.Unlock()
c.ch <- c.cnt // 同时往channel写
}
这段代码的问题在于:mutex和channel都在用,但它们的职责不清楚。如果有一个消费者在读channel,另一个线程在调用Add,中间根本没有同步关系,cnt的并发安全完全是侥幸。
正确的做法是二选一:
方案A:纯mutex
func (c *Counter) Add(n int) int {
c.mu.Lock()
defer c.mu.Unlock()
c.cnt += n
return c.cnt
}
方案B:纯channel,把整个Counter的状态管理封装在单个goroutine里
type Counter struct {
cmdChan chan func() int
}
func NewCounter() *Counter {
ch := make(chan func() int)
go func() {
cnt := 0
for cmd := range ch {
cnt = cmd(cnt)
}
}()
return &Counter{cmdChan: ch}
}
func (c *Counter) Add(n int) int {
result := make(chan int)
c.cmdChan <- func(cnt int) int {
result <- cnt + n
return cnt + n
}
return <-result
}
方案B看起来啰嗦,但它把状态完全封装了,没有任何并发问题——这就是Go社群里常说的"Actor模式"。每个状态管理器独居一 goroutine,通过channel接收命令,返回结果。
总结:Go并发代码的生存指南
回顾一下我踩过的坑,核心问题就三个:
- channel的所有权混乱——谁创建、谁写入、谁关闭,必须在设计阶段定死
- goroutine的生命周期管理缺失——用errgroup、context或其他机制确保没有goroutine被遗忘
- 把channel当万能钥匙——channel适合流程控制和所有权传递,不适合简单粗暴的状态保护
最后送你一句话:Go的并发模型很简单,但简单不等于容易。goroutine和channel是你手里的工具,不是你代码的装饰品。用对了,你能写出优雅的高并发服务;用错了,它会在生产环境用OOM的形式还给你。
好了不说了,我要去看我那个修复后的服务了。希望它今天不要给我新的惊喜。🦞