周末晚上七点,老周的餐厅坐满了客人。传菜口的小票像雪花一样飞来,后厨里却井井有条:洗菜的只洗菜,切配的只切配,三位大厨各守一口锅,传菜员沿着窄道来回穿梭。没有人大喊大叫,靠的是一张张小票和一条传菜通道,整个厨房就能以惊人的效率运转。 Go 语言里的 goroutine 就像老周后厨里的员工——成本极低,你可以轻松开出几万个。但员工多了不会自动高效,你得给他们编排好“谁负责什么、东西怎么传递、打烊怎么通知”。这就是 Go 并发模式:生产者消费者、流水线、扇出扇入、done channel、限流、errgroup、semaphore。掌握这些模式,你就能像老周调度后厨一样,让成千上万个 goroutine 各司其职、高效协作。

岗位关联

并发编程是后端开发的核心能力。掌握这些模式,意味着你能写出高吞吐、可维护的并发服务,从容应对数据管道、实时处理、微服务治理等真实场景——这是从“会写 Go”到“写好 Go”的关键一跃。

目录
  1. 可视化演示
  2. 生产者消费者:点单与传菜
  3. 流水线模式:后厨的分段流水
  4. 扇出扇入:一单多厨,汇成一桌
  5. done channel:打烊广播
  6. 限流模式:餐厅限流闸机
  7. errgroup:值班经理责任制
  8. semaphore:窄道令牌
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Go并发编程核心模式:流水线(Prod→S1→S2→S3→Cons)串联处理、扇出扇入(W1/W2/W3)并行分发、Done Channel取消广播和Semaphore并发度控制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Go并发模式全景 流水线(Prod→S1→S2→S3→Cons)串联处理,扇出(fan-out)将S2输出分发到W1/W2/W3并行worker 扇入(fan-in)合并worker结果到S3,Done Channel广播取消信号,Semaphore控制并发上限 Pipeline → Fan-out → Fan-in → Done → Semaphore

生产者消费者:点单与传菜

老周在餐厅里定了条规矩:前厅只管写小票,后厨只管做菜,中间隔着一个传菜窗口。写小票的人不关心菜怎么做,做菜的人也不关心客人长什么样。这份“小票”就是数据,写小票的就是生产者,做菜的就是消费者。

对应到 Go 里,生产者是负责产生数据的 goroutine,消费者是负责处理数据的 goroutine,而传菜窗口就是 channel。channel 是 Go 提供的类型安全的消息队列,可以用 make(chan int) 创建。生产者往 channel 里发送数据,消费者从 channel 里接收数据,两端互不直接依赖。最关键的是:channel 本身自带阻塞机制——窗口没位置了,写小票的人就得等着;窗口空了,做菜的人就得等着。这个自动的“等待”让两端不需要额外加锁。

channel 分为无缓冲和有缓冲两种。无缓冲 channel 就像老周的“面对面窗口”:生产者和消费者必须同时到场,一手交小票一手接小票——这强制同步,也叫“握手”;有缓冲 channel 就像在窗口外放了个架子(比如能放 5 张单子):生产者可以先把单子放架子上,不用等消费者立刻来取,双方节奏解耦。

在 Go 的并发哲学里有一句名言:"Do not communicate by sharing memory; instead, share memory by communicating."——不要通过共享内存来通信,而要通过通信来共享内存。channel 就是这句话的落地工具:数据通过传递来协作,而不是一把大锁护着一个共享变量。老周后厨的效率不是靠每个人死盯着同一本账本,而是靠小票在不同岗位之间流转。掌握生产者消费者,你就学会了后厨最基础的协作方式。

