从问题入手:

如果 Mutex 已经被一个 goroutine 获取了锁,其它等待中的 goroutine 们只能一直等待。那么,等这个锁释放后,等待中的 goroutine 中哪一个会优先获取 Mutex呢?

在正常状态下,所有等待锁的 goroutine 按照 FIFO 顺序等待。但是,刚唤醒(即出队)的 goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 去竞争锁。新请求锁的 goroutine 具有一个优势:它正在 CPU 上执行。 而且可能有好几个 goroutine 同时在新请求锁,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 在没有获得锁之后会加入到等待队列的最前面。

如果一个等待的 goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。

在饥饿模式下,锁的所有权将从执行 unlock 的 goroutine 直接交给等待队列中的第一个等待锁者。 新来的 goroutine 将不能再去尝试竞争锁,即使锁是 unlock 状态,也不会去尝试自旋操作,而是放在等待队列的尾部。

如果一个等待的 goroutine 获取了锁,并且满足以下其中一个条件:

  • (1)它是队列中的最后一个;
  • (2)它等待的时候小于1ms,那么该 goroutine 会将锁的状态转换为正常状态。

正常模式具有较好的性能,因为 goroutine 可以连续多次尝试获取锁,即使还有其他的阻塞等待锁的 goroutine,也不需要进入休眠阻塞。 饥饿模式也很重要的,它的作用是阻止尾部延迟的现象。

版本更迭

  • 初版: 使用一个flag字段标记是否持有锁
  • 第二版: 新的goroutine也能有机会竞争锁
  • 第三版: 新来的和被唤醒的有更多的机会竞争锁
  • 第四版: 解决竞争问题,不会让goroutine等待太久

初版

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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,则没有其它等待者
        return
    }
    semrelease(&m.sema) // 唤醒其它阻塞的goroutine
}

CAS指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性的。

由代码可知

  • 字段key: 是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有;
  • 字段 sema: 是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。

初版

Unlock 方法可以被任意的 goroutine 调用释放锁,即使是没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex 本身并没有包含持有这把锁的goroutine 的信息,所以,Unlock 也不会对此进行检查。Mutex 的这个设计一直保持至今

第二版

Go 开发者在 2011 年 6 月 30 日的 commit 中对 Mutex 做了一次大的调整,调整后的 Mutex 实现如下:

1
2
3
4
5
6
7
8
9
type Mutex struct {
    state int32
    sema uint32
} 
const (
    mutexLocked = 1 << iota // mutex is locked
    mutexWoken
    mutexWaiterShift = iota
)

mutex第二版

state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个字段被分成了三部分,代表三个数据

请求锁

请求锁代码也变的复杂

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (m *Mutex) Lock() {
	// Fast path: 幸运case,能够直接获取到锁
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	} 
	awoke := false
	for {
		old := m.state
		new := old | mutexLocked // 新状态加锁
		if old&mutexLocked != 0 {
			new = old + 1<<mutexWaiterShift //等待者数量加一
		}
		if awoke {
			// goroutine是被唤醒的,
			// 新状态清除唤醒标志
			new &^= mutexWoken
		}
		if atomic.CompareAndSwapInt32(&m.state, old, new) {//设置新状态
			if old&mutexLocked == 0 { // 锁原状态未加锁
				break
			}
			runtime.Semacquire(&m.sema) // 请求信号量
			awoke = true
		}
	}
}

其请求锁有以下几步

1)通过 CAS 检测 state 字段中的标志,如果没有goroutine持有锁,则直接获取锁;

2)state 不是零值,那么就通过一个循环进行检查,如果atomic.CompareAndSwapInt32(&m.state, old, new) 能成功将state设置为新值,那么则表示抢夺锁成功,

但是需要注意的是,如果成功地设置了 state 的值,但是之前的 state 是有锁的状态, 那么,state 只是清除 mutexWoken 标志或者增加一个 waiter 而已。

请求锁的 goroutine 有两类,

  • 一类是新来请求锁的 goroutine
  • 另一类是被唤醒的等待请求锁的goroutine。

锁的状态也有两种:加锁和未加锁。

以下表格表示goroutine不同来源不同状态下的处理逻辑 不同来源不同状态

释放锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func (m *Mutex) Unlock() {
  // Fast path: drop lock bit.
  new := atomic.AddInt32(&m.state, -mutexLocked) //去掉锁标志
  if (new+mutexLocked)&mutexLocked == 0 { //本来就没有加锁 
    panic("sync: unlock of unlocked mutex")
  } 
  old := new
  for {
    if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken) != 0{
        return
    }
    new = (old - 1<<mutexWaiterShift) | mutexWoken // 新状态,准备唤醒goroutine
    if atomic.CompareAndSwapInt32(&m.state, old, new) {
        runtime.Semrelease(&m.sema)
        return
    }
    old = m.state
  }
}

同样释放锁也有几个步骤

1)尝试将持有锁的标识设置为未加锁的状态,这是通过减 1 而不是将标志位置零的方式实现

