跳到主要内容

Go 的锁的底层原理是什么

Go 里面锁是怎么工作的?

Go 锁的数据结构详解

核心数据结构

// sync.Mutex 的完整结构
type Mutex struct {
state int32 // 锁的状态字段(包含锁状态和等待者数量)
sema uint32 // 信号量句柄,指向运行时的等待队列
}

关键理解:阻塞队列存储在哪里?

重点:阻塞的 goroutine 并不直接存储在 Mutex 结构体中,而是通过 sema 字段关联到 Go 运行时的全局信号量表中。

// 运行时内部的信号量表结构(位于 runtime/sema.go)
type semTable struct {
lock mutex
root semaRoot // 信号量树的根节点
len uint32 // 信号量数量
}

// 每个信号量的根节点
type semaRoot struct {
lock mutex
treap *sudog // 平衡树,存储等待的 goroutine
nwait uint32 // 等待者数量
}

// 等待队列中的节点(每个阻塞的 goroutine)
type sudog struct {
g *g // 指向被阻塞的 goroutine
next *sudog // 链表中的下一个节点
prev *sudog // 链表中的前一个节点
elem unsafe.Pointer // 数据元素指针
selectdone *uint32 // select 操作完成标志
// ... 其他字段
}

阻塞队列的工作机制

state 字段的位布局详解

// state 字段(int32,32位)的位分布
// +--------------------------------+-------+-------+-------+
// | 29 位 | 1位 | 1位 | 1位 |
// +--------------------------------+-------+-------+-------+
// | waiter count | starv | woken | locked|
// | (等待者数量) | ed | | |
// +--------------------------------+-------+-------+-------+
// 31 3 2 1 0

const (
mutexLocked = 1 << iota // 0001: 第0位,锁定状态
mutexWoken // 0010: 第1位,唤醒状态
mutexStarving // 0100: 第2位,饥饿模式
mutexWaiterShift = iota // 值为3,等待者数量的位移
)

如何判断哪些 G 被阻塞?

// 1. 通过 state 字段获取等待者数量
func getWaiterCount(state int32) int32 {
return state >> mutexWaiterShift // 右移3位获取等待者数量
}

// 2. 具体的阻塞 goroutine 存储在运行时信号量表中
func blockOnMutex(m *Mutex) {
// 当 goroutine 无法获取锁时,会调用运行时函数
runtime_SemacquireMutex(&m.sema, false, 1)
}

// 3. 运行时内部会将当前 goroutine 包装成 sudog 并加入等待队列
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags) {
gp := getg()
if gp == gp.m.curg {
// 创建 sudog 节点
s := acquireSudog()
s.g = gp
// 加入到对应 sema 地址的等待队列中
semqueue(addr, s, lifo)
// 阻塞当前 goroutine
gopark(...)
}
}

完整的锁竞争示例

// 示例:多个 goroutine 竞争锁的完整过程
func demonstrateLockContention() {
var mu sync.Mutex
var wg sync.WaitGroup

// 启动多个 goroutine 竞争同一个锁
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()

mu.Lock() // 这里可能会阻塞
defer mu.Unlock()

fmt.Printf("Goroutine %d 获得锁\n", id)
time.Sleep(100 * time.Millisecond)
}(i)
}

wg.Wait()
}

阻塞状态的可视化

正常模式下的锁竞争

饥饿模式的触发

下面只是简单介绍,饥饿模式细节可以参考文档:

docs/编程语言/Go/Go 并发相关/Go 如何使用锁/Go 的锁饥饿模式是怎么样的.md

正常模式特点

  • 自旋优化: 新到达的 goroutine 会先自旋等待
  • 性能优先: 减少上下文切换,提高吞吐量
  • 可能不公平: 新 goroutine 可能抢占等待中的 goroutine

饥饿模式特点

  • 公平性保证: 严格按照 FIFO 顺序获取锁
  • 防止饿死: 确保等待时间过长的 goroutine 能获取锁
  • 性能牺牲: 禁用自旋,可能增加延迟

如下图:

饥饿模式下的锁传递

锁状态的深度解析

state 字段的位结构

通过时序图我们看到锁在不同时刻的状态变化,现在来详细分析这个状态是如何工作的:

状态转换的关键时机

const (
mutexLocked = 1 << iota // 1 (0001): 锁定状态
mutexWoken // 2 (0010): 唤醒标志
mutexStarving // 4 (0100): 饥饿模式
mutexWaiterShift = iota // 3: 等待者计数的位移量

// 饥饿模式的关键阈值
starvationThresholdNs = 1e6 // 1毫秒 = 1,000,000纳秒
)

// 状态检查函数
func (m *Mutex) isLocked() bool { return m.state&mutexLocked != 0 }
func (m *Mutex) isStarving() bool { return m.state&mutexStarving != 0 }
func (m *Mutex) isWoken() bool { return m.state&mutexWoken != 0 }
func (m *Mutex) waiterCount() int { return int(m.state >> mutexWaiterShift) }

模式切换的触发条件

底层实现机制

锁获取的核心流程

结合前面的时序图,锁的获取遵循以下优化路径:

关键的原子操作

