想象你是一家热门餐厅的后厨主管。周五晚上的订单像雪片一样飞来,出票机吐出的小票堆成了小山。你当然知道,不能为每一张订单都新雇一位厨师——一百个厨师挤在厨房里,锅碗瓢盆撞得叮当响,反倒一盘菜都端不出去。真正靠谱的做法是精兵简政:安排固定的几位厨师,每人负责一口灶,订单排队挂在取单栏上,谁空下来谁接单;菜做好后统一放到出菜口,再由传菜员端给客人。 这正是 Go 语言中三种并发模式的现实原型。Worker Pool 决定了灶台(goroutine)的上限;Fan-out 把订单(任务)从唯一的取单栏分发到各厨师手中;Fan-in 把所有灶台做好的菜(结果)汇聚到同一个出菜口。理解这三者的组合艺术,你就掌握了用 Go 编写高并发服务的核心心法。

岗位关联

对 AI 从业者而言,高并发数据的采集、批处理和模型服务都依赖这套模式——它决定了系统在流量洪峰下是稳如泰山还是瞬间崩盘,是后端工程师走向资深的分水岭。

目录
  1. 可视化演示
  2. 问题背景与动机:从“多多益善”到“有界并发”
  3. channel 通信基础:前厅与后厨之间的传送带
  4. Worker Pool 模式:固定厨师团队的排班表
  5. Fan-out 分发:一张菜单喂饱所有厨师
  6. Fan-in 汇总:所有成果汇聚到出菜口
  7. 错误处理与背压:厨房的应急预案
  8. 动态调整数量:忙时加人,闲时轮休
  9. 实战组合:errgroup 与场景选型
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Go三种经典并发模式:Worker Pool限制并发数消费任务、Fan-out并行分发、Fan-in多路合并汇总结果,交互切换模式理解任务流转机制。

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

Worker Pool:固定并发度限制 紫色为输入channel(任务队列) 浅紫为Worker goroutine 绿色为输出channel(结果) Concurrency = len(workers), QPS = N * 120 固定数量Worker从共享channel消费任务,限制并发保护资源

问题背景与动机:从“多多益善”到“有界并发”

继续回到我们的后厨。假设你一时冲动,决定“来单就招人”——客流量翻一倍,厨师翻一倍。头几次翻倍还能勉强运转,但厨房面积是固定的,通风、燃气、台面都有上限。很快你就会发现,人多了反而互相挡路,菜单传不出来,菜也端不出去,后厨变成一锅粥。

Go 的 goroutine 以轻量著称,启动时栈大小约 2KB,比操作系统线程动辄几 MB 的栈小了几个数量级。但“轻量”不等于“无限”。当 goroutine 数量达到百万级别时,光是栈内存就要消耗数十 GB,再加上调度器的负担、锁竞争、GC 压力,程序的有效吞吐会断崖式下跌。这正是许多初学者踩过的坑:go func() 加 for 循环,看起来爽,实际是给自己埋了一颗定时炸弹。

接下来要理解一对基础概念:并发与并行。并发是逻辑上的“同时推进”,并行是物理上的“同一时刻执行”。Go 的 GMP 调度模型把成千上万个 goroutine 映射到少量 OS 线程上交错执行。因此,与其让无数 goroutine 自由竞争,不如用 Worker Pool 主动把并发数限制在一个可预测的范围内。设任务总数为 N,池大小为 W,那么任意时刻真正同时处于运行状态的 goroutine 数量为:

所以,Worker Pool 模式的精髓就是四个字:有界并发。它告诉你,与其祈祷系统能扛住无限流量,不如一开始就设好天花板。厨师就这几位,订单再多,也请安心排队。

Nactive=min⁡(Ntask,  Wpool)N_{\text{active}} = \min(N_{\text{task}},\; W_{\text{pool}})
通俗地说,真正干活的人 = 任务数和厨师数里较小的那个,多余的订单在门外排队。
  1. N_active(实际干活数)某一时刻真正在工作的 goroutine 数,对应后厨里正在颠勺的厨师数。
    NactiveN_{\text{active}}
  2. 最小值运算(min)取两个数中较小的那个,表达“餐厅不会为没有订单的厨房付空饷”。
    min⁡(⋅,⋅)\min(\cdot,\cdot)
  3. N_task(任务总数)等待被处理的全部任务数量,对应排队等待的订单数。
    NtaskN_{\text{task}}
  4. W_pool(池大小)Worker Pool 中创建的 worker 数量,对应后厨的固定厨师人数。
    WpoolW_{\text{pool}}

