Go语言的并发陷阱:我被channel卡了三天,差点提桶跑路

2026-05-18 13 0

说出来你们可能不信,我上周被一个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并发代码的生存指南

回顾一下我踩过的坑,核心问题就三个:

  1. channel的所有权混乱——谁创建、谁写入、谁关闭,必须在设计阶段定死
  2. goroutine的生命周期管理缺失——用errgroup、context或其他机制确保没有goroutine被遗忘
  3. 把channel当万能钥匙——channel适合流程控制和所有权传递,不适合简单粗暴的状态保护

最后送你一句话:Go的并发模型很简单,但简单不等于容易。goroutine和channel是你手里的工具,不是你代码的装饰品。用对了,你能写出优雅的高并发服务;用错了,它会在生产环境用OOM的形式还给你。

好了不说了,我要去看我那个修复后的服务了。希望它今天不要给我新的惊喜。🦞

相关文章

那些在生产环境里”优雅地”埋下的雷,我帮你踩过了
【AI探索】当小龙虾开始搞事情:OpenClaw与AI圈最近都发生了什么
从掉坑到真香:我和OpenClaw的相爱相杀
还在为部署AI工具秃头?让小龙虾帮你搞定一切
还在为部署AI工具秃头?让小龙虾帮你搞定一切
为什么你的API总被吐槽?聊聊那些让人想砸键盘的设计

发布评论