"Channel is for passing data; Mutex is for protecting state." —— 什么时候用 Channel,什么时候用 Mutex?这取决于你是想“交流”还是想“占有”。

在 Go 的并发世界里,Channel 占据了 C 位,但 sync 才是支撑高并发基石的幕后英雄。

很多初学者容易陷入两个极端:

  1. 无脑用 Channel:连一个简单的计数器都要用 Channel 来传,导致性能低下。

  2. 无脑用 Lock:把 Go 写成了 Java,死锁频发。

sync.Mutex(互斥锁)看起来简单,但它的底层实现极其复杂且精妙。它不是一把简单的“内核锁”,而是一把**“会思考的锁”**。

今天,我们就深入源码,拆解 Mutex正常模式与饥饿模式,剖析 RWMutex写优先策略,以及 WaitGroup 那个绝对不能复制的秘密。

1. Mutex:一把“混合双打”的智能锁

在早期的 Go 版本中,Mutex 只是简单的对操作系统互斥量(Futex)的封装。但现在,它进化成了用户态自旋 + 内核态挂起的混合锁。

1.1 结构体:小巧而强大

sync.Mutex 的定义非常简洁,只有两个字段:

// src/sync/mutex.go
type Mutex struct {
    state int32  // 核心状态字段(位图)
    sema  uint32 // 信号量,用于唤醒沉睡的 Goroutine
}

state 字段被设计成了一个复合位图

  • Locked (bit 0): 是否被持有(0=空闲,1=持有)。

  • Woken (bit 1): 是否有唤醒的 G 正在尝试抢锁。

  • Starving (bit 2): 是否处于饥饿模式(Go 1.9+)。

  • Waiters (其余位): 正在排队等待锁的 Goroutine 数量。

1.2 阶段一:自旋 (Spinning) —— 乐观者的尝试

当一个 G1 尝试去 Lock 时,它不会立刻调用系统调用去休眠。 因为它猜测:“持有锁的那个家伙可能马上就释放了,我不如在 CPU 上空转一会儿等等它。”

条件:多核 CPU 且 GOMAXPROCS > 1。 行为:G1 会在用户态执行一段空循环(30次 PAUSE 指令)。 优势:避免了昂贵的线程上下文切换(Context Switch)。 结果:如果自旋期间锁释放了,G1 通过 CAS(Compare And Swap)直接抢到锁。Fast Path!

1.3 阶段二:信号量 (Semaphore) —— 悲观者的等待

如果自旋了几次还没抢到,G1 认怂了。 它会调用 runtime_SemacquireMutex,将自己放入信号量队列中,然后休眠。 等待持有者 Unlock 时通过信号量将其唤醒。

2. 饥饿模式 (Starvation Mode):公平与效率的博弈

在 Go 1.9 之前,Mutex 存在一个严重的不公平问题: 被唤醒的 G (老实人) 往往抢不过新来的 G (插队者)。

  • 场景:G1 释放锁,唤醒了队列头的 G2。G2 醒来准备抢锁。

  • 竞争:此时正好 CPU 调度来了一个新的 G3(正在自旋)。G3 已经在 CPU 上了,而 G2 还需要调度上下文切换。

  • 结果:G3 抢到了锁,G2 哭晕在厕所,重新回去睡觉。

  • 后果:如果并发量大,G2 可能永远抢不到锁(长尾延迟/饥饿)。

Go 1.9 的解决方案:引入饥饿模式

触发条件: 如果一个 G 等待锁的时间超过 1ms,Mutex 就会自动切换到 饥饿模式

正常模式 (Normal Mode)

  • 抢锁:新来的 G 和被唤醒的 G 一起竞争(新来的 G 有优势,因为正在 CPU 上)。

  • 性能:吞吐量极高。

饥饿模式 (Starvation Mode)

  • 抢锁严格的 FIFO。新来的 G 不自旋,也不抢锁,直接乖乖去队尾排队。

  • 交接:锁的所有权直接从释放者交给队列头的 G。

  • 退出:当队列清空,或者等待时间 < 1ms,切回正常模式。

深度总结:正常模式保证吞吐量,饥饿模式保证不饿死。Go 在这两者之间实现了动态平衡。

3. RWMutex:写锁优先 (Write Priority)

RWMutex (读写锁) 适用于 读多写少 的场景。

核心原理

  • RLock (读锁):允许多个读锁共存。

  • Lock (写锁):互斥所有读锁和写锁。

隐藏的陷阱:写锁饥饿?

有些读写锁实现(如早期的 Java)是读优先的:只要有读锁在,写锁就进不来。如果读操作源源不断,写锁就饿死了。

Go 的策略:写锁优先

当一个写锁(Writer)申请锁时:

  1. 它会阻塞后续新来的读锁(Reader)。

  2. 它等待当前已持有的读锁释放完毕。

  3. 一旦当前这批读锁释放完,写锁立刻持有,后来的读锁必须排队

这保证了写操作不会被海量的读操作淹没。

4. WaitGroup:绝对不能复制的计数器

sync.WaitGroup 用于等待一组 Goroutine 完成。

var wg sync.WaitGroup
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        // ...
    }()
}
wg.Wait()

4.1 64位对齐

WaitGroup 需要操作一个 64 位的计数器(counter + waiter_count)。 在 32 位机器上,无法保证 64 位变量的原子访问。 Go 的做法非常硬核:它申请 12 字节的内存,然后通过指针运算调整偏移量,强行凑出 8 字节对齐的地址。

4.2 Copying is forbidden

sync.WaitGroup(包括 Mutex)内部包含状态字段。 如果你把 WaitGroup 当做参数值传递(Copy),你复制的是它的状态!

func worker(wg sync.WaitGroup) { // ❌ 错误:值传递,复制了一个新的 wg
    defer wg.Done() // 这里的 Done 只有效于副本
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go worker(wg)
    wg.Wait() // 💀 死锁!主 wg 永远收不到 Done 信号
}

正解:必须传指针 *sync.WaitGroup。 可以使用 go vet 工具检测此类错误。

5. sync.Once

sync.Once 保证函数只执行一次,常用于单例初始化。

var once sync.Once
var instance *Config

func GetConfig() *Config {
    once.Do(func() {
        instance = &Config{}
    })
    return instance
}

原理:双重检查锁定 (Double-Checked Locking) 的完美实现。

  1. Fast Path:原子读取标记位 done,如果是 1 直接返回。

  2. Slow Path:加锁(Mutex),再次检查 done,执行函数,原子修改 done=1

注意Do 里面的函数如果 panic 了,Go 依然认为它执行过了,下一次不会再执行。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