channel 通信基础:前厅与后厨之间的传送带

订单从出票机出来之后,怎么流转到厨师手里?靠传菜员一张张递?效率太低。真正的后厨会设计一条传送带:订单挂到传送带上,自动送到取单栏,厨师从取单栏拿单做菜。Go 中的 channel 就是这条传送带。

channel 是 Go 并发模型的核心原语,它有两种形态:无缓冲和有缓冲。无缓冲 channel 就像一部直连电话——发送方和接收方必须同时在线才能通话,任意一方不在,另一方就干等。有缓冲 channel 则像一条传送带——发送方把消息放到传送带上就可以继续干别的,传送带满了才需要等待。

选择哪种 channel 在做设计决策时很有讲究。无缓冲 channel 天然同步,常用于 goroutine 间的握手和信号传递;有缓冲 channel 则是实现任务队列和背压的利器。发送阻塞由容量决定:

理解了传送带,你就能理解并发模式中一切协作的秘密:任务用 channel 流转,阻塞用容量来解释。后面所有模式的优雅之处,都建立在这个简单机制之上。

send blocked  ⟺  len(ch)=cap(ch)\text{send blocked} \iff \text{len}(ch) = \text{cap}(ch)
传送带已经塞满时,想再往上放新订单的人,只能在货梯口等着。
  1. send blocked(发送阻塞)goroutine 尝试向 channel 发送数据时被挂起,对应传送带满员时传菜员不得不排队。
    send blocked\text{send blocked}
  2. 等价条件(iff)当且仅当,左边成立时右边一定成立,反之亦然。
      ⟺  \iff
  3. len(ch)channel 中当前缓存的元素个数,就是传送带上目前的订单数。
    len(ch)\text{len}(ch)
  4. cap(ch)channel 的最大容量,就是传送带能承载的订单上限。
    cap(ch)\text{cap}(ch)

Worker Pool 模式:固定厨师团队的排班表

确定了厨房要养几位厨师之后,你需要的是一张排班表。哪怕再忙,厨师总数不变,下单再猛,也只靠这些厨师轮流转。这张排班表在 Go 里就是 Worker Pool——一批预先启动的 goroutine,循环消费一个共享的任务 channel。

Worker Pool 由三件事组成:一个保存待办任务的缓冲 channel(取单栏);若干个执行任务的 goroutine(厨师);以及一个在它们全部完工后触发结束的 WaitGroup(打烊清点)。当 main goroutine 向任务 channel 发送任务后,空闲 worker 通过 for range 从 channel 中竞争获取并执行任务。

worker 数量怎么定?过少,CPU 闲着;过多,大规模上下文切换和内存占用让性能下降。排队论中有一条经典参考:设到达率为 λ,单个 worker 的服务率为 μ,那么最优 worker 数近似为:

实践中,上式计算出的只是一个理论起点,最终还要靠压测修正。因为再精确的排班表,也抵不过真实客流的变化——这就引出后面要讲的动态调整。但核心思想不变:池子大小就是厨房的天花板。

W∗=⌈λarrivalμservice⌉W^* = \left\lceil \frac{\lambda_{\text{arrival}}}{\mu_{\text{service}}} \right\rceil
上取整的意思是:如果算出来需要 3.2 个厨师,那就招 4 个,毕竟不能招半个厨师。
  1. W\*(最优 worker 数)理论上最合适的并发规模,对应后厨的合理编制。
    W∗W^*
  2. 到达率 λ单位时间内新增任务的数量,即后厨单位时间涌入的订单数。
    λarrival\lambda_{\text{arrival}}
  3. 服务率 μ单个 worker 单位时间能完成的任务数,即一位厨师单位时间能出几道菜。
    μservice\mu_{\text{service}}
  4. 向上取整无论小数部分是多少,一律向大取整。
    ⌈⋅⌉\lceil \cdot \rceil

Fan-out 分发:一张菜单喂饱所有厨师

