周末晚上七点,老周的餐厅坐满了客人。传菜口的小票像雪花一样飞来,后厨里却井井有条:洗菜的只洗菜,切配的只切配,三位大厨各守一口锅,传菜员沿着窄道来回穿梭。没有人大喊大叫,靠的是一张张小票和一条传菜通道,整个厨房就能以惊人的效率运转。 Go 语言里的 goroutine 就像老周后厨里的员工——成本极低,你可以轻松开出几万个。但员工多了不会自动高效,你得给他们编排好“谁负责什么、东西怎么传递、打烊怎么通知”。这就是 Go 并发模式:生产者消费者、流水线、扇出扇入、done channel、限流、errgroup、semaphore。掌握这些模式,你就能像老周调度后厨一样,让成千上万个 goroutine 各司其职、高效协作。
并发编程是后端开发的核心能力。掌握这些模式,意味着你能写出高吞吐、可维护的并发服务,从容应对数据管道、实时处理、微服务治理等真实场景——这是从“会写 Go”到“写好 Go”的关键一跃。
目录
可视化演示
展示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 就是这句话的落地工具:数据通过传递来协作,而不是一把大锁护着一个共享变量。老周后厨的效率不是靠每个人死盯着同一本账本,而是靠小票在不同岗位之间流转。掌握生产者消费者,你就学会了后厨最基础的协作方式。
- 发送阻塞条件当 channel 缓冲里已有的元素个数等于容量上限时,发送方 goroutine 会停下等待,直到有接收方取走数据腾出空间。$\operatorname{len}(ch) = \operatorname{cap}(ch)$ 表示缓冲恰好在临界点。
- 接收阻塞条件当缓冲一个元素都没有时,接收方 goroutine 会停下等待,直到有发送方送来数据。$\operatorname{len}(ch) = 0$ 表示缓冲为空。
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_{\mathrm{startup}}$ 表示管道建立和排空的固定开销。
- 任务总数$n$ 表示要处理的菜(任务)总数量,它和每道菜的耗时相乘。
- 瓶颈阶段$\max(t_1, t_2, \dots, t_k)$ 表示所有阶段耗时的最大值,$t_i$ 是第 $i$ 个阶段处理单道菜的耗时。这个最大值就是瓶颈,它决定了稳态下每道菜出品的间隔。
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 数量,让扇出的效率真正落地。
- 串行时间$T_{\mathrm{serial}}$ 表示一个 worker 从头到尾处理完所有任务所需的时间,这是加速的基准。
- 并行时间$T_{\mathrm{parallel}}$ 表示多个 worker 分工后处理完同样任务所需的总时间。
- 加速比近似值$\min(N_{\mathrm{worker}}, N_{\mathrm{CPU}})$ 表示理论最大加速比取 worker 数和 CPU 物理核数的较小者,因为超过核数后线程切换反而互相抢资源。
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 知道怎么下班吗?
- 打烊分支$\gets\{\text{done}$ 表示从 done channel 接收到了关闭信号,此时 goroutine 应当进行清理工作并退出,对应餐厅伙计收工下班。
- 工作分支$v \gets \text{in}$ 表示从业务 channel 中收到了待处理的数据,此时继续正常处理业务,对应后厨还有活要干。
- select 竞争两个分支是竞争关系,select 内部采用随机选择机制,保证两个分支都不会被饿死,因为数据可能随时到来。
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 网关等场景尤其重要:模型服务的容量是有限的,不加限流的后果就是雪崩。
- 令牌数量$C$ 表示在一个时间窗口内允许放行的请求数,比如 10 秒内放行 50 个请求。
- 窗口时长$T_{\mathrm{interval}}$ 表示这个时间窗口持续的时间,它是令牌发放的周期基准。
- 最大速率$r$ 是计算得出的速率上限,任何超过 $r$ 的流量都会被限流器挡住,从而保护下游系统。
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 都是你管理任务组的首选——尤其当你想让“一个失败,全组撤退”时,它已经把取消传播封装好了。
Wait() 的返回值只可能是两种情况:要么是所有任务中的第一个错误,要么是 nil(代表全部成功)。这就是“值班经理只报第一声警”的设计。- 组等待方法$g.\mathrm{Wait}()$ 表示 errgroup 的等待方法,它会阻塞直到组内所有 goroutine 执行完毕。
- 首个错误返回第一个非 nil 错误,说明组内只要有一个任务出错,这个错误就会被报告出来,无需等待其他任务的结果。
- 成功返回 nil若所有任务都成功执行(没有返回任何错误),则
Wait()返回 nil,表示整组任务顺利完成。
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) 保住了系统的稳定。这是最朴素也最有效的并发度控制手段。
- 持有状态变量$x_i$ 是第 $i$ 个 goroutine 的持有状态,取值只能是 0 或 1,代表它是否占有资源。
- 并发数总和$\sum_{i=1}^{n} x_i$ 把所有正在持有令牌的 goroutine 数量加起来,就是当前的实际并发数。
- 信号量上限$S$ 是信号量允许的最大并发数,也就是令牌盒里的令牌总数。求和结果永远不超过 $S$,这就是并发度被控制住的数学保证。
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 演示
"""用 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()
threading 和 queue 模拟 Go 的流水线 + 扇出扇入 + done 广播模式,演示生产者、3 个并行 worker、消费者如何通过队列协作,以及如何用事件优雅停止所有线程。out_q.put(STOP)生产者发完所有任务后,放入一个特殊的“结束哨兵”,模拟 Go 中“关闭 channel”的语义,通知 worker 任务已清空。if item is STOP: in_q.task_done(); breakworker 拿到哨兵后确认队列任务完成并退出,相当于 Go 中case <-done: return。in_q.get(timeout=0.05)带超时地取数据,模拟 Go 的select超时分支,让线程能定期醒来检查停止事件,避免永久阻塞。stop_evt.set()广播停止信号给所有仍阻塞的 worker,就像 Go 中close(done)让所有监听者同时退出,是防止线程泄漏的关键。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 的正确语法是?
- 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 的发送和接收,以下描述正确的是?
- A.发送者必须等待接收者就绪才能完成发送← 正确答案
- B.发送者发送后立即继续执行,无需等待
- C.数据会在 channel 内部排队等待接收
- D.接收者必须凑齐一批数据才开始接收
答案:A。无缓冲 channel 是同步握手——发送方和接收方必须同时就绪,数据才完成交接。B 错在无缓冲没有“立即发送”这回事,C 描述的是有缓冲 channel 的行为,D 是批处理的概念,与 channel 语义无关。好比老周的面对面传菜窗口:你递过去同时对方得接着,任何一放慢了都递不出手。
Q3. 对于 ch := make(chan int, 3),以下说法正确的是?
- A.
len(ch)表示当前缓冲中未取走的元素个数← 正确答案 - B.
cap(ch)表示已发送的元素总个数 - C.缓冲满时接收操作会直接失败
- D.缓冲空时发送操作不会阻塞
答案:A。len 是当前缓冲区的“占用量”,cap 是缓冲区的“总容量”,两者都是容量概念而不是历史计数。C 错,缓冲满时接收正常执行(正好腾出空间);D 错,缓冲空时发送正常执行,但缓冲满时发送会阻塞。就像传菜架子:len 是架子上现有几张单子,cap 是架子最多放几张。
Q4. done channel 模式中,用 close(done) 而不是 done <- 1 来广播通知,原因是?
- A.关闭后所有监听者同时收到信号,发送只能被一个接收者收到← 正确答案
- B.发送数据不需要创建 channel,更省内存
- C.关闭比发送的速度快,能提升性能
- D.只有关闭操作才能触发 channel 的垃圾回收
答案:A。channel 的发送是“单播”——只有一个 goroutine 能收到数据;而关闭是“广播”——所有正在接收该 channel 的 goroutine 都会立即返回零值。这正是老周喊“打烊了”而不是挨个单独通知的原因。B、C、D 的描述在语义上都不成立:发送也需要 channel,性能与关闭没有可比性,GC 也不依赖关闭操作。
Q5. 在流水线+扇出扇入模式中,输出 channel 应该由谁负责关闭?
- 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 泄漏的成因和修复,以下说法正确的是?
- 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 时,以下叙述正确的有?
- 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 时,“获取令牌”对应的操作是?
- A.向 channel 发送一个空结构体← 正确答案
- B.从 channel 接收一个空结构体
- C.修改 channel 的容量值
- D.关闭 channel 并重新创建
答案:A。sem <- struct{}{} 是获取令牌——缓冲满则阻塞,相当于从窄道口的盒子里拿走一枚令牌。B 是归还令牌(释放)。C 修改容量在 Go 中不可能,cap 是创建时固定的;D 关闭 channel 会导致后续发送 panic。信号量的本质是“容量即令牌数”,获取和释放都是围绕 buffer 槽位做操作。
Q9. 在 for + select 循环中,多个 case 同时满足条件时,Go 会?
- A.随机选择一个 case 执行← 正确答案
- B.按代码书写顺序执行
- C.同时执行所有满足的 case
- D.阻塞直到某个 case 独占就绪
答案:A。select 的多个 case 同时就绪时,Go 运行会随机选择一个执行,这是语言规范明确保证的。这样做是为了防止某个 case 长期被“饿死”。B 是常见误解,C 不可能,D 也不正确——select 不会等待“只剩一个就绪”。就像老周同时听到传菜员喊“上菜”和经理喊“打烊”,他随机处理一件,保证两件都有机会被处理。
Q10. 实现一个「限流 + 扇出」的 AI 推理管道,以下设计合理的有?
- A.用
time.Ticker控制请求进入速率,发放固定间隔令牌← 正确答案 - B.用带缓冲 channel 作为令牌桶,吸收短时突发流量← 正确答案
- C.为每个请求创建一个新的 channel 单独传递结果
- D.用 semaphore 限制同时推理的任务数为 GPU 容量← 正确答案
答案:ABD。A 是限流的基石,控制入口速率;B 是令牌桶,缓冲突发后平滑处理;D 用信号量保护 GPU 资源,限流和并发控制双保险,这是生产级推理服务的标准组合。C 错在为每个请求创建 channel 既浪费资源又难以管理,正确做法是共享 channel + 请求 ID 关联结果。就像老周既控制门口进客速度,又限制传菜通道人数,但绝不给每道菜单独修一条通道。