一、同步原语 - Sync
这些基本原语提高了较为基础的同步功能,但是它们是一种相对原始的同步机制,在多数情况下,我们都应该使用抽象层级的更高的 Channel 实现同步。
1-1 并发状态下的资源冲突
由于引用传递,在并发状态下的数据资源获取无序,导致最终结果重复或者错误。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var a = 0
for i := 0; i < 10; i++ {
go func(idx int) {
a += 1
fmt.Printf("goroutine %d, a=%d\n", idx, a)
}(i)
}
// 等待 1s 结束主程序 确保所有协程执行完
time.Sleep(time.Second)
}
/*
goroutine 2, a=4
goroutine 0, a=1
goroutine 4, a=2
goroutine 3, a=10
goroutine 5, a=8
goroutine 8, a=5
goroutine 6, a=9
goroutine 1, a=3
goroutine 7, a=7
goroutine 9, a=7
*/
二、 sync.Mutex - 互斥锁
一份互斥锁对一个资源加锁,只能同时被一个 goroutine 锁定,其他 goroutine 阻塞等待资源释放。
注意:对一个未锁定的互斥锁解锁,会抛错;首次使用后不能复制复制该互斥锁
2-1 锁结构
type Mutex struct {
state int32
sema uint32
}
- state:互斥锁的当前状态
mutexLocked
— 表示互斥锁的锁定状态;mutexWoken
— 表示从正常模式被从唤醒;mutexStarving
— 当前的互斥锁进入饥饿状态;waitersCount
— 当前互斥锁上等待的 Goroutine 个数;
- sema:控制锁状态
- 正常模式 - 锁的等待者会按照先进先出的顺序获取资源。
- 刚被唤起的 goroutine 与新建的 goroutine 竞争资源的时,大概率竞争失败而无法获取锁资源,所以若 goroutine 超过 1ms 没有获取到锁,互斥锁会自动被切换成饥饿模式,防止 goroutine 没有资源。
- 饥饿模式 - 互斥锁会直接将资源交给等待队列最前的 goroutine,新创建的 goroutine 会被至于等待最尾端。目的是为了确保互斥锁的公平性。
- 若一个 goroutine 获得了互斥锁,并且它在队列尾端,或者等待的时间少于 1ms ,则互斥锁会自动切换成正常模式
- 饥饿模式可以有效的避免 goroutine 陷入由于等待无法获取锁的高危延时。
- 正常模式 - 锁的等待者会按照先进先出的顺序获取资源。
2-2 应用举例
2-2-1 资源的有序化
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var a = 0
var lock sync.Mutex
for i := 0; i < 10; i++ {
go func(idx int) {
lock.Lock()
defer lock.Unlock()
a += 1
fmt.Printf("goroutine %d, a=%d\n", idx, a)
}(i)
}
// 等待 1s 结束主程序
// 确保所有协程执行完
time.Sleep(time.Second)
}
/*
goroutine 0, a=1
goroutine 7, a=2
goroutine 5, a=3
goroutine 1, a=4
goroutine 2, a=5
goroutine 3, a=6
goroutine 4, a=7
goroutine 8, a=8
goroutine 9, a=9
goroutine 6, a=10
*/
2-2-2 资源的占有
package main
import (
"fmt"
"sync"
"time"
)
func main() {
ch := make(chan struct{
}, 2)
var l sync.Mutex
go func() {
l.Lock()
defer l.Unlock()
fmt.Println("goroutine1: 锁定 2s")
time.Sleep(time.Second * 2)
fmt.Println("goroutine1: 解锁资源")
ch <- struct{
}{
}
}()
go func() {
fmt.Println("goroutine2: 等待解锁")
l.Lock()
defer l.Unlock()
fmt.Println("goroutine2: 获取资源,锁定资源")
ch <- struct{
}{
}
}()
// 等待 goroutine 执行结束
for i := 0; i < 2; i++ {
<-ch
}
}
/*
goroutine2: 等待解锁
goroutine1: 锁定 2s
goroutine1: 解锁资源
goroutine2: 获取资源,锁定资源
*/
2-3 互斥锁总结
加锁过程
- 如果互斥锁处于初始化状态,就会直接通过置位
mutexLocked
加锁; - 如果互斥锁处于
mutexLocked
并且在普通模式下工作,就会进入自旋,执行 30 次PAUSE
指令消耗 CPU 时间等待锁的释放; - 如果当前 Goroutine 等待锁的时间超过了 1ms,互斥锁就会切换到饥饿模式;
- 互斥锁在正常情况下会通过 sync.runtime_SemacquireMutex 函数将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒当前 Goroutine;
- 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,当前 Goroutine 会将互斥锁切换回正常模式
解锁过程
- 当互斥锁已经被解锁时,那么调用 sync.Mutex.Unlock 会直接抛出异常;
- 当互斥锁处于饥饿模式时,会直接将锁的所有权交给队列中的下一个等待者,等待者会负责设置
mutexLocked
标志位; - 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,就会直接返回;在其他情况下会通过 sync.runtime_Semrelease 唤醒对应的 Goroutine;
三、sync.RWMutex - 读写互斥锁
读写互斥锁是细粒度的互斥锁,不限制资源的并发读,但是可以对 读、写 操作进行锁定。
通常在大量读操作,少量写操作的业务场景下提高服务的性能。进行读写资源的操作分离,提高服务的性能。
3-1 锁结构
type RWMutex struct</