Golang并发(3)
Golang 并发 (三)
基本同步原语
Go 语言在 sync 包中提供了用于同步的一些基本原语,包括常见的互斥锁 Mutex 和读写互斥锁 RWMutex 以及 Once,WaitGroup.这些基本原语的主要作用是提供较为基础的同步功能
Mutex
Mutex 是 golang 标准库的互斥锁,主要用来处理并发场景下共享资源的访问冲突问题。
Mutex
互斥锁在 sync
包中,它由两个字段 state
和 sema
组成,state
表示当前互斥锁的状态,而 sema
真正用于控制锁状态的信号量,这两个加起来只占 8 个字节空间的结构体就表示了 Go 语言中的互斥锁。
|
|
互斥锁的作用,就是同步访问共享资源。互斥锁这个名字来自互斥(mutual exclusion)的概念,互斥锁用于在代码上创建一个临界区,保证同一个时间只有一个 goroutine 可以执行这个临界区代码。
不使用互斥锁:
|
|
使用互斥锁
|
|
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)
定义
多个进程(或线程)因竞争资源而陷入互相等待的状态,每个进程都无法继续执行,除非对方释放资源。此时系统处于僵持状态,无法自动恢复。
必要条件
死锁的发生必须满足以下四个条件(缺一不可):
- 互斥:资源一次只能被一个进程占用。
- 持有并等待:进程已持有资源,同时又在等待其他资源。
- 不可抢占:资源只能由持有它的进程主动释放,不可被强制抢占。
- 循环等待:存在一个进程等待链,每个进程都在等待下一个进程持有的资源。
示例
- 线程 A 持有锁 L1,等待锁 L2;
- 线程 B 持有锁 L2,等待锁 L1。
此时,两个线程都无法继续执行,形成死锁。
解决方法
- 破坏必要条件(如资源按顺序申请,避免循环等待)。
- 死锁检测与恢复(强制终止进程或回滚操作)。
- 超时机制(等待超过时间后主动释放资源)。
2. 活锁(Livelock)
定义
进程(或线程)在尝试解决资源冲突时,持续改变自身状态,但始终无法取得进展。活锁的特点是进程并未阻塞(Blocked),而是处于“忙等”状态。
原因
通常由不完善的冲突解决策略导致。例如,两个进程反复“礼让”资源,导致谁都无法获取资源。
示例
- 两人在狭窄走廊相遇,同时向左或向右避让,结果始终面对面无法通过。
- 两个线程尝试获取两把锁时,若检测到冲突就释放已持有的锁并重试,可能陷入无限循环。
解决方法
- 引入随机性(例如退让时随机等待一段时间)。
- 调整资源分配策略,避免对称操作。
3. 饥饿锁(Starvation)
定义
某个进程(或线程)长期无法获取所需资源,导致任务无法执行。饥饿通常由资源分配策略不公平引起(如优先级调度)。
原因
- 高优先级进程频繁抢占资源。
- 资源分配策略偏向某些进程(如“先来先服务”导致后续进程被忽略)。
示例
- 低优先级线程始终无法获得 CPU 时间。
- 文件系统中,某个进程的 I/O 请求一直被其他进程的请求插队。
解决方法
- 使用公平的调度算法(如轮转调度、优先级老化)。
- 避免资源独占,设置超时机制。
三者对比
特性 | 死锁 | 活锁 | 饥饿 |
---|---|---|---|
进程状态 | 阻塞(Blocked) | 运行/就绪(Active) | 可能阻塞或就绪 |
原因 | 循环等待资源 | 冲突解决策略失败 | 资源分配不公平 |
解决难度 | 需要系统干预 | 调整策略即可 | 调整调度策略即可 |
是否主动 | 被动等待 | 主动尝试但无效 | 被动等待 |
总结
- 死锁是相互等待的僵局,需打破四个必要条件。
- 活锁是无效的主动尝试,需优化冲突解决策略。
- 饥饿是长期资源分配不公平,需引入公平机制。
在实际系统中,需通过合理设计资源分配策略、调度算法和超时机制来避免这些问题。
死锁例子:
|
|
自旋锁
自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断地判断是否能够成功获取锁,知道获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是没有执行任何有效的任务,使用这种锁会造成 busy-waiting
它是为了实现保护共享资源而提出的一种锁机制。其实,自旋锁和互斥锁比较类似,它们都是 h 为了解决某项资源的互斥使用。
无论互斥锁还是自旋锁,在任何时刻,都最多只能有一个持有者,也就是说,在任何时刻最多只能有一个执行单元获得锁,但是两者在调度机制上略有不同。
对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者休眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋”一词就是因此得名
读写锁
读写锁即针对读写操作的互斥锁。 它与普通的互斥锁最大的不同,就是可以分别针对读操作和写操作进行锁定和解锁操作。读写锁遵循的访问控制规则有所不同。读写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也都是互斥的。
但是,多个读操作之间却不存在互斥关系。在这样的互斥策略之下,读写锁可以在大大降低因使用锁造成的性能损耗的情况下,完成对共享资源的访问控制。