现在厨房里坐着四位厨师,但只有一条传送带从出票机通到取单栏。好消息是,传送带是共享的——每一位空闲的厨师都能从取单栏上取走最上面那张订单。一张订单被某个厨师取走后,其他厨师就不会再碰它。这种“一个队列、多人竞争”的模式,就叫 Fan-out。

在 Go 中,Fan-out 的实现不需要任何复杂的负载均衡器。多个 goroutine 同时从一个只读的 channel 中读取数据,语言层面天然保证了“每条消息恰好被一个 goroutine 处理”。因为 channel 的接收具有独占性:一个值从 channel 中弹出后,不会出现在其他接收者的视野里。

假设分发方往 channel 中写入任务,W 个 worker 同时执行接收操作。分发过程可以这样模型化:

值得注意的是,“一读多写”并不保证公平——手快的 worker 会接到更多任务,这恰好是自然的负载均衡。当你发现某个 worker 长期没事干时,很可能是任务粒度不均,而不是 Fan-out 本身有问题。

⋃j=1Wworkerj  ←  recv(ch)\bigcup_{j=1}^{W} \text{worker}_j \; \leftarrow \; \text{recv}(ch)
所有厨师都盯着同一个取单栏,谁空闲谁伸手,拿到谁的算谁的。
  1. 并集符号表示把 W 个 worker 看作一个整体,任何一个 worker 接到任务都算分发成功。
    ⋃j=1W\bigcup_{j=1}^{W}
  2. worker_j第 j 个 worker goroutine,对应厨师 j 号。
    workerj\text{worker}_j
  3. recv(ch)从 channel ch 接收一个值,对应从取单栏取走一张订单。
    recv(ch)\text{recv}(ch)
  4. 箭头方向任务总是从 channel 流向 worker,也就是订单从取单栏流向厨师。
    ←\leftarrow

Fan-in 汇总:所有成果汇聚到出菜口

菜做好了,不能四散在厨房各角落。出菜口是唯一的交菜地点:四位厨师做完菜都往出菜口一放,传菜员守在出菜口,等菜齐了再一起端走。这种“多人交菜、一人收菜”的模式,就是 Fan-in。

在 Go 中,Fan-in 通常表现为多个 goroutine 向同一个 channel 发送结果,而主 goroutine 循环接收这些结果。关键是发送方的协作:所有发送方都完成之后,接收方如何知道“菜已经上齐了”?答案是借助 sync.WaitGroup 等待所有发送方结束,再由唯一一方负责 close(resultCh)。

接收方无法预知总结果数,只能持续读取直到 channel 关闭。将 W 个 worker 的结果合并到同一个结果 channel 的过程可以表示为:

Fan-in 最大的坑是关闭时机。在 Go 中,向已关闭的 channel 发送数据会引发 panic,这就像往已经关闭的出菜口硬塞盘子——菜撒一地不说,勺子也要扔。所以永远记住:close 只能由发送方来执行,并且只能执行一次。

Rall=⋃j=1WRj  ⟶  recv(resultCh)R_{\text{all}} = \bigcup_{j=1}^{W} R_j \;\longrightarrow\; \text{recv}(\text{resultCh})
出菜口最终汇聚了所有厨师的成品,服务员只站在出菜口等着收菜就好。
  1. R_j第 j 个 worker 产生的部分结果,对应某位厨师做好的一道菜。
    RjR_j
  2. 并集把 W 份部分结果合在一起。
    ⋃j=1W\bigcup_{j=1}^{W}
  3. R_all结果全集,对应最终端上桌的整桌菜。
    RallR_{\text{all}}
  4. recv(resultCh)接收方从结果 channel 中不断读取,直到发送方关闭 channel。
    recv(resultCh)\text{recv}(\text{resultCh})

错误处理与背压:厨房的应急预案

再怎么精心备料,也会碰到食材变质或燃气断供的突发状况。厨师要把坏消息传出来,不能自己憋着;更麻烦的是,如果订单积压到传送带都堵死了,整个厨房就瘫痪了。这就是错误处理与背压要解决的问题。

在 Go 的 Worker Pool 中,错误的处理方式不是 panic,而是把 error 作为结果的一部分放进 result channel。调用方接收结果后主动检查 error,这就是“错误即数据”的思想。而背压更简单:任务 channel 一旦被填满,上游的发送方就会被阻塞,发送速率自动放慢,系统因此获得了一层本能的自我保护。

