news 2026/8/2 12:36:11

Go设计取舍之六: sync.Mutex正常模式与饥饿模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go设计取舍之六: sync.Mutex正常模式与饥饿模式

2023 年 6 月 3 日,在golang-nuts询问了sync.Mutex源码注释中的 waiter 和饥饿模式。Ian Lance Taylor 参与回复,共有 6 封邮件。完整讨论:Can we further optimize the scheduling order of goroutines in sync.Mutex?。

Gosync.Mutex为什么有正常模式和饥饿模式?

第一次读 Go 的 Mutex 源码时,最吸引人的不是 CAS,而是这段很长的注释:

// Mutex can be in 2 modes of operations: normal and starvation.// In normal mode waiters are queued in FIFO order, but a woken up waiter// does not own the mutex and competes with new arriving goroutines over// the ownership.// ...// If a waiter fails to acquire the mutex for more than 1ms,// it switches mutex to the starvation mode.

我当时有两个疑问。

第一,注释里的 waiter 指队头 goroutine,还是等待队列里的任意 goroutine?

第二,如果是某个等待者因为超过 1ms 触发了饥饿模式,为什么不让“触发饥饿的那一个”马上获得锁,而是继续按照队列顺序移交?

Ian Lance Taylor 的回复很短,却指出了我理解中的关键误区:waiter 指的是队头等待者;等待队列本身已经是 FIFO,队头天然就是等待时间最长的 goroutine。

要理解这句话,需要先理解 Mutex 为什么不从一开始就严格公平。

快路径:大多数 Lock 不会进入复杂逻辑

现代 Go 中,sync.Mutex的实现主体位于internal/sync/mutex.go。2023 年讨论时,相关实现和注释还直接位于sync/mutex.go;目录会随版本调整,但核心算法保持连续。

Mutex 内部有两个字段:

typeMutexstruct{stateint32semauint32}

state的低位记录锁状态:

const(mutexLocked=1<<iotamutexWoken mutexStarving mutexWaiterShift=iota)

没有竞争时,Lock的快路径只是一次 CAS:

ifatomic.CompareAndSwapInt32(&m.state,0,mutexLocked){return}

这条路径很重要。Mutex 是基础原语,绝大多数未竞争或低竞争加锁都应该尽量短,才能被内联并保持低开销。

只有 CAS 失败,代码才进入lockSlow,处理自旋、排队、唤醒和饥饿模式。

正常模式:队列是 FIFO,但锁不是严格 FIFO

正常模式下,等待者按 FIFO 排队。

这句话容易让人误以为:解锁后,队头一定获得锁。实际不是。

解锁 goroutine 会唤醒队头,但被唤醒只意味着它从睡眠状态变成可运行状态。它还需要被调度到 CPU 上,再次尝试 CAS。

这段时间里,新来的 goroutine 可能已经在 CPU 上运行:

持锁者 Unlock ↓ 唤醒队头 waiter ↓ waiter 等待调度 与此同时: 新 goroutine 已经在 CPU 上执行 Lock ↓ 先一步 CAS 成功

这类行为通常被称为 barging 或抢占式获取。新到达者越过等待者拿走了锁。

从公平性看,它不太好;从吞吐量看,却可能更快。新 goroutine 已在运行,不需要额外的唤醒和上下文切换。缓存也可能仍然是热的。

如果强制每次都把锁交给沉睡的队头,就会增加调度延迟。高频短临界区下,CPU 可能花更多时间在线程切换,而不是执行实际工作。

所以正常模式选择的是:

允许一定程度的不公平,换取更高吞吐量。

被唤醒后又失败,会发生什么

队头 waiter 被唤醒,却被新来的 goroutine 抢走锁后,会重新进入等待。

为了避免它回到队尾、再等完整一轮,源码会把这种已经被唤醒过但失败的 waiter 放到队列前部。

这样设计仍然不是严格公平,但会给老等待者更高优先级。

如果竞争一直很激烈,新 goroutine 连续抢锁,某个 waiter 可能多次失败。于是需要第二种模式。

1ms 后进入饥饿模式

源码使用:

starvationThresholdNs=1e6

也就是 1 毫秒。

当队头等待者等待超过这个阈值,Mutex 会进入 starvation mode。

这里的 1ms 不是 API 承诺,也不意味着业务代码可以依赖“最多等 1ms”。它是当前实现用于平衡吞吐与尾延迟的经验阈值,未来版本可以调整。

进入饥饿模式后,规则发生变化:

  1. 解锁 goroutine 把锁的所有权直接交给队头;
  2. 新来的 goroutine 即使看到锁表面上未被占用,也不尝试抢锁;
  3. 新来的 goroutine 不自旋,直接排到队尾;
  4. 队列按顺序向前推进。

