想象你经营一家网红奶茶店,店里只有一个操作台、一台封口机。高峰期三个店员同时冲过去,你挤我我撞你,奶茶撒了一地。Go 语言里也有这种混乱:很多 goroutine(你可以理解成手脚麻利的店员)会同时涌向同一块内存区域。官方建议用 channel 沟通,相当于给店员配了对讲机,可有些场景喊半天对讲机,不如直接在操作台挂把锁。sync 包就是 Go 官方给你的“店面管理规则手册”:Mutex 是操作台的钥匙,WaitGroup 是门口签到的黑板,Once 是开业剪彩的剪刀,Cond 是厨房煮珍珠的铃铛。这篇带你把这套工具摸透,以后写并发服务,心里就有谱了。
学会 sync 包是写好 Go 并发服务的分水岭:面试常考、线上故障常见。掌握它,等于给你的并发代码上了双保险,是和普通 CRUD 程序员拉开差距的关键一步。
目录
可视化演示
\u53ef\u89c6\u5316\u5c55\u793aGo\u8bed\u8a00sync\u5305\u7684\u56db\u5927\u5e76\u53d1\u539f\u8bed\uff0c\u5305\u62ecMutex\u4e92\u65a5\u9501\u3001WaitGroup\u7b49\u5f85\u673a\u5236\u3001sync.Once\u5355\u4f8b\u521d\u59cb\u5316\u548cCond\u6761\u4ef6\u53d8\u91cf\uff0c\u5206\u6b65\u9aa4\u5c55\u793agoroutine\u540c\u6b65\u8fc7\u7a0b\u3002
本节配有交互动效(进入视口后自动加载,约 3KB)。
Mutex\u4e92\u65a5\u9501 \u540c\u4e00\u65f6\u523b\u53ea\u6709\u4e00\u4e2agoroutine\u6301\u9501 \u5176\u4ed6goroutine\u963b\u585e\u7b49\u5f85\u91ca\u653e Lock() \u2192 Unlock() \u70b9\u51fb\u4e0b\u4e00\u6b65\u67e5\u770bWaitGroup
锁的初心:临界区与互斥
奶茶店只有一台封口机,店员 A 刚把手里的杯子塞进去,店员 B 也把杯子往同一个位置挤,两杯奶茶全洒了。在 Go 里,“封口机”就是一块共享内存——比如一个计数器、一份配置、一段订单列表。多个 goroutine 同时读写它,就产生了竞态条件(Race Condition)。竞态不一定会每次出错,但它像薛定谔的猫:同一份代码,有时对有时错,线上跑着跑着就冒出诡异数字。
解决思路直白:把那台封口机围起来,立一块牌子“操作中,闲人免进”。被围起来的代码区域叫临界区(Critical Section),规则是任意时刻最多只有一个 goroutine 可以进去。sync.Mutex 就是那块牌子加一把钥匙:调用 Lock() 拿钥匙进门,调用 Unlock() 归还钥匙。其他 goroutine 看到门锁着,就老老实实在门口排队,直到门打开。
用数学语言看互斥约束,就一句话:
封口机前的秩序恢复后,奶茶再也不会洒了。但要记住:锁只是规矩,得所有店员都自觉走门,如果有人翻窗,锁就白挂了——所有访问共享数据的代码都必须用同一把锁。
- 指示函数\mathbb{1} 像一个探针,当括号里的条件成立时返回 1,不成立返回 0。
- 求和汇总把所有 goroutine 的探针值加起来,得到此刻临界区里的人数。
- 互斥保证M(t)≤1 表示“不超过一个人”,这正是 Mutex 保护临界区的核心语义。
var mu sync.Mutexvar orderCount int func takeOrder() { mu.Lock() defer mu.Unlock() orderCount++ // 临界区:同一时刻只有一个人进来}
Mutex使用:钥匙要还
给操作台上了锁,可如果店员 A 进去做完奶茶,把钥匙往兜里一揣就下班了,全店都傻眼:后面所有员工只能在门口干等。Mutex 不会自动释放,Lock 和 Unlock 必须成对出现。最常见的坏味道是“忘记解锁”或“提前 return 跳过了 Unlock”,这些都会让程序悄悄变成死锁。
Go 社区给出的标准答案是 defer:一进门就把“出门还钥匙”写进备忘录,哪怕中途 panic、中途 return,defer 也能保证 Unlock 执行。这就像店员进了操作台,先在门上贴一张便利贴“走之前把钥匙挂回钩子”,天王老子来了也拦不住他还钥匙。
锁的粒度同样值得琢磨:是锁一整家店,还是只锁封口机那一平米?粒度越小,其他店员能自由活动的空间越大,程序并发度越高。可粒度太小时,你可能需要很多把锁,反而容易记混。实际工程里常因为“上锁顺序不一致”造成死锁——A 员工拿着锁 X 等锁 Y,B 员工拿着锁 Y 等锁 X,两人瞪眼到下班。
所以钥匙要还,还要快点用、少进几次操作台。锁不是越多越好,而是越短越好——像打台球,等别人打完的速度,取决于每个人霸占球桌的时间。
- 并行工作耗时T_parallel 是大家各干各的、不抢锁时花的时间。
- 串行开销叠加每次进临界区都要排他执行,耗时 t_CS 被一项项加进总时间。
- 总量关系总耗时等于并行部分加上所有临界区串行部分,锁竞争越多,并行收益越少。
读写锁RWMutex
奶茶店的墙上挂着一份配方表,顾客和店员随时都能看,这本身没问题;但店长改配方(比如糖度从七分改成五分)时,不能有人正盯着旧配方琢磨,否则改到一半,有人看到“半新半旧”的配方就乱了。Mutex 一刀切不让所有人进,可看配方并不改变什么,大家同时看完全安全。
RWMutex 把锁拆成两档:读锁(RLock)和写锁(Lock)。多个 goroutine 可以同时持有读锁——就像一群人围在一起看配方表;但写锁是独占的——店长一挂“修改中”,所有读的人也必须退出去。它严格保证:要么多人读,要么一人写,绝不能又读又写。
什么时候用 RWMutex?配方这种“读多写少”的场景收益巨大:十个人读不用排队,只有偶尔的写操作才让大家等。反过来,如果写操作频繁,写锁频繁把所有读者赶走,RWMutex 的维护成本比普通 Mutex 更高,反而更慢。选型要看读写比例,别拿一把“高级锁”到处乱套。
配方表前的秩序一下子灵活了:看配方的人各看各的,互不打扰;店长一改配方,所有人暂停。读多写少的服务,比如配置中心、活动商品列表,用 RWMutex 能明显减少排队。
- 读锁非负R(t) 是时刻 t 持有读锁的 goroutine 数,可以是 0、1、2……任意多个。
- 写锁二值W(t) 只能取 0 或 1,代表“没人写”或“一个写者”。
- 读写互斥只要有一个写者(W=1),读锁就必须是 0,两者乘积恒为 0。
WaitGroup等待
早上 10 点开店,店长数了数:还差三个店员没到。他不能直接开门,得站在签到板前等。这块签到板就是 WaitGroup 的计数器:Add(1) 表示“又来了一个要等的店员”,Done() 表示“这位店员到位了,划掉一笔”,Wait() 表示“板子没划干净就继续等”。
对应到代码里:主 goroutine 启动一批子 goroutine 干活,然后调用 wg.Wait() 等它们全部结束。注意 Add 一定要在启动 goroutine 之前调用——就像店长要先在板上记上人名,再派人去干活。如果 Add 写在 goroutine 内部,主 goroutine 可能看到计数器还是 0,直接放行,等于店长看了一眼空黑板就开门,员工其实还在路上。
Done() 在底层就是 Add(-1),用多了会出大事:计数器一旦变成负数,程序直接 panic。另外 WaitGroup 也内嵌了 noCopy,按值拷贝会让计数器分裂成两份,互相失去联系。正确的做法是传指针,或者干脆让 WaitGroup 待在结构体的指针字段里。
签到板让“等所有人收工”变得无比简单。批量下载图片、并发爬取页面、并行跑模型推理,主流程只要一行 wg.Wait(),就能笃定地等所有活干完再汇总。
- 计数更新C_{n+1} 等于旧计数加上增量 Δ_n,Add(1) 让 Δ=1,Done 让 Δ=-1。
- 阻塞条件Wait 会一直阻塞,直到计数变成 0;计数为 0 时立刻放行。
- 负数崩溃如果计数掉到 0 以下,Go 运行时会抛出 panic,相当于签到板上被划出了负数。
Once单次执行
奶茶店开业要剪彩,但彩带只能剪一次。如果三周年庆时,老板又把当年的剪彩仪式搬出来重新剪一次,那就贻笑大方了。sync.Once 就是这把“只响一次的鞭炮”:无论多少 goroutine 同时来点它,只有第一个能成功引爆,其他人都乖乖等着,等第一声响完就各自散去。
Once 最常见的用法是单例初始化:懒加载一个全局配置、一个数据库连接池、一个 AI 模型实例。第一次请求触发 Do(f),f 执行;之后并发来一百个请求,全部被 Once 挡住,f 永远不会再跑第二遍。这在并发环境下比“先检查再初始化”的按需模式安全得多——那种模式在并发时会重复初始化。
不过 Once 有个隐蔽的坑:Do 内部再调用同一个 Once 的 Do,会直接死锁。想象店员 A 拿着钥匙进了操作台,又在操作台里给 B 发消息“你来一下”,B 想进门却发现 A 把自己锁在里面了。因为 Once 内部有一把互斥锁,执行 f 时锁一直持有,f 里再次 Do,只能永远等自己。所以 Do 里千万别递归调用同一个 Once。
剪彩仪式一辈子只办一次,模型加载、配置初始化也只该跑一次。Once 用最小的心智负担,解决了并发环境里“只做一次”的千古难题。
- 状态迁移Do(f) 让 Once 从 idle(未执行)进入 running(正在执行),f 返回后再进入 done(已完成)。
- 完成态f 返回后,Once 永久停留在 done 状态,后续 Do 调用直接跳过。
- 计数唯一集合 {i : f executed} 的大小为 1,说明 f 在所有调用里只真正执行了一次。
条件变量Cond
煮珍珠的师傅不用一直站在锅边盯着。他把珍珠下锅,设好计时器,就回休息室躺着;计时器一响(铃响了),他再起身去处理。sync.Cond 就是这个“铃铛”系统:goroutine 在条件不满足时调用 Wait(),把自己挂起、进入休息室;另一个 goroutine 在条件满足时调用 Signal() 或 Broadcast() 按铃,把等待者叫起来干活。
Cond 的使用比前面几个原语讲究。首先,Cond 必须和一个锁绑定(通常是 Mutex),Wait() 内部会先释放这把锁,再挂起;被唤醒后,又抢回锁才返回。这就好比师傅进休息室前先把操作台钥匙交出来,被铃吵醒后先去拿钥匙,再进操作台。其次,Signal 只唤醒一个等待者,Broadcast 唤醒全部——对应“一个店员去取珍珠”和“所有店员都知道珍珠好了”。
最关键的纪律是:Wait 必须放在 for 循环里,而不是 if。因为等待者可能被“伪唤醒”——铃没响,或者响了但珍珠其实还没好。醒来后必须再检查一遍条件,不满足就继续睡。这就像师傅被外面的猫碰响了铃,起来一看锅还没动静,又躺回去。
铃铛系统让“等待某个条件发生”从傻等变成高效睡眠。生产者消费者、任务队列、事件通知,凡是“条件不满足就等,满足就干活”的场景,Cond 都是趁手工具。
- 释放锁并挂起Wait 第一步先解锁,让自己不再占着操作台,然后进入睡眠队列。
- 唤醒后重抢锁被 Signal 或 Broadcast 唤醒后,Wait 返回前会重新获取锁,确保临界区又开始互斥。
- 信号守恒按铃的次数不会超过等铃的人数,多余的通知会丢失——所以条件变量不能凭信号计数,必须靠 for 循环复查条件。
锁拷贝陷阱
奶茶店的核心资产就是那把操作台钥匙。如果有人偷偷复印了一把,拿着复印件也能开门,那“只有一个人能进操作台”的规矩就废了。Go 里的 sync.Mutex、RWMutex、WaitGroup、Cond 都内置了 noCopy 标记,意图就是告诉编译器:这东西不能复制!可编译器不会自动拦截,得靠 go vet 这个“保安”帮你盯。
noCopy 是怎么起作用的?Go 的 vet 工具会检查代码里有没有复制含 noCopy 字段的结构体。常见肇事场景有两个:把包含 Mutex 的结构体按值传给函数,或者把一个结构体直接赋值给另一个变量。这两种操作都会把锁复制出一份。复制的锁和原锁各管各的排队队列:一个 goroutine 锁住了原操作台,另一个 goroutine 却拿着复印件打开了复制品操作台,互斥彻底失效。
想要安全,记住一个原则:凡是内含锁、WaitGroup、Cond 的结构体,一律用指针传递。函数参数写成 *MyStruct,赋值时手动取地址,让所有 goroutine 操作的是同一把锁。go vet 会像保安一样帮你扫出复印件,写完代码跑一遍 go vet ./…… 是好习惯。
复印钥匙等于没上锁。把 noCopy 记成一条肌肉记忆:锁和等待组一律指针传递,遇到复制就上 go vet,安全感和工程质量都拉满。
- noCopy 标记sync 类型的内嵌字段把自己划入“禁止复制”家族。
- 复制检测go vet 扫描到结构体按值传递或赋值时,识别出锁的复制行为。
- 报错输出一旦发现,vet 工具输出错误,提醒你改用指针传递。
并发安全Map
节假日客流爆棚,前台的爆款奶茶排行榜被顾客围得水泄不通。排行榜是典型的“读多写少”:一百个人在看,偶尔一个店员更新名次。如果用普通 map 加一把大 Mutex,所有人看排行榜都得排队,客人都要骂街了。sync.Map 就是为这类场景设计的“公告板加便签”双缓存结构。
sync.Map 内部把数据分成两个区:read 区是一块“原子读”的公告板,几乎所有读操作都只碰它,不用加锁;dirty 区是草稿板,写操作先改这里。当一个 goroutine 在 read 区没找到某个 key,就算一次 miss,它得去 dirty 区找,同时会把数据“抄一份”放进自己的便签(read 区),下次再读就不用跑第二趟了。miss 次数越积越多时,两区会自动同步。这就是它读多写少时飞快的原因。
用起来很简单:Store(key, value) 写入,Load(key) 读取,LoadOrStore 有则取旧值无则写入,Delete 删除,Range 遍历。但 sync.Map 不是万能银弹——它适合 key 集合相对稳定、读多写少、多个 goroutine 各自只操作自己 key 的场景;如果写很频繁,或者只是单 goroutine 访问,普通 map 加 Mutex 反而更简单更快。
公告板加便签的巧思,让高并发读不再挤在一条锁的队伍里。写 Go 缓存、计数排行榜、配置快照时,先想想读写比例,再决定用 Mutex 还是 sync.Map。
- 失手率定义P_miss 是“没在 read 区命中”的比例,等于 miss 次数除以总查询次数。
- 缓存升温随着运行时间变长,热门 key 逐渐被抄进 read 区,miss 比例越来越小,最终趋近于 0。
AI 应用场景
秒杀场景下的库存保护
阶段:模型推理服务部署与并发控制
解决的问题:AI 服务上线后,常有多个请求同时打到一个共享状态上——比如模型版本号、累计调用次数、在线人数统计。传统做法是直接 count++,高并发下会丢更新,数字忽大忽小。用 Mutex 把累加操作包进临界区,像奶茶店的操作台一样,同一时刻只放一个 goroutine 进来改数字,准确率立刻拉满。
具体案例:一个图像识别 API 服务,每次请求结束都要把成功次数写入监控指标。最初并发压测时指标总是偏小,定位后发现是多个 goroutine 同时 count++ 互相覆盖。给统计逻辑加上 mu.Lock() / defer mu.Unlock() 后,指标与真实请求数完全一致。
模型全局单例的懒加载
阶段:服务启动与模型权重初始化
解决的问题:AI 推理服务启动时,模型加载很重(几百 MB 到几个 GB)。如果用“每次请求都检查模型在不在”的方式,第一个瞬间会有一百个请求同时发现“模型不存在”,然后各自加载一遍,内存直接爆炸。sync.Once 保证模型只被加载一次,其余 goroutine 等着用同一个实例。
具体案例:一个语音识别服务部署了 8 副本,每个副本在收到第一条请求时才加载模型。用 sync.Once 包住加载逻辑后,无论多少请求同时到达,模型文件只被读一次,内存占用稳定,冷启动延迟从 8 秒降到 2 秒。
批量数据任务的并发汇总
阶段:数据批处理与并行加速
解决的问题:处理 1000 张图片的特征提取,天然可以并发。但主程序必须等所有 worker 都干完才能汇总裁决结果。WaitGroup 就是为这种“等所有店员到齐再开门”的场景设计的:任务分发前 Add,每个 worker 完成后 Done,主流程 Wait 到计数器归零再汇总。
具体案例:一个 OCR 数据清洗管线,用 16 个 goroutine 并行识别 PDF 页面,每个页面完成后向共享切片写入结果(用 Mutex 保护)。主协程 wg.Wait() 后统一评估识别准确率,整批处理耗时从串行的 20 分钟压到 3 分钟。
Python 演示
import threadingimport time # 对应 Go 的 sync.Mutex 保护共享计数器orders: int = 0orders_mu = threading.Lock() def make_milk_tea(worker_id: int) -> None: global orders for _ in range(5): with orders_mu: # 相当于 Lock + defer Unlock orders += 1 print(f"worker{worker_id} 完成一份 当前订单 {orders}") time.sleep(0.01) workers = [threading.Thread(target=make_milk_tea, args=(i,)) for i in range(4)] # 相当于 WaitGroup.Add(1) 四次for w in workers: w.start() # 相当于 WaitGroup.Wait()for w in workers: w.join() print(f"打烊时总订单数={orders}") # 对应 Go 的 sync.Cond:珍珠锅煮熟后广播通知pearl_ready = Falsecond = threading.Condition() def boil_pearls() -> None: global pearl_ready with cond: time.sleep(0.05) pearl_ready = True cond.notify_all() # 等价 Cond.Broadcast print("珍珠熟了 按铃通知") def take_pearls(waiter_id: int) -> None: with cond: while not pearl_ready: # for 循环防伪唤醒 cond.wait() # 等价 Cond.Wait 自动释放并重获锁 print(f"店员{waiter_id}取到珍珠") waiters = [threading.Thread(target=take_pearls, args=(i,)) for i in range(3)]for w in waiters: w.start() boil_thread = threading.Thread(target=boil_pearls)boil_thread.start() for w in waiters: w.join()boil_thread.join()print("所有珍珠处理完毕")
orders_mu = threading.Lock()相当于sync.Mutex,操作台的钥匙,一个人拿着别人就不能进。with orders_mu:相当于 Go 里的Lock()+defer Unlock(),无论是否异常,出了代码块自动归还钥匙。orders += 1共享变量更新,没有锁保护就会数据竞争,像两杯奶茶同时挤同一个出口。w.start()启动一个 worker 线程,对应 Go 的go func()。w.join()主线程阻塞到该 worker 结束,和WaitGroup.Wait()一个道理。cond.notify_all()等价Cond.Broadcast(),按铃把所有等着的店员都叫醒。cond.wait()先交还锁、进休息室睡觉,被唤醒后再抢回锁,对应Cond.Wait()。while not pearl_ready:醒来后重新检查条件,防止伪唤醒白高兴一场,对应 Go 中for循环包Wait()的写法。
常见误区
误区我以为 `mu.Lock()` 是“锁住某个变量”,锁住之后其他 goroutine 就不能碰这个变量了。那我把变量保护起来,应该就不会有问题吧?
正解Mutex 锁的从来不是变量,而是“代码片段”(临界区)。它只约束那些同样调用了这把锁的代码。如果另一段代码不经过 mu.Lock() 直接读写同一个变量,锁根本管不住它。就像操作台挂锁只能挡住走正门的人,翻窗的店员依然能进去捣乱。所以必须约定:所有访问共享数据的代码,都要走同一把锁。
为何常见:初学并发时容易把锁想象成“变量上的封条”,因为直观;但实际模型是“通往操作台的钥匙”,理解成“给代码上锁”才准确。
误区我先 `go worker()` 再 `wg.Add(1)`,反正 goroutine 肯定会在 Wait 之前执行到 Add,这样计数应该没问题吧?实际运行却发现 Wait 有时立即返回。
正解goroutine 的调度是异步的,主 goroutine 完全可能抢在子 goroutine 执行 Add 之前先跑完 Wait()。此时计数器还是 0,Wait 直接放行,子 goroutine 还没开始呢,活就“等完了”。正确做法是在启动 goroutine 之前就 Add(1),相当于店长先在本子上记好人名,再让员工出门干活。
为何常见:人说话是顺序的,直觉上“先 go 再 Add”没有毛病;但并发世界里顺序取决于调度器,必须显式保证 Add 先于 Wait。
误区RWMutex 也是锁,那读锁之间应该也像 Mutex 一样互斥吧?所以我写代码时,读操作也老老实实排队,结果性能一点没提升。
正解RWMutex 的设计初衷恰恰相反:读锁之间可以无限并发,只有写锁才独占。多个 goroutine 同时 RLock() 是允许的,它们可以一起进临界区;只有 Lock()(写锁)会挡住所有人。这就好比配方表可以十个人同时看,只有店长改配方时大家才需要回避。
为何常见:看到“读写锁”里的“锁”字,容易默认它和 Mutex 一样严格互斥,忽略了“读共享”才是它存在的意义。
误区sync.Map 是官方出的并发安全字典,肯定全面碾压 map+Mutex。所以我把所有并发 map 都换成 sync.Map,结果性能反而下降。
正解sync.Map 靠 read 区缓存和 miss 计数器换取性能,适合“读多写少、key 集合稳定、goroutine 各自操作不同 key”的场景。如果写频繁,read 区频繁失效,miss 追踪和双区同步反而带来额外开销;如果只有一个 goroutine 访问,普通 map 加锁更简单高效。选型先看读写比例,不盲目追新。
为何常见:官方库自带“权威光环”,让人忽略它其实是特定场景下的优化工具,而非通用银弹。
误区我写了一个 Once,在 Do 的回调函数里又调用了同一个 Once 的 Do,心想“反正只执行一次,再调一次应该没问题”。结果程序直接卡死。
正解Once 的 Do 在执行回调时持有内部锁,回调里再次调用同一个 Once 的 Do,会一直等待自己释放锁,形成死锁。这就像店员已经拿着钥匙进了操作台,又在屋里喊“我要再拿一次钥匙进来”,只能永远等自己。解决办法:不要在 Do 的回调里直接或间接调用同一个 Once 的 Do,把内部逻辑拆成普通函数。
为何常见:只想着“只执行一次”的语义,忽略了实现里的互斥锁细节;尤其当回调函数较大、不容易一眼看出嵌套时,最容易踩坑。
面试题
Q1. Mutex 加锁后忘记调用 Unlock,最直接的后果是什么?
- A.触发 panic 终止程序
- B.锁永不释放致阻塞← 正确答案
- C.锁超时自动释放
- D.其他协程照常进入
答案:B。Mutex 没有超时机制,未调用 Unlock 会让锁一直处于锁定状态,后续所有 Lock 调用都会永久阻塞,就像操作台钥匙被店员揣走,全店瘫痪。A 错,忘记 Unlock 不会 panic;C 错,锁不会自动释放;D 错,其他协程进不了临界区。
Q2. 在 WaitGroup 中,Done() 方法等价于?
- A.Add(0)
- B.Add(-1)← 正确答案
- C.Add(1)
- D.Wait(1)
答案:B。WaitGroup 的计数器靠 Add 增减,Done() 在底层就是 Add(-1),表示“一个等待任务完成了”。Add(0) 什么都不做,Add(1) 反而增加计数,Wait(1) 不是合法调用。就像签到板上划掉一笔,计数减一。
Q3. RWMutex 的读锁(RLock)之间是什么关系?
- A.完全互斥的
- B.可以并发的← 正确答案
- C.只能串行执行
- D.依赖写锁次数
答案:B。RWMutex 的核心设计就是“读共享,写独占”。多个 goroutine 可以同时持有读锁进入临界区,互不阻塞;只有写锁才需要排他。这就像配方表可以很多人同时看,所以读锁之间不互斥。
Q4. 下列哪种场景最适合使用 sync.Map?
- A.读写比例相当且写频繁
- B.读多写少且 key 集合稳定← 正确答案
- C.只有单 goroutine 访问
- D.需要自定义哈希函数
答案:B。sync.Map 通过 read 区缓存热门 key 来减少锁竞争,适合“读多写少、key 稳定、多个 goroutine 分散访问”的场景。写频繁时 read 区反复失效,反而更慢;单 goroutine 时普通 map 更快;自定义哈希函数与 sync.Map 无关。
Q5. N 个 goroutine 同时调用同一个 Once.Do(f),f 会被执行几次?
- A.N 次
- B.0 次
- C.1 次← 正确答案
- D.可能 panic
答案:C。sync.Once 保证 f 只执行一次:第一个抢到内部锁的 goroutine 执行 f,其余 goroutine 等它完成后直接返回。就像剪彩仪式只剪一次,后来的人看一眼就散。不会执行 N 次、也不会是 0 次或 panic。
Q6. Cond.Wait() 返回之前,一定会先做什么?
- A.重新获取关联的锁← 正确答案
- B.释放全局互斥锁
- C.通知其他等待者
- D.将计数器加一
答案:A。Cond.Wait 的完整流程是:释放传入的锁、挂起睡眠、被唤醒后重新抢回锁再返回。所以调用者从 Wait 返回后,一定重新持有了锁,可以直接检查条件。B 错在不是全局锁;C、D 与 Wait 语义无关。
Q7. 把包含 sync.Mutex 的结构体按值传给函数,会带来哪些后果?
- A.锁会被自动释放
- B.锁的副本与原件各自独立← 正确答案
- C.go vet 会标记该复制行为← 正确答案
- D.锁会变成只读锁
答案:BC。按值传递会拷贝整个结构体,锁的副本和原件拥有各自的等待队列,互斥保护失效,相当于复印了操作台钥匙。go vet 的 copylocks 检查能识别这种复制并报错。A 和 D 都不是真实后果,锁不会自动释放,也不会改变类型。
Q8. 关于 WaitGroup 的正确使用方式,下列说法正确的是?
- A.在启动 goroutine 之前调用 Add← 正确答案
- B.在 goroutine 内部调用 Done← 正确答案
- C.在 Wait 之后继续调用 Add
- D.把 WaitGroup 按值复制给 goroutine
答案:AB。Add 必须在启动 goroutine 前调用,否则 Wait 可能提前放行;Done 通常放在 goroutine 内,用 defer 保证执行。Wait 之后再 Add 会破坏语义,可能 panic;按值复制 WaitGroup 会让计数分裂,所以必须传指针。
Q9. sync.Cond 使用 for 循环而不是 if 判断等待条件,主要原因是?
- A.for 循环运行更高效
- B.防止伪唤醒导致条件不满足← 正确答案
- C.if 无法通过编译
- D.减少锁的竞争时间
答案:B。条件变量的等待可能被伪唤醒——没有信号、或信号对应的条件已被消费。醒来后必须再检查一次条件,不满足就继续 Wait。for 循环就是“醒来复查”的保障,而 if 只检查一次,可能在条件不成立时继续执行导致逻辑错误。这与性能无关。
Q10. 关于 sync.Once,下列说法正确的是?
- A.Do 内部再次调用同一 Once 的 Do 会死锁← 正确答案
- B.Once 可以保证函数只执行一次← 正确答案
- C.多个 goroutine 调用 Do 会导致多次执行
- D.适合做全局单例初始化← 正确答案
答案:ABD。Do 执行回调时持有内部锁,回调里再调同一个 Do 会等待自己释放锁,必然死锁,A 对。多个 goroutine 同时调用,只有第一个真正执行 f,B 对、C 错。Once 让懒加载的单例初始化在并发下安全,D 对。这是 Once 最经典的组合坑。