背压的触发条件非常直观——缓冲区的长度达到容量上限,发送方阻塞。此外还有 context 取消机制:当某个 worker 返回致命错误时,通过 context.WithCancel 统一取消所有 worker,避免无意义的继续处理:

餐厅面对爆单,最有效的办法不是增加人手,而是门口先取号限流。背压就是 Go 世界里的取号机:它让系统在过载时优雅变慢,而不是直接崩溃。

backpressure  ⟺  len(ch)=cap(ch)  ∧  sender blocked\text{backpressure} \iff \text{len}(ch) = \text{cap}(ch) \;\land\; \text{sender blocked}
传送带满了,前面还想往上传的人就得停手——不是不想传,是传不上去了。
  1. backpressure(背压)系统通过阻塞上游来压制过高的任务流速。
    backpressure\text{backpressure}
  2. len = cap缓冲量达到上限,也就是传送带已经塞满。
    len(ch)=cap(ch)\text{len}(ch) = \text{cap}(ch)
  3. 逻辑与(and)两个条件同时成立时,背压才真正发生。
    ∧\land
  4. sender blocked发送方 goroutine 进入阻塞等待,直到消费者腾出空间。
    sender blocked\text{sender blocked}

动态调整数量:忙时加人,闲时轮休

固定厨师团队解决了秩序问题,但生意有淡旺季。节假日客人爆满,4 个厨师忙不过来;工作日稀稀拉拉,10 个厨师站着看报纸。理想的后厨应该能临时加人,又能让多余的厨师下班回家——这就是动态调整 worker 数量。

在 Go 中,“减人”不是简单地杀掉 goroutine(Go 不支持从外部杀死一个 goroutine),而是给 worker 发一个退出信号,让它处理完当前任务后自己退出。常见的实现方式:维护一个停止 channel,worker 通过 select 同时监听任务 channel 和退出信号;“加人”则只需按需启动新的 goroutine,并把它们纳入同一个 WaitGroup 管理。

若把基准 worker 数记为 W_base,运行时新增的 worker 数为 ΔW_add,优雅退出的 worker 数为 ΔW_remove,那么当前活跃的 worker 数为:

动态调整的本质是“让退出优雅、让扩张有序”。提前设计的退出通道和 WaitGroup 协同机制,能让厨师们体面地收拾好手头那道菜再离开,而不是被连人带锅扔出厨房。

Wnow=Wbase+ΔWadd−ΔWremoveW_{\text{now}} = W_{\text{base}} + \Delta W_{\text{add}} - \Delta W_{\text{remove}}
现在的厨师数 = 固定班底 + 临时兼职 - 已经下班回家的人。
  1. W_now(当前人数)任意时刻仍在工作的 worker 总数。
    WnowW_{\text{now}}
  2. W_base(基准人数)初始启动的固定 worker 数量,对应厨房的正式编制。
    WbaseW_{\text{base}}
  3. ΔW_add(新增数量)运行中临时增加的 worker 数量,对应节假日兼职帮厨。
    ΔWadd\Delta W_{\text{add}}
  4. ΔW_remove(退出数量)已接收退出信号并结束工作的 worker 数量,对应下班回家的厨师。
    ΔWremove\Delta W_{\text{remove}}

实战组合:errgroup 与场景选型

一位经验丰富的厨师长不会只盯着一道菜。他会在开餐前把任务拆解给不同档口的厨师,又在某个档口出问题时及时喊停,不让所有人白忙。Go 的 errgroup 正是这位厨师长的指挥台。

errgroup 可以看成 WaitGroup 的升级版:errgroup.Group 的 Go 方法启动 goroutine,Wait 方法等待所有 goroutine 结束,并返回第一个错误。配合 errgroup.WithContext,一旦某个任务出错,它还会自动 cancel 上下文,让所有监听 ctx 的 goroutine 提前退出。

结合 errgroup、Worker Pool 和 Fan-in 三种模式的完整流水线,统一错误收口,适用于批处理、爬虫、消息消费等场景。假设任务执行后收集到的错误集合为 E,当任一任务出错时:

选型时请记住:线上爬虫适合 Worker Pool + Fan-in(控制抓取速率、汇总页面数据);批处理任务适合 Fan-out + Fan-in(并行处理加归并结果);消息消费服务适合 Worker Pool + 背压(消费速率与积压自动平衡)。没有银弹,只有匹配场景的组合。