{发送阻塞if len⁡(ch)=cap⁡(ch)接收阻塞if len⁡(ch)=0\begin{cases}\text{发送阻塞} \quad \text{if} \ \operatorname{len}(ch) = \operatorname{cap}(ch) \\\text{接收阻塞} \quad \text{if} \ \operatorname{len}(ch) = 0\end{cases}
这个公式描述了 channel 的自动阻塞条件:缓冲满了发送者停下,缓冲空了接收者停下。这两条规则不需要你手动实现,Go 运行时已经替你把传菜窗口管好了。
  1. 发送阻塞条件当 channel 缓冲里已有的元素个数等于容量上限时,发送方 goroutine 会停下等待,直到有接收方取走数据腾出空间。$\operatorname{len}(ch) = \operatorname{cap}(ch)$ 表示缓冲恰好在临界点。
  2. 接收阻塞条件当缓冲一个元素都没有时,接收方 goroutine 会停下等待,直到有发送方送来数据。$\operatorname{len}(ch) = 0$ 表示缓冲为空。
go3 行
ch := make(chan int, 3)  // 架子能放 3 张小票go func() { ch <- 42 }() // 生产者在窗口放一张v := <-ch                // 消费者取走

流水线模式:后厨的分段流水

老周的餐厅越做越大,他发现让一个大厨从头到尾做整桌菜太慢了。于是他改成流水线:洗菜工只洗菜,切配工只负责把菜切成型,炒菜师傅只管下锅。每一道工序都是独立的人,菜从前一道流到后一道,后厨再也不用人等锅、锅等人。

流水线模式(Pipeline)就是把一个大的处理任务拆成多个阶段(stage),每个阶段是一个独立的 goroutine,阶段之间用 channel 连接。前一个阶段的输出 channel,就是后一个阶段的输入 channel。每个 goroutine 都在自己的节奏上工作,互不等待——洗菜工洗完一棵菜丢进筐里,立刻洗下一棵,不用等切配工切完这棵再开工。

这种“级联”用代码写出来非常优雅:

每个函数接收一个只读 channel,返回新的只读 channel,数据像流水一样从一端流到另一端。流水线的核心价值在于重叠:当洗菜工在处理第 5 棵菜时,切配工可能正在切第 3 棵,炒菜师傅正在炒第 1 棵。三个阶段的耗时重叠在一起,整体吞吐大幅提升。

理解了公式你就明白:想提升流水线速度,不是平均优化每个阶段,而是找到最慢的那道工序去优化它。老周就是这样做的——他发现切配工太慢,就给他换了把快刀,整条后厨的出品速度立刻上来了。在设计 Go 流水线时,你要学会“盯着瓶颈看”,而不是盲目给每个阶段加人手。

T(n)≈Tstartup+n×max⁡(t1,t2,…,tk)T(n) \approx T_{\mathrm{startup}} + n \times \max(t_1, t_2, \dots, t_k)
这是流水线的总耗时公式:处理 n 个任务的总时间,约等于启动开销加上 n 乘以所有阶段中最耗时的那个。也就是说,最慢的环节决定了整条管道的速度。
  1. 启动开销流水线刚开动时,第一道菜在进入各阶段前有一段“空跑”时间。$T_{\mathrm{startup}}$ 表示管道建立和排空的固定开销。
  2. 任务总数$n$ 表示要处理的菜(任务)总数量,它和每道菜的耗时相乘。
  3. 瓶颈阶段$\max(t_1, t_2, \dots, t_k)$ 表示所有阶段耗时的最大值,$t_i$ 是第 $i$ 个阶段处理单道菜的耗时。这个最大值就是瓶颈,它决定了稳态下每道菜出品的间隔。
go5 行
func wash(in <-chan Dish) <-chan Dish { ... }func cut(in <-chan Dish) <-chan Dish { ... }func cook(in <-chan Dish) <-chan Dish { ... } dishes := cook(cut(wash(source)))

扇出扇入:一单多厨,汇成一桌

老周发现午市客流一大,单靠一位大厨炒菜根本顶不住。他的对策是:一个订单里的多道菜,分给好几位大厨同时做,等所有菜都出锅了,再统一端到传菜口上桌。这就是扇出(fan-out)和扇入(fan-in)。

在 Go 中,扇出指的是多个 goroutine 同时从同一个 channel 中读取任务。每个任务只会被其中一个 goroutine 取走,多个 worker 自动竞争分工。扇入指的是把多个 worker 的输出汇集到同一个 channel,由下游统一处理。扇出解决了“单点处理太慢”的问题,扇入解决了“结果汇总”的问题。两者配合,就是 Go 里最经典的工作池(worker pool)模式。

扇入的关键细节是关闭时机:必须等所有 worker 都结束,才能关闭输出 channel。否则消费者会在 worker 还没干完活时就收到“通道关闭”的信号,丢掉还没到的数据。标准做法是配合 sync.WaitGroup 计数器——每个 worker 启动时计数加一,退出时减一,等计数器归零后,由唯一的“协调者”关闭输出 channel。

老周深谙此道:午市高峰他最多让三位大厨同时炒菜,因为厨房只有三口锅。你设计扇出扇入时也要想清楚“锅有几口”——用 runtime.NumCPU() 或者用后面要讲的 semaphore 来限制 worker 数量,让扇出的效率真正落地。

S=TserialTparallel≈min⁡(Nworker,NCPU)S = \frac{T_{\mathrm{serial}}}{T_{\mathrm{parallel}}} \approx \min(N_{\mathrm{worker}}, N_{\mathrm{CPU}})
扇出的加速比公式:并行处理比串行处理快多少倍,近似等于 worker 数量与 CPU 核心数中的较小值。开 100 个 worker 但机器只有 4 核,加速比也到不了 100——后厨再大,锅就只有那么几口。
  1. 串行时间$T_{\mathrm{serial}}$ 表示一个 worker 从头到尾处理完所有任务所需的时间,这是加速的基准。
  2. 并行时间$T_{\mathrm{parallel}}$ 表示多个 worker 分工后处理完同样任务所需的总时间。
  3. 加速比近似值$\min(N_{\mathrm{worker}}, N_{\mathrm{CPU}})$ 表示理论最大加速比取 worker 数和 CPU 物理核数的较小者,因为超过核数后线程切换反而互相抢资源。
go7 行
var wg sync.WaitGroupout := make(chan Result)for i := 0; i < 3; i++ {    wg.Add(1)    go func() { defer wg.Done(); work(out) }()}go func() { wg.Wait(); close(out) }() // 全部完成后才关闭

done channel:打烊广播

晚上十点半,老周站在后厨门口喊了一声:“打烊了!把手头的活收完就下班!”正在洗菜的、切配的、炒菜的伙计们听到后,各自把手里的最后一道工序做完,然后熄火、关水、撤出厨房。老周不需要挨个走到每个人面前说一遍,这一嗓子就是广播。

在 Go 里,这种“广播停止”的标准机制就是 done channel。它的用法很特殊:不是发送数据,而是关闭 channel。Go 的 channel 有一个重要特性:对一个已关闭的 channel 做接收操作,会立即返回零值,并且永远不会阻塞。这意味着当 close(done) 执行后,所有正在监听 done 的 goroutine 会同时收到“结束”信号——这就是广播。

监听 done 的标准姿势是 for + select 循环:

为什么不用发送数据的方式通知?因为发送只能被一个 goroutine 接收到,而关闭可以让所有监听者同时收到通知——这正是“广播”和“单播”的区别。done channel 也是防止 goroutine 泄漏的兜底手段:如果一个 goroutine 永远在等 channel 里的数据,而数据永远不会来了,它就泄漏了。加上 done channel 和 select 之后,它至少有一个“后门”可以干净退出。

老周的打烊广播让每个伙计都主动收尾,而不是被强行断电。在 Go 里,close(done) 就是那一声“打烊了”,它是唯一不发送数据却能让所有监听者感知的信号。在编写任何长时间运行的 goroutine 时,都要问自己:如果程序要退出了,这个 goroutine 知道怎么下班吗?

select{←done:立即收尾并退出v←in:继续处理业务数据\text{select} \begin{cases} \gets\text{done}: & \text{立即收尾并退出} \\ v \gets \text{in}: & \text{继续处理业务数据} \end{cases}
这个公式表达了 done channel 的决策逻辑:select 同时在两个 channel 上等待,谁先就绪就执行谁。done 就绪表示收到停止信号,in 就绪表示有新的任务要处理。
  1. 打烊分支$\gets\{\text{done}$ 表示从 done channel 接收到了关闭信号,此时 goroutine 应当进行清理工作并退出,对应餐厅伙计收工下班。
  2. 工作分支$v \gets \text{in}$ 表示从业务 channel 中收到了待处理的数据,此时继续正常处理业务,对应后厨还有活要干。
  3. select 竞争两个分支是竞争关系,select 内部采用随机选择机制,保证两个分支都不会被饿死,因为数据可能随时到来。
go14 行
done := make(chan struct{})go func() {    for {        select {        case <-done:          // 收到打烊广播            cleanup()         // 收尾            return        case v := <-in:       // 还有活要干            process(v)        }    }}()// 打烊时只需要:close(done)

限流模式:餐厅限流闸机

周末晚市排队的人太多,老周发现如果放太多客人进店,后厨会直接崩溃——单子堆积、出菜超时、客人投诉。于是他安排了一个门口保安:每 30 秒放一桌客人进店,店里的客流量被严格控制在后厨的承受范围内。这就是限流。

在 Go 里,限流模式最经典的工具是 time.Ticker。它像一个节拍器,每隔固定的时间间隔,就在自己的 channel 上发送一次“滴答”。你的处理逻辑只需要在每次干活之前,先从 Ticker 的 channel 里消费一个“滴答”——没收到滴答就阻塞等待,收不到就不放行,从而严格控制处理速率。

更进一步,限流还常与带缓冲的 channel 结合,实现“令牌桶”:预先往缓冲里装好一定数量的令牌,请求来了先抢令牌,抢到就处理,抢不到就排队或拒绝。这就像老周还额外准备了一个“候补名单”,缓冲能吸收短时间内的突发流量,不至于把所有请求都挡在门外。

老周把门口的限流闸机当作餐厅的“安全阀”:宁可让客人在外面等,也不能让后厨崩溃。Go 里的限流模式做的是同一件事——对外部流量的主动控制,保证你的服务在洪峰下依然稳定。这对接 AI 推理服务、API 网关等场景尤其重要:模型服务的容量是有限的,不加限流的后果就是雪崩。

r=CTintervalr = \frac{C}{T_{\mathrm{interval}}}
这是限流速率公式:$C$ 是时间窗口内发放的令牌数量,$T_{\mathrm{interval}}$ 是窗口时长,两者的比值就是系统允许的最大请求速率。注意,实际处理速率永远不会超过这个上限 $r$。
  1. 令牌数量$C$ 表示在一个时间窗口内允许放行的请求数,比如 10 秒内放行 50 个请求。
  2. 窗口时长$T_{\mathrm{interval}}$ 表示这个时间窗口持续的时间,它是令牌发放的周期基准。
  3. 最大速率$r$ 是计算得出的速率上限,任何超过 $r$ 的流量都会被限流器挡住,从而保护下游系统。
go5 行
limiter := time.Tick(200 * time.Millisecond) // 每 200ms 一个令牌for req := range requests {    <-limiter            // 没令牌就等着    go handle(req)       // 拿到令牌才放行}

errgroup:值班经理责任制

老周给后厨设了一个值班经理的岗位。每个伙计如果遇到处理不了的问题——比如食材变质、锅坏了——就向值班经理报告。值班经理的职责是:只要听到一个伙计报告问题,立刻广播全体停手,统一善后。他不会等所有伙计都报告完再行动,因为那样损失已经太大了。

Go 里这个“值班经理”就是 errgroup(来自 golang.org/x/sync/errgroup)。它让你把多个 goroutine 组织成一个小组,统一管理错误和取消。它的核心 API 有三个:g.Go(f) 把任务加入组并启动 goroutine,g.Wait() 等待所有任务结束并返回错误,errgroup.WithContext(ctx) 创建组的同时生成一个可取消的 context。

errgroup 的行为和老周的值班经理一模一样:只要有一个 goroutine 返回了非 nil 的错误,errgroup 就自动调用 context 的取消函数(广播 cancel()),其他所有 goroutine 通过监听 ctx.Done() 收到取消信号并自行退出。所以它天然和 done channel 模式配合——errgroup 是“谁出错谁报警”,done channel 是“报警后怎么广播撤退”。

老周常说:“一个经理管一组人,比每个人都互相报告更高效。” errgroup 的价值正是把复杂的多 goroutine 错误处理收敛成一次 Wait() 调用。无论你是并发下载文件、并发调用多个 AI 模型、还是并行处理分片数据,errgroup 都是你管理任务组的首选——尤其当你想让“一个失败,全组撤退”时,它已经把取消传播封装好了。

err=g.Wait()={第一个非 nil 错误若有任务失败nil全部任务成功\text{err} = g.\mathrm{Wait}() = \begin{cases}\text{第一个非 nil 错误} & \text{若有任务失败}\\nil & \text{全部任务成功}\end{cases}
这个公式说明 Wait() 的返回值只可能是两种情况:要么是所有任务中的第一个错误,要么是 nil(代表全部成功)。这就是“值班经理只报第一声警”的设计。
  1. 组等待方法$g.\mathrm{Wait}()$ 表示 errgroup 的等待方法,它会阻塞直到组内所有 goroutine 执行完毕。
  2. 首个错误返回第一个非 nil 错误,说明组内只要有一个任务出错,这个错误就会被报告出来,无需等待其他任务的结果。
  3. 成功返回 nil若所有任务都成功执行(没有返回任何错误),则 Wait() 返回 nil,表示整组任务顺利完成。
go9 行
g, ctx := errgroup.WithContext(context.Background())for _, file := range files {    g.Go(func() error {        return process(file) // 一旦失败返回非nil错误    })}if err := g.Wait(); err != nil {    log.Fatal("有任务失败:", err)}

semaphore:窄道令牌

老周的后厨到传菜口之间有一条窄道,同时只能走三个人。他发现如果不设限制,伙计们挤作一团反而更慢。于是他在窄道口放了一个盒子,里面放着三枚令牌:谁要经过,必须拿一枚令牌;用完了放回盒子;盒子空了,后面的人就得排队等着。这就是信号量(semaphore)——用固定数量的“令牌”来控制同时访问某个资源的并发数。

在 Go 里实现一个信号量非常简单,因为带缓冲的 channel 天生就是令牌盒:

为什么用 struct{}{}?因为空结构体不占任何内存,我们只关心“有没有令牌”这个信号,不关心令牌的内容。sem <- struct{}{} 是获取令牌,如果 buffered channel 已满就会阻塞;<-sem 是归还令牌,腾出一个空位。同时活跃的 goroutine 数量永远不会超过缓冲容量——这就是并发度控制的数学保证。

窄道令牌的思路可以推广到几乎所有“资源有限”的场景:数据库连接池、AI 模型推理接口、下游 API 的调用额度……凡是担心“并发太高会压垮资源”的地方,都可以用信号量来设置一道闸门。老周用三枚令牌保住了传菜通道的畅通,你用 make(chan struct{}, N) 保住了系统的稳定。这是最朴素也最有效的并发度控制手段。

∑i=1nxi≤S,xi∈{0,1}\sum_{i=1}^{n} x_i \le S, \quad x_i \in \{0, 1\}
这是信号量的核心约束:$x_i$ 表示第 $i$ 个 goroutine 是否正在持有令牌(1 表示持有,0 表示未持有),所有持令牌者之和永远不超过信号量上限 $S$。超出的 goroutine 必须排队等待。
  1. 持有状态变量$x_i$ 是第 $i$ 个 goroutine 的持有状态,取值只能是 0 或 1,代表它是否占有资源。
  2. 并发数总和$\sum_{i=1}^{n} x_i$ 把所有正在持有令牌的 goroutine 数量加起来,就是当前的实际并发数。
  3. 信号量上限$S$ 是信号量允许的最大并发数,也就是令牌盒里的令牌总数。求和结果永远不超过 $S$,这就是并发度被控制住的数学保证。
go7 行
sem := make(chan struct{}, 3) // 盒子最多放 3 枚空令牌 go func() {    sem <- struct{}{}         // 取令牌:发送一个空结构体    defer func() { <-sem }()  // 还令牌:接收释放一个位置    work()                    // 只有拿到令牌才能干活}()

AI 应用场景

日志采集与清洗管道

阶段:数据处理阶段

解决的问题:AI 系统的日志和训练数据量巨大,传统做法是串行读取、清洗、写入,速度慢且阻塞。用 Go 的流水线模式组织日志处理管道,第一阶段采集原始日志,第二阶段过滤噪音,第三阶段格式化并写入存储,每个阶段都是独立 goroutine,吞吐量提升数倍。

具体案例:一个推荐系统的点击日志平台,利用生产者消费者模式让业务服务直接往 channel 里写日志,后台 4 个清理 worker 并行消费、聚合、落盘,同时用带缓冲 channel 吸收秒级流量高峰。管道通过 done channel 优雅停机,日志零丢失。

大规模爬虫的扇出扇入抓取

阶段:数据采集阶段

解决的问题:训练 AI 模型需要海量网页数据,串行爬取太慢,无限制并发又会把目标网站打死。用扇出扇入模式:一个调度 goroutine 生成抓取任务,50 个 worker 并发下载(扇出),结果统一汇入收集 channel(扇入),配合 semaphore 限制并发数保护目标站点。

具体案例:某 NLP 团队抓取新闻语料,sem := make(chan struct{}, 20) 控制 20 路并发,每个 worker 抓完一个页面后通过 errgroup 统一上报错误。某个域名超时导致部分任务失败时,errgroup 自动取消剩余任务,第一时间暴露问题,而不是让整个管道卡死。

AI 推理服务的限流网关

阶段:模型部署与推理阶段

解决的问题:大模型推理服务昂贵且时延高,无限制的请求会让 GPU 排队爆炸、单请求响应变慢。用限流模式给每个用户按配额发放令牌,再配合带缓冲 channel 削峰填谷,保证在线服务的稳定时延。

具体案例:一个文生图 API 服务,用 time.Ticker 实现每秒最多 10 个请求的全局速率限制,再加 semaphore 限制同时进行推理的任务数为 4(对应 GPU 显存容量)。超限请求直接返回 429,用户在重试策略下获得平稳体验。

Python 演示

python67 行
"""用 Python 线程模拟 Go 流水线:生产者 + 多个 worker + 消费者 + done 广播"""import threadingimport queueimport timeimport random STOP = object()  def produce(out_q, total=6):    """生产者:模拟产生 6 个任务,最后发送停止哨兵"""    for i in range(total):        out_q.put(i)        time.sleep(random.uniform(0.01, 0.05))    out_q.put(STOP)  def worker(in_q, out_q, name, stop_evt):    """worker: 消费任务, 计算结果, 转发到输出队列"""    while not stop_evt.is_set():        try:            item = in_q.get(timeout=0.05)        except queue.Empty:            continue        if item is STOP:            in_q.task_done()            break        out_q.put((name, item * item))        in_q.task_done()  def consume(in_q):    """消费者: 收集所有结果"""    result = []    while True:        item = in_q.get()        if item is STOP:            break        result.append(item)    return result  def main():    input_q = queue.Queue(maxsize=3)    output_q = queue.Queue()    stop_evt = threading.Event()     producer = threading.Thread(target=produce, args=(input_q, 6))    producer.start()     workers = [threading.Thread(target=worker, args=(input_q, output_q, f"W{i}", stop_evt)) for i in range(3)]    for t in workers:        t.start()     producer.join()    input_q.join()    stop_evt.set()    for t in workers:        t.join()     output_q.put(STOP)    result = consume(output_q)    print("流水线结果:", sorted(result))  if __name__ == "__main__":    main()
目的:用 Python 的 threading 和 queue 模拟 Go 的流水线 + 扇出扇入 + done 广播模式,演示生产者、3 个并行 worker、消费者如何通过队列协作,以及如何用事件优雅停止所有线程。
  1. out_q.put(STOP)生产者发完所有任务后,放入一个特殊的“结束哨兵”,模拟 Go 中“关闭 channel”的语义,通知 worker 任务已清空。
  2. if item is STOP: in_q.task_done(); breakworker 拿到哨兵后确认队列任务完成并退出,相当于 Go 中 case <-done: return。
  3. in_q.get(timeout=0.05)带超时地取数据,模拟 Go 的 select 超时分支,让线程能定期醒来检查停止事件,避免永久阻塞。
  4. stop_evt.set()广播停止信号给所有仍阻塞的 worker,就像 Go 中 close(done) 让所有监听者同时退出,是防止线程泄漏的关键。
  5. input_q.join()等待队列所有任务被标记为完成,相当于 Go 的 sync.WaitGroup.Wait(),保证所有任务都已消费完。

常见误区

误区我刚开始写 Go 并发代码时,觉得 channel 和文件一样,用完必须 `close()`,否则就是资源泄漏。于是我在每个 goroutine 里到处关闭 channel,结果经常触发 `panic: send on closed channel`(向已关闭的 channel 发送数据会导致程序崩溃)。

正解channel 不是文件,它不会被“泄漏”。channel 的关闭是信号机制,不是资源回收机制。只有当你需要通知接收方“没有更多数据了”时才应该关闭,而且应该由发送方(且只有一个发送方)负责关闭。向已关闭的 channel 发送数据会 panic,因此胡乱关闭比不关闭更危险。

为何常见:很多编程语言里“资源用完必须释放”的观念根深蒂固,但 Go 的 channel 由 GC 管理,不需要手动释放。把“关闭”误解为“释放”,就会犯这个典型错误。

误区无缓冲 channel 每次发送都要等接收方,太慢了。我干脆 `make(chan int, 1000)`,缓冲越大越好,这样发送方永远不会被阻塞。

正解缓冲不是越大越好。无缓冲 channel 的“同步握手”正是其价值所在——它天然实现了生产者、消费者节奏的严格同步,不需要额外的锁或信号量就能保证“数据交付完成”。而大缓冲会掩盖处理瓶颈,数据堆积在缓冲里,消费者已经处理不过来了,生产者还以为一切正常。缓冲大小应该根据业务节奏精心设计,而不是盲目追求大。

为何常见:直觉上“排队的人越多越好”似乎能提高吞吐,但缓冲的本质是削峰,不是提效。真正的吞吐瓶颈在消费者,不在缓冲大小。

误区goroutine 这么轻量,那我开一万个是不是所有任务都能瞬间完成?我曾经在一个循环里不加限制地 `go` 出几万个 goroutine,结果程序反而变慢,内存暴涨。

正解goroutine 虽然轻量(初始栈约 2KB),但每个都要占内存和调度资源。CPU 核心数是有限的,同时运行的 goroutine 超过核数后,增加的是调度切换开销而不是并行能力。正确的做法是像老周控制传菜通道一样,用 semaphore 或工作池把并发数限制在合理范围,再配合扇出扇入充分发挥多核性能。

为何常见:“轻量”被误解为“免费”。goroutine 的轻量是相对于操作系统线程而言的,不是绝对的零成本。资源的边界思考是每个并发程序员都要修炼的功课。

误区main 函数退出了程序就结束了,goroutine 反正会跟着进程一起消失,没必要费心搞 cancel 机制。我之前写的一个常驻服务,goroutine 越积越多,内存逐步涨到 OOM,才发现问题。

正解在常驻服务(比如 HTTP server、消息消费者)里,一个 goroutine 阻塞在 channel 上永不结束,就是内存泄漏。它占用的栈空间和关联的对象永远不会被回收。正确做法是:为每个长时间运行的 goroutine 配一个 done channel 或可取消的 context,让它在程序退出时能收到信号、完成清理、干净退出。

为何常见:本地小脚本跑完就退出,掩盖了泄漏问题。一旦部署到线上长时间运行,泄漏就会持续累积,直到系统资源耗尽。理解“goroutine 生命周期需要管理”是并发进阶的第一课。

面试题

Q1. 在 Go 中,声明一个可传递整数的通用 channel 的正确语法是?

单选题 · easy

  • A.ch := make(chan int)← 正确答案
  • B.ch := make(chan struct{})
  • C.ch := make(int chan)
  • D.ch := new(chan int)

答案:A。make(chan int) 创建传递整数数据的 channel。B 是传递空结构体(常用于信号通知),C 语法顺序错误,D 的 new 只分配指针类型而不初始化 channel 内部结构,会导致后续发送 panic。就像传菜窗口必须说明“里面传什么菜”,channel 需要指定元素类型。

Q2. 关于无缓冲 channel 的发送和接收,以下描述正确的是?

单选题 · easy

  • A.发送者必须等待接收者就绪才能完成发送← 正确答案
  • B.发送者发送后立即继续执行,无需等待
  • C.数据会在 channel 内部排队等待接收
  • D.接收者必须凑齐一批数据才开始接收

答案:A。无缓冲 channel 是同步握手——发送方和接收方必须同时就绪,数据才完成交接。B 错在无缓冲没有“立即发送”这回事,C 描述的是有缓冲 channel 的行为,D 是批处理的概念,与 channel 语义无关。好比老周的面对面传菜窗口:你递过去同时对方得接着,任何一放慢了都递不出手。

Q3. 对于 ch := make(chan int, 3),以下说法正确的是?

单选题 · mid

  • A.len(ch) 表示当前缓冲中未取走的元素个数← 正确答案
  • B.cap(ch) 表示已发送的元素总个数
  • C.缓冲满时接收操作会直接失败
  • D.缓冲空时发送操作不会阻塞

答案:A。len 是当前缓冲区的“占用量”,cap 是缓冲区的“总容量”,两者都是容量概念而不是历史计数。C 错,缓冲满时接收正常执行(正好腾出空间);D 错,缓冲空时发送正常执行,但缓冲满时发送会阻塞。就像传菜架子:len 是架子上现有几张单子,cap 是架子最多放几张。

Q4. done channel 模式中,用 close(done) 而不是 done <- 1 来广播通知,原因是?

单选题 · mid

  • A.关闭后所有监听者同时收到信号,发送只能被一个接收者收到← 正确答案
  • B.发送数据不需要创建 channel,更省内存
  • C.关闭比发送的速度快,能提升性能
  • D.只有关闭操作才能触发 channel 的垃圾回收

答案:A。channel 的发送是“单播”——只有一个 goroutine 能收到数据;而关闭是“广播”——所有正在接收该 channel 的 goroutine 都会立即返回零值。这正是老周喊“打烊了”而不是挨个单独通知的原因。B、C、D 的描述在语义上都不成立:发送也需要 channel,性能与关闭没有可比性,GC 也不依赖关闭操作。

Q5. 在流水线+扇出扇入模式中,输出 channel 应该由谁负责关闭?

单选题 · hard

  • A.所有 worker 全部完成后,由唯一的上游协调者关闭← 正确答案
  • B.每个 worker 完成自己的任务后各自关闭一次
  • C.消费者读取完毕后主动关闭输出 channel
  • D.不需要关闭,让消费者用超时方式结束读取

答案:A。Go 规定只能由发送方关闭 channel,多个 worker 各自关闭会导致重复关闭 panic。正确做法是用 sync.WaitGroup 统计所有 worker,全部退出后由协调者统一 close(out),下游用 for v := range out 自然结束。C 错在消费者关闭发送方 channel 是危险操作;D 用超时结束会丢数据。这就像后厨打烊后,由值班经理确认所有厨师都熄火了,才统一关灯。

Q6. 关于 goroutine 泄漏的成因和修复,以下说法正确的是?

多选题 · mid

  • A.goroutine 阻塞在 channel 上且不会有数据到来,会造成泄漏← 正确答案
  • B.用带缓冲的 channel 就能彻底杜绝 goroutine 泄漏
  • C.用 done channel 配合 select 可以让 goroutine 安全退出← 正确答案
  • D.用 context 的 Done() 可以完全替代 sync.WaitGroup

答案:AC。A 是泄漏的典型成因——goroutine 永远在等待一个不会来的数据。C 是标准修复手段:select 监听 done 分支,收到取消信号就退出。B 错,缓冲只改变阻塞时机,不解决“永远等不到”的问题;D 错,context 管取消、WaitGroup 管等待,两者职责不同,不能互相替代。就像给伙计配了对讲机(done),但最后还是要点名确认所有人都到齐(WaitGroup)。

Q7. 使用 errgroup 时,以下叙述正确的有?

多选题 · mid

  • A.g.Wait() 返回组内第一个非 nil 错误← 正确答案
  • B.errgroup.WithContext 会自动生成可取消的 context← 正确答案
  • C.组内某个任务出错时,其他任务会被自动取消← 正确答案
  • D.g.Go 中的函数返回值必须是 string 类型

答案:ABC。A 是 errgroup 的核心设计——只返回第一个错误;B 是 WithContext 的功能,它返回 (g, ctx),出错时自动调用 cancel;C 是 B 的推论,监听 ctx 的任务会收到取消信号;D 错,g.Go 中的函数签名是 func() error,返回值是 error 而非 string。值班经理只记第一声警报,然后广播所有人撤退。

Q8. 用带缓冲 channel 实现 semaphore 时,“获取令牌”对应的操作是?

单选题 · mid

  • A.向 channel 发送一个空结构体← 正确答案
  • B.从 channel 接收一个空结构体
  • C.修改 channel 的容量值
  • D.关闭 channel 并重新创建

答案:A。sem <- struct{}{} 是获取令牌——缓冲满则阻塞,相当于从窄道口的盒子里拿走一枚令牌。B 是归还令牌(释放)。C 修改容量在 Go 中不可能,cap 是创建时固定的;D 关闭 channel 会导致后续发送 panic。信号量的本质是“容量即令牌数”,获取和释放都是围绕 buffer 槽位做操作。

Q9. 在 for + select 循环中,多个 case 同时满足条件时,Go 会?

单选题 · easy

  • A.随机选择一个 case 执行← 正确答案
  • B.按代码书写顺序执行
  • C.同时执行所有满足的 case
  • D.阻塞直到某个 case 独占就绪

答案:A。select 的多个 case 同时就绪时,Go 运行会随机选择一个执行,这是语言规范明确保证的。这样做是为了防止某个 case 长期被“饿死”。B 是常见误解,C 不可能,D 也不正确——select 不会等待“只剩一个就绪”。就像老周同时听到传菜员喊“上菜”和经理喊“打烊”,他随机处理一件,保证两件都有机会被处理。

Q10. 实现一个「限流 + 扇出」的 AI 推理管道,以下设计合理的有?

多选题 · extreme

  • A.用 time.Ticker 控制请求进入速率,发放固定间隔令牌← 正确答案
  • B.用带缓冲 channel 作为令牌桶,吸收短时突发流量← 正确答案
  • C.为每个请求创建一个新的 channel 单独传递结果
  • D.用 semaphore 限制同时推理的任务数为 GPU 容量← 正确答案

答案:ABD。A 是限流的基石,控制入口速率;B 是令牌桶,缓冲突发后平滑处理;D 用信号量保护 GPU 资源,限流和并发控制双保险,这是生产级推理服务的标准组合。C 错在为每个请求创建 channel 既浪费资源又难以管理,正确做法是共享 channel + 请求 ID 关联结果。就像老周既控制门口进客速度,又限制传菜通道人数,但绝不给每道菜单独修一条通道。