Sirius
Sirius

目录

Golang sync.Mutex

sync.Mutex 是 Go 中最基础的互斥锁,用于保护共享资源,确保在同一时刻只有一个 Goroutine 可以访问该资源。它的实现非常精妙,并不仅仅是一个简单的锁,而是一个兼顾了性能和公平性的复杂同步原语。

sync.Mutex 的结构非常简单,只包含两个字段:

// src/sync/mutex.go
type Mutex struct {
    state int32  // 32位的状态位,通过位操作存储锁的多种状态
    sema  uint32 // 信号量,用于实现 Goroutine 的阻塞和唤醒
}

关键在于这个 32 位的 state 字段,它通过位掩码(bitmask)的方式,巧妙地存储了锁的四种信息:

  1. Locked Bit (第 0 位): 标记锁是否被持有。 1 表示已锁定,0 表示未锁定。

  2. Woken Bit (第 1 位): 标记是否有 Goroutine 已经被唤醒。1 表示有,0 表示没有。

  3. Starvation Bit (第 2 位): 标记锁是否处于饥饿模式1 表示饥饿模式,0 表示正常模式。

  4. Waiter Count (高 29 位): 记录正在等待锁的 Goroutine 数量。state >> 3 即可得到。

sema 字段则是一个信号量,当 Goroutine 无法获取锁时,会通过这个信号量进入休眠(阻塞),当锁被释放时,再通过它被唤醒。

下面的 Mermaid 图展示了 Mutex 的内部结构,特别是 state 字段的分解:

graph TD
    subgraph sm [sync.Mutex 结构]
        state["state (int32)"]
        sema["sema (uint32)"]
    end

    subgraph state_detail ["state (32位整数) 的位布局"]
        direction LR
        waiters["Waiter Count (29 bits)"]
        starvation["Starvation Bit (1 bit)"]
        woken["Woken Bit (1 bit)"]
        locked["Locked Bit (1 bit)"]
    end
    
    state --> state_detail
    
    subgraph usage [用途]
       direction LR
       waiters_usage["等待者数量"]
       starvation_usage["是否饥饿模式"]
       woken_usage["是否有G被唤醒"]
       locked_usage["是否已锁定"]
    end
    
    waiters --> waiters_usage
    starvation --> starvation_usage
    woken --> woken_usage
    locked --> locked_usage

    style sm fill:#f9f,stroke:#333,stroke-width:2px

这是 sync.Mutex 设计的精髓所在,它通过两种模式来平衡性能和公平。

  • 特点:性能优先,吞吐量高。

  • 行为:这是 Mutex 的默认模式。当一个 Goroutine 释放锁时,它会唤醒一个正在等待的 Goroutine(如果存在)。但是,这个被唤醒的 Goroutine 不会立即得到锁,它需要和新到达的 Goroutine(那些刚刚调用 Lock() 但还未进入等待队列的)进行竞争。

  • “闯入” (Barging):新到达的 Goroutine 有可能会“闯入”并抢在被唤醒的 Goroutine 之前获得锁。这种设计是为了性能,因为如果新到达的 Goroutine 能直接获得锁,就避免了唤醒一个已休眠的 Goroutine 所带来的上下文切换开销。

  • 缺点:可能会导致不公平。如果“闯入”的 Goroutine 络绎不绝,那么等待队列中的 Goroutine 可能会长时间得不到锁。

  • 特点:公平性优先。

  • 触发条件:当一个正在等待的 Goroutine 的等待时间超过了 1毫秒 (1ms)Mutex 就会从正常模式切换到饥饿模式。

  • 行为

    1. 直接交接 (Handoff):在饥饿模式下,当锁被释放时,它会直接被移交给等待队列头部的那个 Goroutine

    2. 禁止“闯入”:新到达的 Goroutine 不允许参与竞争,它们会直接被放入等待队列的尾部。

  • 退出条件

    1. 如果一个 Goroutine 获得了锁,并且它是等待队列中的最后一个,或者它的等待时间没有超过 1ms,那么 Mutex 就会切换回正常模式。

这种设计确保了没有任何 Goroutine 会被“饿死”,保证了长远来看的公平性。

  1. 快速路径 (Fast Path)

    • 尝试通过一个原子操作(Compare-And-Swap, CAS) 直接将 state 字段从 0(未锁定且无等待者)修改为 1(已锁定)。

    • 如果 CAS 成功,表示没有竞争,成功获取锁,方法立即返回。这是最快的情况。

  2. 慢速路径 (Slow Path)

    • 如果 CAS 失败(意味着锁已被其他 Goroutine持有,或已有等待者),则会进入一个更复杂的 lockSlow 逻辑。

    • 自旋 (Spinning):在多核 CPU 的情况下,Goroutine 会先进行几次“自旋”——执行一个空循环,消耗少量 CPU 时间,期望在这期间锁能被释放。这可以避免立即进入休眠所带来的昂贵开销。

    • 排队与休眠:如果自旋后锁仍然不可用,Goroutine 会:

      • 原子地增加 state 中的等待者数量。

      • 检查并根据需要切换到饥饿模式。

      • 将自己加入等待队列,并调用 runtime_SemacquireMutex(内部使用 sema 信号量)使自己进入休眠(阻塞),等待被唤醒。

  1. 快速路径 (Fast Path)

    • 通过一个原子操作(Add)state1 (即 mutexLocked 状态位)。

    • 如果结果为 0,表示在解锁前没有任何等待者,成功释放锁,方法立即返回。这是最快的情况。

  2. 慢速路径 (Slow Path)

    • 如果相减结果不为 0,说明有等待的 Goroutine,需要进入 unlockSlow 逻辑。

    • 检查模式

      • 正常模式:唤醒等待队列中的一个 Goroutine,但不保证它能获得锁(它需要和新来的 Goroutine 竞争)。

      • 饥饿模式:直接将锁的所有权移交给等待队列头部的 Goroutine,并唤醒它。

sync.Mutex 远比表面看起来的要复杂,它是一个智能的、自适应的锁。

  • 混合设计:它不是一个纯粹的自旋锁,也不是一个纯粹的排队锁,而是两者的结合。

  • 性能与公平的权衡:通过正常模式饥饿模式的动态切换,它在低竞争时追求高性能和高吞吐量,在高竞争且出现不公平时自动切换到保证公平的模式。

  • 状态压缩:通过一个 int32state 字段和精巧的位操作,高效地管理了锁的多种状态,避免了多个变量带来的额外开销和同步问题。

理解 sync.Mutex 的这些底层原理,有助于我们编写出更高效、更健壮的并发程序,并在进行性能调优时能有更深入的洞察。