efirst=Wait(),efirst≠nil  ⇒  cancel(ctx)e_{\text{first}} = \text{Wait()},\quad e_{\text{first}} \neq nil \;\Rightarrow\; \text{cancel}(ctx)
只要有一个厨师喊了“出事了”,厨师长就摇铃让全厨房停手,避免更多人白忙。
  1. e_first(首个错误)Wait 返回的第一个非 nil 错误,对应第一声“出事了”的喊声。
    efirste_{\text{first}}
  2. Wait()阻塞等待所有 goroutine 结束后返回首个错误,对应厨师长清点全场。
    Wait()\text{Wait()}
  3. 蕴含符号(implies)如果……那么……,构成因果逻辑。
    ⇒\Rightarrow
  4. cancel(ctx)取消上下文,通知所有监听者停止工作,对应摇铃全员停手。
    cancel(ctx)\text{cancel}(ctx)

AI 应用场景

高并发爬虫任务

阶段:数据采集与预处理

解决的问题:爬虫需要抓取海量 URL。传统逐个请求速度太慢;无限开 goroutine 则会让内存爆掉、目标站封 IP。用 Worker Pool 把并发数锁定在 50,Fan-out 分发 URL,Fan-in 汇总页面内容,让抓取速度与服务器压力达到平衡。

具体案例:某电商数据团队要采集 100 万个商品页面。他们启动 50 个 worker 的池子,每个 worker 抓完页面后随机 sleep 100~500ms 做退避,结果 channel 汇总到写入协程,最终 3 小时完成全量采集,目标站点零封禁。

批量消息消费服务

阶段:数据管道与批处理

解决的问题:从消息队列拉取海量消息,处理速度远跟不上生产速度。不加限制地开协程会击穿下游数据库。通过 Worker Pool 固定消费协程数,队列积压时背压自动限制消费速度。

具体案例:订单系统的消息消费者,用 100 个 worker 并发消费订单事件。每个 worker 处理一条消息就开启一个短事务写入数据库;高峰时段消息积压不会导致雪崩,低谷时段空转的 worker 也几乎没有额外开销。

Python 演示

python77 行
import queueimport threadingimport time  def worker_task(n: int) -> int:    """模拟耗时任务: 计算 n 的平方"""    time.sleep(0.05)    return n * n  class WorkerPool:    """简易 Worker Pool: 多个 worker 共享任务队列, 结果写入结果队列"""     def __init__(self, num_workers: int, task_fn):        self.num_workers = num_workers        self.task_fn = task_fn        self.task_queue = queue.Queue()        self.result_queue = queue.Queue()        self.threads = []     def start(self):        for _ in range(self.num_workers):            t = threading.Thread(target=self._work_loop)            t.start()            self.threads.append(t)     def _work_loop(self):        while True:            item = self.task_queue.get()            if item is None:                self.task_queue.task_done()                break            idx, arg = item            try:                result = self.task_fn(arg)            except Exception as exc:                self.result_queue.put((idx, exc))            else:                self.result_queue.put((idx, result))            finally:                self.task_queue.task_done()     def submit(self, idx: int, arg):        self.task_queue.put((idx, arg))     def close(self):        for _ in self.threads:            self.task_queue.put(None)        for t in self.threads:            t.join()  def main():    pool = WorkerPool(4, worker_task)    pool.start()     # Fan-out: 把任务分发到共享队列    total = 10    for i in range(total):        pool.submit(i, i + 1)     # Fan-in: 汇总所有结果    results = [None] * total    seen = 0    while seen < total:        idx, val = pool.result_queue.get()        results[idx] = val        seen += 1     pool.close()    print("结果:", results)    assert results == [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]  if __name__ == "__main__":    main()
目的:用 Python 标准库的 queue 和 threading 模拟 Go 的 Worker Pool 模式,演示 Fan-out 分发任务、Fan-in 汇总结果的完整流程。
  1. self.task_queue = queue.Queue()这就是“取单栏”,所有 worker 共享同一个任务队列。
  2. threading.Thread(target=self._work_loop)每个 worker 就是一条线,对应一位厨师。
  3. item = self.task_queue.get()worker 从队列竞争取单,天然实现 Fan-out。
  4. self.result_queue.put((idx, result))处理结果放入结果队列,实现 Fan-in 汇聚。
  5. for i in range(total): pool.submit(i, i + 1)主线程把任务分批提交到任务队列。
  6. while seen < total: idx, val = pool.result_queue.get()主线程从结果队列循环读取,直到收齐全部结果。
  7. pool.close()关闭时向队列发送哨兵值 None,worker 收到后各自退出。

