Golang初版Mutex
2008年,Russ Cox提交的初版Mutex的代码如下:
go// CAS操作,当时还没有抽象出atomic包 func cas(val *int32, old, new int32) bool func semacquire(*int32) func semrelease(*int32) // 互斥锁的结构,包含两个字段 type Mutex struct { key int32 // 锁是否被持有的标志 sema int32 // 信号量专用,用于阻塞/唤醒goroutine } // 保证成功在val上增加delta的值 func xadd(val *int32, delta int32) (new int32) { for { v := *val if cas(val, v, v+delta) { return v + delta } } panic("unreached") } // 获取锁 func (m *Mutex) Lock() { if xadd(&m.key, 1) == 1 { // ①将标志量加1,如果等于1,则表示成功获取到锁 return } semacquire(&m.sema) // ②否则阻塞等待 } // 释放锁 func (m *Mutex) Unlock() { if xadd(&m.key, -1) == 0 { // ③将标志量减1,如果等于0,则表示没有其他waiter return } semrelease(&m.sema) // ④ 唤醒其他被阻塞的goroutine }
当时还没有抽象出原子操作的atomic包,所以这里直接实现了一个 cas 函数。如果不了解 CAS 操作也没有关系,现在,你可以把它理解成一个“神奇”的操作,这个操作要么成功,要么失败,不会只修改了部分数据。这里是对一个 int32 变量进行操作,提供了它的原始值和新值。如果这个变量的值和原始值相同,那么它会被赋值为新值,否则赋值不成功。
这里还提供了两个重要的函数:semacquire 和 semrelease。看名字就知道它们与信号量(semaphore)有关。semacquire 用来把调用者 goroutine 压入一个队列,并把此 goroutine 设置为阻塞状态,主要用来处理不能获取到锁的 goroutine(称为 waiter,等待者)。semrelease 用来从队列中取出一个 goroutine,并且唤醒它,被唤醒的 goroutine 会获取到锁。
Mutex 结构体包含两个字段:
- key:一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有。如果 key 大于或等于 1,则说明这个排外锁已经被持有。
- sema:一个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。
当 goroutine 调用 Lock 获取锁时,通过 xadd 方法进行 CAS 操作(①行)。xadd 方法通过循环执行 CAS 操作直到成功,保证对 key 加 1 的操作成功完成。如果比较幸运,锁没有被其他 goroutine 所持有,那么 Lock 方法成功地将 key 设置为 1,这个 goroutine 就持有了这个锁;如果锁已经被其他 goroutine 持有了,那么当前的 goroutine 会把 key 加 1,而且还会调用 semacquire 函数(②行),使用信号量将自己休眠,等锁释放时,信号量会将它们其中的一个唤醒。
持有锁的 goroutine 调用 Unlock 释放锁时,它会将 key 减 1(③行)。如果当前没有其他等待这个锁的 goroutine,这个方法就返回了。但是,如果还有等待此锁的其他 goroutine,那么它会调用 semrelease 函数(④行),利用信号量唤醒等待锁的其他 goroutine 中的一个。被唤醒的这个 goroutine 原来被阻塞在 Lock 方法中的 semacquire 调用上,一旦 semrelease 被调用,它就会被唤醒,继续执行下去,Lock 方法返回,也就是这个 goroutine 获取到了锁。
我们可以看到,初版的 Mutex 利用 CAS 原子操作,对 key 这个标志量进行设置。key 不仅仅标识了锁是否被 goroutine 所持有,还记录了当前持有锁和等待获取锁的 goroutine 的数量。我们还可以看到,Mutex 这个数据结构并没有标记当前是哪个 goroutine 持有了这个锁,这也导致任意的 goroutine 都可以释放这个锁,而无论此 goroutine 是否持有这个锁。
其他 goroutine 可以强制释放锁,这是一个非常危险的操作。因为在临界区原来持有这个锁的 goroutine 可能不知道锁已经被释放了,还会继续执行临界区的业务操作,这就可能会带来意想不到的结果——这个 goroutine 天真地以为自己还持有锁,有可能导致数据竞争问题。
所以,我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的锁,一定要遵循“谁持有,谁释放”的原则。在实践中,我们使用互斥锁的时候,很少在一个方法中单独获取锁,而在另一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。
以前,我们通常会基于性能的考虑,及时释放锁,所以在一些 if-else 分支中加上释放锁的代码,使代码看起来很臃肿。而且,在重构的时候,也很容易因为误删或者遗漏代码而出现死锁的现象。
gotype Foo struct { mu sync.Mutex count int } func (f *Foo) Bar() { f.mu.Lock() // 此处加锁 if f.count < 1000 { f.count += 3 f.mu.Unlock() // 此处释放锁 return } f.count++ f.mu.Unlock() // 此处释放锁 }
从 Go 1.14 版本开始,Go 对 defer 做了优化,采用更有效的内联方式,取代之前的生成 defer 对象到 defer 链中,defer 对所在函数运行时间的影响微乎其微,所以修改成下面简洁的写法也基本没问题:
gofunc (f *Foo) Bar() { f.mu.Lock() // 加锁 defer f.mu.Unlock() // defer 释放锁 if f.count < 1000 { f.count += 3 return } f.count++ return }
这样做的好处就是 Lock/Unlock 总是成对地出现,不会遗漏或者多调用,代码更少。但是,如果临界区只是方法中的一部分,即使不需要锁,也有很多耗时的操作,在这种情况下,为了尽快释放锁,还是应该第一时间调用 Unlock,而不是一直等到方法返回时才释放锁。所以,我们应该根据实际情况灵活处理,在代码可维护性和性能之间寻找平衡。
在初版的 Mutex 实现之后,Go 开发组又对 Mutex 做了一些微调,比如把字段类型变成了 uint32 类型;调用 Unlock 方法会做检查;使用 atomic 包的同步原语执行原子操作等,这些小的改动都不涉及核心功能,简单地知道就行,这里就不详细介绍了。
但是,初版的 Mutex 实现有一个问题:请求锁的 goroutine 会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上看,却不是最优的。因为:如果能够把锁交给当前正在占用 CPU 时间片的 goroutine,那么就不需要做上下文切换了,在高并发的情况下,这可能会有更好的性能。
- Golang锁-Mutex的用法1/9/2026
- Go运行时调度器1/7/2026
- 检查Golang程序中的数据竞争1/12/2026
- Go并发并不一定最快1/6/2026
- 竞争条件与数据竞争1/8/2026
- 适合并发编程的语言Golang1/2/2026