想象你是一家网红咖啡店的店长。这家店一天能卖出几千杯咖啡,但店面很小,只有四个咖啡师工位。如果客流高峰时只有一位咖啡师,他会手忙脚乱、做一杯咖啡就耽误十杯的订单;但如果盲目招一百个咖啡师,小店又站不下,还会互相撞来撞去。聪明的做法是:让咖啡师们只管做咖啡,把“谁来接单、谁来传杯、哪个单子该做”这些协调工作,交给一条条传送带和叫号屏来完成。 Go 语言的并发世界,就是这家咖啡店。goroutine 是那些轻盈的咖啡师——他们不像传统餐厅招聘的“全职员工”(操作系统线程)那样昂贵稀缺,而是像兼职伙伴一样随叫随到、成本极低。channel 则是连接他们的传送带——订单从柜台滑向咖啡师,做好的咖啡从咖啡师滑向取餐台,没人需要敲着桌子大喊“这杯是谁的”。这就是 Go 并发哲学的精髓:“不要通过共享内存来通信,而要通过通信来共享内存。” 这篇文章,我们就以“开一家 Go 咖啡店”为线索,从咖啡师的管理(GMP 调度)、传送带的设计(channel 环形缓冲)、叫号屏的进化(select 多路复用),一路聊到打烊时如何优雅地关店(context 取消传播)。读完你会发现,并发编程不再是张牙舞爪的怪兽,而是一套清晰、优雅的经营手册。
掌握 goroutine 与 channel,是理解 Go 高并发服务端架构的钥匙——无论是微服务网关、消息队列还是实时推荐系统,Go 的并发模型都是它们的核心引擎。对于 AI 从业者,这意味着你能读懂并构建高性能的数据管道与服务底座,让模型推理、特征服务在海量请求下依然丝滑稳定。
目录
可视化演示
展示Go语言三大并发核心:GMP调度模型(G协程-P处理器-M线程映射)、Channel环形缓冲区收发机制、以及select多路复用同时监听多个channel。交互切换不同并发视角。
本节配有交互动效(进入视口后自动加载,约 3KB)。
GMP调度模型 G(goroutine)→P(processor)→M(machine)三级调度 M为OS线程,P持有本地G队列 go f() → spawn G → schedule on P → run on M GMP模型实现用户态轻量协程高效调度
goroutine 基础:咖啡师的“兼职合同”
还记得我们开的那家网红咖啡店吗?传统餐厅招聘全职员工,签合同、交五险一金、安排工位,成本高还不灵活——这就像操作系统线程。而 Go 的 goroutine 则像咖啡店里的“兼职咖啡师”,不用正式的工位和合同,来一个订单就临时支应一下,忙时多叫几位,闲时他们自动歇着,成本几乎可以忽略。
在 Go 语言里,启动一个 goroutine 只需在函数调用前加一个 go 关键字。它的初始栈空间只有约 2KB(Go 1.4 之前是更小的分段栈,现在是最小 2KB 的连续栈),而系统线程的栈空间默认是 1MB 起步,相差 500 倍。这意味着,你可以在一个进程里轻松创建成千上万个 goroutine,而如果创建等量的系统线程,内存早就爆了。
更有趣的是,goroutine 的栈是自动伸缩的。就像咖啡师忙的时候能一个人同时盯三个出杯口,闲的时候只需要占一个角落。Go 运行时会在函数调用时检查栈空间,不够了就自动扩容,最大可扩展到 1GB(64 位系统)。这种“按需分配、动态调整”的栈管理,让 goroutine 既轻巧又安全。
所以,当你在 Go 服务里写 go handleRequest() 时,咖啡店的门口已经站好了成千上万随时能上场的兼职咖啡师。他们不需要你操心工位和合同,只需要一句“上!”就立刻投入工作。
- 初始栈对比goroutine 的初始栈空间约 2KB,系统线程约 1MB,差距近 500 倍——这正是 goroutine“轻量”的直接来源。
- 容量比值将两者相除,约等于五百分之一,说明同样的内存能承载的 goroutine 数量远超线程。
GMP 调度模型:咖啡店的三层管理架构
现在咖啡店生意爆棚,兼职咖啡师(goroutine)来了几百个。总不能让他们挤在吧台前抢活干吧?于是我们设计了三层管理架构:咖啡师(G)、操作台(P)和店铺(M)。
这里的 G(Goroutine) 就是每个兼职咖啡师,他手头有订单和配方(栈和上下文);M(Machine/Thread) 是咖啡店本身——一个大楼,它提供真正的物理空间(操作系统线程)让咖啡师站进去干活;P(Processor) 则是操作台,每个操作台上有一个“待办订单夹”(本地运行队列)。咖啡师必须站在某个操作台前才能接单干活,而每个操作台同一时刻只能站一个咖啡师。
Go 调度器的核心循环是这样的:每个 P(操作台)维护一个本地任务队列,里面排着等待执行的 G。M(大楼里的物理空间)从 P 的队列中取出一个 G 来执行。当 G 执行完毕或阻塞时,M 会去取下一个 G。如果本地队列空了,M 就会像店长一样“偷单”——从其他 P 的队列尾部偷一半任务过来,这就是 work stealing 机制。
这套三层架构还有个关键设计——抢占式调度。Go 1.14 之后实现了基于信号的抢占:如果一个 G 运行超过 10ms,运行时就会发出信号,强制把它换下来,让其他 G 有机会运行。这就像店长发现某位咖啡师做拉花做了十分钟还没完,立刻喊停:“你先去休息,让别人来做下一杯。” 没有这种机制,一个死循环的 goroutine 就会像霸占操作台的员工一样,让整个店铺瘫痪。
- P 的数量决定并发度GOMAXPROCS 默认等于 CPU 核数,这个数字决定了同时有多少个操作台(P)在运行。
- G 排队等待执行每个 P(操作台)有一个本地队列,处于 Runnable 状态的 G(咖啡师)在其中排队。
- 工作窃取当某个 P 的队列空了,对应的 M 会去其他 P 的队列尾部“偷”任务,保持负载均衡。
channel 机制:咖啡店的传送带
操作台有了,咖啡师有了,但订单怎么传递?客人点单后,总不能直接对每个咖啡师大喊“三杯拿铁、两杯美式”吧——那会乱成一锅粥。于是,我们在柜台和咖啡师之间、咖啡师和取餐台之间,铺设了一条条传送带,这就是 channel。
一条 channel 在创建时就确定了它运输的“货物类型”:
channel 的核心规则是:发送和接收必须配对。向一个 channel 发送数据,必须有另一个 goroutine 在接收;如果没有人接收,发送者就会阻塞等待——就像你把托盘放到传送带上,如果传送带那头没人取,托盘就卡住,你也走不开。反过来,如果接收方想从空 channel 取数据,也会阻塞——就像你站在传送带前等托盘,传送带空空如也,你只能干等着。
这种设计优雅地实现了“不要通过共享内存来通信”的理念。传统的并发编程是多个线程操作同一个变量,需要加锁防冲突——就像多个咖啡师抢一个糖罐,得规定“一次只能一个人用”。而 Go 的做法是:咖啡师 A 把做好的咖啡放在传送带上,咖啡师 B 从传送带另一端取走。数据不是被争夺的,而是被传递的——传递即所有权转移,不存在两个人同时摸到同一杯咖啡的问题。
- 发送与接收配对发送操作会阻塞,直到有对应的接收方就绪;反之亦然。这保证了数据不会无人认领。
ch := make(chan int) // 无缓冲:就像一条不存东西的滑梯,一端放上去,另一端立刻要有人接着chBuffered := make(chan string, 4) // 有缓冲:像一条能堆 4 个托盘的传送带,放上去先堆着
环形缓冲区:传送带的内部构造
你可能好奇:channel 有缓冲时,那些暂时堆在传送带上的托盘是怎么存放的?答案是一个环形缓冲区(ring buffer)。
回想传送带的设计:它有四个托盘位,咖啡师把咖啡放上传送带的一端,客人从另一端取走。如果传送带是直线,送走一个就得把后面所有托盘都往前挪一格——太笨重了。环形缓冲区的巧妙之处在于,它逻辑上是一个环:读写双方各持有一个指针,写指针(send index)负责把数据放到当前空位并前移,读指针(recv index)负责从当前位取走数据并前移。当指针到达末尾时,自动回绕到开头。
环形缓冲区的容量管理也很有讲究。当缓冲全满时,发送方会阻塞,进入 channel 内部的发送等待队列(sendq);当缓冲变空时,接收方会阻塞,进入接收等待队列(recvq)。一旦有数据被读取空出位置,Go 运行时就会从 sendq 中唤醒一个等待的发送者;一旦有数据被发送填充空位,就从 recvq 中唤醒一个等待的接收者。整个机制就像传送带上的感应器:托盘满了就亮红灯,传送带入口自动暂停;空出一个位置就亮绿灯,让下一个托盘滑进来。
- 写指针推进每放入一个数据,写指针加一并取模缓冲大小,保证不会越界。
- 读指针消费每取走一个数据,读指针加一并取模,与写指针类似。
select 多路复用:咖啡师的多任务处理
回到咖啡店场景。现在传送带多了起:一条传送带送咖啡豆,一条送牛奶,一条送杯子和盖子。咖啡师总不能一直只盯一条传送带吧?他需要同时照看多条传送带,一旦哪条有货就立刻取用。这就是 select 语句的用途。
select 让一个 goroutine 同时等待多个 channel 操作,其中任何一个就绪就会执行对应的 case。如果多个 case 同时就绪,Go 会随机选择一个执行。这种随机性很重要——它保证多个消费者场景下,没有人会被“饿死”,就像咖啡师不会一直偏爱从最左边的传送带取货,而是公平地兼顾所有传送带。
select 还有一个妙用:超时控制。通过 time.After 配合 select,可以给阻塞操作设置一个期限——如果一秒内传送带都没来货,就放弃等待,去做别的事。而 default 分支更是提供了非阻塞的“瞟一眼”能力:所有 channel 都没就绪时,立即执行 default,而不是傻等。这就像咖啡师路过的传送带时扫一眼:“都没货?好,那我继续手头的拉花。”
select 是管理“多方输入”的利器。在真实系统中,一个 goroutine 可能需要同时监听“新任务到达”“用户取消请求”“心跳超时”等多个事件,select 让这一切变得清晰、可控——就像一位从容的咖啡师,面对多条传送带,永远知道该优先接住什么。
- 随机选择当 n 个 case 同时就绪时,每个 case 被选中的概率是 1/n。这避免了长期优先某个 channel 造成的不公平。
select {case msg1 := <-ch1: fmt.Println("收到咖啡豆", msg1)case msg2 := <-ch2: fmt.Println("收到牛奶", msg2)case <-time.After(time.Second): fmt.Println("等了一秒,啥也没有,先干别的")default: fmt.Println("所有传送带都空着,继续拉花")}
context 取消传播:打烊时的广播通知
经营咖啡店还有个大问题:晚上十点打烊,但还有一批咖啡师在给“幽灵订单”做咖啡——有些订单顾客早就走了,但他们还在忙活。作为店长,你需要一个广播系统,对着全店喊一声“打烊了!所有做到一半的咖啡直接倒掉,没开始做的别做了!”而且每一位收到通知的咖啡师,还要把这个消息传给正在配合的伙伴——比如呼叫供应商的、联系顾客的——大家同时停手。这就是 context 的取消传播机制。
在 Go 中,context.Context 可以携带取消信号、超时时间和一些元数据,并沿着 goroutine 的调用链向下传递。父 context 被取消时,所有派生出来的子 context 都会收到取消信号。这构成了一棵取消传播树:
更常用的是 context.WithTimeout,它会在指定时间后自动取消,不需要手动调用。这就像店长给每位咖啡师一个“打烊闹钟”:闹钟一响,大家自动停止手头的工作。
context 的核心价值在于避免协程泄漏。在真实的 AI 服务中,一个预测请求可能触发多个 goroutine 去查特征、调模型、写日志。如果用户中途断开了连接,而服务端不传递取消信号,这些 goroutine 就会一直空转到超时甚至永远挂起——就像咖啡师对着空气做咖啡做到天亮。有了 context,连接一断,取消信号像多米诺骨牌一样沿着调用链迅速传递,所有相关 goroutine 立刻收工,资源被及时释放。这正是大型并发系统必备的“优雅下线”能力。
- 取消信号传播父 context 被取消时,取消信号会传递给所有从它派生的子 context,形成树状传播。
- 所有派生 context 收到通知整个传播链上的每个 context 都会收到取消通知,对应的 goroutine 应该检查
ctx.Done()并退出。
ctx, cancel := context.WithCancel(parentCtx)defer cancel() // 函数结束时自动取消,通知所有后代 goroutine 停止工作
AI 应用场景
高并发推理服务的请求编排
阶段:模型推理服务端开发/部署
解决的问题:AI 推理服务通常要同时处理成千上万个在线请求,每个请求又需要并行调用特征服务、模型推理、结果后处理等多个步骤。如果每个请求创建一个系统线程,线程切换开销和内存占用会迅速压垮进程。传统多线程加锁的模型在复杂依赖关系下极易出死锁和数据竞争。
具体案例:某推荐系统团队用 Go 重写推理网关。每个用户请求到达时,go handler(req) 启动一个 goroutine,内部用 select 同时等待“特征服务返回”和“超时信号”。特征到达后,通过带缓冲 channel 将 batch 数据送到 GPU 推理 worker 池。当用户断开连接时,context 的取消信号沿调用链传播,特征查询和推理任务同时中止。最终单机并发从 800 提升到 15000,P99 延迟下降 60%。
异步特征流水线中的数据管道
阶段:特征工程/数据预处理流水线
解决的问题:离线特征计算和在线特征拼接往往是 IO 密集型的。传统同步管道串行执行,每个环节都等待上一个完成;而用共享内存加锁的并发模型又容易在数据推挤中出错。
具体案例:在 CTR 预估系统的特征管道中,每个特征源(用户画像、物品属性、上下文信号)各由一个 goroutine 负责拉取,结果通过 3 个独立 channel 汇入聚合函数。聚合函数用 select 监听所有特征 channel,收集齐后组装成特征向量,送入模型服务。得益于 channel 的“无锁数据传递”,管道吞吐量翻了 4 倍,且没有出现数据竞争。
大规模数据流处理任务
阶段:批处理任务并行化
解决的问题:在大模型训练的数据管线中,需要从大量原始数据生成训练样本。单线程处理瓶颈明显,多线程共享数据的加锁开销又太高。
具体案例:一个数据处理平台用 Go 编写预处理模块:一个 goroutine 从消息队列消费原始数据,通过 channel 分发给 8 个 worker goroutine 并行做清洗和增强,结果汇入输出 channel 送往训练集群。工作时通过 sync.WaitGroup 等待所有 worker 完成,若父任务被取消则所有 worker 通过 context 感知并退出。整个过程像咖啡店的流水线一样流畅、有序。
Python 演示
import threadingimport queueimport timeimport random # 模拟 Go 的 channel:线程安全的队列task_channel = queue.Queue(maxsize=4) # 有缓冲 channel,容量为 4 def producer(producer_id: int, count: int): """模拟 goroutine 中的发送方,向 channel 发送数据""" for i in range(count): task = f"P{producer_id}-Task{i}" task_channel.put(task) # 等价于 ch <- task print(f"[发送] {task}") time.sleep(random.uniform(0.1, 0.5)) print(f"生产者 {producer_id} 完成") def consumer(consumer_id: int, count: int): """模拟 goroutine 中的接收方,从 channel 接收数据""" for _ in range(count): task = task_channel.get() # 等价于 task := <-ch print(f"[接收] 消费者{consumer_id} 拿到 {task}") time.sleep(random.uniform(0.2, 0.6)) print(f"消费者 {consumer_id} 完成") def main(): producers = [ threading.Thread(target=producer, args=(i, 3), name=f"producer-{i}") for i in range(2) ] consumers = [ threading.Thread(target=consumer, args=(i, 3), name=f"consumer-{i}") for i in range(2) ] for t in producers + consumers: t.start() for t in producers + consumers: t.join() print("所有 goroutine 执行完毕") if __name__ == "__main__": main()
task_channel = queue.Queue(maxsize=4)创建容量为 4 的有缓冲 channel,模拟 Go 的make(chan string, 4),数据先进先出。task_channel.put(task)等价于 Go 的ch <- task,向 channel 发送数据;队列满时自动阻塞(等待消费者取走)。task_channel.get()等价于 Go 的task := <-ch,从 channel 接收数据;队列空时自动阻塞(等待生产者放入)。threading.Thread(target=producer, args=(i, 3))创建生产者线程,相当于 Go 的go producer(i, 3)启动 goroutine。t.join()等待所有"goroutine"执行完毕,等价于 Go 中配合sync.WaitGroup等待并发任务完成。
常见误区
误区反正 goroutine 很轻量,那我一个请求开 100 个 goroutine,系统就能跑得飞快。开得越多,并发能力越强。
正解goroutine 虽轻,但并非免费。每个 goroutine 至少占 2KB 栈空间,10 万个就占约 200MB 内存。更关键的是,CPU 核数是有限的(GOMAXPROCS 默认等于核数),大量 goroutine 只会增加调度器的切换开销,让单个任务的延迟变长。就像咖啡店里站 1000 个咖啡师,工位只有 4 个,大多数人在闲聊,还挡住了彼此的路。合理的做法是控制并发度,用 channel 或信号量做限流。
为何常见:因为 goroutine 的创建成本确实远低于线程,让很多人误以为它是“无限免费”的,忽略了调度开销和内存累积。
误区用 channel 通信很容易死锁,所以多线程编程还是加锁最安全。channel 是花架子。
正解channel 的死锁往往源于使用不当,而不是机制本身的缺陷。常见的死锁场景有:无缓冲 channel 的发送方和接收方不配对就相互等待、在 main goroutine 中直接读 channel 而没有其他发送方。正如传送带两头必须各站一个人才有意义,使用 channel 前你需要明确“谁在发送、谁在接收、何时结束”这个三元组。Go 的 go vet 和运行时检测能帮你快速定位这类问题。相比之下,锁引发的死锁更难排查,channel 的设计恰恰让并发逻辑更清晰,更容易发现错误。
为何常见:初学者一开始容易在各种场景毫无规划地使用 channel,导致频繁死锁,于是归咎于 channel 本身,而忽略了问题出在并发逻辑的编排上。
误区我创建了容量为 10 的 channel,那么发送 10 个数据都不会阻塞,只要不超过容量就永远畅通。
正解有缓冲 channel 只是延迟了阻塞,并没有消除阻塞。当缓冲满时,发送方依然会阻塞,直到有人取走数据。此外,如果接收方始终没有准备好(比如接收 goroutine 已经退出),即使有缓冲空间,发送也可能永久阻塞。关键在于理解 channel 的完整语义:缓冲只是“暂时堆放”的容量,不是“无脑收容”的空间。
为何常见:初学者看到“有缓冲”三个字,就直觉地认为不再需要关心阻塞问题,忽略了缓冲耗尽与接收方退出两个边界场景。
误区select 和 switch 一样,从上到下依次判断,第一个满足条件的分支会被执行。所以我可以把优先级最高的 case 写在最上面。
正解select 与 switch 完全不同。当多个 case 同时就绪时,Go 会随机均匀地选择一个执行,而不是按书写顺序。这是刻意设计的公平性保证——如果按顺序执行,排在前面的 case 会一直抢占资源,后面的 case 可能“饿死”。就像咖啡师如果只从最左边传送带取货,右边传带上的牛奶就永远用不到。
为何常见:程序员容易把 select 类比成 switch,忽视了 Go 语言设计者对公平调度(fair scheduling)的刻意追求,从而习惯于用“顺序”思维去理解它。
误区既然 context 能携带值,我就用它来传递各种业务参数,比如用户 ID、请求 ID、甚至数据库连接对象,省得写函数签名。
正解context 的核心职责有三个:取消信号传播、超时管理、少量元数据传递。Go 官方明确建议:context 里的值应该是请求级的元数据(如 trace ID、认证信息),不应该是业务函数的核心参数。将大量业务数据塞进 context,会破坏函数签名的自文档性,让代码难以测试和维护。就像打烊广播系统是用来通知“停手”的,而不是用来传递咖啡配方的。
为何常见:context 提供了 WithValue 接口,且顺着调用链传递很方便,容易让开发者把它滥当成一个隐藏的全局变量包。
面试题
Q1. 关于 goroutine 的初始栈空间,以下说法正确的是?
- A.固定为 1MB,与操作系统线程相同
- B.初始约 2KB,可动态伸缩← 正确答案
- C.初始为 0,首次调用时才分配
- D.固定为 4KB,不可扩展
答案:B。goroutine 的初始栈约 2KB,且是动态伸缩的——这正像咖啡师“按需占地盘”。选项 B 正确。A 是系统线程的栈大小,C 搞混了懒分配,D 错在“固定不可扩展”。
Q2. GMP 调度模型中,P 的核心作用是什么?
- A.真正的操作系统线程
- B.逻辑处理器,管理本地运行队列← 正确答案
- C.存放全局数据的容器
- D.负责网络 IO 的组件
答案:B。P(Processor)是逻辑处理器,即“操作台”,每个 P 维护一个本地 goroutine 队列,M 从 P 取 G 执行。A 是 M 的角色,C、D 与 P 的实际职责无关。
Q3. 下面哪个语句可以创建一个容量为 3 的字符串类型 channel?
- A.
ch := make(chan string, 3)← 正确答案 - B.
ch := make(chan string) - C.
ch := make(chan int, 3) - D.
ch := new(chan string)
答案:A。make(chan string, 3) 创建类型为 string、容量为 3 的 channel,对应“能堆 3 个托盘的传送带”。B 是无缓冲 channel;C 的元素类型是 int 而非 string;D 用 new 创建的是指针而非可用的 channel。
Q4. 关于无缓冲 channel 的发送与接收,以下描述正确的是?
- A.发送方把数据放入缓冲后立即返回
- B.接收方随时都可以读取历史数据
- C.发送和接收必须同时就绪,否则双方阻塞← 正确答案
- D.发送方永远不阻塞,只有接收方阻塞
答案:C。无缓冲 channel 没有“堆放区”,发送必须遇到等待的接收方才能完成配对,否则发送方阻塞;同理接收方没有发送方也会阻塞。这正是“传送带两头必须同时有人”的场景。A、B 描述的是有缓冲 channel 的行为,D 完全颠倒。
Q5. 环形缓冲区中,读写指针到达数组末尾时如何回到开头?
- A.手动将指针清零
- B.通过取模运算自动回绕← 正确答案
- C.指针越界后由垃圾回收器修正
- D.使用链表替代数组
答案:B。环形缓冲区通过 index = (index + 1) % bufSize 让指针自动回绕到数组开头,无需搬移数据。这就像传送带首尾相接,托盘走到尽头自然绕回起点。A 是低效的手动做法,C 概念错误,D 是另一种数据结构而非回绕机制。
Q6. 当有缓冲 channel 已满时,发送操作的后果是什么?
- A.立即失败并返回错误
- B.发送方阻塞,直到有接收方取走数据← 正确答案
- C.覆盖最早未被读取的数据
- D.自动扩容缓冲容量
答案:B。缓冲满时,发送方会阻塞进入 channel 的发送等待队列,直到有接收方取走数据腾出空间。就像传送带堆满后,入口自动暂停。A 只有非阻塞发送(select default)才会出现,C 是覆盖式队列的行为,D 中 channel 不支持自动扩容。
Q7. select 语句在多个 case 同时就绪时会如何选择?
- A.按书写顺序执行第一个
- B.永远执行优先级最高的 case
- C.随机均匀地选择一个执行← 正确答案
- D.同时执行所有就绪的 case
答案:C。select 在多个 case 同时就绪时,会以相等的概率随机选择一个执行,这是 Go 为了保证多个 channel 之间的公平调度而做的设计。就像咖啡师公平兼顾多条传送带。A 是 switch 的行为,B 不存在优先级概念,D 并发编程中 select 无法并行执行多个分支。
Q8. 下列关于 channel 环形缓冲区的说法,正确的有哪些?
- A.写指针指向下一个可写入的位置← 正确答案
- B.读指针指向下一个可读取的位置← 正确答案
- C.缓冲区满时接收方会被阻塞
- D.缓冲区空时发送方会被阻塞
答案:AB。写指针和读指针分别标记下一个可写、可读的位置,A、B 正确。缓冲满时发送方阻塞(不是接收方),C 错误;缓冲空时接收方阻塞(不是发送方),D 错误。简记:满了卡“入口”,空了卡“出口”。
Q9. 关于 context 的取消传播,以下说法正确的是?
- A.父 context 取消后,子 context 会自动收到取消信号← 正确答案
- B.子 context 取消后,父 context 也会被取消
- C.
context.WithTimeout会在超时后自动触发取消← 正确答案 - D.context 只能手动调用 cancel 才能取消
答案:AC。取消信号是自顶向下传播的:父取消会传到所有子 context,但子取消不会影响父——这正是“店长广播后全员收工,但咖啡师自己不会让店打烊”。WithTimeout 依赖定时器自动取消,无需手动调用 cancel,故 C 正确、D 错误。
Q10. 在 Go 并发编程中,下面哪些做法符合“不要通过共享内存来通信”的理念?
- A.使用 channel 在 goroutine 间传递数据← 正确答案
- B.用 sync.Mutex 保护多个 goroutine 共享的切片
- C.通过 context 向 goroutine 传递取消信号← 正确答案
- D.多个 goroutine 直接读写同一个全局 map
答案:AC。A 用 channel 传递数据所有权,C 用 context 传递控制信号,都体现了“通过通信来共享”的 Go 哲学。B 虽然能保证安全,但仍是“共享内存加锁”的传统模式;D 直接并发读写全局 map 会引发数据竞争,是必须避免的危险做法。就像传送带传递咖啡是优雅协作,而多人同抢一个糖罐就要打架。