一、锁的方案简介
锁是并发编程中的同步原语,他可以保证多线程在访问同一片内存时不会出现竞争来保证并发安全。对于获取锁,一般来讲有两种方案,一种是不断地自旋+CAS,另一种就是阻塞+唤醒。两种方式各有优劣。Go语言结合了这两种方案,自动的判断当前锁的竞争情况,先尝试自旋几次,如果锁一直没被释放,再加入阻塞队列。
锁竞争方案 | 优势 | 劣势 | 适用场景 |
阻塞/唤醒 | 精准打击,不浪费 CPU 时间片 | 需要挂起协程,进行上下文切换,操作较重 | 并发竞争激烈的场景 |
自旋+CAS | 无需阻塞协程,短期来看操作较轻 | 长时间争而不得,会浪费 CPU 时间片 | 并发竞争强度低的场景 |
此外,Go语言的锁还支持正常模式和饥饿模式,因此整体的实现有一定的复杂性。下面我们依据自旋锁的原理来实现一版简单的互斥锁,以便对锁的实现有一个初步的理解。
二、自旋锁实现(Golang版本)
从自旋锁的定义可知,要想实现自旋锁初步需要关注四个地方:加锁、解锁、锁的状态值、循环尝试获取锁。主干流程:
-
通过锁内一个状态值标识锁的状态,例如,取 0 表示未加锁,1 表示已加锁;
-
上锁:把 0 改为 1;
-
解锁:把 1 置为 0.
-
上锁时,假若已经是 1,则上锁失败,需要等他人解锁,将状态改为 0.
代码实现
package mutex
import (
"runtime"
"sync/atomic"
)
type Mutex struct {
state int32
}
const (
mutexLocked = 1
)
// 自旋条件如下:GOMAXPROCS>1,否则会死锁
func canSpin() bool {
if runtime.GOMAXPROCS(0) <= 1 {
return false
}
return true
}
//让协程忙等待
func doSpin() {
for i, j := 0, 0; i < 30; i++ {
j = j + 2
}
return
}
func (m *Mutex) Lock() {
//通过原子操作加锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
for ;m.state&mutexLocked == mutexLocked && canSpin(); { // 满足自旋条件
doSpin()
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
}
panic("cant lock with one core cpu!")
}
func (m *Mutex) Unlock() {
//通过原子操作解锁
new := atomic.AddInt32(&m.state, -mutexLocked)
if new == 0 {
return
}
//解锁时倘若发现 Mutex 此前未加锁,直接panic
if (new+mutexLocked)&mutexLocked == 0 {
panic("unlock of unlocked mutex")
}
}
三、运行测试
此处我们通过一个经典的例子来对锁的性能进行验证:
package main
import (
"fmt"
"mySpinMutex/mutex"
"sync"
)
var count int
var mu mutex.Mutex
var wg sync.WaitGroup
func increment() {
mu.Lock()
count++
mu.Unlock()
wg.Done()
}
func main() {
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment()
}
wg.Wait()
fmt.Println("Final count:", count)
}
运行结果:
四、结语
到此自旋锁就的功能便实现完成了,但还有些点值得我们去思考:
1、在 Go 1.18 中,为 sync.Mutex 新增了一个新的方法 TryLock(),它是一种非阻塞模式的取锁操作。当调用 TryLock() 时,该函数仅简单地返回 true 或者 false,代表是否加锁成功。它的应用场景并不常见,并且也不被鼓励使用。那么使用TryLock()会产生什么并发的问题呢?
2、当协程A 自旋锁获取成功后,协程B尝试获取锁,但在协程A持有锁的阶段,协程B一直失败,此时协程B很可能会陷入饥饿状态,如何避免此种情况发生?
3、对于Go来说有内置的协程调度器GMP,对于协程B来说,如果协程B为了获取锁一直自旋,那协程B的本地队列里的其他协程就无法执行。如何在使用锁的同时不影响协程的调度效率呢?