Channel 还是锁?聊聊 Go 里那些好用的并发模式

Posted on 二 07 7月 2026 in Tech

Abstract Channel 还是锁?Go 并发模式实战
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-07-07
License CC-BY-NC-ND 4.0

Channel 还是锁?聊聊 Go 里那些好用的并发模式

先说个场景。做后端的都熟:某个接口平时 QPS 几百,风平浪静,一到大促或者某个热点事件,流量瞬间翻几十倍。这时候你脑子里过的第一件事,永远是那句老话——分而治之

一个人干不完的活,就拆给一群人干。落到工程上,就是我们熟悉的那几板斧:多进程(比如 Nginx 起一堆 worker)、多线程(Java 的线程池、Go 的 goroutine)、多路复用(一个线程盯一堆连接,epoll 那套)、异步(别傻等,回调或者 future 通知你)。手段就这么多,翻来覆去用。

但只要一"分",就绕不开一个头疼事:这群人同时去动同一份数据,怎么办? 这就是资源竞争。在 Go 里,这个问题会具体成一个每个人都纠结过的选择题——面对共享数据,我该用 Channel,还是老老实实加一把互斥锁(Mutex)?

这篇就想把这事说清楚:先给一个能记住的判断口诀,再把 Go 里几种真正扛用的并发模式一个个拆开,配上能直接跑的代码。看完你至少不会再对着 Channel 和 Mutex 两眼一抹黑。

先约定几个词,怕有读者不熟: - goroutine:Go 里的"轻量级线程",起一个只要几 KB 栈,一个进程里跑几十万个都不稀奇。可以理解成"很便宜的一个干活的人"。 - Channel:goroutine 之间传数据的"管道"。一头塞进去,另一头拿出来,天然带同步。像流水线上传递零件的传送带。 - Mutex(互斥锁):同一时刻只让一个 goroutine 进"临界区"动共享数据的锁。像公共厕所的门闩,进去了就锁上,别人得等。 - 数据竞争(Data Race):多个 goroutine 同时读写同一份内存,且至少有一个在写,结果不可预测。Go 自带的 -race 检测器(go test -race)能在程序跑到竞争时报警。


一、那句被神化、又被误读的口诀

Go 官方有句名言,几乎是并发编程的"祖训":

Don't communicate by sharing memory; share memory by communicating. 不要通过共享内存来通信,而要通过通信来共享内存。

这话很漂亮,却常被念歪成:"Go 推荐 Channel,那锁就是落后的、要避免的。" 于是什么场景都硬套 Channel,把一个简单计数器写成一坨绕来绕去的管道。

这是典型的"把手段当目的"。祖训的本意其实是:当问题本质是"数据要在不同单元之间流动、交接"时,Channel 表达最自然;当问题本质是"保护一小块共享状态别被同时改坏"时,一把锁反而最直接。 Go 团队自己也在官方 Wiki 里说过,这不是铁律,该用锁就用锁——sync(Mutex、RWMutex)和 sync/atomic 一直在标准库里,不是历史包袱,而是本就有不可替代的场合。

所以别纠结"哪个更高级"了。它俩不是竞品,是分工。


二、一张表 + 一句口诀:到底怎么选

我自己的判断,浓缩成一句话:

传递数据的所有权,用 Channel;保护共享的状态,用锁。

再展开成一张对照表,方便你对号入座:

维度 更适合用 Channel 更适合用 Mutex / atomic
问题本质 数据要"传递、交接、流动" 数据要"就地保护,别改坏"
典型场景 生产者-消费者、任务分发、流水线、事件通知 计数器、缓存 map、共享配置、状态标志位
临界区大小 一段处理逻辑,数据要转手给别人 一两行,读写一个字段就完事
性能 有调度和拷贝开销,不适合超高频小操作 极轻量,尤其 atomic 几乎零成本
心智负担 结构清晰但容易多起 goroutine、忘关 channel 简单直接,但容易忘解锁、死锁
一句话 "把活儿和数据一起交出去" "我就锁一下,改完马上放"

