Go语言Mutex互斥锁的用法(非常详细,附带实例)
互斥锁也叫排他锁,Go 语言中的 sync.Mutex(简称 Mutex)就是互斥锁的一种具体实现。
Mutex 实现了 Locker 接口中定义的 Lock() 方法,示例代码如下:
使用互斥锁的伪代码如下:
我们通常会在相应的结构体中嵌入互斥锁,这样就可以直接调用 Lock() 和 Unlock() 方法。此外,还可以将获取锁、释放锁的逻辑封装在方法内,这样就只是对外提供方法而不会暴露锁的逻辑。示例代码如下:

图 1 Mutex的属性和方法
Mutex 结构体仅包含 2 个字段,分别是 state 和 sema。
state 是一个 int32 类型的字段,表示互斥锁的状态,它主要包含两部分信息,即锁是否被持有以及等待锁的协程数量。state 的取值为 0~3,对应的常量为 mutexWoken、mutexStarving 和 mutexWaiterShift。示例代码如下:
当一个协程尝试获取一个已被其他协程持有的互斥锁时,该协程会被阻塞并进入等待状态。当持有锁的协程调用 Unlock() 方法释放锁时,Mutex 会唤醒一个等待的协程并将锁的所有权转移给它。
举一个计数器的例子,示例代码如下:
使用互斥锁重构 TestGoroutineUnsafe() 函数,以使其并发安全,具体如下:
对临界区的代码段加锁,可确保同一时刻最多只有一个协程能够拿到这把锁。获取锁使用 Lock() 方法,执行完代码段后,释放锁使用 UnLock() 方法。互斥锁实际上提供了这样一种能力:划分出一个关键的代码段,确保其中的代码以原子的方式执行,不管有多少条指令。
当然,使用互斥锁是有代价的。我们必须注意因为使用它而导致的延迟。等待的协程越多,因此而产生的延迟就越大,因为有的协程可能需要等待很久才能获取那把锁。既然它会导致性能下降,那么使用锁的操作越少越好。
虽然互斥锁 Mutex 是个结构体,但尽量不要把它作为函数的参数使用。这是因为在 Go 语言中,函数的参数都是以值的方式传递的,这意味着函数会获取参数的一个副本。当我们将一个互斥锁作为参数传递给一个函数时,函数会获取这个互斥锁的副本,而不是原始的互斥锁。互斥锁是有状态的对象,它的状态字段(state)记录了锁的状态。这就会导致问题,因为互斥锁的副本会有自己独立的状态,这可能会破坏互斥锁的同步特性。
例如,如果一个协程锁定了一个互斥锁,然后另一个协程获取了这个互斥锁的副本并尝试锁定它,那么这个尝试会成功,因为副本是一个全新的、未被锁定的互斥锁。这就违反了互斥锁的基本保证,即在任何时刻,最多只有一个协程可以持有锁。
另外,Go 语言的互斥锁是不支持复制的,如果尝试复制一个互斥锁,则会引起 panic。如果需要在函数之间共享一个互斥锁,应该使用指针而不是值来传递它。
为了解决该问题,互斥锁提供了两种工作模式,即正常模式和饥饿模式。
如果当前代码段的锁已经被一个协程获取,那么其他协程执行到这个代码段时,就会进入一个等待队列。释放锁后,等待的协程在等待队列中被唤醒,但被唤醒的协程并不会直接持有锁,而是会与新进入的协程竞争。
正常模式会带来一个问题,即新进入的协程有先天的优势,因为它们正运行在 CPU 中,所以在高并发的场景下,被唤醒的协程可能竞争不过新进入的协程。在极端情况下,被唤醒的协程永远无法获取到锁。
为了解决这个问题,Go 语言中增加了另一种机制,即被唤醒的协程将被插到队列的最前面。如果被唤醒的协程获取不到锁的时间超过了阈值 1 毫秒,那么此时这个互斥锁就进入饥饿模式。
饥饿模式虽然可以避免出现因为性能原因而让协程长时间等待锁的情况,解决了锁的公平性问题,但是也会导致性能降低。在饥饿模式下,那些一直在等待的协程会被优先调度。
Mutex 实现了 Locker 接口中定义的 Lock() 方法,示例代码如下:
func (m *Mutex) Lock() func (m *Mutex) Unlock()某个协程通过调用 Lock() 方法获得锁后,其他请求锁的协程在调用 Lock() 方法时就会被阻塞,直到锁被释放,其他协程才能再次竞争这把锁。
使用互斥锁的伪代码如下:
var s sync.Mutex s.Lock() // 这里的代码是串行的 代码... s.Unlock()锁定和释放锁必须成对出现!在加锁后一定要记得释放锁,否则会造成死锁。
我们通常会在相应的结构体中嵌入互斥锁,这样就可以直接调用 Lock() 和 Unlock() 方法。此外,还可以将获取锁、释放锁的逻辑封装在方法内,这样就只是对外提供方法而不会暴露锁的逻辑。示例代码如下:
type Sequence struct { value int sync.Mutex } func (s *Sequence) increment() { s.Lock() defer s.Unlock() s.value++ } func (a *Sequence) get() int { a.Lock() defer a.Unlock() return a.value }
Go语言Mutex的数据结构
互斥锁 Mutex 的实现源码位于 sync/mutex.go 中,它的属性和方法如下图所示:
图 1 Mutex的属性和方法
Mutex 结构体仅包含 2 个字段,分别是 state 和 sema。
state 是一个 int32 类型的字段,表示互斥锁的状态,它主要包含两部分信息,即锁是否被持有以及等待锁的协程数量。state 的取值为 0~3,对应的常量为 mutexWoken、mutexStarving 和 mutexWaiterShift。示例代码如下:
const ( //0表示没有锁 mutexLocked = 1 << iota //持有锁的标识 mutexWoken //唤醒标识位 mutexStarving //饥饿标识 )sema 是一个 uint32 类型的字段,它被用作一个信号量,用于控制等待协程的阻塞、休眠和唤醒,常用于加锁和释放锁的过程中。
当一个协程尝试获取一个已被其他协程持有的互斥锁时,该协程会被阻塞并进入等待状态。当持有锁的协程调用 Unlock() 方法释放锁时,Mutex 会唤醒一个等待的协程并将锁的所有权转移给它。
举一个计数器的例子,示例代码如下:
// 测试共享内存并发不安全时,是否会造成数据不一致 func TestGoroutineUnSafe() { counter:=0 for i := 0; i < 100000; i++ { go func() { counter++ }() } time.Sleep(time.Millisecond) fmt.Println(counter) }上述代码的输出结果是 90000 多,而不是预期值 100000。这是因为多个协程会同时对变量 counter 做自增操作,counter++ 就是这段代码的临界区,但上面的代码并没有对此临界区做出限制,导致多个协程可以同时访问和修改它,因此是线程不安全的,所以最终的结果并非预期的 100000。
使用互斥锁重构 TestGoroutineUnsafe() 函数,以使其并发安全,具体如下:
// 测试协程并发时的锁 func TestGoroutineSafeByLock(t *testing.T) { counter:=0 var mutex sync.Mutex for i := 0; i < 100000; i++ { go func() { mutex.Lock() counter++ mutex.Unlock() }() } ... }在上述代码中,为临界区代码 counter++ 加上了互斥锁 Mutex,在代码执行完以后,会使用 mutex.Unlock 方法释放互斥锁。这样一来,counter++ 这块临界区就是并发安全的了。
对临界区的代码段加锁,可确保同一时刻最多只有一个协程能够拿到这把锁。获取锁使用 Lock() 方法,执行完代码段后,释放锁使用 UnLock() 方法。互斥锁实际上提供了这样一种能力:划分出一个关键的代码段,确保其中的代码以原子的方式执行,不管有多少条指令。
当然,使用互斥锁是有代价的。我们必须注意因为使用它而导致的延迟。等待的协程越多,因此而产生的延迟就越大,因为有的协程可能需要等待很久才能获取那把锁。既然它会导致性能下降,那么使用锁的操作越少越好。
虽然互斥锁 Mutex 是个结构体,但尽量不要把它作为函数的参数使用。这是因为在 Go 语言中,函数的参数都是以值的方式传递的,这意味着函数会获取参数的一个副本。当我们将一个互斥锁作为参数传递给一个函数时,函数会获取这个互斥锁的副本,而不是原始的互斥锁。互斥锁是有状态的对象,它的状态字段(state)记录了锁的状态。这就会导致问题,因为互斥锁的副本会有自己独立的状态,这可能会破坏互斥锁的同步特性。
例如,如果一个协程锁定了一个互斥锁,然后另一个协程获取了这个互斥锁的副本并尝试锁定它,那么这个尝试会成功,因为副本是一个全新的、未被锁定的互斥锁。这就违反了互斥锁的基本保证,即在任何时刻,最多只有一个协程可以持有锁。
另外,Go 语言的互斥锁是不支持复制的,如果尝试复制一个互斥锁,则会引起 panic。如果需要在函数之间共享一个互斥锁,应该使用指针而不是值来传递它。
Go语言Mutex的工作模式
每个协程都必须在获取锁以后再对临界区的资源进行操作,完成操作后它会释放锁,这样就能保证共享资源的并发安全。但是,这种方式也带来了一个问题,由于有的协程一直获取不到锁,因此导致业务逻辑的执行不完整,这个问题称为“饥饿问题”。为了解决该问题,互斥锁提供了两种工作模式,即正常模式和饥饿模式。
1) 正常模式
所谓正常模式,指的是代码段的当前锁在同一时刻只能被一个协程获取。如果当前代码段的锁已经被一个协程获取,那么其他协程执行到这个代码段时,就会进入一个等待队列。释放锁后,等待的协程在等待队列中被唤醒,但被唤醒的协程并不会直接持有锁,而是会与新进入的协程竞争。
正常模式会带来一个问题,即新进入的协程有先天的优势,因为它们正运行在 CPU 中,所以在高并发的场景下,被唤醒的协程可能竞争不过新进入的协程。在极端情况下,被唤醒的协程永远无法获取到锁。
为了解决这个问题,Go 语言中增加了另一种机制,即被唤醒的协程将被插到队列的最前面。如果被唤醒的协程获取不到锁的时间超过了阈值 1 毫秒,那么此时这个互斥锁就进入饥饿模式。
2) 饥饿模式
在饥饿模式下,释放锁后,锁的拥有者会直接将锁交给队列最前面的协程。新进入的协程不会尝试获取锁,即使锁看起来没有被持有,它会直接跑到等待队列的末尾。当拥有锁的协程遇到以下两种情况中的任意一种时,它会将这个互斥锁转换为正常模式:- 此协程已经是等待队列中的最后一个协程;
- 该协程的等待时间小于 1 毫秒。
饥饿模式虽然可以避免出现因为性能原因而让协程长时间等待锁的情况,解决了锁的公平性问题,但是也会导致性能降低。在饥饿模式下,那些一直在等待的协程会被优先调度。