2)通过信号量的方式唤醒等待这个锁的goroutine中的一个,其中涉及的逻辑又有两种情况

  • 如果没有其它的 waiter,说明对这个锁的竞争的 goroutine 只有一个,那就可以直接返回了
  • 如果有等待者,并且没有唤醒的 aiter,那就需要唤醒一个等待的 waiter。在唤醒之前,需要将 waiter 数量减 1,并且将 mutexWoken 标志设置上

第三版

在 2015 年 2 月的改动中,如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁,它们就会通过自旋的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑

加锁逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func (m *Mutex) Lock() {
  // Fast path: 幸运之路,正好获取到锁
  if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return
  }
  awoke := false
  iter := 0
  for { // 不管是新来的请求锁的goroutine, 还是被唤醒的goroutine,都不断尝试请求锁
    old := m.state            // 先保存当前锁的状态
    new := old | mutexLocked  // 新状态设置加锁标志
    if old&mutexLocked != 0 { // 锁还没被释放
      if runtime_canSpin(iter) { // 还可以自旋
        if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift
                atomic.CompareAndSwapInt32(&m.state, old, old|mutexWok
        awoke = true
      }
      runtime_doSpin()
      iter++
      continue // 自旋,再次尝试请求锁
    }
    new = old + 1<<mutexWaiterShift
  }
  if awoke { // 唤醒状态
    if new & mutexWoken == 0 {
      panic("sync: inconsistent mutex state")
    }
    new &^= mutexWoken // 新状态清除唤醒标记
  }
  if atomic.CompareAndSwapInt32(&m.state, old, new) {
    if old & mutexLocked == 0 { // 旧状态锁已释放,新状态成功持有了锁,直接
      break
    }
    runtime_Semacquire(&m.sema) // 阻塞等待
    awoke = true                // 被唤醒
    iter = 0
  }
}

第四版

经过几次优化,Mutex 的代码越来越复杂,应对高并发争抢锁的场景也更加公平。 但是极端情况下,等待中的 goroutine 可能会一直获取不到锁

Mutex 不能容忍这种事情发生。所以,2016 年 Go 1.9 中 Mutex 增加了饥饿模式,让锁 变得更公平,不公平的等待时间限制在 1 毫秒,并且修复了一个大 Bug:总是把唤醒的goroutine 放在等待队列的尾部,会导致更加不公平的等待时间

现在的 Mutex 代码已经复杂得接近不可读的状态了。

第四版

Java中的ReentrantLock

在Java中,ReentrantLock的实现是基于AbstractQueuedSynchronizer(AQS)的。它有两种状态:未锁定和锁定。当一个线程请求锁定时,如果当前没有被锁定,则它可以获得锁定并继续执行。如果已经被锁定,则请求锁定的线程就会进入阻塞状态,等待锁定被释放并唤醒它。

性能比较

在性能方面,Go语言中的Mutex和Java中的ReentrantLock都是高效的锁。它们都允许多个goroutine或线程同时访问共享变量,而只有一个goroutine或线程可以修改变量。在单个goroutine或线程的情况下,Go语言中的Mutex比Java中的ReentrantLock稍快。但是,当涉及到多个goroutine或线程时,性能差异就会很小,因为它们的底层实现都依赖于操作系统的原语。

实现方式比较

在实现方式方面,Go语言中的Mutex是基于操作系统提供的原语实现的,而Java中的ReentrantLock是基于AQS实现的。AQS提供了一些高级功能,例如可重入性、公平性和条件等待等。这些功能使ReentrantLock比Mutex更灵活和强大。

锁的特性比较

Go语言中的Mutex只有两种状态,即锁定和未锁定,而Java中的ReentrantLock则提供了许多特性,例如可重入性、公平性、超时等待、条件等待等。这些特性使ReentrantLock更加灵活和适应性更强。

总体而言,Go语言中的Mutex和Java中的ReentrantLock都是可靠和高效的锁,它们的底层实现都依赖于操作系统的原语。在选择哪种锁时,需要根据实际需求和场景来进行评估。如果只需要简单的锁定机制,则可以选择Go语言中的Mutex。如果需要更多的功能和特性,例如

最后

  1. 目前 Mutex 的 state 字段有几个意义,这几个意义分别是由哪些字段表示的?

    • 前三个 bit 是 mutexLocked(锁标记)、mutexWoken(唤醒标记)、mutexStarving(饥饿标记)
    • 剩余 bit 标示 mutexWaiter(等待数量)
  2. 等待一个 Mutex 的 goroutine 数最大是多少?是否能满足现实的需求? 目前的设计来看取决于 state 的类型,目前是 uint32,由于3个字节代表了状态,还有: 2^(32 – 3) – 1 等于 536870911, 一个 goroutine 初始化的为 2kb, 536870911 & 2kb 是相当大的数,所以可以满足现实的需求。

常见错误的四种场景

Lock/Unlock 不是成对出现、Copy 已使用的 Mutex、重入和死锁。