简述
Mutex
(互斥锁)是一种用于多线程编程的同步机制。它是"Mutual Exclusion
"(互斥)的缩写,用于控制多个线程对共享资源的访问,以避免出现并发访问引起的问题,如数据竞争(Data Race
)和不一致性。
在多线程环境中,多个线程可以并发地访问共享资源,例如共享变量、数据结构或文件等。如果没有适当的同步机制,多个线程可能会同时修改同一份数据,导致不可预测的结果或者程序错误。
使用互斥锁可以确保在任意时刻只有一个线程能够获得锁,从而访问共享资源。当一个线程获得了互斥锁,其他试图获得锁的线程会被阻塞,直到持有锁的线程释放锁。这样可以确保在同一时间内只有一个线程访问共享资源,从而避免了并发冲突。
多个 goroutine
并发更新一个资源,像下列场景,如果没有互斥控制,就会出现一些异常情况,导致结果错误并引发数据混乱,造成严重后果:
- 计数器:计数器结果不准确;
- 秒杀系统:由于同一时间访问量比较大,导致的超卖;
- 用户账户异常:同一时间支付导致的账户透支;
- buffer 数据异常:更新 buffer 导致的数据混乱。
使用互斥锁,限定临界区只能同时由一个线程持有,若是临界区此时被一个线程持有,那么其他线程想进入到这个临界区的时候,就会失败或者等待释放锁,持有此临界区的线程退出,其他线程才有机会获得这个临界区。
![79335ca1b23a8e1048b3ff22f63d802a-0.jpg](https://i-blog.csdnimg.cn/blog_migrate/5539607f3a972cd6eb0fa9b94c8253a2.jpeg)
互斥锁是并发程序中对共享资源进行访问控制的主要手段,对此Go语言提供了非常简单易用的Mutex。
基本使用
在Go的标准库中,由 sync
包提供了锁的一系列同步原语。sync
包定义了一个 Locker
接口:
type Locker interface {
Lock()
Unlock()
}
Mutex
就是实现了这个接口,提供了两个方法: Lock
和 Unlock
, 在操作并发资源的时候调用 Lock
方法,操作完成后调用 Unlock
方法:
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
在此,举个在不使用互斥锁情况下对一个计数器进行计数的例子,代码如下:
import (
"fmt"
"sync"
)
func main() {
var count int64 = 0
var wg sync.WaitGroup
wg.Add(10)
for x := 0; x < 10; x++ {
go func() {
defer wg.Done()
for t := 0; t < 10000; t++ {
count++
}
}()
}
wg.Wait()
fmt.Println(count)
}
这段代码中,采取 sync.WaitGroup
来等待所有的 goroutine
计数的完成。最后输出结果。
多次执行代码,结果如下:
PS D:\wamp64\www\golang\src\main> go run .\main.go
41022
PS D:\wamp64\www\golang\src\main> go run .\main.go
38348
PS D:\wamp64\www\golang\src\main> go run .\main.go
47335
分析结果发现,每次执行的结果都不一样,也并不是如我们想象的会输出 100000
的结果,这是为什么呢?
这是因为 count++
操作并非是一个原子操作, 多个 goroutine
同时访问和修改 count
变量,前一个 goroutine
对count
值进行+1
操作并返回,后一个 goroutine
还没取到最新的 count
值而在原取值的 count
值上做加1
操作,导致很多操作被吞,从而导致了结果不能正常确定并每次结果不断变化。
针对这类并发资源问题,Go
提供了一个检测访问并发共享资源是否有问题的工具: race detector
。
race detector 的使用方法就是 在 编译,运行,测试的时候,加上 race
参数即可, 例如:
go run -race main.go #运行main.go
go test -race mypkg #测试包
go build -race main.go #编译包
go install -race mypkg #安装包
race detector 使用结果示例:
PS D:\wamp64\www\golang\src\main> go run -race .\main.go
==================
WARNING: DATA RACE
Read at 0x00c00012e058 by goroutine 8:
main.main.func1()
D:/wamp64/www/golang/src/main/main.go:25 +0x84
Previous write at 0x00c00012e058 by goroutine 7:
main.main.func1()
D:/wamp64/www/golang/src/main/main.go:25 +0x9d
Goroutine 8 (running) created at:
main.main()
D:/wamp64/www/golang/src/main/main.go:21 +0xeb
Goroutine 7 (finished) created at:
main.main()
D:/wamp64/www/golang/src/main/main.go:21 +0xeb
==================
50296
Found 1 data race(s)
exit status 66
race detector
使用返回的警告不但会告诉你有并发问题,而且还会告诉你哪个 goroutine
在哪一行对哪个变量有写操作,同时,哪个 goroutine
在哪一行对哪个变量有读操作,就是这些并发的读写访问,引起了 data race
。
所以,警告信息可能会很长。虽然这个工具使用起来很方便,但是,因为它的实现方式,只能通过真正对实际地址进行读写访问的时候才能探测,所以它并不能在编译的时候发现 data race
的问题。而且在运行的时候,只有在触发了 data race
之后才能检测到,如果碰巧没有触发(比如一个 data race
问题只能在 2 月 14 号零点或者 11 月 11 号零点才出现),是检测不出来的。而且,把开启了 race
的程序部署在线上,还是比较影响性能的。
我们可以运行 go tool compile -race -S main.go
来可以查看计数器例子的代码,重点关注一下 count++
前后的编译后的代码:
0x0060 00096 (.\main.go:22) CALL runtime.deferprocStack(SB)
.......
0x0065 00101 (.\main.go:22) TESTL AX, AX
0x0067 00103 (.\main.go:22) JNE 221
0x0069 00105 (.\main.go:22) XORL AX, AX
0x006b 00107 (.\main.go:22) JMP 192
0x006d 00109 (.\main.go:23) MOVQ AX, "".t+8(SP)
0x0072 00114 (.\main.go:25) MOVQ "".&count+128(SP), AX
0x007a 00122 (.\main.go:25) MOVQ AX, (SP)
0x007e 00126 (.\main.go:25) NOP
0x0080 00128 (.\main.go:25) CALL runtime.raceread(SB)
0x0085 00133 (.\main.go:25) MOVQ "".&count+128(SP), AX
0x008d 00141 (.\main.go:25) MOVQ (AX), CX
......
0x00c0 00192 (.\main.go:23) CMPQ AX, $10000
0x00c6 00198 (.\main.go:23) JLT 109
0x00c8 00200 (.\main.go:28) PCDATA $1, $2
0x00c8 00200 (.\main.go:28) XCHGL AX, AX
0x00c9 00201 (.\main.go:28) CALL runtime.deferreturn(SB)
0x00ce 00206 (.\main.go:28) CALL runtime.racefuncexit(SB)
0x00d3 00211 (.\main.go:28) MOVQ 104(SP), BP
0x00d8 00216 (.\main.go:28) ADDQ $112, SP
在编译的代码中,增加了 runtime.racefuncenter
、runtime.raceread
、runtime.racewrite
、runtime.racefuncexit
等检测 data race
的方法。通过这些插入的指令,Go race detector
工具就能够成功地检测出 data race
问题了。
既然发生了data race
问题,那我们利用 Mutex
来解决它,代码如下:
import (
"fmt"
"sync"
)
func main() {
var count int64 = 0
var mu sync.Mutex //声明Mutex
var wg sync.WaitGroup
wg.Add(10)
for x := 0; x < 10; x++ {
go func() {
defer wg.Done()
for t := 0; t < 10000; t++ {
mu.Lock() //加锁
count++
mu.Unlock() //解锁
}
}()
}
wg.Wait()
fmt.Println(count)
}
再运行下程序,发现结果跟我们预期的一致了,输出 100000
.
Mutex
还可以结合 struct
使用,下面示例将 计数器代码进行封装并嵌入struct
中使用:
import (
"fmt"
"sync"
)
//线程安全计数器 struct
type Counter struct {
mu sync.Mutex
count int64
}
//获取计数器计数
func (m *Counter) Count() int64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.count
}
//计数器计数加1
func (m *Counter) Incr() {
m.mu.Lock()
m.count++
m.mu.Unlock()
}
func main() {
var counter Counter
var wg sync.WaitGroup
wg.Add(10)
for x := 0; x < 10; x++ {
go func() {
defer wg.Done()
for t := 0; t < 10000; t++ {
counter.Incr()
}
}()
}
wg.Wait()
fmt.Println(counter.Count())
}
底层结构
前置概念
CAS
在 Go
语言中,CAS
(Compare and Swap
,比较并交换)是一种并发编程中常用的原子操作,用于实现非阻塞算法和数据结构。CAS
操作允许你通过比较某个内存位置的当前值与预期值是否相等来决定是否进行交换操作。如果相等,就将新值写入该内存位置;如果不相等,说明在你读取值和尝试写入之间有其他线程进行了修改,操作失败,你可以根据需要重试。
在 Go
语言中,标准库中的 sync/atomic
包提供了原子操作的函数,包括 CAS
操作。以下是一个简单的示例来说明 CAS
在 Go
中的使用:
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var value int32 = 0
// 使用 CAS 操作来增加 value 的值
success := atomic.CompareAndSwapInt32(&value, 0, 1)
if success {
fmt.Println("CAS operation successful, value =", value)
} else {
fmt.Println("CAS operation failed, value =", value)
}
}
在上面的示例中,我们使用了 atomic.CompareAndSwapInt32
函数来进行 CAS
操作。它接受三个参数:要修改的内存位置的指针、预期的旧值和要写入的新值。如果当前内存位置的值等于预期的旧值,那么 CAS
操作就会成功,将新值写入该位置,并返回 true
。如果当前值不等于预期的旧值,CAS
操作就会失败,返回 false
。
需要注意的是,CAS
操作虽然是原子操作,但它并不能解决所有并发问题,特别是在高并发情况下,可能会出现竞态条件。在使用 CAS
时,需要仔细考虑逻辑和确保正确性。
CAS
在构建无锁算法和数据结构时非常有用,因为它避免了使用传统的互斥锁带来的开销和复杂性,但也需要开发者理解并发编程的细节以及如何正确使用 CAS
来避免问题。
spinlock(自旋锁)
自旋锁(Spinlock
)是一种在并发编程中使用的锁,它不会像传统的互斥锁那样将等待的线程阻塞在一个忙等待的状态,而是会一直尝试去获取锁,不断地自旋(循环)直到获取到锁为止。自旋锁适用于一些短暂的临界区(一小段需要互斥的代码),因为它避免了线程在阻塞和唤醒中切换带来的开销。
在 Go
语言中,标准库的 sync/atomic
包提供了原子操作,可以用来实现自旋锁。以下是一个简单的示例来说明如何使用自旋锁:
package main
import (
"fmt"
"runtime"
"sync/atomic"
"time"
)
type SpinLock struct {
flag int32
}
func (s *SpinLock) Lock() {
for !atomic.CompareAndSwapInt32(&s.flag, 0, 1) {
runtime.Gosched() // 让出时间片,避免忙等待
}
}
func (s *SpinLock) Unlock() {
atomic.StoreInt32(&s.flag, 0)
}
func main() {
var spinLock SpinLock
for i := 0; i < 5; i++ {
go func(id int) {
spinLock.Lock()
defer spinLock.Unlock()
fmt.Printf("Goroutine %d is in the critical section.\n", id)
time.Sleep(time.Millisecond * 100)
fmt.Printf("Goroutine %d is leaving the critical section.\n", id)
}(i)
}
time.Sleep(time.Second) // 等待所有 goroutine 完成
}
在上面的示例中,我们定义了一个名为 SpinLock
的结构体类型,其中包含一个 flag
字段,用于表示锁的状态。
Lock
方法使用自旋来尝试获取锁,如果当前锁已经被占用,它会不断循环尝试获取,期间使用 runtime.Gosched()
让出时间片,避免忙等待。
Unlock
方法用于释放锁。
在 main
函数中,我们启动了 5 个 goroutine
,每个 goroutine
都尝试获取自旋锁,并在临界区内打印一些信息。通过等待一段时间,我们确保所有的 goroutine
都能完成。
需要注意的是,自旋锁适用于临界区很小且锁的竞争时间很短暂的情况。在高竞争情况下或者临界区的执行时间较长时,自旋锁可能会导致性能下降,因为它会消耗大量的 CPU
资源。因此,在使用自旋锁时需要仔细考虑使用场景和性能影响。
Semaphore(信号量)
在 Go
语言中,信号量(Semaphore
)是一种用于控制多个线程对共享资源访问的同步机制。
信号量维护着一个计数器,用来记录可用的资源数量。线程在访问共享资源之前必须先获取信号量,如果信号量的计数器大于零,线程就可以获取资源并将计数器减一;如果计数器为零,线程则会被阻塞,直到有资源可用。
这里就不详细说明了,有时间会独立章节说明此内容。
数据结构
Mutex结构体
源码包中 sync/mutex.go
中定义了Mutex
的结构体:
type Mutex struct {
state int32
sema uint32
}
-
Mutex.sema 表示信号量。是一个非负数的全局变量,该字段用于实现
Mutex
的锁定和解锁操作,以及阻塞和唤醒等待锁的线程(实现锁的底层同步机制)。Mutex.sema
基于信号量(Semaphore
)的概念,使用了P
操作(Wait
操作)、V
操作(Signal
操作)和S
操作(Set
操作)来实现互斥锁的功能。P
操作(Wait
操作):P
操作是用于获取资源或等待资源的操作。在Mutex
的语境下,P
操作表示试图获取锁。当一个线程尝试获取锁时,它会进行P
操作,即将sema
字段的值减1
。如果结果小于零,表示锁已被占用,线程将进入等待状态。V
操作(Signal
操作):V
操作是用于释放资源或通知等待者的操作。在Mutex
的语境下,V
操作表示释放锁。当一个线程调用Unlock
方法释放锁时,它会进行V
操作,即将sema
字段的值加1
。如果有等待的线程,V
操作会唤醒其中一个线程以继续执行。S
操作(Set
操作):S
操作用于设置信号量的值。在Mutex
的语境下,S
操作可以用于初始化sema
字段的值,即设置初始的可用资源数量。S>0
:表示有S
个资源可用;S=0
:表示无资源可用;S<0
: 绝对值表示等待队列或链表中的进程个数。
-
Mutex.state 储存的是互斥锁的状态,加锁和解锁都是通过
atomic
包提供的函数原子性操作来操作该字段。Mutex.state
是一个int32
类型数据,内部实现时把该变量分成四份,分别为:Locked
(是否上锁),Woken
(是否有协程在抢锁),Starving
(是否处于饥饿模式),Waiter
(等待的G
数量),来记录不同状态值。如下图所示:代码如下:
const ( mutexLocked = 1 << iota // 用来表示互斥锁锁定状态 mutexWoken // 用来表示当前互斥锁模式 mutexStarving // 用来表示当前互斥锁饥饿状态 mutexWaiterShift = iota // 用来表示当前互斥锁上等待的Goroutine个数 )
-
第
1
位 代表Locked
,大小为1bit
,表示当前互斥锁锁定状态,0
表示未锁定,1
代表已锁定。对应定常量为mutexLocked
; -
第
2
位 代表Woken
, 大小为1bit
,表示当前互斥锁模式,0
表示正常模式,1
表示唤醒模式。对应常量为mutexWoken
; -
第
3
位代表Starving
,大小为1bit
,表示当前互斥锁饥饿状态,0
表示没有饥饿,1
代表在饥饿状态。对应常量为mutexStarving
; -
剩下
4-32
位代表Waiter
,大小29bit
,表示当前互斥锁上等待的Goroutine
个数。对应常量为mutexWaiterShift
。
-
RWMutex结构体
RWMutex
(读写互斥锁)是 Go
语言标准库 sync
包中的一个同步原语,用于实现读写分离的锁。它提供了对共享资源的读取和写入操作进行互斥控制,以实现多个读操作同时进行,但只允许一个写操作进行的功能。
RWMutex
支持以下操作:
RLock()
:获取读锁。多个goroutine
可以同时获取读锁,只要没有写锁被占用。如果有写锁被占用,则读锁会阻塞。RUnlock()
:释放读锁。当读操作完成后,必须调用该方法来释放读锁。Lock()
:获取写锁。写锁会阻塞其他读锁和写锁,只有当前持有写锁的 goroutine 可以进行写操作。Unlock()
:释放写锁。
RWMutex
的特点在于多个 goroutine
可以同时获取读锁,这适用于多读少写的场景,可以有效提高并发性能。但是,当有写锁被占用时,所有的读锁和写锁都会被阻塞,以保证写操作的独占性。
源码包 sync.RWMutex
中定义了RWMutex
的结构体:
type RWMutex struct {
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount atomic.Int32 // number of pending readers
readerWait atomic.Int32 // number of departing readers
}
上面代码中的各个字段含义解释:
- w :
RWMutex
内置的一把普通互斥锁sync.Mutex
; - writerSem : 关联写锁阻塞队列的信号量;
- readerSem : 关联读锁阻塞队列的信号量;
- readerCount : 正常情况下等于介入读锁流程的
goroutine
数量;当goroutine
接入写锁流程时,该值为实际介入读锁流程的goroutine
数量减rwmutexMaxReaders
,rwmutexMaxReaders
数量上限值为 229; - readerWait : 记录在当前
goroutine
获取写锁前,还需要等待多少个goroutine
释放读锁;
巧用位运算
在理解了 Mutex.state
各个区段代表的意思基础上,我们接着来看看 位运算在 Mutex.state
中的运用巧妙之处。理解了位运算的巧妙用意后才能更加深入的了解代码中的操作用意。 Mutex.state
主要 涉及到位运算如下表:
运算符 | 说明 | 示例(二进制) |
---|---|---|
& | 位与 | 1100 & 1010 = 1000 |
| | 位或 | 1100 | 1010 = 1110 |
>> | 左移位 | 1100 >> 3 = 1 |
<< | 右移位 | 1100 << 3 = 1100000 |
&^ | 清位 | 1100 &^ 1010 = 0100 |
^ | 异或 | 1100 ^ 1010 = 0110 |
举个例子来说明下 Mutex.state
的相关位操作,我们 Mutex.state
取值为 87
,转为二进制 1010111
,代表着
Locked
是锁定状态, 二进制值为1
;Woken
是唤醒状态,二进制值为1
;Staving
是饥饿状态,二进制值为1
;Waiter
数量为10
,代表着 有10
个Goroutine
在等待, 转化为二进制值为1010
。
图示如下:
![image-20220420160858398](https://i-blog.csdnimg.cn/blog_migrate/e578802c6c66a34f3d0c0ad261c8d658.png)
再看看几个跟 Mutex.state
关联的常量值:
const (
mutexLocked = 1 << iota // 固定值为1,代表锁定状态
mutexWoken // 固定值为2,代表唤醒状态
mutexStarving // 固定值为4,代表饥饿状态
mutexWaiterShift = iota // 固定值为3,因为前面三个状态已经占据了3位,所以默认值为3
)
Mutex.state
相关位运算操作如下:
-
Mutex.state & mutexLocked
由于
mutexLocked
值为1,转为二进制为1
,所以Mutex.state&mutexLocked
操作其实最终只和低1
位的值有关,最终的&
值由低1
位值决定,当最终&
值为0
时候,表示未加锁,&
值为1
时候表示已经加锁。状态表如下:
Mutex.state Locked值(二进制) mutexLocked值(二进制) 结果(二进制) 结果(十进制) 说明 1 1 1 & 1 = 1 1 加锁状态 0 1 0 & 1 = 0 0 未加锁状态 示意图如下:
Mutex
源码中常用写法://伪代码 old := m.state if old&mutexLocked{...} if old&mutexLocked != 0{...} if old&mutexLocked == 0{...} if old&mutexLocked == 1{...} if old&mutexLocked == mutexLocked{...}
-
Mutex.state & mutexWoken
由于
mutexWoken
值为2,转为二进制为10
,所以Mutex.state&mutexWoken
操作其实最终只和低2位值有关,最终的&
值由低2
位值决定,当最终&
值为0
时候,表示未加锁,&值为2
时候表示已经加锁。状态表如下:
Mutex.state Woken值(二进制) mutexWoken值(二进制) 结果(二进制) 结果(十进制) 说明 0 10 0 & 10 = 0 0 未唤醒状态 1 10 1 & 10 = 10 2 唤醒状态 示意图如下:
Mutex
源码中常用写法://伪代码 old := m.state if old&mutexWoken == 0{...} if old&mutexWoken != 0{...} if old&mutexWoken == mutexWoken{...}
-
Mutex.state & mutexStarving
由于
mutexStarving
值为4
,转为二进制为100
,所以Mutex.state&mutexStarving
操作其实最终和低3
位值有关,最终的&
值由低3
位值决定,&值为0
时表示非饥饿模式,&
值为4
时候表示饥饿模式。状态表如下:
Mutex.state Staving值(二进制) mutexStarving值(二进制) 结果(二进制) 结果(十进制) 说明 0 100 0 & 100 = 0 0 非饥饿模式 1 100 1 & 100 = 100 4 饥饿模式 Mutex
源码中常用写法://伪代码 old := m.state if old&mutexStarving == 0{...} if old&mutexStarving != 0{...} if old&mutexStarving == mutexStarving{...}
-
Mutex.state & (mutexLocked | mutexWoken)
Mutex.state&(mutexLocked|mutexWoken)
的位运算结果表示该mutex
是否加锁或者唤醒状态,状态表如下:Mutex.state 相关值(二进制) mutexLocked | mutexWoken 值 (二进制) 结果(二进制) 结果(十进制) 说明 0 11 0 & 11 = 0 0 未加锁
未唤醒1 11 1 & 11 = 1 1 已加锁
未唤醒11 11 11 && 11 = 11 3 已加锁
已唤醒Mutex
源码中常用写法://伪代码 old := m.state if old&(mutexLocked|mutexWoken) != 0 //判断是否加锁或者唤醒
-
Mutex.state & (mutexLocked | mutexStarving)
Mutex.state&(mutexLocked|mutexStarving)
的位运算结果表示该mutex
是否加锁或者饥饿状态,状态表如下:Mutex.state 相关值(二进制) mutexLocked | mutexStarving值 (二进制) 结果(二进制) 结果(十进制) 说明 0 101 0 & 101 = 0 0 未加锁
非饥饿100 101 100 & 101 = 100 4 未加锁
饥饿101 101 101 && 101 = 101 5 已加锁
饥饿1 101 1 && 101 = 1 1 已加锁
非饥饿Mutex
源码中常用写法://伪代码 old := m.state if old&(mutexLocked|mutexStarving) != 0 {...} //加锁或者饥饿状态 if old&(mutexLocked|mutexStarving) == 0 {...} //未加锁与非饥饿状态 if old&(mutexLocked|mutexStarving) == mutexLocked //已加锁并非饥饿
-
Mutex.state & (mutexLocked | mutexWoken | mutexStarving)
Mutex.state&(mutexLocked|mutexWoken|mutexStarving)
的位运算结果表示该mutex
是否加锁,唤醒状态,饥饿状态的综合状态,状态表如下:Mutex.state 相关值(二进制) mutexLocked|mutexWoken|mutexStarving值 结果(二进制) 结果(十进制) 说明 0 111 0 & 111 = 0 0 未加锁
未唤醒
非饥饿1 111 1 & 111 = 1 1 加锁
未唤醒
非饥饿10 111 10 & 111 = 10 2 未加锁
唤醒
非饥饿11 111 11 & 111 = 11 3 加锁
唤醒
非饥饿100 111 100 & 111 = 100 4 未加锁
未唤醒
饥饿110 111 110 & 111 = 110 6 加锁
唤醒
饥饿101 111 101 & 111 = 101 5 加锁
未唤醒
饥饿111 111 111 & 111 = 111 7 加锁
唤醒
饥饿Mutex
源码中常用写法://伪代码 old := m.state old&(mutexLocked|mutexWoken|mutexStarving) != 0 //加锁或者唤醒或者饥饿状态符合其中之一
-
<< mutexWaiterShift and >>mutexWaiterShift
由于
Mutex.state
由四部分组成,前三位分别是代表锁状态,唤醒状态以及饥饿状态,而Goroutine
数量的值则由Mutex.state
的第4-29
位位置决定,因此将mutexWaiterShift
值设定位3
,是为了方便移位操作。比如 数值
1
,正常二进制表示 为:1
,而在Mutex.state
中则需要二进制表示为:1000
,所以用位运算转换正常数值与Mutex.state
中数值需要移位操作:1 << 3
表示Mutex.state
中的1
,等同于1 << mutexWaiterShift
;Mutex.state >> 3
,等同于Mutex.state >> mutexWaiterShift
, 则表示将Mutex.state
向右移动三位,这样将锁状态,唤醒状态以及饥饿状态所占用的三位数值去除,转换后则可正常的二进制进制换算
Mutex
源码中常用写法://伪代码 old := m.state new += 1 << mutexWaiterShift //new值+1 old>>mutexWaiterShift == 1 //只有一个等待的Goroutine if old>>mutexWaiterShift == 0 //没有等待的Goroutine
-
&^= mutexWoken
将运算符左边数据相异的位保留,相同位清零。在很多项目中,常用于一个二进制位的重置操作。
Mutex
源码中常用写法:old := m.state new := old if awoke { ...... new &^= mutexWoken //重置Woken状态 }
源码解读
Mutex.Lock
Mutex.Lock
方法用于获取互斥锁,它的调用将会阻塞当前线程,直到锁被成功获取为止。如果当前锁已经被其他线程占用,调用 Lock
方法的线程将会被阻塞,直到锁被释放并且当前线程成功获取锁。一旦成功获取锁,该线程就可以安全地访问共享资源。
首先看下 Mutex.Lock
方法的源码:
func (m *Mutex) Lock() {
//进入Fast-path模式,尝试能否幸运的拿锁,如果成功则返回,否则进入Slow-path模式
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// 进入 Slow-path模式模式
m.lockSlow()
}
-
当我们调用
Lock
方法的时候, 会先尝试走Fast-Path
,即通过调用atomic.CompareAndSwapInt32
来竞争更新m.state
,成功则获得锁返回。-
atomic.CompareAndSwapInt32
atomic.CompareAndSwapInt32
源码函数体:func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
该函数作用是判断参数
addr
指向的值是否与参数old
的值相等,如果相等,用参数new
的新值替换掉addr
存储的旧值,否则操作就会被忽略。在
Lock
函数中,则表示在&m.state
中 使用mutexLocked
(锁定状态)值替换掉0
值(非锁定状态),即当前互斥锁中的m.state
中Locked
(锁状态)值为0
,并且能用mutexLocked
值替换该值则表示 成功获取锁,否则获取锁失败。 -
race.Acquire
if race.Enabled { race.Acquire(unsafe.Pointer(m)) }
竞争检测逻辑,
go
中使用goroutine
比较常见,在大型项目中可能会在多个goroutine
中用到某个全局变量,如果有竞争就需要加锁操作。go
提供了race
检测工具,可以使用go run -race
或者go build -race
来进行竞争检测。
-
-
如果获取锁失败,则进入
Slow-path
模式。Slow-path
模式就会调用sync.Mutex.lockSlow
方法,该方法的主体是一个非常大for
循环,这里将它分成几个部分介绍获取锁的过程:-
判断当前
Goroutine
能否进入自旋,通过自旋来等待锁的释放 -
通过计算当前的
Goroutine
最新状态值 -
更新互斥锁的状态并获取锁;
我们先来介绍互斥锁是如何判断当前
Goroutine
能否进入自旋等互斥锁的释放:func (m *Mutex) lockSlow() { var waitStartTime int64 //等待时间 starving := false //饥饿状态标识 awoke := false //唤醒状态标识 iter := 0 // 自旋次数 old := m.state // 当前Mutex的state值 for { // 当前互斥锁被其他goroutine持有且非饥饿模式,并支持自旋,则开始自旋等待机会 if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { // 当前互斥锁未被唤醒状态并且有至少一个等待goroutine,则将awoke状态设置为唤醒 if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() //自旋 iter++ //更新自旋次数 old = m.state // 不断获取最新互斥锁state值 continue } ...... } }
lockSlow
方法第一步就是通过old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter)
来判断当前Goroutine
是否满足自旋等待锁释放的条件:- 当前互斥锁的状态是非饥饿状态,并且已经被锁定了
- 支持自旋
那什么是自旋呢?自旋是一种多线程同步机制,当前的进程在进入自旋的过程中会一直保持
CPU
的占用,持续检查某个条件是否为真。在多核的CPU
上,自旋可以避免Goroutine
的切换,使用恰当会对性能带来很大的增益,但是使用的不恰当就会拖慢整个程序,所以支持进入自旋的条件非常苛刻,我们通过runtime_canSpin
源码来分析:func sync_runtime_canSpin(i int) bool { if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 { return false } if p := getg().m.p.ptr(); !runqempty(p) { return false } return true }
综上源码所示,需要
runtime_canSpin
函数返回true
(支持自旋),则需要满足下列条件:cpu
个数大于1
,必须要是多核cpu
;- 当前
Goroutine
为了获取该锁进入自旋的次数小于4
次; GOMAXPROCS >1
;- 当前正在执行当中,并且队列空闲的
p
的个数大于等于1
。
一旦当前
Goroutine
能够进入自旋就会调用[runtime.sync_runtime_doSpin
] 和 [runtime.procyield
]并执行30
次的PAUSE
指令,该指令只会占用CPU
并消耗CPU
时间:func sync_runtime_doSpin() { procyield(active_spin_cnt) } TEXT runtime·procyield(SB),NOSPLIT,$0-0 MOVL cycles+0(FP), AX again: PAUSE SUBL $1, AX JNZ again RET
如果当前
Goroutine
未被唤醒并且有多个Goroutine
在等待队列中,则在自旋的过程中会尝试设置mutexWoken
来通知解锁,从而避免唤醒其他已经休眠的goroutine
在自旋模式下,这样做就可以使得当前的goroutine
更快的获取到锁。代码如下:if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true }
如果自旋超过了次数或者目前锁没有被持有,互斥锁会根据上下文计算当前互斥锁最新的状态,几个不同的条件分别会更新
state
字段中存储的不同信息 —Locked
、Starving
、Woken
和Waiter
:func (m *Mutex) lockSlow() { ...... new := old // 当前互斥锁非饥饿模式则将new的mutexLocked设置为1 准备抢占锁 if old&mutexStarving == 0 { new |= mutexLocked } //当前互斥锁加锁状态或饥饿模式下,当前goroutine进入等待队列 if old&(mutexLocked|mutexStarving) != 0 { new += 1 << mutexWaiterShift } // 假如starving为true并且当前互斥锁已经被加锁,则将状态值设置饥饿模式 if starving && old&mutexLocked != 0 { new |= mutexStarving } // 当前goroutine自旋过已被被唤醒,则需要将mutexWoken重置 if awoke { if new&mutexWoken == 0 { throw("sync: inconsistent mutex state") } new &^= mutexWoken } ...... }
计算当前互斥锁最新的状态主要是做了下列几个值的变更:
-
当前互斥锁在非饥饿模式下,则更改将锁的状态变更为
mutexLocked
,抢占持有锁; -
如果当前互斥锁在饥饿或者锁定状态,则当前
goroutine
将会进入等待队列中,等待队列数+1; -
如果当前
goroutine
是饥饿状态并且锁被持有,则将当前互斥锁的饥饿状态变更改为mutexStarving
; -
如果当前
goroutine
自旋并唤醒状态,则将当前互斥锁唤醒状态重置。
计算了新的互斥锁状态之后,会使用
CAS
函数sync/atomic.CompareAndSwapInt32
更新状态://尝试用计算后的新状态更新替代旧值 if atomic.CompareAndSwapInt32(&m.state, old, new) { //当前goroutine获取锁前mutex处于未加锁 正常模式下 if old&(mutexLocked|mutexStarving) == 0 { break // 使用CAS成功抢占到锁,直接返回 } //waitStartTime!=0表示当前goroutine是等待状态唤醒的 queueLifo := waitStartTime != 0 if waitStartTime == 0 { //记录等待开始时间 waitStartTime = runtime_nanotime() } // 将被唤醒但是没有获得锁的goroutine插入到当前等待队列队首 // 使用信号量阻塞当前goroutine runtime_SemacquireMutex(&m.sema, queueLifo, 1) // 当goroutine等待时间超过starvationThresholdNs,mutex进入饥饿模式 starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs old = m.state if old&mutexStarving != 0 { if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 { throw("sync: inconsistent mutex state") } //等待状态的goroutine-1 delta := int32(mutexLocked - 1<<mutexWaiterShift) //如果等待时间小于1ms 或 当前goroutine是队列中最后一个 if !starving || old>>mutexWaiterShift == 1 { delta -= mutexStarving // 退出饥饿模式 } atomic.AddInt32(&m.state, delta) break } awoke = true iter = 0 }
状态计算完成之后就会尝试使用
CAS
操作获取锁,如果获取成功就会直接退出循环 如果获取失败,则会调用runtime_SemacquireMutex(&m.sema, queueLifo, 1)
方法保证锁不会同时被两个goroutine
获取。runtime_SemacquireMutex
方法的主要作用是:- 不断调用尝试获取锁;
- 休眠当前
goroutine
; - 等待信号量,唤醒
goroutine
;
goroutine
被唤醒之后就会去判断当前是否处于饥饿模式,如果当前等待超过1ms
就会进入饥饿模式-
饥饿模式下:会获得互斥锁,如果等待队列中只存在当前
Goroutine
,互斥锁还会从饥饿模式中退出; -
正常模式下:会设置唤醒和饥饿标记、重置迭代次数并重新执行获取锁的循环;
-
Mutex.Lock
流程图如下:
![image-20230830153117616](https://i-blog.csdnimg.cn/blog_migrate/1b474a7ed118c8b5b33dc972d9ff5a59.png)
Mutex.Unlock
Mutex.Unlock
方法用于释放互斥锁,允许其他线程获取锁并访问共享资源。它应该在临界区代码执行完毕后被调用,以便将锁让给其他等待的线程。
互斥锁的解锁过程 sync.Mutex.Unlock
与加锁过程相比就很简单,代码如下:
func (m *Mutex) Unlock() {
//date.race检测逻辑
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
//去除加锁状态
new := atomic.AddInt32(&m.state, -mutexLocked)
//存在等待的goroutine,则进入unlockSlow处理
if new != 0 {
m.unlockSlow(new)
}
}
首先该过程会先使用 sync/atomic.AddInt32
函数快速解锁,解锁后会出现2种结果:
-
如果该函数返回的新状态等于
0
, 说明当前只有一个goroutine
占有锁,解锁成功直接结束 -
如果该函数返回的新状态不等于
0
,则说明还存在其他goroutine
在等待,则要进入m.unlockSlow
函数,进行下一步解锁
Mutex.unlockSlow
函数源码如下:
func (m *Mutex) unlockSlow(new int32) {
//检查互斥锁锁状态,确保解锁前的锁是locked状态
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
//普通模式
if new&mutexStarving == 0 {
//获取新状态赋值给old变量
old := new
for {
//如果当前不存在其他goroutine等待或者当前互斥锁的mutexLocked mutexWoken mutexStarving状态不都为0,则直接返回
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
//将当前互斥锁状态唤醒并减少一个goroutine等待数量
new = (old - 1<<mutexWaiterShift) | mutexWoken
//更新状态值
if atomic.CompareAndSwapInt32(&m.state, old, new) {
//通过信号移交锁的所有权
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
//饥饿模式
} else {
runtime_Semrelease(&m.sema, true, 1)
}
}
进入 sync.Mutex.unlockSlow
函数后,该过程会先校验锁状态的合法性 — 如果当前互斥锁已经被解锁过了会直接抛出异常 “sync: unlock of unlocked mutex
” 中止当前程序。
通过 (new+mutexLocked)&mutexLocked == 0
判断检测通过锁状态的合法性后,通过 if new&mutexStarving == 0
来判断当前Mutex
的模式:
-
正常模式
在正常模式下,上述代码会使用如下所示的处理过程:
- 如果互斥锁不存在等待者或者有存在有
goroutine
被唤醒,锁定或者当前互斥锁处于饥饿模式,则直接返回,不需要唤醒其他等待者; - 如果互斥锁存在等待者,则唤醒一个
goroutine
,调用sync.runtime_Semrelease
移交锁的所有权
- 如果互斥锁不存在等待者或者有存在有
-
饥饿模式
如果当前
Mutex
是饥饿模式,则会直接调用sync.runtime_Semrelease
将当前锁交给下一个正在尝试获取锁的等待者,等待者被唤醒后会得到锁,在这时互斥锁还不会退出饥饿状态
Mutex.UnLock
流程图如下:
RWMutex.RLock
RWMutex.RLock
方法用于获取读锁。通过调用这个方法,多个 goroutine
可以同时获取读锁,只要没有写锁被占用。如果有写锁被占用,那么 RLock
方法会被阻塞,直到写锁被释放。该函数源码如下:
func (rw *RWMutex) RLock() {
......
if rw.readerCount.Add(1) < 0 {
runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
}
......
}
通过 rw.readerCount.Add(1)
方法将读取goroutine
数量+1
,根据操作返回值分两种情况:
- 如果该方法返回负数,则当前
Goroutine
添加到进读锁的阻塞队列中,然后调用runtime.sync_runtime_SemacquireMutex
陷入休眠等待锁的释放; - 如果该方法的结果为非负数,基于原子操作将
RWMutex
的readCount
变量加1
,表示占用或等待读锁的goroutine
数加1
;
RWMutex.RUnLock
RWMutex.RUnlock
方法用于释放读锁。在读操作完成后,必须调用这个方法来释放读锁,以便其他等待的读锁可以获得。当所有的读锁都被释放后,可能会触发一个等待中的写锁。源码如下:
func (rw *RWMutex) RUnlock() {
......
if r := rw.readerCount.Add(-1); r < 0 {
// Outlined slow-path to allow the fast-path to be inlined
rw.rUnlockSlow(r)
}
......
}
该方法会先减少正在读资源的 readerCount
整数,根据 sync/atomic.AddInt32
的返回值不同会分别进行处理:
- 如果返回值大于等于零, 读锁直接解锁成功;
- 如果返回值小于零 , 说明有一个正在执行的写操作,在这时会调用
sync.RWMutex.rUnlockSlow
方法操作;
再来看看sync.RWMutex.rUnlockSlow
源码:
func (rw *RWMutex) rUnlockSlow(r int32) {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
race.Enable()
fatal("sync: RUnlock of unlocked RWMutex")
}
if rw.readerWait.Add(-1) == 0 {
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
该代码会减少获取锁的写操作等待的读操作数 readerWait
,并在所有读操作都被释放之后触发写操作的信号量 writerSem
,该信号量被触发时,调度器就会唤醒尝试获取写锁的 Goroutine
。
RWMutex.Lock
RWMutex.Lock
方法用于获取写锁,阻塞其他的读锁和写锁,以保证只有一个 goroutine
可以进行写操作。当获取到写锁后,其他的读写锁都会被阻塞,直到当前持有写锁的 goroutine
释放它。
源码如下:
func (rw *RWMutex) Lock() {
......
rw.w.Lock()
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for active readers.
if r != 0 && rw.readerWait.Add(r) != 0 {
runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
}
......
}
大致流程如下:
- 调用结构体持有的
sync.Mutex
结构体的sync.Mutex.Lock
函数阻塞后续的写操作; - 调用
sync/atomic.AddInt32
函数,基于原子操作对RWMutex.readerCount
进行减少rwmutexMaxReaders
数量的操作; - 倘若此时存在未释放读锁的
gouroutine
,则基于原子操作在RWMutex.readerWait
的基础上加上介入读锁流程的goroutine
数量,并将当前goroutine
添加到写锁的阻塞队列中挂起;
RWMutex.UnLock
RWMutex.Unlock
方法用于释放写锁。当写操作完成后,必须调用这个方法来释放写锁,以便其他的读写操作可以继续进行。同时,释放写锁也会唤醒可能正在等待写锁的其他 goroutine
。
源码如下:
func (rw *RWMutex) Unlock() {
......
r := rw.readerCount.Add(rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
race.Enable()
fatal("sync: Unlock of unlocked RWMutex")
}
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
rw.w.Unlock()
......
}
与加锁的过程正好相反,写锁的释放分以下几个执行:
- 基于原子操作,将
RWMutex.readerCount
的值加上rwmutexMaxReaders
,使其值变为正常的数值; - 倘若发现
RWMutex.readerCount
的新值大于rwmutexMaxReaders
,则说明要么当前RWMutex
未上过写锁,要么介入读锁流程的goroutine
数量已经超限,因此直接抛出fatal
; - 因此唤醒读锁阻塞队列中的所有
goroutine
; - 解开
RWMutex
内置的互斥锁
获取写锁时会先阻塞写锁的获取,后阻塞读锁的获取,这种策略能够保证读操作不会被连续的写操作『饿死』。
参考资料:
-
(鸟窝) 极客时间 - Go 并发编程实战课 https://time.geekbang.org/column/intro/100061801
-
幼麟实验室 https://space.bilibili.com/567195437/dynamic
-
林泽宇 https://blog.csdn.net/LINZEYU666/article/details/123073481
-
Go 语言设计与实现- https://draveness.me/golang/
-
Go 进阶训练营 https://lailin.xyz/post/go-training-week3-sync.html