常见误区

误区“goroutine 才几 KB,开几十万个也没事吧?我之前 for 循环里直接 `go func()` 处理所有请求,速度飞快,也没见崩呀!”

正解goroutine 的栈只有几 KB 底薪,但运行时要动态扩栈、要占用调度器的时间片和内存分配器资源。当 goroutine 数量达到几十万甚至上百万时,单是栈内存就要几十 GB,GC 和调度压力会让整体吞吐急剧下降。Worker Pool 的意义就是为并发设上限,让资源使用可预测。

为何常见:在低并发、低压力下,无限 goroutine 确实不崩,而且写法最简单。问题往往只在流量洪峰来临时暴露,这给了人一种“不需要 Pool”的错觉。

误区“要把任务分发给 5 个 worker,那肯定要建 5 个 channel 分别往里发吧?”

正解Fan-out 的经典实现只有一个共享 channel,多个 worker 同时接收。channel 的接收是互斥的,语言层面天然保证每条任务只被一个 worker 取走。如果一人一个 channel,反而要额外维护分发策略,任务量不均时还得手动负载均衡,得不偿失。

为何常见:初学者容易把“分发”误解为“定向投递”,其实多个厨师从同一个取单栏拿单才是最自然的做法。

误区“所有任务发完之后,直接 `close(taskCh)`,worker 的 `for range` 就会退出,这没错吧?”

正解对了一半。close(taskCh) 加 for range 确实能让 worker 在处理完队列里剩余任务后退出,但前提是只有发送方可以 close 且只 close 一次。如果有两个 goroutine 都想“任务发完了”,第二个 close 会直接 panic。而且如果 worker 还在监听其他信号,单靠 close 任务 channel 无法让它退出,必须配合退出信号 channel 加 select。

为何常见:很多教程都展示了 close 加 for range 的标准用法,但没有强调 close 是发送方的专属权限和一次性的契约,导致多发送方场景下踩 panic。

误区“`errgroup.Group.Wait()` 返回第一个错误,那其他 goroutine 应该也会自动停止吧?”

正解只有使用 errgroup.WithContext(ctx) 时,第一个错误才会触发 cancel,并且其他 goroutine 只有在主动监听 ctx.Done() 时才会停止。如果只用普通的 errgroup.Group,Wait 会等待所有 goroutine 跑完,哪怕已有一个错误。差别非常大。

为何常见:把 errgroup 当作万能取消器,忽略了它与 context 的配合机制。文档里写着“WithContext 用派生 ctx 执行任务组”,但初学者只看示例不看细节。

面试题

Q1. Worker Pool 的核心价值是什么?

单选题 · easy

  • A.提升单个任务速度
  • B.限制并发避免资源耗尽← 正确答案
  • C.简化源码编写工作
  • D.完全杜绝程序出错

答案:B。Worker Pool 的核心价值是给并发设一个上限。goroutine 虽轻,但过量的 goroutine 会耗尽内存和调度资源;限流之后系统才能稳定运行。A 是误解,池化不会加快单个任务,C 和 D 更不是它的目的。

Q2. 在 Go 中,Fan-out 的含义是?

单选题 · easy

  • A.单个 goroutine 串行消费任务
  • B.多 goroutine 并发读一个 channel← 正确答案
  • C.把多个 channel 合并为一个
  • D.把任务复制交给所有 worker

答案:B。Fan-out 指多个 worker 从同一个 channel 竞争读取任务,每条消息只会被一个 goroutine 取走。C 描述的是 Fan-in,D 是错误理解——消息不会被复制,取走即消失。

Q3. 有缓冲 channel 容量满时,发送方会怎样?

单选题 · mid

  • A.丢弃消息继续执行
  • B.阻塞直到有空位← 正确答案
  • C.立即触发 panic
  • D.强制覆盖最旧的消息