打个生活里的比方。Channel 像是外卖流水线:接单的、备菜的、打包的、送餐的,各管一段,订单(数据)顺着传送带往下走,谁也不用去抢别人手里的东西。Mutex 像是公司里那台唯一的彩色打印机:大家都要用,但同一时刻只能一个人操作,你得先占着,打完赶紧让给下一个。

你看,这两个场景硬要互换就很别扭:让四个厨师抢一口锅(该用流水线的地方用了锁),或者给一台打印机修一条传送带(该用锁的地方用了 channel),都是自找麻烦。


三、先看反面教材:什么时候老老实实用锁

我见过太多人,一上来就想秀 Channel。最典型的翻车,是拿 Channel 去实现一个"并发安全的计数器"。

先看该用锁的正确写法,简单到无聊——这恰恰是它的优点:

// 一个并发安全的计数器:用锁,清爽
type Counter struct {
    mu    sync.Mutex
    count int64
}

func (c *Counter) Inc() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

func (c *Counter) Value() int64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

临界区就 c.count++ 一行,锁的范围小得不能再小。如果嫌 Mutex 都重,这种纯计数场景直接上 atomic 更快:

type Counter struct {
    count int64
}

func (c *Counter) Inc()          { atomic.AddInt64(&c.count, 1) }
func (c *Counter) Value() int64  { return atomic.LoadInt64(&c.count) }

硬用 Channel 呢?得起一个专门的 goroutine 守着计数,所有加减和读取都通过 channel 发指令进去——代码量翻几倍,每次加一都过一遍调度器,更慢、更绕、还更容易死锁。这就是为祖训而祖训。

一句话记住:临界区就一两行、只是保护一个字段,别犹豫,上锁。


四、Go 里真正扛用的几种并发模式

选型讲完,下面几种模式在生产里反复出现,吃透了大部分并发需求都能拼出来。

4.1 Worker Pool(工作池):控制并发度的老实人

我用得最多的模式。核心诉求一句话:任务很多,但我不想无脑起几万个 goroutine 把下游(数据库、第三方 API)打爆,得控制同时干活的人数。

思路是:开固定数量的 worker,从同一个任务 channel 里抢活,干完把结果丢到结果 channel。

func workerPool(jobs <-chan int, results chan<- int, workerNum int) {
    var wg sync.WaitGroup
    for i := 0; i < workerNum; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs { // channel 关了,range 自动退出
                results <- job * job // 假装这是耗时的处理
            }
        }()
    }
    // 单独一个 goroutine 等所有 worker 干完,再关结果 channel
    go func() {
        wg.Wait()
        close(results)
    }()
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    workerPool(jobs, results, 4) // 只用 4 个 worker

    for i := 1; i <= 10; i++ {
        jobs <- i
    }
    close(jobs) // 活派完了,关掉,worker 们 range 结束后自然收工

    for r := range results {
        fmt.Println(r)
    }
}

Channel 和 Mutex 各司其职的味道就出来了:任务分发、结果回收是"数据流动",用 channel;sync.WaitGroup 本质是个带锁的计数器,负责"等所有人干完"这个状态同步。两者搭配,不是二选一。

一个坑:workerNum 别拍脑袋,它应该由下游能承受的并发上限决定,而不是你机器几个核——32 核不代表数据库连接池能扛 32 个并发写。

4.2 Fan-out / Fan-in(扇出/扇入):把一件大事拆成小事再合并

名字唬人,其实就是"分而治之"的 Channel 版:

  • Fan-out(扇出):把一个 channel 的活儿分给多个 goroutine 并行处理。
  • Fan-in(扇入):把多个 goroutine 的结果汇总回一个 channel。

Worker Pool 就是它的一个特例。适合"一批数据,每条都要做同样的重活儿(比如各自去调一个慢接口)、彼此不依赖"的场景:扇出去并行跑,再扇回来统一收。

关键点是 Fan-in 的合并:

func fanIn(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out) // 所有源都干完了,才关合并出口
    }()
    return out
}

4.3 用 context 做超时与取消:并发里最容易被忽略的一环

前面都在讲"怎么把活干起来",但更要命的问题是:活干到一半,不想干了怎么办? 客户端断开、超时、上游取消——你得让一堆正在跑的 goroutine 及时收手,别空转泄漏。

