Golang并发(3)

Golang 并发 (三)

基本同步原语

Go 语言在 sync 包中提供了用于同步的一些基本原语,包括常见的互斥锁 Mutex 和读写互斥锁 RWMutex 以及 Once,WaitGroup.这些基本原语的主要作用是提供较为基础的同步功能

image-20250127222758333

Mutex

Mutex 是 golang 标准库的互斥锁,主要用来处理并发场景下共享资源的访问冲突问题。

Mutex 互斥锁在 sync 包中,它由两个字段 statesema 组成,state 表示当前互斥锁的状态,而 sema 真正用于控制锁状态的信号量,这两个加起来只占 8 个字节空间的结构体就表示了 Go 语言中的互斥锁。

1
2
3
4
type Mutex struct {
  state int32
  sema  uint32
}

互斥锁的作用,就是同步访问共享资源。互斥锁这个名字来自互斥(mutual exclusion)的概念,互斥锁用于在代码上创建一个临界区,保证同一个时间只有一个 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
package main

import (
	"fmt"
	"sync"
)

var (
	counter int
	wg      sync.WaitGroup
	mutex   sync.Mutex
)

func main() {
	wg.Add(2)
	go incCounter()
	go incCounter()
	wg.Wait()
	fmt.Println("counter: ", counter)
}
func incCounter() {
	defer wg.Done()
	for count := 0; count < 10000000; count++ {
		//mutex.Lock()
		value := counter
		value++
		counter = value
		//mutex.Unlock()
	}
}

image-20250127223854013

使用互斥锁

 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
package main

import (
	"fmt"
	"sync"
)

var (
	counter int
	wg      sync.WaitGroup
	mutex   sync.Mutex
)

func main() {
	wg.Add(2)
	go incCounter()
	go incCounter()
	wg.Wait()
	fmt.Println("counter: ", counter)
}
func incCounter() {
	defer wg.Done()
	for count := 0; count < 10000000; count++ {
		mutex.Lock()
		value := counter
		value++
		counter = value
		mutex.Unlock()
	}
}

https://blog.meowrain.cn/api/i/2025/01/27/6PHSMW1737988758854870121.avif

Lock()Unlock() 函数调用定义的临界区里被保护起来。 使用大括号只是为了让临界区看起来更清晰,并不是必需的。同一时刻只有一个 goroutine 可以进入临界区,直到调用 Unlock() 函数之后,其他 goroutine 才能进入临界区。

Mutex 的几种状态

  • mutexLocked — 表示互斥锁的锁定状态
  • mutexWoken — 表示从正常模式被唤醒
  • mutexStarving – 当前互斥锁进入解状态
  • waitersCount —当前互斥锁上等待的 Goroutine 数

正常模式和饥饿模式

正常模式中,锁的等待者会按照先进先出的顺序获取锁,但是刚被唤起的 goroutine 与新创建的 goroutine 竞争的时候,大概率会获取不到锁,为了减少这种情况的出现,一旦 goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换到饥饿模式,放在部分 goroutine 被饿死

在饥饿模式中,互斥锁会直接教给等待队列最前面的 goroutine,新的 goroutine 在该状态下不能获取锁,也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间小于 1ms,那么当前互斥锁就会切换回正常模式。

常见锁类型

死锁,活锁和饥饿锁

在并发编程或操作系统中,死锁(Deadlock)、活锁(Livelock)和饥饿锁(Starvation)是三种常见的资源竞争问题,它们都会导致系统无法正常运行。以下是它们的定义、区别和示例:


1. 死锁(Deadlock)

定义

多个进程(或线程)因竞争资源而陷入互相等待的状态,每个进程都无法继续执行,除非对方释放资源。此时系统处于僵持状态,无法自动恢复。

必要条件

死锁的发生必须满足以下四个条件(缺一不可):

  1. 互斥:资源一次只能被一个进程占用。
  2. 持有并等待:进程已持有资源,同时又在等待其他资源。
  3. 不可抢占:资源只能由持有它的进程主动释放,不可被强制抢占。
  4. 循环等待:存在一个进程等待链,每个进程都在等待下一个进程持有的资源。
