想象你是一家繁忙中餐厅的后厨。传统并发编程的写法,就像让十位厨师共用一张大案板:谁要切菜、谁要腌制、谁要摆盘,全都挤在同一张台面上操作。为了防止两个人同时动刀,还得安排一个“锁先生”守在旁边,谁要用案板就先举手拿锁,用完再还回去。厨师越多,抢案板越激烈,锁先生也忙得焦头烂额——这就是“共享内存 + 加锁”的并发模型,一不小心就死锁、数据错乱。 而 Go 语言给出的答案,是另一种更优雅的厨房设计:每位厨师只守着自己的灶台,通过传菜窗口互相递东西。你要配菜,就把需求写在小票上从窗口递过去;对方做完,把成品从窗口递回来。整个过程中没有任何人碰别人的案板,自然也不需要锁。这个“传菜窗口”在 Go 里叫 Channel,在窗口间穿梭协作的“厨师”叫 goroutine,统领这套理念的理论叫 CSP(Communicating Sequential Processes,通信顺序进程)。 这篇文章从零开始,带你彻底搞懂 CSP 模型:它为什么主张“不要通过共享内存来通信,而要通过通信来共享内存”?传菜窗口有“单盘交接”和“多盘保温架”两种形态,各自在什么场景下使用?当一位厨师要同时盯着多个窗口时,select 如何帮他随机应变?以及生产中最头疼的 goroutine 泄漏、扇出扇入流水线,又该如何用厨房逻辑一眼看穿?
对 AI 从业者而言,并发是推理服务、数据管道和训练调度器的地基。理解 CSP 模型,能让你在高并发推理与大规模数据处理中设计出无锁、可扩展、易排查的架构,这是从“能跑”迈向“扛得住”的关键一跃。
可视化演示
演示 Go 并发 CSP 模型:Channel 无缓冲与有缓冲语义,select 多路复用,扇出扇入并发流水线,通过通信而非共享内存实现协程协作。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Channel 缓冲语义 无缓冲同步 vs 有缓冲解耦 通信而非共享内存 生产者-消费者经 channel 协作
CSP核心思想
1978 年,英国计算机科学家 Tony Hoare 提出 CSP 时,想的正是“厨师分工协作”这件事:与其让一堆进程共享一份数据(抢案板),不如让每个进程只拥有自己的私有状态,需要协作时就通过命名通道发送消息。这个看似简单的念头,后来成了 Go 并发设计的哲学根基。
在 Go 里,这个思想落地成一句名言:Do not communicate by sharing memory; instead, share memory by communicating(不要通过共享内存来通信,而要通过通信来共享内存)。传统模型里,多个线程读写同一份变量,为了安全必须加锁;CSP 模型里,数据始终属于某个 goroutine,其他人想要数据,只能通过 channel 传递一份消息。传递的瞬间,数据的“所有权”就从发送方移交给了接收方。
收发双方的行为可以用一个同步条件描述:发送方把数据放上通道的那一刻,必须有一个接收方正等在通道另一端,交接才算完成。就像一个厨师端着刚出锅的菜,必须亲眼看到对面有人接住盘子才松手。这个“一手交菜、一手接盘”的同步过程,在学术上叫 rendezvous(会合)。
所以 CSP 的本质,是把“共享”从“同处一室抢东西”改成了“隔窗传递、一手交一手接”。厨房里不再需要锁先生,因为没有人共享案板;消息在传递的瞬间完成所有权交接,天然避免了“两个人同时改一道菜”的竞态。
- 进程 P 与 QP 和 Q 是并发执行的独立实体,各自拥有私有状态,互不直接访问对方内部,就像两个各守灶台的厨师。
- 通道 chch 是一条有名字的通信通道,数据只能沿箭头方向流动,对应后厨里那根“只许从灶台递向出餐口”的管道。
- 同步条件发送只有当接收方同时就绪时才完成,两边必须“碰面”才能交接,这正是无缓冲通信的核心。
Goroutine并发体
传统线程像请一个全职大厨:每个人都要占一张完整的操作台(1MB 左右的栈空间),请 1000 个大厨,厨房就被塞满了。而 goroutine 像随时可以叫来的临时帮厨,每个人只需要一个小马扎(初始栈约 2KB),干完活收工,马扎还能收缩腾地方。
在 Go 里,启动一个并发任务只需要一个关键字:go 加上一个函数调用。go doWork() 执行完,一个 goroutine 就排上了调度器的队列,几乎零成本。相比之下,创建一个操作系统线程要陷入内核,动辄微秒级开销,数量一多系统就被拖垮。
goroutine 的轻量来自三点:一是初始栈极小(约 2KB)且随调用深度动态伸缩;二是 Go 运行时采用 M:N 调度,m 个 goroutine 被复用映射到 n 个操作系统线程上,省去了频繁创建销毁线程的开销;三是 goroutine 间的切换由用户态调度器完成,比内核线程切换快一个数量级。一台普通机器上并发跑十万个 goroutine 是家常便饭。
请帮厨的成本低到可以忽略,你就敢把一道大菜拆成一百个小任务分出去。这也是 Go 社区常说“用 goroutine 不要吝啬”的原因——但请记住:每个帮厨都该有明确的“收工指令”,否则他会在传菜窗口前站到地老天荒,那就是后面要讲的泄漏问题。
- 初始栈对比goroutine 初始栈只有 2KB,线程则要预留 1MB,两者相差约 500 倍,这是并发规模差异的根源。
- 栈动态伸缩goroutine 的栈会随调用深度自动增长、随返回自动收缩,程序员无需手工管理内存。
- MN 调度:m 个 goroutine 由运行时调度器复用映射到 n 个线程上执行,m 远大于 n,创建和切换都极廉价。
Channel通道
Channel 就是后厨的传菜窗口,但它不是普通的窗口——它是一根“有类型、有方向”的管道。就像甜品窗口只传饮品和甜点,传错菜是要出事的。创建通道用 make(chan T),T 是传输的数据类型;向通道发送用 ch <- v,从通道接收用 v := <-ch。箭头方向就是数据流动方向,语法上已经写明了“谁给谁”。
Channel 有三个容易忽略的细节:方向性、零值和关闭。方向性指函数参数可以声明为“只发送”chan<- T 或“只接收”<-chan T,编译期就防止误用——好比给传菜窗口贴上“仅出餐”或“仅回收空盘”的标签,端错了盘子编译器直接拦住。零值指未初始化的 channel 是 nil,对 nil channel 的收发会永久阻塞。关闭由发送方执行 close(ch),接收方从已关闭的通道取值会立刻拿到该类型的零值,配合 v, ok := <-ch 可以判断通道是否已关闭。
传菜窗口的规矩总结成三条:窗口有方向(只能从后厨递向出餐口)、空窗口不能取菜(nil 通道不能收发)、打烊由后厨说了算(只有发送方可以关闭)。这三条规矩理解透了,后面所有并发模式都建立在它们之上。
- 通道类型ch 在创建时就确定了传输类型 T,像窗口挂上“本窗只传饮品”的牌子,类型不符编译直接报错。
- 发送操作把值 v 送入通道,如果通道满或无人接收,发送方就地阻塞等待。
- 接收操作从通道取出一个值,如果通道为空,接收方阻塞等待直到有数据到来。
缓冲与同步
传菜窗口有两种形态。一种是“单盘交接台”:台面上只有一个位置,厨师 A 把菜放上去,必须等厨师 B 端走才能做下一道菜——这叫无缓冲 channel。另一种是“保温架”:架子上有 N 个位置,A 可以连续放 N 盘菜再去忙别的,B 慢慢取——这叫有缓冲 channel。
无缓冲 channel 的收发是严格同步的:发送会一直等到有接收方就绪,接收会一直等到有数据到来。有缓冲 channel 则允许“先放后取”,只要保温架没满,发送方就不用等;只要架子上还有菜,接收方就不用等。
判断是否会阻塞只有两条规则:发送时如果 len(ch) == cap(ch),说明架子满了,发送方阻塞等待;接收时如果 len(ch) == 0,说明架子空了,接收方阻塞等待。注意 cap 是创建时指定的容量,len 是当前已经放进架子的数量。可视化演示里那两个红绿圆点,正是这两条规则的实时写照。
很多初学者以为“有缓冲就不阻塞”,其实保温架满了一样会堵住送菜口。理解 len 与 cap 的语义,就等于读懂了后厨的实时库存:架子上还有几个位置、还有几道菜,一眼便知,这也是排查性能问题时的第一手情报。
- len 与 caplen 是当前队列中等待交接的元素个数,cap 是缓冲区最多容纳的元素数,二者都由运行时维护。
- 发送阻塞条件缓冲区已满时,发送方必须停在窗口前,等接收方取走一份腾出位置才能继续。
- 接收阻塞条件缓冲区为空时,接收方必须等在窗口前,直到发送方递来新的一份。
Select复用
想象大厨站在出餐口,面前有三四个传菜窗口:左边是饮品窗口、中间是热菜窗口、右边是甜品窗口。哪个窗口的铃响了,他就去处理哪个;如果他同时听到两声铃响,就随手挑最近的一个先处理——这就是 select 的工作方式。
select 是 Go 专门为多通道设计的控制结构,它可以同时监听多个 channel 的收发操作。哪个 case 的通道就绪(不阻塞),就执行哪个 case。这在 CSP 世界里极其重要:一个 goroutine 往往不只依赖一个输入来源,它需要“多窗口待命”的能力。
当多个 case 同时就绪时,Go 运行时随机均匀选择一个执行,而不是按书写顺序,这保证了公平性——不会出现靠前的 case 永远抢占、靠后的饿死。如果在 select 中加一个 default 分支,那么当所有 case 都未就绪时,会立刻执行 default,实现非阻塞的通道操作。超时控制通常用 time.After 配合 select:等不到数据就执行超时分支,避免无限期等待。
大厨的聪明之处在于:他不会死盯一个窗口,而是让所有窗口的铃铛同时待命;也懂得给自己设一个下班时限——用 time.After 说“再等三秒没人来,我就先干别的”。这正是应对多个不确定来源的标准姿势。
- 就绪分支select 会先检查所有 case 的收发条件,只有不阻塞的分支才有资格被选中,相当于“铃响了才算数”。
- 随机公平选择多个就绪分支同时存在时,运行时随机均匀挑一个执行,避免前序 case 长期霸占资源。
- default 兜底若所有 case 都未就绪,则立即执行 default,实现非阻塞收发,相当于“没人按铃就先去干别的”。
泄漏排查
厨房里最隐蔽的问题,是一个帮厨端着一盘菜站在窗口前,而窗口对面永远没有人来接。他不会喧哗,也不会崩溃,只是安静地站着,占着人力。一两个还好,如果每天都有十几个帮厨这样“站桩”,真正干活的厨师就越来越少了——这就是 goroutine 泄漏。
goroutine 泄漏的本质是:一个 goroutine 阻塞在某个 channel 操作上,而这个操作永远不可能完成。最常见的场景:向一个没有人接收、容量也为 0 的无缓冲 channel 发送;从没有发送方的无缓冲 channel 接收;执行一个没有任何 case 的 select{};或者一个 goroutine 等一个永远不会被关闭的 done 通道。
排查手段有三个层次。第一,用 runtime.NumGoroutine() 定期打印 goroutine 数量,观察是否随时间单调上升、从不回落——相当于定期清点厨房里的人数。第二,用 net/http/pprof 抓取 goroutine 栈,概览里一眼能看到大量阻塞在相同 channel 地址上的栈。第三,规范预防:所有 goroutine 都要有明确的“关窗信号”——通常用 context.Context 的取消或专门的 quit chan struct{} 来通知退出。
给帮厨定规矩:要么活干完自己走,要么听到“收工”铃声立刻走。在代码里,“收工铃声”就是关闭 channel 或取消 context。只要明确“每个 goroutine 必然有一条退出路径”,站桩的帮厨就不会出现。
- 阻塞状态协程 g 阻塞在 channel 收发上,等待一个永远不会到来的对端,像帮厨端着菜干站。
- 无可配对操作既没有发送方,也没有接收方,更没有关闭信号来唤醒它,等待注定落空。
- 泄漏的代价阻塞协程持续占用内存与调度资源,数量累积会让系统吞吐不断恶化,最终拖垮整个服务。
扇出与扇入
一道宫保鸡丁从备菜到出锅如果只靠一个厨师,流程是串行的。但后厨可以这样分工:主厨把“切鸡丁、炸花生、调酱汁”三张任务单同时递到三个窗口(扇出),三个帮厨各干各的,完成后把成品统一交回主厨面前的汇总窗口(扇入)。并行度瞬间翻了数倍。
扇出(fan-out)指一个输入通道的数据被多个 worker goroutine 同时竞争接收,谁空闲谁取走任务,天然实现了负载均衡;扇入(fan-in)指多个 worker 把结果写入同一个输出通道,汇聚成单一结果流。两者组合,就是 Go 并发流水线的标准骨架。
典型流水线是:生产者 → 扇出分发 → N 个 worker 并行处理 → 扇入汇总 → 消费者。因为每个环节只跟 channel 打交道,不共享任何变量,整条流水线完全无锁。唯一要注意的是关闭时机:多个 worker 都往同一个 out channel 写,必须由“等所有 worker 都结束之后”的那个 goroutine 负责关闭 out channel,否则接收方无法知道数据流何时结束。
扇出扇入把“一个大厨包办一切”改成了“主厨拆单、帮厨并行、统一收菜”。这也是 AI 推理服务批处理的标准架构:单个请求太轻,攒一批再统一送 GPU,吞吐能翻好几个量级。
- 扇出 1→N一个输入通道被多个 worker 同时竞争接收,任务被动态分发,谁空闲谁取走。
- 扇入 N→1多个 worker 把结果写入同一个输出通道,汇成一股结果流供下游消费。
- 无锁收益每个 worker 只和通道交互,不共享内存,因此不存在数据竞争,也就不需要加锁。
生产实践
传菜窗口的设计不是拍脑袋定的。保温架放几个位置最合适?太少,送菜的老被堵住;太多,菜放久了会凉(数据滞留,新鲜度下降)。这里有一个朴素的估算方法:保温架容量 ≈ 每秒钟送进来的菜数 × 每盘菜平均在架上待多久——在途的菜越多,需要的架位越多。
这个公式对应并发系统里的 Little's Law:系统内平均滞留的任务数等于任务到达速率乘以平均停留时间。据此做 buffer 容量规划,既不会让生产者频繁阻塞,也不会让内存被无界队列拖垮。容量定小了就加 worker 或扩容缓存,定大了就回收内存,一切有据可依。
生产实践还有三条铁律。第一,关闭原则:channel 只能由发送方关闭,接收方绝不能关闭,向已关闭 channel 发送会 panic。第二,优雅退出:用 defer close(ch) 或专门的 done 通道配合 select,确保 worker 收到退出信号后再收工。第三,超时与重试:凡是可能阻塞的地方都要有超时兜底——select + time.After,超时后决定重试、降级还是返回错误,而不是无限期卡死。
一个成熟的厨房,既算得清保温架该买多大,也定得下“只有后厨有权挂打烊牌”的规矩,更知道每道菜等太久就要重做。把这三件事做到位,CSP 模型就从“能跑”变成了“能扛事”。
- 生产速率R_produce 是单位时间内送进窗口的菜盘数,衡量后厨的出菜速度。
- 消费延迟T_consume 是每盘菜从进窗口到被取走的平均滞留时间,衡量下游消化速度。
- 乘积即容量滞留中的菜盘总数就是需要预留的窗口容量,容量过小则生产者频繁阻塞。
AI 应用场景
场景一:AI 推理服务的请求批处理
阶段:在线推理服务架构设计
解决的问题:传统做法是每个请求开一个线程同步处理,GPU 算力被小请求打得稀碎,利用率不到 20%。用 CSP 的扇入模式,把大量离散请求汇聚成批次(batch),凑满一批再统一送 GPU,吞吐量成倍提升。
具体案例:某日活百万的推荐系统,网关把用户请求写入有缓冲 channel,批处理协程攒够 32 个请求后统一送 TensorRT 推理,再通过另一个 channel 把结果广播回各个等待协程。改造后单机 QPS 提升 3 倍,P99 延迟下降 40%。
场景二:日志特征采集流水线
阶段:数据工程与特征管道
解决的问题:训练样本需要“采集 → 解析 → 特征提取 → 落库”四步,传统串行脚本在流量高峰追不上,用内存队列又难控制背压。用 goroutine + channel 搭流水线:每个阶段是一个协程,阶段之间用有缓冲 channel 连接,天然实现削峰填谷。
具体案例:某广告系统的点击日志管道,采集协程每分钟吞入百万条日志,解析与特征提取各开 8 个 worker 并行(扇出),特征结果汇入一个 channel 批量写 ClickHouse(扇入)。高峰期不再丢数据,背压自动传导到源头限速。
场景三:模型热更新的优雅停机
阶段:模型部署与版本管理
解决的问题:模型服务升级时,旧实例正在处理的推理请求如果被强杀,会造成大量失败。利用 channel 的关闭语义:主进程关闭一个 quit 通道,所有 worker 通过 select 监听到关闭事件后,把手头请求处理完再退出,实现优雅停机。
具体案例:某语音识别服务的部署脚本发布新版本时,先向旧实例的 quit 通道发送关闭信号,旧 worker 在 10 秒内排空积压请求后自动退出,期间新流量已切到新实例,用户无感知。
Python 演示
import queueimport randomimport threadingimport time def worker(wid, in_q, out_q): """模拟一个帮厨:从 in_q 取任务,处理完放进 out_q。""" while True: task = in_q.get() if task is None: in_q.task_done() break result = task * 2 time.sleep(random.uniform(0.01, 0.06)) out_q.put(result) in_q.task_done() def main(): # 有缓冲 channel:容量为 4 的传菜窗口 tasks = queue.Queue(maxsize=4) results = queue.Queue() workers = [] for i in range(3): # 扇出:3 个帮厨并行处理 t = threading.Thread(target=worker, args=(i, tasks, results)) t.start() workers.append(t) for i in range(10): # 送 10 道菜进窗口 tasks.put(i) for _ in range(3): # 发 3 个"收工"信号,对应 3 个帮厨 tasks.put(None) for t in workers: t.join() done = [] while not results.empty(): # 扇入:汇总所有结果 done.append(results.get()) print("处理结果:", sorted(done)) if __name__ == "__main__": main()
queue.Queue + 线程模拟 Go 的 CSP 扇出扇入流水线:3 个 worker 并行消费任务,结果统一汇聚,最后优雅收工。tasks = queue.Queue(maxsize=4)相当于 Go 里容量为 4 的有缓冲 channel,放满就阻塞task = in_q.get()对应 Go 的接收操作<-ch,队列空则阻塞等待out_q.put(result)对应 Go 的发送操作ch <- v,队列满则阻塞if task is None:用“毒丸”信号模拟关闭 channel,通知帮厨收工for i in range(3):扇出分发:3 个 worker 竞争取任务,天然负载均衡tasks.put(None)发 3 个收工信号,确保每个 worker 都能退出while not results.empty():扇入汇总:从结果队列收集所有输出
常见误区
误区创建 channel 时给了容量,比如 `make(chan int, 10)`,就觉得发送方永远不会被堵住,随便往里塞数据。
正解有缓冲 channel 只是把“立刻交接”改成“放进保温架”。保温架只有 10 个位置,放满第 10 个之后,第 11 次发送照样阻塞,直到有人取走一份。判断标准永远是 len == cap,而不是“有没有缓冲”。
为何常见:人们容易把“缓冲”误解为“无限”,忽略了容量上限的存在;只有真正压测到满负荷时才惊觉阻塞。
误区`make(chan int)` 和 `make(chan int, 1)` 差别不大,反正都只能放一份数据,换着用无所谓。
正解两者语义天差地别。无缓冲是“必须当面交接”——发送方要等接收方伸出手才松盘子,这是同步 rendezvous;容量为 1 的有缓冲是“放下就走”——发送方把菜放进架子就可以去干别的,接收方稍后再来取。前者强制握手,后者允许解耦。
为何常见:只看“能放几个”的表面容量,忽略了“是否必须同时在场”这个更本质的语义差异。
误区以为 `close(ch)` 之后,接收方再取值就会报错或者拿不到数据,所以接收方也可以主动关闭。
正解关闭是发送方在说“我不再生产了”,接收方不仅不会报错,还能把队列里剩余的数据全部取完,之后每次取值拿到该类型的零值(配合 v, ok := <-ch 可以判断是否已关闭)。关闭的责任只在发送方,接收方关闭属于滥用,向已关闭 channel 发送更是会 panic。
为何常见:很多语言里“关闭”意味着“销毁资源”,而 Go 的 close 只是“停止生产”的信号,两者语义很容易混淆。
误区既然 CSP 不需要加锁,goroutine 之间靠 channel 通信,那并发 bug 就自动消失了。
正解CSP 消除了“数据竞争”(没有共享内存可抢),但没有消除“死锁、饥饿、泄漏”。无缓冲 channel 两边都在等人、空 select 永久挂起、goroutine 没人通知退出,这些照样让你的系统卡死或内存暴涨。并发问题从“数据错乱”变成了“流程卡死”,依然存在。
为何常见:把“无锁”等同于“无问题”,忽略了 CSP 解决的是访问冲突,而不是流程协调。
误区觉得 select 和 switch 一样,从上到下匹配,第一个满足条件的分支会被执行。
正解select 在所有 case 都就绪时是随机均匀选择的,专门避免“靠前分支永远抢占、靠后分支饿死”。这也是设计者的刻意为之:并发环境下你无法预判哪个通道先到,随机选择反而保证了公平。
为何常见:select 的语法长得太像 switch,初学者自然而然套用顺序匹配的心智模型。
面试题
Q1. 在 Go 中创建无缓冲 Channel 的正确方式是?
- A.使用 make(chan int) 创建← 正确答案
- B.使用 make(chan int, 1) 创建
- C.使用 new(chan int) 创建
- D.使用 chan int{} 直接声明
答案:A。make(chan int) 不指定容量,创建的就是无缓冲 channel,收发必须同步交接。B 创建的是容量为 1 的有缓冲 channel;C 的 new 返回指针,不能用于 channel;D 的语法不存在。记住:有无缓冲只看 make 的第二参数是否省略。
Q2. 无缓冲 Channel 的发送操作何时才算真正完成?
- A.数据写入操作系统内核缓冲时
- B.接收方就绪并完成数据交接时← 正确答案
- C.发送方调用返回时就完成
- D.编译器完成类型检查之后
答案:B。无缓冲 channel 是同步 rendezvous,像厨师必须看到对面有人接住盘子才松手。发送操作要一直阻塞到接收方就绪并完成交接才返回。它不经过内核缓冲,A 错;C 忽略了阻塞等待;D 与运行时行为无关。
Q3. 以下哪些情况会造成 goroutine 永久阻塞(泄漏)?
- A.向无人接收的无缓冲通道发送← 正确答案
- B.从无人发送的无缓冲通道接收← 正确答案
- C.使用带 default 分支的 select
- D.向有缓冲通道发送未满数据
答案:AB。无缓冲 channel 必须有一对对端同时在场,任何一方缺失都会让另一边永远等下去,A、B 都属于“等不到人”的站桩场景。C 的 default 恰恰是防止永久阻塞的兜底;D 队列未满,发送立即成功,不会阻塞。
Q4. 当 select 的多个 case 同时就绪时,Go 运行时如何选择?
- A.按源代码书写的先后顺序
- B.优先执行首个就绪的分支
- C.随机均匀选择一个执行← 正确答案
- D.同时并发执行所有分支
答案:C。select 在所有就绪分支中随机均匀选择,这是刻意设计的公平策略,防止靠前 case 长期霸占导致后面的饿死。A、B 是 switch 的顺序语义,不适用于 select;D 说并发执行所有分支更是错误,select 一次只执行一个 case。
Q5. 关于 goroutine 与操作系统线程,下列说法正确的是?
- A.goroutine 栈初始极小且动态伸缩← 正确答案
- B.每个 goroutine 独占一个操作系统线程
- C.goroutine 的栈大小固定为 1MB
- D.goroutine 无法在同一进程内通信
答案:A。goroutine 初始栈仅约 2KB,并随调用深度动态增长和收缩,这是它轻量的关键。它并不独占线程,而是由 M:N 调度器复用少量线程,B 错;C 描述的是传统线程栈;D 完全错误——goroutine 正是通过 channel 在同一进程内通信。
Q6. 关于关闭 Channel,下列说法正确的是?
- A.只能由发送方负责关闭通道← 正确答案
- B.接收方从已关闭通道取值会拿到零值← 正确答案
- C.发送方可以向已关闭通道继续发送
- D.通道关闭后 len 会立即变为零
答案:AB。关闭是“停止生产”的信号,只有发送方有权挂打烊牌,A 对;接收方取完剩余数据后,继续取值会立刻得到该类型零值,B 对;向已关闭通道发送会 panic,C 错;已放入队列的存量数据仍可被取出,len 不会“立即”归零,D 错。
Q7. 有缓冲 Channel 的 len 与 cap 分别表示什么?
- A.len 表示当前缓存元素个数← 正确答案
- B.cap 表示当前缓存元素个数
- C.len 与 cap 数值始终相等
- D.len 表示剩余可用空间大小
答案:A。len 是保温架上现有的菜盘数,cap 是架子最多能放多少盘,两者没有必然相等的关系。B 把 cap 当成了实时数据;C 只在恰好装满时成立;D 混淆了 len(已有量)与剩余容量(cap 减 len)。
Q8. 在 select 中使用 time.After 的主要目的是什么?
- A.为阻塞操作添加超时限制← 正确答案
- B.提升通道的数据发送速率
- C.自动关闭空闲的 goroutine
- D.动态增大通道的缓冲容量
答案:A。select { case v := <-ch: ...; case <-time.After(3*time.Second): ... } 是最经典的超时模式——等 3 秒没数据就走超时分支,避免无限期卡死。它不改变通道速率、容量,也不会自动管理 goroutine 生命周期,B、C、D 都是臆测。
Q9. 关于扇出与扇入模式,下列说法正确的是?
- A.扇出把一个任务分发给多个协程← 正确答案
- B.扇入把多个协程结果汇聚一处← 正确答案
- C.扇出模式只能用于无缓冲通道
- D.扇入必须依赖多个输入通道
答案:AB。扇出是“一分多”,多个 worker 竞争消费同一个输入通道;扇入是“多合一”,多个 worker 的结果写入同一个输出通道,A、B 正确。扇出完全可以用有缓冲通道,C 错;多个生产者写同一个通道也是扇入,不一定需要多个输入通道,D 错。
Q10. 执行一个没有任何 case 的空 select{} 语句会导致什么?
- A.立刻返回并继续后续代码
- B.随机挑选一个分支执行
- C.当前 goroutine 永久阻塞← 正确答案
- D.抛出运行时异常导致崩溃
答案:C。空 select{} 没有任何就绪分支,也没有 default 兜底,当前 goroutine 会永久阻塞——这是最隐蔽的泄漏写法之一。它不返回、不崩溃、不 panic,只是安静地站桩。代码审查时应将空 select{} 视为高危模式,通常意味着漏写了 case 或 default。