// Lock 的简化实现逻辑
func (m *Mutex) Lock() {
// 快速路径:尝试直接获取未锁定的mutex
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return // 成功获得锁
}

// 慢速路径:需要等待
m.lockSlow()
}

func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state

for {
// 在正常模式下且可以自旋时进行自旋
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
// 尝试设置唤醒标志
if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
awoke = true
}
runtime_doSpin() // 执行自旋
iter++
old = m.state
continue
}

// 准备新的状态
new := old
if old&mutexStarving == 0 {
new |= mutexLocked // 尝试获取锁
}
if old&(mutexLocked|mutexStarving) != 0 {
new += 1 << mutexWaiterShift // 增加等待者计数
}
if starving && old&mutexLocked != 0 {
new |= mutexStarving // 设置饥饿模式
}
if awoke {
new &^= mutexWoken // 清除唤醒标志
}

// 原子更新状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 状态更新成功,进入等待或获得锁
// ... 后续处理逻辑
}

old = m.state
}
}

加锁过程详解

锁的获取过程是一个多层级的优化策略:

自旋条件判断

func shouldSpin(state int32) bool {
// 不能在饥饿模式下自旋
if state&mutexStarving != 0 {
return false
}

// 必须是多核环境
if runtime.NumCPU() == 1 {
return false
}

// 当前 P 的本地队列不能为空
if runtime_canSpin() {
return true
}

return false
}

自旋的作用

  • 减少系统调用: 避免立即进入内核态
  • 利用时间局部性: 锁通常很快被释放
  • 降低延迟: 避免 goroutine 调度开销

被锁住的 Goroutine 状态变化

当 goroutine 无法立即获取锁时,它会经历多个状态转换:

状态变化的性能影响

func measureLockContention() {
var mu sync.Mutex
var wg sync.WaitGroup

// 模拟高竞争场景
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 1000; j++ {
mu.Lock()
// 临界区工作
time.Sleep(time.Microsecond)
mu.Unlock()
}
}(i)
}

wg.Wait()
}

在高竞争场景中:

  • 自旋阶段: goroutine 仍在 Running 状态,消耗 CPU
  • 等待阶段: goroutine 进入 Waiting 状态,释放 P
  • 唤醒阶段: goroutine 变为 Runnable,等待调度

完整的锁竞争场景

让我们通过一个完整的示例来理解锁竞争的全过程:

竞争场景代码示例

func demonstrateLockContention() {
var mu sync.Mutex
var counter int

// G1: 持锁时间较长的 goroutine
go func() {
mu.Lock()
fmt.Println("G1: 获取锁,执行长任务")
time.Sleep(50 * time.Millisecond) // 模拟复杂计算
counter++
mu.Unlock()
fmt.Println("G1: 释放锁")
}()

// 稍后启动多个竞争者
time.Sleep(10 * time.Millisecond)

// G2-G5: 多个竞争者
for i := 2; i <= 5; i++ {
go func(id int) {
start := time.Now()
mu.Lock()
waitTime := time.Since(start)
fmt.Printf("G%d: 获取锁成功,等待时间: %v\n", id, waitTime)
counter++
mu.Unlock()
}(i)

time.Sleep(5 * time.Millisecond) // 错开到达时间
}

time.Sleep(200 * time.Millisecond) // 等待所有 goroutine 完成
fmt.Printf("最终计数: %d\n", counter)
}

性能分析要点

  1. 吞吐量 vs 延迟: 正常模式优化吞吐量,饥饿模式保证延迟
  2. CPU 使用: 自旋会消耗 CPU,但减少上下文切换
  3. 公平性: 饥饿模式保证公平,但可能影响整体性能
// 监控锁竞争的方法
func monitorMutexContention() {
// 使用 pprof 分析锁竞争
f, _ := os.Create("mutex.prof")
defer f.Close()

runtime.SetMutexProfileFraction(1) // 启用 mutex profiling
defer runtime.SetMutexProfileFraction(0)

// 执行业务逻辑...

pprof.Lookup("mutex").WriteTo(f, 0)
}

优化建议

基于锁的底层原理,我们可以采用以下优化策略:

实际应用示例

// 优化前:锁粒度过大
type Counter struct {
mu sync.Mutex
value int
name string // 不需要保护的字段
}

func (c *Counter) BadIncrement() {
c.mu.Lock()
defer c.mu.Unlock()

// 不必要的长临界区
time.Sleep(time.Millisecond) // 模拟其他操作
c.value++
}

// 优化后:缩小临界区
func (c *Counter) GoodIncrement() {
// 非临界区操作
time.Sleep(time.Millisecond)

// 最小临界区
c.mu.Lock()
c.value++
c.mu.Unlock()
}

// 更好的选择:使用原子操作
type AtomicCounter struct {
value int64
}

func (c *AtomicCounter) Increment() {
atomic.AddInt64(&c.value, 1) // 无锁操作
}

通过深入理解 Go 锁的底层原理,我们能够:

  • 编写更高效的并发代码
  • 合理选择同步原语
  • 识别和解决性能瓶颈
  • 在性能和公平性之间做出权衡

Go 的 Mutex 实现展现了系统级编程的精妙之处,它在简单的接口背后隐藏了复杂而高效的实现机制。