示例
  • 线程 A 持有锁 L1,等待锁 L2;
  • 线程 B 持有锁 L2,等待锁 L1。
    此时,两个线程都无法继续执行,形成死锁。
解决方法
  • 破坏必要条件(如资源按顺序申请,避免循环等待)。
  • 死锁检测与恢复(强制终止进程或回滚操作)。
  • 超时机制(等待超过时间后主动释放资源)。

2. 活锁(Livelock)

定义

进程(或线程)在尝试解决资源冲突时,持续改变自身状态,但始终无法取得进展。活锁的特点是进程并未阻塞(Blocked),而是处于“忙等”状态。

原因

通常由不完善的冲突解决策略导致。例如,两个进程反复“礼让”资源,导致谁都无法获取资源。

示例
  • 两人在狭窄走廊相遇,同时向左或向右避让,结果始终面对面无法通过。
  • 两个线程尝试获取两把锁时,若检测到冲突就释放已持有的锁并重试,可能陷入无限循环。
解决方法
  • 引入随机性(例如退让时随机等待一段时间)。
  • 调整资源分配策略,避免对称操作。

3. 饥饿锁(Starvation)

定义

某个进程(或线程)长期无法获取所需资源,导致任务无法执行。饥饿通常由资源分配策略不公平引起(如优先级调度)。

原因
  • 高优先级进程频繁抢占资源。
  • 资源分配策略偏向某些进程(如“先来先服务”导致后续进程被忽略)。
示例
  • 低优先级线程始终无法获得 CPU 时间。
  • 文件系统中,某个进程的 I/O 请求一直被其他进程的请求插队。
解决方法
  • 使用公平的调度算法(如轮转调度、优先级老化)。
  • 避免资源独占,设置超时机制。

三者对比

特性 死锁 活锁 饥饿
进程状态 阻塞(Blocked) 运行/就绪(Active) 可能阻塞或就绪
原因 循环等待资源 冲突解决策略失败 资源分配不公平
解决难度 需要系统干预 调整策略即可 调整调度策略即可
是否主动 被动等待 主动尝试但无效 被动等待

总结

  • 死锁是相互等待的僵局,需打破四个必要条件。
  • 活锁是无效的主动尝试,需优化冲突解决策略。
  • 饥饿是长期资源分配不公平,需引入公平机制。
    在实际系统中,需通过合理设计资源分配策略、调度算法和超时机制来避免这些问题。

死锁例子:

 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
package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu1, mu2 sync.Mutex

	// Goroutine 1
	go func() {
		mu1.Lock()
		fmt.Println("Goroutine 1: Locked mu1")
		mu2.Lock() // 等待 mu2
		fmt.Println("Goroutine 1: Locked mu2")
		mu2.Unlock()
		mu1.Unlock()
	}()

	// Goroutine 2
	go func() {
		mu2.Lock()
		fmt.Println("Goroutine 2: Locked mu2")
		mu1.Lock() // 等待 mu1
		fmt.Println("Goroutine 2: Locked mu1")
		mu1.Unlock()
		mu2.Unlock()
	}()

	// 主 goroutine 等待
	select {}
}

自旋锁

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断地判断是否能够成功获取锁,知道获取到锁才会退出循环。

获取锁的线程一直处于活跃状态,但是没有执行任何有效的任务,使用这种锁会造成 busy-waiting

它是为了实现保护共享资源而提出的一种锁机制。其实,自旋锁和互斥锁比较类似,它们都是 h 为了解决某项资源的互斥使用。

无论互斥锁还是自旋锁,在任何时刻,都最多只能有一个持有者,也就是说,在任何时刻最多只能有一个执行单元获得锁,但是两者在调度机制上略有不同。

对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者休眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋”一词就是因此得名

读写锁

读写锁即针对读写操作的互斥锁。 它与普通的互斥锁最大的不同,就是可以分别针对读操作和写操作进行锁定和解锁操作。读写锁遵循的访问控制规则有所不同。读写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也都是互斥的。

但是,多个读操作之间却不存在互斥关系。在这样的互斥策略之下,读写锁可以在大大降低因使用锁造成的性能损耗的情况下,完成对共享资源的访问控制。

https://blog.meowrain.cn/api/i/2025/01/27/taVmOu1737992189625533294.avif

image-20250127233645242


相关内容

0%