答案:B。当 channel 的 len 达到 cap 时,发送方会阻塞,这正是背压机制的基础。A、C、D 都不是 channel 的行为——channel 不会丢消息、不会 panic、也不会覆盖旧数据。

Q4. Fan-in 汇总结果时,如何安全地关闭结果 channel?

单选题 · mid

  • A.等发送方全部结束再关
  • B.由接收方来负责执行关闭
  • C.唯一发送方完成任务后关← 正确答案
  • D.读取超时后就自动关闭

答案:C。原则是“发送方关闭、且只关一次”。接收方关闭会留下向已关闭 channel 发送的 panic 隐患;唯一发送方(通常是 WaitGroup 等待全部完成后的一方)执行 close 才安全。题中 C 最准确。A 的表述不够精确——由多个发送方各自 aware 等待不安全。

Q5. 下列哪种情况最适合用 Worker Pool?

单选题 · mid

  • A.只有两个简单任务要处理
  • B.海量耗时任务且需要限流← 正确答案
  • C.任务间有严格顺序依赖
  • D.任务全部瞬间完成执行

答案:B。Worker Pool 的价值在海量任务且单个任务耗时可观时最大——既能并行加速,又能靠池大小限流。任务太少或瞬时完成时池化收益甚微;严格有序的任务链应该用单一 goroutine 串行保证顺序。

Q6. 关于 errgroup.WithContext,当某个 goroutine 返回错误时会发生什么?

单选题 · hard

  • A.其余 goroutine 立即被强制终止
  • B.ctx 取消且是否停止取决于监听← 正确答案
  • C.错误被静默吞不影响他人
  • D.整个程序直接 panic 退出

答案:B。WithContext 在第一个错误出现时会 cancel 派生 ctx,但其他 goroutine 只有监听 ctx.Done() 才会退出;不监听的 goroutine 会继续跑完。所以不能说“立即强制终止”,也不能说“不影响他人”——取消信号已经发出,只是响应与否由各 goroutine 决定。

Q7. 动态减少 worker 数量的正确做法是?

单选题 · easy

  • A.调用 Goexit 硬杀 goroutine
  • B.退出信号令 worker 做完当前任务再退← 正确答案
  • C.直接把任务 channel 关闭掉
  • D.只能重启整个进程来解决

答案:B。Go 不支持从外部强杀 goroutine,优雅的做法是通过退出信号 channel 通知 worker,worker 用 select 感知后处理完当前任务自行退出。C 只适用于“所有任务结束”的收尾场景,不是动态减人的通用手段。

Q8. 背压在任务队列中的作用包括?

多选题 · mid

  • A.通过发送者阻塞限流生产速度← 正确答案
  • B.自动增加 worker 处理数量
  • C.防止队列积压撑爆内存空间← 正确答案
  • D.让消费者主动等待生产者

答案:AC。背压的本质是当缓冲满时阻塞发送方,从而限制生产速度,避免任务无限积压撑爆内存。它不会自动扩 worker,也不是让消费者等生产者——恰恰相反,是生产者等消费者腾出空间。

Q9. 实现 Worker Pool 时,下列哪些做法是良好的?

多选题 · mid

  • A.用有缓冲 channel 存放任务← 正确答案
  • B.用 WaitGroup 等待 worker 完成← 正确答案
  • C.每个任务启动一个新 goroutine
  • D.用 for range 循环消费任务← 正确答案

答案:ABD。有缓冲 channel 提供任务队列和背压缓冲,WaitGroup 负责收尾等待,for range 是 worker 消费任务 channel 的标准写法。C 违背了池化的初衷——每任务一个 goroutine 正是 Worker Pool 要避免的无界并发模式。

Q10. 以下哪些场景适合 Fan-out/Fan-in 模式?

多选题 · hard

  • A.爬虫抓取页面并汇总结果← 正确答案
  • B.批处理文件并合并统计← 正确答案
  • C.严格有序的状态机任务链
  • D.多源日志并行消费分析← 正确答案

答案:ABD。Fan-out/Fan-in 适合“大任务可拆成相互独立的子任务、结果需要汇总”的场景。爬虫抓取、文件批处理、多源日志消费都符合。C 是严格顺序依赖的任务链,强行并行反而破坏顺序语义,应单线程执行。