这时,锁更接近严格 FIFO。

为什么不是“谁触发饥饿,谁插队”

我当时设想:如果队列中某个 goroutine 等了很久,能否记录每个人的等待时间,再选择最久者?

Ian 指出,FIFO 已经隐含了等待时间顺序。

假设队列是:

G1 → G2 → G3 → G4

在正常入队规则下,G1 最早进入队列,也就是等待最久的 goroutine。没有必要再维护一个按时间排序的优先队列。

额外记录等待时间会带来:

  • 更多状态;
  • 更复杂的更新;
  • 更高的原子操作成本;
  • 更难证明的并发正确性;
  • 在基础同步原语热路径上的持续开销。

而结果仍然大概率是选择队头。

至于“触发饥饿的 goroutine”,正常情况下它就是当前被唤醒的队头。如果不是队头,就说明它还没有到获取锁的时机;让它越过前面的等待者,反而破坏了已经存在的公平顺序。

饥饿模式为什么还要退出

如果公平模式能避免饿死,为什么不一直保持?

因为源码注释明确指出:正常模式性能明显更好。一个 goroutine 甚至可能在有等待者的情况下连续几次获取 Mutex,这对尾延迟不公平,却可能减少上下文切换,提高整体吞吐。

饥饿模式的退出条件是,获得锁的 waiter 发现:

  • 它已经是队列最后一个等待者;或者
  • 它实际等待时间少于 1ms。

满足条件后,Mutex 回到正常模式。

这说明 starvation mode 不是“更正确的模式”,而是一种故障保护:在出现病态尾延迟时临时增强公平性,情况缓解后立即恢复高性能策略。

自旋在这里扮演什么角色

低竞争、临界区极短时,立即让 goroutine 睡眠可能得不偿失。锁很可能马上释放,当前 goroutine 在 CPU 上自旋几次就能获得它。

Go 运行时会结合多个条件决定是否主动自旋,例如:

  • 当前机器是否有多个逻辑处理器;
  • 当前 goroutine 是否有机会继续运行;
  • 自旋次数是否过多;
  • Mutex 是否处于饥饿模式。

正常模式允许有限自旋。饥饿模式下,新来者不再自旋,因为锁的所有权已经按队列直接移交,继续竞争只会破坏公平性。

自旋同样体现了吞吐与资源消耗的平衡:

  • 临界区很短时,自旋避免调度;
  • 临界区较长时,自旋浪费 CPU;
  • 竞争过强时,公平移交降低尾部等待。

Mutex 的四个状态维度

把源码简化后,可以把state理解为四类信息:

locked 当前是否被持有 woken 是否已有 waiter 被唤醒,避免重复唤醒 starving 是否处于饥饿模式 waiters 等待队列中的 goroutine 数量

这些信息被压在一个int32中,通过 CAS 更新。

为什么不使用更清晰的多个字段?因为多个独立字段会引入中间状态:一个 goroutine更新了 locked,却还没更新 waiter 数;另一个 goroutine可能观察到不一致组合。单个状态字让关键转换能通过一次 CAS 原子完成。

代价是源码难读。lockSlow里大量位运算和循环,并不是为了炫技,而是在一个极小状态空间里编码并发协议。

公平锁不一定更好

业务开发中,“公平”听上去天然优于“不公平”。但锁的评价指标至少有三个:

  • 平均吞吐量;
  • 平均等待时间;
  • P99、P999 等尾延迟。

严格 FIFO 往往改善尾延迟,却可能降低吞吐。允许 barging 则可能让平均性能更好,但极端情况下会让某个 waiter 长期失败。

Go Mutex 没有选择其中一端,而是动态切换:

正常竞争 → 允许抢锁和有限自旋 → 优先吞吐 等待超过阈值 → 直接移交 → 优先避免饿死和病态尾延迟 队列恢复正常 → 回到正常模式

这种设计比“公平锁”或“非公平锁”的标签更接近真实运行负载。

业务代码需要知道这些吗

大多数时候,不需要根据 Mutex 内部模式编写业务逻辑。

但理解它能解释几类现象。

锁没有严格 FIFO 保证

不能假设先调用Lock的 goroutine 必然先进入临界区。sync.Mutex的公开 API 没有承诺公平顺序。

锁竞争会出现尾延迟

即使平均等待很低,正常模式下也可能有人连续抢锁。运行时用饥饿模式抑制病态情况,但业务仍应减少过长临界区。

短临界区和长临界区表现不同

Mutex 的自旋和抢锁策略适合短临界区。把网络 I/O、磁盘操作或大计算放在锁内,会让算法假设失效。

不要围绕 1ms 写逻辑

1ms 是实现细节,不是锁的服务等级协议。需要超时控制时,应在业务层使用 Context、channel 或其他机制。