context 就是干这个的,像发给所有下属的一个"随时可能拉响的收工哨":

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done(): // 哨声响了,赶紧收工
            fmt.Printf("worker %d 收到取消信号: %v\n", id, ctx.Err())
            return
        default:
            // 干一小步活,然后回来再看看哨响没
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
    defer cancel()

    for i := 0; i < 3; i++ {
        go worker(ctx, i)
    }
    <-ctx.Done() // 主 goroutine 也等哨声
    time.Sleep(50 * time.Millisecond) // 给 worker 一点收尾时间
}

我的经验:会起 goroutine、会做 I/O、会等待的函数,第一个参数就该是 ctx context.Context 这是防止 goroutine 泄漏最省心的护栏。很多"内存慢慢涨、goroutine 数只增不减"的线上诡异问题,根子就在起了 goroutine 却没给它留取消的退路。

4.4 select + 超时:别让任何一个 channel 操作无限期傻等

select 是 Go 并发的瑞士军刀:它能同时盯着多个 channel,哪个先就绪就走哪个。配上 time.After,就能给任何阻塞操作加个"闹钟":

select {
case res := <-resultCh:
    fmt.Println("拿到结果:", res)
case <-time.After(2 * time.Second):
    fmt.Println("等太久了,超时!") // 兜底,别死等
case <-ctx.Done():
    fmt.Println("被取消了")
}

一句话:只要有 channel 的读写可能阻塞,就想想"万一对面永远不来,我要等到什么时候",然后用 select 加个超时或取消分支兜底。

4.5 一个真实的开源 Bug:Kubernetes controller-runtime 的 goroutine 泄漏

