锁是为了避免竞争而建立的并发控制手段,为有序地访问共享资源。 互斥锁mutex

Mutex为一结构体类型,对外暴露Lock与Unlock接口。加锁与解锁要成对出现(应加锁后,立即用defer解锁),重复解锁会引起panic

Mutex内存布局:
Mutex内存布局

Mutex有以下状态:

  • Locked:是否已被锁定(0:没锁定,1:锁定);
  • Woken:是否有协程已被唤醒,正处于加锁状态(0:无协程唤醒,1:有协程唤醒);
  • Starving:是否处于饥饿状态(0:没有饥饿,1:饥饿状态);
  • Waiter:阻塞的等待锁的协程数(解锁时据此判断是否要释放信号量);

加解锁

协程间抢锁实际上是抢给Locked赋值的权利(能给Locked域置1,说明抢锁成功);抢不到的阻塞等待Mutex.sema信号量。

Woken状态用于加锁和解锁过程的通信;处于自旋状态的加锁协程会把Woken标记为1,通知解锁协程不必释放信号量了。

加锁、唤醒示意图(最基本的情形):
加解锁示意图

正常模式下,被阻塞的协程会进入等待队列;当持有锁协程释放锁时,会释放唤醒信号来唤醒等待的协程。

自旋

自旋对应于CPU的‘PAUSE’指令(CPU空转),不同于sleep,其不需要把协程转为睡眠状态;加锁时程序会自动判断是否可自旋,自旋必须满足(要不忙):

  • 自旋次数要足够小(通常最多不超4次);
  • CPU核数要大于1(否则自旋无意义);
  • 协程调度机制中的Process数量要大于1;
  • 协程调度机制中可运行队列必须为空;

自旋优势是更充分利用CPU,尽量避免协程切换。若自旋过程中获得锁,那么之前被阻塞协程将无法获得锁,从而可能会进入饥饿状态;为避免协程长时间无法获取锁,自1.8版本后,Mutex增加了Starving状态,此状态下不会自旋(释放锁时,一定会唤醒一个协程并让其成功加锁)。

加锁模式

每个Mutex都有两个模式,称为Normal和Starving:

  • Normal模式:若加锁不成功,不会立即转入阻塞队列,而是判断是否满足自旋条件;
  • Starvation模式:处于饥饿模式下,不会启动自旋过程;一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取锁,同时也会把等待计数减1。

释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始运行,此时发现锁已被抢占了,自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms的话,会将Mutex标记为”饥饿”模式,然后再阻塞。

基本使用

sync包中提供了锁相关的一系列同步原语,用于加解锁:

import (
	"fmt"
	"sync"
)

func ShowMutex() {
	var syn sync.Mutex
	var count = 0

	var wg sync.WaitGroup
	wg.Add(10)

	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			for j := 0; j < 100000; j++ {
				syn.Lock()
				count++
				syn.Unlock()
			}
		}()
	}

	wg.Wait()
	fmt.Println(count)
}
读写锁rwmutex

读写互斥锁,可增加并发能力(程序中一般是读多、写少):

  • 写锁阻塞其他写锁;
  • 写锁阻塞读锁;
  • 读锁阻塞写锁;
  • 读锁不阻塞读锁;

接口

RWMutex提供了四个接口:

  • RLock(读锁定):增加读操作计数,(若有写操作)阻塞等待写操作结束;
  • RUnlock(读解锁):减少读操作计数,(最后一个读操作、且有写锁定)唤醒等待等待写操作的协程;
  • Lock(写锁定):获取互斥锁,(若有读操作)等待所有读操作结束;
  • Unlock(写解锁):唤醒因读锁定而被阻塞的协程(若有),解除互斥锁;

读写锁定义:

type RWMutex struct {
	w Mutex //用于控制多个写锁, 获得写锁首先要获取该锁, 如果有一个写锁在进行, 那么再到来的写锁将会阻塞于此
	writerSem uint32 //写阻塞等待的信号量, 最后一个读者释放锁时会释放信号量
	readerSem uint32 //读阻塞的协程等待的信号量, 持有写锁的协程释放锁后会释放信号量
	readerCount int32 //记录读者个数
	readerWait int32 //记录写阻塞时读者个数
}

互斥

写阻塞读

写操作是如何阻止读操作的:

  • readerCount是个整型值,用于表示读者数量,不考虑写操作的情况下,每次读锁定将该值+1,每次解除读锁定将该值-1,所以readerCount取值为[0,N](N为读者个数,最大可支持 2 30 2^{30} 230个并发读者)。
  • 当写锁定进行时,会先将readerCount减去 2 30 2^{30} 230,从而readerCount变成了负值,此时再有读锁定到来时检测到readerCount为负值,便知道有写操作在进行,只好阻塞等待。
  • 真实的读操作个数并不会丢失,只需要将readerCount加上 2 30 2^{30} 230即可获得。

读阻塞写

读操作是如何阻止写操作的:

  • 读锁定会先将readerCount加1,此时写操作到来时发现读者数量不为0,会阻塞等待所有读操作结束。

避免饿死

为什么写锁定不会被饿死:

  • 写操作到来时,会把readerCount值拷贝到readerWait中,用于标记排在写操作前面的读者
    个数。
  • 读操作结束后,除了会递减readerCount,还会递减readerWait值,当readerWait值变为0时唤醒写操作。