怎样减少 Mutex 竞争

第一,缩小临界区:

mu.Lock()v:=cache[key]mu.Unlock()// 在锁外做昂贵处理use(v)

第二,不要持锁执行未知回调:

mu.Lock()callback()// callback 可能很慢、重入甚至再次加锁mu.Unlock()

第三,先用 mutex profile 定位,而不是猜测:

runtime.SetMutexProfileFraction(1)

然后通过 pprof 查看竞争来源。

第四,只有数据和访问模式确实允许时,才考虑分片、原子变量或无锁结构。复杂并发结构的正确性成本,通常比一次 Mutex 更昂贵。

结论

Go Mutex 的两种模式解决的是一对无法彻底消除的冲突:

  • 正常模式允许新 goroutine 抢锁,提高吞吐;
  • 饥饿模式按队列直接移交,防止等待者长期失败;
  • FIFO 队列已经表达等待时间先后,无需另建按时长排序的结构;
  • 1ms 是触发保护的实现阈值,而不是公开语义;
  • 当竞争恢复正常,Mutex 会主动回到性能更好的模式。

我最初的问题是“能不能进一步优化等待顺序”。读完回复和源码后才发现,很多看似额外的信息已经隐含在队列结构中,而真正困难的不是找出最公平的顺序,是让公平性只在需要时付费。

这也是sync.Mutex源码最值得学习的地方:它不是简单的一把锁,而是一套围绕 CPU 调度、缓存、自旋、唤醒和尾延迟不断折中的小型状态机。

参考链接

  • golang-nuts:完整讨论
  • 当前 Go 源码:internal/sync/mutex.go
  • sync.Mutex 文档
  • Go Memory Model
  • runtime.SetMutexProfileFraction
  • Go Blog:Profiling Go Programs
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/2 12:32:28

2025年终极IDM激活脚本完全指南:三步永久解决下载难题

2025年终极IDM激活脚本完全指南&#xff1a;三步永久解决下载难题 【免费下载链接】IDM-Activation-Script IDM Activation & Trail Reset Script 项目地址: https://gitcode.com/gh_mirrors/id/IDM-Activation-Script 想要免费享受Internet Download Manager的极速…

作者头像 李华
网站建设 2026/8/2 12:30:45

3步掌握ComfyUI-Ollama:让AI大模型成为你的可视化工作流助手

3步掌握ComfyUI-Ollama&#xff1a;让AI大模型成为你的可视化工作流助手 【免费下载链接】comfyui-ollama 项目地址: https://gitcode.com/gh_mirrors/co/comfyui-ollama 想象一下&#xff0c;你正在ComfyUI中构建一个创意工作流&#xff0c;突然需要让AI帮你分析图片内…

作者头像 李华
网站建设 2026/8/2 12:30:20

AI与形式化验证:从Lean实战看数学证明的范式革命

1. 从“辅助”到“重启”&#xff1a;AI如何重塑数学研究的底层逻辑 最近&#xff0c;陶哲轩教授关于AI“重启”千年数学规则的提法&#xff0c;在数学和计算机科学交叉领域激起了不小的波澜。这远不止是“又一个AI工具”那么简单。作为一名长期关注形式化验证与自动化推理的从…

作者头像 李华
网站建设 2026/8/2 12:26:58

Python求解方程组实战:从线性到非线性,从SymPy到SciPy

1. 项目概述&#xff1a;从数学公式到可执行代码的桥梁 在工程计算、数据分析、金融建模乃至游戏开发的背后&#xff0c;常常隐藏着一个核心的数学问题&#xff1a;求解方程组。无论是计算电路中的电流电压&#xff0c;还是预测经济模型的均衡点&#xff0c;甚至是调整游戏角色…

作者头像 李华
网站建设 2026/8/2 12:23:40

原来大家好奇的康品,究竟是不是集成墙板知名品牌呢?

在集成墙板市场蓬勃发展的当下&#xff0c;众多品牌如雨后春笋般涌现&#xff0c;“康品”也受到了大家的关注。那么&#xff0c;它究竟是不是集成墙板知名品牌呢&#xff1f;下面为你深入分析。品牌实力见证知名度康品&#xff0c;全名为浙江德清康品集成家居股份有限公司&…

作者头像 李华
网站建设 2026/8/2 12:20:25

Steam Deck模拟器终极配置指南:如何用EmuDeck一键安装30+游戏平台

Steam Deck模拟器终极配置指南&#xff1a;如何用EmuDeck一键安装30游戏平台 【免费下载链接】EmuDeck Emulator configurator for Steam Deck 项目地址: https://gitcode.com/gh_mirrors/em/EmuDeck 想在Steam Deck上重温童年经典游戏&#xff0c;却被复杂的模拟器配置…

作者头像 李华