别以为这种坑只有新手会踩。2025 年,Kubernetes 官方 SIG 的 controller-runtime——几乎所有 K8s Operator/Controller 都依赖的核心库——就修过一个教科书式的 goroutine 泄漏(issue #3218、修复 PR #3247),成因恰好把 context 超时和 channel 阻塞串到了一起:

  • manager 关停(shutdown)时有个"优雅退出"超时 gracefulShutdownTimeout;
  • 超时一到,manager 不再等 runnable,顺手把负责从 errChan 排空错误的那个 goroutine 也停了;
  • 偏偏这时某个"慢半拍"的 runnable 才失败,照常往共享的 errChan 发一个 error;
  • 结果接收方已经没了,errChan <- err 永远阻塞,goroutine 退不出来,泄漏了。

作者复现时,goleak 打出的泄漏栈状态清清楚楚写着 chan send——就卡在往 channel 发送那一行。修复很干净,把会阻塞的 r.errChan <- err 改成带取消分支的 select:

select {
case r.errChan <- err:
    // 正常:错误成功送出
case <-r.ctx.Done():
    // 已在关停,没人接收,记个日志丢掉,让 goroutine 干净退出
}

review 里还有个小插曲:一开始有人建议直接用 default 兜底,作者指出那样在正常运行时只要 errChan 偶尔占满就会误丢错误。最终定为"正常时坚持发送、只有 context 取消才走丢弃分支",既堵泄漏又不误伤——连一个 select 分支怎么写,背后都是权衡。

这个例子想说的是:"起了 goroutine、往 channel 发东西,却忘了留取消的退路"——这种坑连 Kubernetes 都会踩,直到 2025 年还在修。 它不挑人,只挑你有没有"起了 goroutine 就先想好它怎么退"的习惯。


五、几个把人坑惨的细节

模式会用了,还有几个反复咬人的坑,提前打个预防针:

  • 关 channel 的责任在"发送方",不在接收方。 而且只能关一次,关两次直接 panic。惯例是:谁发,谁负责关;接收方只管 range<-
  • sync.WaitGroupAdd 要在起 goroutine 之前调。 别在 goroutine 内部才 Add,那可能 Wait 已经先跑过去了,计数根本没加上。
  • defer mu.Unlock() 是好习惯,但小心锁的粒度。 如果临界区里夹了一个慢 I/O(比如网络请求),整个锁期间别人全在门口排队,并发直接变串行。锁只护数据,别把 I/O 也圈进去。
  • 读多写少用 sync.RWMutex,但别看到"读"就无脑上。 它只在读远多于写、且读临界区有一定耗时时才划算——RWMutex 内部要维护读者计数和写者标志,比 Mutex 更重。像前面那个计数器,临界区就一行、写还频繁,上 RWMutex 反而是净亏损:纯计数用 atomic,带复合判断(如 if count>0 then count--)用普通 Mutex,都更快。
  • 别忘了 -race CI 里养成 go test -race 的习惯,数据竞争这种"平时不出事、一上量就诡异"的 bug,靠它能提前抓出一大半。

六、最后一句

回到开头那个选择题。Channel 和 Mutex 不是"新 vs 旧""高级 vs 低级"的对立,而是两种表达方式:一个擅长"数据在流动",一个擅长"状态被保护"。 别急着站队,先看清手头的问题属于哪一类。

分而治之只是把大问题拆小,真正的手艺是拆完之后,让这些小单元既能各干各的、又能在该同步时稳稳握手。工具就那么几样,难的是每次都用对地方。

传数据用 Channel,护状态用锁,该超时就超时,起了 goroutine 就想好它怎么退。

行动清单

写并发代码前,拿这几条对一遍:

  1. 先分类:这个共享数据,是要"传递交接"还是"就地保护"?—— 决定 Channel 还是锁。
  2. 控并发度:会起大量 goroutine 吗?下游扛得住吗?—— 上 Worker Pool,别裸奔。
  3. 留退路:每个会阻塞/会 I/O 的函数,第一个参数带上 ctx,想清楚它怎么被取消。
  4. 加闹钟:每个可能阻塞的 channel 操作,配 select + 超时/取消分支兜底。
  5. 收好尾:channel 由发送方关且只关一次;WaitGroup.Add 在起 goroutine 之前调。
  6. 锁要瘦:临界区里别塞 I/O;读多写少换 RWMutex
  7. 抓竞争:CI 里跑 go test -race,别等上线才发现。

你在项目里踩过哪个并发的坑?是 goroutine 泄漏,还是 channel 关早了 panic,还是死锁?欢迎到我的个人网站留言聊聊。

扩展阅读

书:

  • 《Concurrency in Go》(Katherine Cox-Buday,O'Reilly)—— 首推,专讲 Go 并发,CSP、Channel、sync 包、pipeline、fan-out/fan-in、context 取消都讲得系统又落地,本文提到的模式书里都有更完整的推导。有中译本《Go 语言并发之道》。
  • 《The Go Programming Language》(Donovan & Kernighan)—— Go 圣经,第 8、9 章专讲 goroutine、channel 与共享变量同步,是理解"祖训"由来的一手材料。
  • 《The Art of Multiprocessor Programming》(Herlihy & Shavit)—— 跳出语言看并发,锁、无锁、内存模型的理论根基都在这儿。

一手查证资料:

  • controller-runtime issue #3218 / PR #3247 —— 本文 4.5 节那个真实泄漏 bug 的原始记录。
  • Uber Engineering —— LeakProf(2022)—— 大厂怎么在生产环境批量抓 goroutine 泄漏。
  • go.uber.org/goleak —— 单元测试里检测 goroutine 泄漏的工具,defer goleak.VerifyNone(t) 一行就能用。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* Go 并发:Channel 还是锁
** 大背景
*** 高并发/大流量
*** 分而治之
*** 多进程/多线程/多路复用/异步
** 怎么选
*** 传数据 → Channel
*** 护状态 → 锁 / atomic
*** 别为祖训而祖训
** 好用的模式
*** Worker Pool 控并发度
*** Fan-out / Fan-in
*** context 超时与取消
*** select + 超时兜底
*** 真实 bug:k8s controller-runtime 泄漏
** 常见坑
*** channel 发送方负责关
*** WaitGroup.Add 要提前
*** 锁里别塞 I/O
*** 读多写少用 RWMutex
*** go test -race
@endmindmap

Go 并发:Channel 还是锁 - 思维导图


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。