Skip to content

实战复盘:从"死锁厨房"看 Channel 的解耦设计

日期: 2026-01-22
标签: Go, Concurrency, Channel, WorkerPool
分类: Go 实战

在实现一个简单的 Worker Pool 时,我经历了主程猝死、全员死锁等一系列经典事故。本文记录了如何通过异步 Sender 模式解开循环依赖,理解 Channel 的解耦本质。


引言

在学习 Go 并发模式(Worker Pool)时,我试图模拟一个简单的"并发厨房"场景:主线程发布 150 个任务(汉堡订单),3 个 Worker(厨师)并发处理,处理完将结果发回主线程。

看似简单的逻辑,我却连续踩了两个足以让生产环境崩溃的大坑。

事故一:主程猝死 (The Main Exit)

最初的代码逻辑是这样的:

go
func main() {
    go worker(...) // 启动厨师
    go worker(...)
    
    // 发任务...
    // 关通道...
} // main 结束

现象

程序运行瞬间结束,没有任何输出。

原因

Go 的 main 函数本身是一个 Goroutine。当它执行完毕退出时,整个进程(Process)会被直接终止,它根本不会等待后台那些刚启动的 Worker 协程。

修正

主线程必须承担"守门员"的角色,利用 Channel 的阻塞特性等待结果,直到收齐所有反馈才退出。

事故二:循环依赖死锁 (Circular Deadlock)

修复了主程退出问题后,我遇到了一个更棘手的死锁。

问题代码逻辑

  1. Main: for 循环发送 150 个任务 -> jobChan (容量 10)
  2. Workers: 消费 jobChan -> 处理 -> 写入 resultChan (容量 10)
  3. Main: 发送完 150 个任务后,开始接收 resultChan

死锁现场

  • 当程序运行到第 10~20 个任务时,卡死了
  • Runtime 报错:all goroutines are asleep - deadlock!

核心矛盾

这是一个经典的循环依赖

角色状态
Worker 视角resultChan 满了(因为 Main 还没开始收结果),我卡在写结果上,没法去拿新任务
Main 视角jobChan 满了(因为 Worker 卡住了不来拿),我卡在发任务上,没法去收结果

双方都在等对方先动,结果谁也动不了。

┌─────────────────────────────────────────────────────────┐
│                    死锁示意图                            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│   Main ──发任务──> jobChan [满] ──X──> Worker           │
│     │                                    │              │
│     │                                    │              │
│     X 等待发送                      等待写入 X           │
│     │                                    │              │
│     │                                    ↓              │
│   Main <──X── resultChan [满] <──写结果── Worker        │
│                                                         │
│              双向阻塞 = 死锁                             │
└─────────────────────────────────────────────────────────┘

解决方案:异步 Sender 模式

怎么解开这个死结?核心在于解耦

Main 线程不能同时既负责"发任务"又负责"收结果",因为这是串行的。我们需要把"发任务"这个动作剥离出去,交给一个新的 Goroutine:

go
func main() {
    // ... 初始化 channels ...

    // 1. 启动 Workers (消费者)
    for i := 0; i < 3; i++ {
        go worker(i, jobChan, resultChan)
    }

    // 2. 【关键】异步发送任务 (生产者)
    // 把它扔到独立的协程里,Main 就不需要在这里阻塞等待了
    go func() {
        for j := 1; j <= 150; j++ {
            jobChan <- j
        }
        close(jobChan)
    }()

    // 3. Main 专心致志收结果
    for i := 0; i < 150; i++ {
        <-resultChan
        // ...
    }
}

为什么这样就通了?

现在,即使 jobChan 满了,阻塞的也只是那个匿名的发送协程。此时,Main 线程依然是自由的,它会继续执行"收结果"的代码。

Main 取走一个结果 
    -> resultChan 腾出空间 
    -> Worker 写入结果并腾出手 
    -> Worker 取走任务 
    -> jobChan 腾出空间 
    -> 发送协程继续发任务

整个齿轮完美咬合运转。

┌─────────────────────────────────────────────────────────┐
│                    解耦后的流程                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│   Sender Goroutine ──发任务──> jobChan ──> Worker       │
│         ↑                                    │          │
│         │ 独立阻塞                            │          │
│         │ 不影响 Main                         ↓          │
│                                                         │
│   Main <────────── resultChan <──写结果── Worker        │
│     │                                                   │
│     └── 自由接收,不被发送阻塞                            │
│                                                         │
└─────────────────────────────────────────────────────────┘

总结

Channel 不仅仅是用来传递数据的管道,它更是程序结构的解耦器

在设计并发流程时,务必警惕 "单线程内的读写闭环"

如果一个协程既要大量写 Channel A,又要大量读 Channel B,而 Channel A 和 B 的另一端又是指向同一个逻辑闭环,那么死锁几乎是必然的。

关键原则

原则说明
职责分离生产者和消费者逻辑分开,不要在同一个串行流程中
异步发送如果发送可能阻塞,考虑用独立 Goroutine
缓冲区不是万能的增大 buffer 只能延缓死锁,不能根治
画数据流图动手前先画出 Channel 的数据流向,检查是否有环

Released under the MIT License.