文章目录
锁
原子操作
原子操作就是不可中断的操作,外界是看不到原子操作的中间状态,要么看到原子操作已经完成,要么看到原子操作已经结束。在某个值的原子操作执行的过程中,CPU 绝对不会再去执行其他针对该值的操作,那么其他操作也是原子操作。
Go 提供 sync/atomic
包来实现 无锁的原子操作,适用于高性能、轻量级的并发控制。
import "sync/atomic"
// 增加操作
atomic.AddInt32(&num, 1)
// 原子读取
atomic.LoadInt32(&num)
// 原子写入
atomic.StoreInt32(&num, 10)
// 原子比较并交换
atomic.CompareAndSwapInt32(&num, oldVal, newVal)
如果不适用原子操作,可能造成竞态条件。这是因为i++不是原子操作,分为new_i = i + 1和i= new_i两步组成,在不同的协程中执行速度可能不一样,导致两个协程读到了同样的i,产生了覆盖加操作,导致最终结果偏小。如下
var counter int32 = 0
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
atomic.AddInt32(&counter, 1)
// count ++ //输出 990
wg.Done()
}()
}
wg.Wait()
fmt.Println("计数器最终值:", counter) // 1000
}
CAS
CAS(Compare-And-Swap) 是一种 无锁(Lock-Free) 的原子操作,广泛用于并发编程中实现线程安全的共享资源操作。它通过硬件指令(如 x86 的 CMPXCHG
)保证操作的原子性,避免了传统锁(如互斥锁)带来的上下文切换和阻塞开销。
操作语义:
func CompareAndSwap(ptr *T, old T, new T) bool {
if *ptr == old {
*ptr = new
return true
}
return false
}
- 检查内存地址
ptr
的当前值是否等于old
:- 若相等,将
ptr
的值设置为new
,并返回true
; - 否则,不修改内存,返回
false
。
- 若相等,将
- 原子性:整个操作由 CPU 指令直接保证,中间不会被其他线程打断。
应用场景
原子计数器
var counter int32 = 0 func increment() { for { old := atomic.LoadInt32(&counter) new := old + 1 if atomic.CompareAndSwapInt32(&counter, old, new) { break } } }
- 通过循环重试(乐观锁)实现无锁的计数器递增。
乐观锁:CAS + 自选锁
- 数据库事务、版本控制等场景中,通过 CAS 检查版本号避免冲突。
优点和缺点:
无死锁风险,避免线程阻塞和上下文切换,适合低竞争场景(如少量并发写)
但自循环时间长,开销大。只能保证一个共享变量的原子操作。存在ABA问题
ABA问题
现象:某线程读取内存值为 A
,其他线程将值改为 B
后又改回 A
,导致 CAS 误判未发生修改。
解决方案:
版本号/标记:每次修改操作递增一个版本号或附加标记,CAS 同时检查值和版本号。
type VersionedValue struct { value int version uint64 } var v atomic.Value // 存储 VersionedValue func update(newValue int) { for { old := v.Load().(VersionedValue) if old.value != newValue { new := VersionedValue{value: newValue, version: old.version + 1} if atomic.CompareAndSwapPointer(&v, old, new) { break } } } }
互斥锁
互斥锁是一种最基本的锁,它的作用是保证在同一时刻只有一个 goroutine 可以访问共享资源。在 Go 语言中,互斥锁由 sync
包提供,使用 syns.Mutex
结构体来表示。
Go语言的
sync.Mutex
是悲观锁的实现。悲观锁:当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量
import "sync"
var mu sync.Mutex
func criticalSection() {
mu.Lock() // 加锁
// 临界区
mu.Unlock() // 解锁
}
适合保护共享资源的并发读写,比如 map、全局变量等。
sync.Mutex
是一种互斥锁(mutual exclusion)。内部使用了 CAS(Compare-And-Swap) + 自旋锁 + 阻塞队列 组合实现。
Lock 时如果锁已被占用,则当前 Goroutine 会阻塞。
使用的是 Go runtime 提供的调度器来挂起和唤醒 Goroutine,避免用户层的 busy-wait。
底层实现
type Mutex struct {
state int32 // 锁状态:0表示未锁定,1表示已锁定
sema uint32 // 信号量,用于协调 Goroutine 的阻塞和唤醒
}
内部状态变化大致流程:
- 调用
Lock()
时,CAS 尝试将state
从 0 改为 1。 - 如果失败,说明已被锁,会进入队列排队(自旋几次后挂起)。
- 解锁时调用
Unlock()
,会将state
设置为 0,并唤醒排队的协程。
同一个 Goroutine 不可以重复 Lock 而不 Unlock,否则会死锁。也就是golang的锁是不可重入的
Mutex 的几种模式
🟩 正常模式(Normal mode)
- 默认工作模式。
- 加锁请求是FIFO公平队列。
- 如果锁被频繁释放又被新的 goroutine 抢占,可能导致等待队列中的老 goroutine 饿死。
- 性能高,但公平性差。
🟨 饥饿模式(Starvation mode)
- 当某个 goroutine 等待锁超过 1ms(在老版本中),系统会把 Mutex 转为饥饿模式。
- 饥饿模式下,锁会严格交给队头的 goroutine,新来的都得排队。
- 保证公平性,防止 goroutine 被饿死,但性能略低。
- 只要系统发现锁竞争不激烈(后面没人排队了),就会回退到正常模式
Go 的 Mutex 默认是非公平锁(谁先抢到谁执行),这样可以提高吞吐。但为了防止“饿死”,当锁被频繁抢占时会让等待者优先执行(抢占调度也会帮助)。
1)正常模式
- 当前的mutex只有一个goruntine来获取,那么没有竞争,直接返回。
- 新的goruntine进来,如果当前mutex已经被获取了,则该goruntine进入一个先入先出的waiter队列,在mutex被释放后,waiter按照先进先出的方式获取锁。该goruntine会处于自旋状态(不挂起,继续占有cpu)。
- 新的goruntine进来,mutex处于空闲状态,将参与竞争。新来的 goroutine 有先天的优势,它们正在 CPU 中运行,可能它们的数量还不少,所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁,这时,它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒,那么,这个 Mutex 就进入到了饥饿模式。
2)饥饿模式
在饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin(自旋),它会乖乖地加入到等待队列的尾部。 如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:
- 此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
- 此 waiter 的等待时间小于 1 毫秒。
特点
互斥性:在任何时刻,只有一个 goroutine 可以持有sync.Mutex
的锁。如果多个 goroutine 尝试同时获取锁,那么除了第一个成功获取锁的 goroutine 之外,其他 goroutine 将被阻塞,直到锁被释放。
非重入性:如果一个 goroutine 已经持有了 sync.Mutex
的锁,那么它不能再次请求这个锁,这会导致死锁(抛出panic)。
读写锁
后端 - go 读写锁实现原理解读 - 个人文章 - SegmentFault 思否
允许多个读操作同时进行,但写操作会完全互斥。这意味着在任何时刻,可以有多个 goroutine 同时读取某个资源,但写入资源时,必须保证没有其他 goroutine 在读取或写入该资源。
适用于读多写少的场景,可以显著提高程序的并发性能。例如,在缓存系统、配置管理系统等场景中,读操作远多于写操作,使用sync.RWMutex
可以在保证数据一致性的同时,提高读操作的并发性。使用方法与普通的锁基本相同,唯一的区别在于读操作的加锁、释放锁用的是RLock
方法和RUnlock
方法
底层原理
在看源码之前我们不妨先思考一下,如果自己实现,需要怎么设计这个数据结构来满足上面那三个要求,然后再参看源码会有更多理解。
首先,为了满足第二点和第三点要求,肯定需要一个互斥锁:
type RWMutex struct{
w Mutex // held if there are pending writers
...
}
这个互斥锁是在写操作时使用的:
func (rw *RWMutex) Lock(){
...
rw.w.Lock()
...
}
func (rw *RWMutex) Unlock(){
...
rw.w.Unlock()
...
}
而读操作之间是不互斥的,因此读操作的RLock()过程并不获取这个互斥锁。但读写之间是互斥的,那么RLock()如果不获取互斥锁又怎么能阻塞住写操作呢?go语言的实现是这样的:
通过一个int32变量记录当前正在读的goroutine数:
type RWMutex struct{
w Mutex // held if there are pending writers
readerCount int32 // number of pending readers
...
}
每次调用Rlock方法时将readerCount加1,对应地,每次调用RUnlock方法时将readerCount减1:
func (rw *RWMutex) RLock() {
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// 如果readerCount小于0则通过同步原语阻塞住,否则将readerCount加1后即返回
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}
func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// 如果readerCount减1后小于0,则调用rUnlockSlow方法,将这个方法剥离出来是为了RUnlock可以内联,这样能进一步提升读操作时的取锁性能
rw.rUnlockSlow(r)
}
}
既然每次RLock时都会将readerCount增加,那判断它是否小于0有什么意义呢?这就需要和写操作的取锁过程Lock()参看:
// 总结一下Lock的流程:1. 阻塞新来的写操作;2. 阻塞新来的读操作;3. 等待之前的读操作完成;
func (rw *RWMutex) Lock() {
// 通过rw.w.Lock阻塞其它写操作
rw.w.Lock()
// 将readerCount减去一个最大数(2的30次方,RWMutex能支持的最大同时读操作数),这样readerCount将变成一个小于0的很小的数,
// 后续再调RLock方法时将会因为readerCount<0而阻塞住,这样也就阻塞住了新来的读请求
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 等待之前的读操作完成
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}
写操作获取锁时通过将readerCount改成一个很小的数保证新来的读操作会因为readerCount<0而阻塞住;那之前未完成的读操作怎么处理呢?很简单,只要跟踪写操作Lock之前未完成的reader数就行了,这里通过一个int32变量readerWait来做这件事情:
type RWMutex struct{
w Mutex // held if there are pending writers
readerCount int32 // number of pending readers
readerWait int32 // number of departing readers
...
}
每次写操作Lock时会将当前readerCount数量记在readerWait里。
回想一下,当写操作Lock后readerCount会小于0,这时reader unlock时会执行rUnlockSlow方法,现在可以来看它的实现过程了:
func (rw *RWMutex) rUnlockSlow(r int32) {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
throw("sync: RUnlock of unlocked RWMutex")
}
// 每个reader完成读操作后将readerWait减小1
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// 当readerWait为0时代表writer等待的所有reader都已经完成了,可以唤醒writer了
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
最后再看写操作的释放锁过程:
func (rw *RWMutex) Unlock() {
// 将readerCount置回原来的值,这样reader又可以进入了
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
throw("sync: Unlock of unlocked RWMutex")
}
// 唤醒那些等待的reader
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
// 释放互斥锁,这样新的writer可以获得锁
rw.w.Unlock()
}
将上面这些过程梳理一下:
- 如果没有writer请求进来,则每个reader开始后只是将readerCount增1,完成后将readerCount减1,整个过程不阻塞,这样就做到“并发读操作之间不互斥”;
- 当有writer请求进来时首先通过互斥锁阻塞住新来的writer,做到“并发写操作之间互斥”;
- 然后将readerCount改成一个很小的值,从而阻塞住新来的reader;
- 记录writer进来之前未完成的reader数量,等待它们都完成后再唤醒writer;这样就做到了“并发读操作和写操作互斥”;
- writer结束后将readerCount置回原来的值,保证新的reader不会被阻塞,然后唤醒之前等待的reader,再将互斥锁释放,使后续writer不会被阻塞。
这就是go语言中读写锁的核心源码(简洁起见,这里将竞态部分的代码省略,TODO:竞态分析原理分析),相信看到这你已经对读写锁的实现原理了然于胸了,如果你感兴趣,不妨一起继续思考这几个问题。
思考
writer lock时在判断是否有未完成的reader时为什么使用
r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0
回想一下Lock方法:
func (rw *RWMutex) Lock() {
rw.w.Lock()
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}
为了判断是否还有未完成的reader,直接判断 r!= 0
不就行了吗,为什么还需要判断atomic.AddInt32(&rw.readerWait, r)!=0
?
这是因为上面第三行和第四行的代码并不是原子的,这就意味着中间很有可能插进其它goroutine执行,假如某个时刻执行完第三行代码,r=1,也就是此时还有一个reader,但执行第四行之前先执行了该reader的goroutine,并且reader完成RUnlock操作,此时如果只判断r!=0
就会错误地阻塞住,因为这时候已经没有未完成的reader了。而reader在执行RUnlock的时候会将readerWait减1,所以readerWait+r
就代表未完成的reader数。
那么只判断atomic.AddInt32(&rw.readerWait, r)!=0
不就可以吗?理论上应该是可以的,先判断r!=0
应该是一种短路操作:如果r==0
那就不用执行atomic.AddInt32
了(注意r==0时readerWait也等于0)。
Benchmark
最后让我们通过Benchmark看看读写锁的性能提升有多少:
func Read() {
loc.Lock()
defer loc.Unlock()
_, _ = fmt.Fprint(ioutil.Discard, idx)
time.Sleep(1000 * time.Nanosecond)
}
func ReadRW() {
rwLoc.RLock()
defer rwLoc.RUnlock()
_, _ = fmt.Fprint(ioutil.Discard, idx)
time.Sleep(1000 * time.Nanosecond)
}
func Write() {
loc.Lock()
defer loc.Unlock()
idx = 3
time.Sleep(1000 * time.Nanosecond)
}
func WriteRW() {
rwLoc.Lock()
defer rwLoc.Unlock()
idx = 3
time.Sleep(1000 * time.Nanosecond)
}
func BenchmarkLock(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
foo := 0
for pb.Next() {
foo++
if foo % writeRatio == 0 {
Write()
} else {
Read()
}
}
})
}
func BenchmarkRWLock(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
foo := 0
for pb.Next() {
foo++
if foo % writeRatio == 0 {
WriteRW()
} else {
ReadRW()
}
}
})
}
这里使用了go语言内置的Benchmark功能,执行go test -bench='Benchmark.*Lock' -run=none mutex_test.go
即可触发benchmark运行,-run=none
是为了跳过单测。
结果如下:
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkLock
BenchmarkLock-12 235062 5025 ns/op
BenchmarkRWLock
BenchmarkRWLock-12 320209 3834 ns/op
可以看出使用读写锁后耗时降低了24%左右。
上面writeRatio用于控制读、写的频率比例,即读:写=3,随着这个比例越高耗时降低的比例也越大,这里作个简单比较:
writeRatio | 3 | 10 | 20 | 50 | 100 | 1000 |
---|---|---|---|---|---|---|
耗时降低 | 24% | 71.3% | 83.7% | 90.9% | 93.5% | 95.7% |
可以看出当读的比例越高时,使用读写锁获得的性能提升比例越高。
自旋锁
自旋锁是指当一个线程(在 Go 中是 Goroutine)在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待(自旋),不断判断锁是否已经被释放,而不是进入睡眠状态。
核心设计目标
- 低延迟获取锁:在低竞争场景下快速通过 CAS 获取锁(无系统调用开销)。
- 高竞争适应性:通过指数退避减少 CPU 空转消耗。
- 公平性平衡:通过
runtime.Gosched()
让出 CPU 时间片,防止 Goroutine 饥饿。
type spinLock uint32
const maxBackoff = 16 // 最大退避所对应的时间
func (sl *spinLock) Lock() {
backoff := 1
for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
for i := 0; i < backoff; i++ {
runtime.Gosched() // 主动让出时间
}
if backoff < maxBackoff {
backoff <<= 1 // 指数退避
}
}
}
func (sl *spinLock) Unlock() {
atomic.StoreUint32((*uint32)(sl), 0)
}
Spinlock
结构体有一个int32
类型的字段locked
,用于表示锁的状态。Lock
方法使用atomic.CompareAndSwapInt32
原子操作来尝试将locked
从0(未锁定)更改为1(已锁定)。如果锁已经被另一个goroutine持有(即locked
为1),则CompareAndSwapInt32
会返回false
,并且循环会继续。Unlock
方法使用atomic.StoreInt32
原子操作将locked
设置回0,表示锁已被释放。
此自旋锁设计通过 CAS 原子操作、指数退避和协作式调度 的三角优化,在 低/中竞争场景 下实现了比标准库锁更低的延迟,同时避免传统自旋锁的 CPU 资源浪费问题。其核心思想是:用短暂的空转换取无系统调用的速度优势,用退避算法平衡竞争强度。
下面的例子是使用*testing.PB 中的 RunParallel() 模拟高并发场景。多个 Goroutine 同时争抢锁,评估锁在高竞争下的性能。
pb.Next()
确保每个 Goroutine 执行足够的次数。三种方式,Mutex互斥锁方案,SpinMutex线性/指数退避方案。
使用
go test -bench .
来运行所有测试案例得出对应的时间
type originSpinLock uint32
func (sl *originSpinLock) Lock() {
for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
runtime.Gosched()
}
}
func (sl *originSpinLock) Unlock() {
atomic.StoreUint32((*uint32)(sl), 0)
}
func NewOriginSpinLock() sync.Locker {
return new(originSpinLock)
}
func BenchmarkMutex(b *testing.B) {
m := sync.Mutex{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Lock()
//nolint:staticcheck
m.Unlock()
}
})
}
func BenchmarkSpinLock(b *testing.B) {
spin := NewOriginSpinLock()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
spin.Lock()
//nolint:staticcheck
spin.Unlock()
}
})
}
func BenchmarkBackOffSpinLock(b *testing.B) {
spin := NewSpinLock()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
spin.Lock()
//nolint:staticcheck
spin.Unlock()
}
})
}
/*
Benchmark result for three types of locks:
goos: darwin
goarch: arm64
pkg: github.com/panjf2000/ants/v2/pkg/sync
BenchmarkMutex-10 10452573 111.1 ns/op 0 B/op 0 allocs/op
BenchmarkSpinLock-10 58953211 18.01 ns/op 0 B/op 0 allocs/op
BenchmarkBackOffSpinLock-10 100000000 10.81 ns/op 0 B/op 0 allocs/op
*/
goroutine 的自旋占用资源如何解决
Goroutine 的自旋占用资源问题主要涉及到 Goroutine 在等待锁或其他资源时的一种行为模式,即自旋锁(spinlock)。自旋锁是指当一个线程(在 Go 中是 Goroutine)在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待(自旋),不断判断锁是否已经被释放,而不是进入睡眠状态。这种行为在某些情况下可能会导致资源的过度占用,特别是当锁持有时间较长或者自旋的 Goroutine 数量较多时。
针对 Goroutine 的自旋占用资源问题,可以从以下几个方面进行解决或优化:
- 减少自旋锁的使用
评估必要性:首先评估是否真的需要使用自旋锁。在许多情况下,互斥锁(mutex)已经足够满足需求,因为互斥锁在资源被占用时会让调用者进入睡眠状态,从而减少对 CPU 的占用。
优化锁的设计:考虑使用更高级的同步机制,如读写锁(rwmutex),它允许多个读操作同时进行,而写操作则是互斥的。这可以显著减少锁的竞争,从而降低自旋的需求。 - 优化自旋锁的实现
设置自旋次数限制:在自旋锁的实现中加入自旋次数的限制,当自旋达到一定次数后,如果仍未获取到锁,则让 Goroutine 进入睡眠状态。这样可以避免长时间的无效自旋,浪费 CPU 资源。
利用 Go 的调度器特性:Go 的调度器在检测到 Goroutine 长时间占用 CPU 而没有进展时,会主动进行抢占式调度,将 Goroutine 暂停并让出 CPU。这可以在一定程度上缓解自旋锁带来的资源占用问题。 - 监控和调整系统资源
监控系统性能:通过工具(如 pprof、statsviz 等)监控 Go 程序的运行时性能,包括 CPU 使用率、内存占用等指标。这有助于及时发现和解决资源占用过高的问题。
调整 Goroutine 数量:根据系统的负载情况动态调整 Goroutine 的数量。例如,在高并发场景下适当增加 Goroutine 的数量以提高处理能力,但在负载降低时及时减少 Goroutine 的数量以避免资源浪费。 - 利用 Go 的并发特性
充分利用多核 CPU:通过设置 runtime.GOMAXPROCS 来指定 Go 运行时使用的逻辑处理器数量,使其尽可能接近或等于物理 CPU 核心数,从而充分利用多核 CPU 的并行处理能力。
使用 Channel 进行通信:Go 鼓励使用 Channel 进行 Goroutine 之间的通信和同步,而不是直接使用锁。Channel 可以有效地避免死锁和竞态条件,并且减少了锁的使用,从而降低了资源占用的风险。
综上所述,解决 Goroutine 的自旋占用资源问题需要从多个方面入手,包括减少自旋锁的使用、优化自旋锁的实现、监控和调整系统资源以及充分利用 Go 的并发特性等。通过这些措施的综合应用,可以有效地降低 Goroutine 在自旋过程中对系统资源的占用。
sync
cond
在并发编程中,sync.Cond
(条件变量)是一种用于 协调多个 Goroutine 间等待和通知的机制。在 ants
协程池库中,条件变量被用来高效管理 Worker 的 休眠与唤醒,避免忙等待(Busy Waiting)造成的 CPU 资源浪费。
核心作用
1. 解决的问题
- 场景:当多个 Goroutine 需要等待某个条件(如任务到达、资源可用)时,若使用忙等待(循环检查条件),会导致 CPU 空转。
- 条件变量的优势:
- 在条件不满足时,Goroutine 主动休眠,释放 CPU。
- 条件满足时,精确唤醒 等待的 Goroutine,减少无效唤醒。
2. 核心方法
方法 | 作用 |
---|---|
Wait() | 释放锁并阻塞,直到被 Signal 或 Broadcast 唤醒(唤醒后重新获取锁) |
Signal() | 唤醒一个等待的 Goroutine(随机选择) |
Broadcast() | 唤醒所有等待的 Goroutine |
底层原理
type Cond struct {
noCopy noCopy
L Locker // L is held while observing or changing the condition
notify notifyList // 指针记录关注的地址
checker copyChecker // 记录条件变量是否被复制
}
1. 依赖关系
- 必须与锁(
sync.Mutex
或sync.RWMutex
)结合使用:
条件变量的操作需要在锁的保护下进行,确保对共享状态的原子访问。
2. 内部实现
- 等待队列:维护一个 FIFO 队列,记录所有调用
Wait()
的 Goroutine。 - 操作系统级阻塞:
Wait()
内部通过sync.runtime_notifyListWait
进入阻塞状态,由调度器管理。
在 ants
中的应用
在 ants
库中,条件变量主要用于 Worker 的休眠与唤醒,以下是关键代码片段和解析:
1. 创建一个Worker
当协程池提交任务时,Submit()
被调用,触发retrieveWorker()
唤醒或创建一个worker执行任务。
// 尝试获取一个可用 worker 来运行任务。如果没有空闲 worker,则可能会:新建一个 worker|阻塞等待一个释放的 worker|报错退出(超出上限或设置为 non-blocking)
func (p *poolCommon) retrieveWorker() (w worker, err error) {
p.lock.Lock() // 线程安全
retry:
// 尝试从 worker 队列中获取可用 worker
if w = p.workers.detach(); w != nil {
p.lock.Unlock()
return
}
// 如果没有,判断是否可以新建 worker。如果还没达到 pool 容量上限,就从缓存拿一个新的 worker 并启动。
if capacity := p.Cap(); capacity == -1 || capacity > p.Running() {
p.lock.Unlock()
w = p.workerCache.Get().(worker)
w.run()
return
}
// 如果是 non-blocking 模式,或排队线程数已达上限,则直接返回错误
if p.options.Nonblocking || (p.options.MaxBlockingTasks != 0 && p.Waiting() >= p.options.MaxBlockingTasks) {
p.lock.Unlock()
return nil, ErrPoolOverload
}
// 使用条件变量阻塞当前 goroutine,等待有 worker 被释放,注意,后续会回到起始的retry处重新分配worker
p.addWaiting(1)
p.cond.Wait() // block and wait for an available worker
p.addWaiting(-1)
if p.IsClosed() {
p.lock.Unlock()
return nil, ErrPoolClosed
}
goto retry
}
条件变量阻塞直到有其他 worker 被放回池子,会通过 p.cond.Signal()
进行唤醒。
注意:
p.cond
是在获取了p.lock
之后调用的Wait()
,这是条件变量的经典用法:条件变量必须和互斥锁配合使用,防止竞态条件。
Wait()
过程中需要 自动释放锁,挂起等待,恢复后重新加锁,这样才能保证在检查条件、挂起等待、被唤醒这整个流程中是安全的,避免竞态条件。条件变量配合互斥锁使用,是为了确保“检查条件 + 挂起等待”这一步是原子操作,避免竞态和虚假唤醒,确保被唤醒后的逻辑是正确的。
例子:没有锁,会有竞态
if pool.length == 0 { cond.Wait() // ⛔ 可能刚好别人 push 进来了,但你还在等 }
- 上面的
if
判断和Wait()
之间不是原子操作!- 如果判断完之后,还没进入
Wait()
,恰好有另一个 goroutine 添加了元素并Signal()
,你就可能错过信号,永远挂起等待!使用
mutex
锁就能让这一段逻辑变成原子性操作:mutex.Lock() for pool.length == 0 { cond.Wait() // 会释放锁,挂起;被唤醒后重新加锁,继续检查 } mutex.Unlock()
这就是通过加锁避免竞态条件。
2. 回收一个worker
将一个空闲的 worker 放回线程池(WorkerQueue) 的函数,也就是一个“回收重用”的逻辑
func (p *poolCommon) revertWorker(worker worker) bool {
if capacity := p.Cap(); (capacity > 0 && p.Running() > capacity) || p.IsClosed() {
p.cond.Broadcast() // 确保池关闭或超容时,所有等待的 Goroutine 能及时退出。
return false
}
worker.setLastUsedTime(p.nowTime()) // 记录回收时间戳,供空闲 Worker 超时清理机制使用(如 ants.WithExpiryDuration 选项)
p.lock.Lock() // 防止竟态条件
// 加锁后的双重检查:防止无锁快速路径检查后,其他 Goroutine 可能已关闭池
if p.IsClosed() {
p.lock.Unlock()
return false
}
if err := p.workers.insert(worker); err != nil {
p.lock.Unlock()
return false
}
p.cond.Signal() // 仅唤醒一个等待的Goroutine
p.lock.Unlock()
return true
}
3. worker容量调整
Tune
函数动态调整池容量的,使用条件变量 cond.Signal()
和 cond.Broadcast()
来唤醒正在等待 worker 的 goroutine
func (p *poolCommon) Tune(size int) {
capacity := p.Cap()
if capacity == -1 || size <= 0 || size == capacity || p.options.PreAlloc { // 容量空|目标大小size不合法|目标和容量相等|预分配内存(循环队列:容量已固定)
return
}
atomic.StoreInt32(&p.capacity, int32(size)) // 更新容量为新的 size
if size > capacity { // 如果变更是“扩容”,说明可能有等待的调用方可以继续执行。
if size-capacity == 1 {
// 如果只是扩容 1 个位置,用 Signal() 唤醒 一个等待中的调用方;
// 否则,用 Broadcast() 唤醒 所有等待的调用方。
p.cond.Signal()
return
}
p.cond.Broadcast()
}
}
ref : go 读写锁实现原理解读 https://segmentfault.com/a/1190000039712353