Go同步原语之sync/mutex ---- 互斥锁

19 篇文章 0 订阅
1 篇文章 0 订阅

简述

Mutex(互斥锁)是一种用于多线程编程的同步机制。它是"Mutual Exclusion"(互斥)的缩写,用于控制多个线程对共享资源的访问,以避免出现并发访问引起的问题,如数据竞争(Data Race)和不一致性。

在多线程环境中,多个线程可以并发地访问共享资源,例如共享变量、数据结构或文件等。如果没有适当的同步机制,多个线程可能会同时修改同一份数据,导致不可预测的结果或者程序错误。

使用互斥锁可以确保在任意时刻只有一个线程能够获得锁,从而访问共享资源。当一个线程获得了互斥锁,其他试图获得锁的线程会被阻塞,直到持有锁的线程释放锁。这样可以确保在同一时间内只有一个线程访问共享资源,从而避免了并发冲突。

多个 goroutine 并发更新一个资源,像下列场景,如果没有互斥控制,就会出现一些异常情况,导致结果错误并引发数据混乱,造成严重后果:

  • 计数器:计数器结果不准确;
  • 秒杀系统:由于同一时间访问量比较大,导致的超卖;
  • 用户账户异常:同一时间支付导致的账户透支;
  • buffer 数据异常:更新 buffer 导致的数据混乱。

使用互斥锁,限定临界区只能同时由一个线程持有,若是临界区此时被一个线程持有,那么其他线程想进入到这个临界区的时候,就会失败或者等待释放锁,持有此临界区的线程退出,其他线程才有机会获得这个临界区。

79335ca1b23a8e1048b3ff22f63d802a-0.jpg

互斥锁是并发程序中对共享资源进行访问控制的主要手段,对此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变量,前一个 goroutinecount 值进行+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.racefuncenterruntime.racereadruntime.racewriteruntime.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 语言中,CASCompare and Swap,比较并交换)是一种并发编程中常用的原子操作,用于实现非阻塞算法和数据结构。CAS 操作允许你通过比较某个内存位置的当前值与预期值是否相等来决定是否进行交换操作。如果相等,就将新值写入该内存位置;如果不相等,说明在你读取值和尝试写入之间有其他线程进行了修改,操作失败,你可以根据需要重试。

Go 语言中,标准库中的 sync/atomic 包提供了原子操作的函数,包括 CAS 操作。以下是一个简单的示例来说明 CASGo 中的使用:

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
}

上面代码中的各个字段含义解释:

  • wRWMutex 内置的一把普通互斥锁 sync.Mutex
  • writerSem : 关联写锁阻塞队列的信号量;
  • readerSem : 关联读锁阻塞队列的信号量;
  • readerCount : 正常情况下等于介入读锁流程的 goroutine 数量;当 goroutine 接入写锁流程时,该值为实际介入读锁流程的 goroutine 数量减 rwmutexMaxReadersrwmutexMaxReaders数量上限值为 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,代表着 有10Goroutine在等待, 转化为二进制值为 1010

图示如下:

image-20220420160858398

再看看几个跟 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值(二进制)结果(二进制)结果(十进制)说明
    111 & 1 = 11加锁状态
    010 & 1 = 00未加锁状态

    示意图如下:

    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值(二进制)结果(二进制)结果(十进制)说明
    0100 & 10 = 00未唤醒状态
    1101 & 10 = 102唤醒状态

    示意图如下:

    image-20220420165933062

    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值(二进制)结果(二进制)结果(十进制)说明
    01000 & 100 = 00非饥饿模式
    11001 & 100 = 1004饥饿模式

    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 值 (二进制)结果(二进制)结果(十进制)说明
    0110 & 11 = 00未加锁
    未唤醒
    1111 & 11 = 11已加锁
    未唤醒
    111111 && 11 = 113已加锁
    已唤醒

    Mutex源码中常用写法:

    //伪代码
    old := m.state
    if old&(mutexLocked|mutexWoken) != 0 //判断是否加锁或者唤醒
    
  • Mutex.state & (mutexLocked | mutexStarving)

    Mutex.state&(mutexLocked|mutexStarving) 的位运算结果表示该 mutex 是否加锁或者饥饿状态,状态表如下:

    Mutex.state 相关值(二进制)mutexLocked | mutexStarving值 (二进制)结果(二进制)结果(十进制)说明
    01010 & 101 = 00未加锁
    非饥饿
    100101100 & 101 = 1004未加锁
    饥饿
    101101101 && 101 = 1015已加锁
    饥饿
    11011 && 101 = 11已加锁
    非饥饿

    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值结果(二进制)结果(十进制)说明
    01110 & 111 = 00未加锁
    未唤醒
    非饥饿
    11111 & 111 = 11加锁
    未唤醒
    非饥饿
    1011110 & 111 = 102未加锁
    唤醒
    非饥饿
    1111111 & 111 = 113加锁
    唤醒
    非饥饿
    100111100 & 111 = 1004未加锁
    未唤醒
    饥饿
    110111110 & 111 = 1106加锁
    唤醒
    饥饿
    101111101 & 111 = 1015加锁
    未唤醒
    饥饿
    111111111 & 111 = 1117加锁
    唤醒
    饥饿

    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.stateLocked(锁状态)值为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 循环,这里将它分成几个部分介绍获取锁的过程:

    1. 判断当前 Goroutine 能否进入自旋,通过自旋来等待锁的释放

    2. 通过计算当前的 Goroutine 最新状态值

    3. 更新互斥锁的状态并获取锁;

    我们先来介绍互斥锁是如何判断当前 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 字段中存储的不同信息 — LockedStarvingWokenWaiter

     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

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 流程图如下:

image-20230830165022569

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陷入休眠等待锁的释放;
  • 如果该方法的结果为非负数,基于原子操作将 RWMutexreadCount 变量加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)
	}
	......
}

大致流程如下:

  1. 调用结构体持有的 sync.Mutex 结构体的 sync.Mutex.Lock函数阻塞后续的写操作;
  2. 调用 sync/atomic.AddInt32 函数,基于原子操作对 RWMutex.readerCount 进行减少 rwmutexMaxReaders 数量的操作;
  3. 倘若此时存在未释放读锁的 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()
  ......
}

与加锁的过程正好相反,写锁的释放分以下几个执行:

  1. 基于原子操作,将 RWMutex.readerCount 的值加上 rwmutexMaxReaders,使其值变为正常的数值;
  2. 倘若发现 RWMutex.readerCount 的新值大于 rwmutexMaxReaders,则说明要么当前 RWMutex 未上过写锁,要么介入读锁流程的 goroutine 数量已经超限,因此直接抛出 fatal
  3. 因此唤醒读锁阻塞队列中的所有 goroutine
  4. 解开 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

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值