golang中的各种锁,应用和原理浅析【互斥、自选、读写】,CAS和条件变量

原子操作

原子操作就是不可中断的操作,外界是看不到原子操作的中间状态,要么看到原子操作已经完成,要么看到原子操作已经结束。在某个值的原子操作执行的过程中,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 的阻塞和唤醒
}

内部状态变化大致流程:

  1. 调用 Lock() 时,CAS 尝试将 state 从 0 改为 1。
  2. 如果失败,说明已被锁,会进入队列排队(自旋几次后挂起)。
  3. 解锁时调用 Unlock(),会将 state 设置为 0,并唤醒排队的协程。

同一个 Goroutine 不可以重复 Lock 而不 Unlock,否则会死锁。也就是golang的锁是不可重入的

Mutex 的几种模式

🟩 正常模式(Normal mode)

  • 默认工作模式。
  • 加锁请求是FIFO公平队列
  • 如果锁被频繁释放又被新的 goroutine 抢占,可能导致等待队列中的老 goroutine 饿死
  • 性能高,但公平性差。

🟨 饥饿模式(Starvation mode)

  • 当某个 goroutine 等待锁超过 1ms(在老版本中),系统会把 Mutex 转为饥饿模式。
  • 饥饿模式下,锁会严格交给队头的 goroutine,新来的都得排队。
  • 保证公平性,防止 goroutine 被饿死,但性能略低
  • 只要系统发现锁竞争不激烈(后面没人排队了),就会回退到正常模式

Go 的 Mutex 默认是非公平锁(谁先抢到谁执行),这样可以提高吞吐。但为了防止“饿死”,当锁被频繁抢占时会让等待者优先执行(抢占调度也会帮助)。

1)正常模式

  1. 当前的mutex只有一个goruntine来获取,那么没有竞争,直接返回。
  2. 新的goruntine进来,如果当前mutex已经被获取了,则该goruntine进入一个先入先出的waiter队列,在mutex被释放后,waiter按照先进先出的方式获取锁。该goruntine会处于自旋状态(不挂起,继续占有cpu)
  3. 新的goruntine进来,mutex处于空闲状态,将参与竞争。新来的 goroutine 有先天的优势,它们正在 CPU 中运行,可能它们的数量还不少,所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁,这时,它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒,那么,这个 Mutex 就进入到了饥饿模式。

2)饥饿模式
在饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin(自旋),它会乖乖地加入到等待队列的尾部。 如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:

  1. 此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
  2. 此 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()
}

将上面这些过程梳理一下:

  1. 如果没有writer请求进来,则每个reader开始后只是将readerCount增1,完成后将readerCount减1,整个过程不阻塞,这样就做到“并发读操作之间不互斥”;
  2. 当有writer请求进来时首先通过互斥锁阻塞住新来的writer,做到“并发写操作之间互斥”;
  3. 然后将readerCount改成一个很小的值,从而阻塞住新来的reader;
  4. 记录writer进来之前未完成的reader数量,等待它们都完成后再唤醒writer;这样就做到了“并发读操作和写操作互斥”;
  5. 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,随着这个比例越高耗时降低的比例也越大,这里作个简单比较:

writeRatio31020501001000
耗时降低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)
}
  1. Spinlock 结构体有一个 int32 类型的字段 locked,用于表示锁的状态。
  2. Lock 方法使用 atomic.CompareAndSwapInt32 原子操作来尝试将 locked 从0(未锁定)更改为1(已锁定)。如果锁已经被另一个goroutine持有(即 locked 为1),则 CompareAndSwapInt32 会返回 false,并且循环会继续。
  3. 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 的自旋占用资源问题,可以从以下几个方面进行解决或优化:

  1. 减少自旋锁的使用
    评估必要性:首先评估是否真的需要使用自旋锁。在许多情况下,互斥锁(mutex)已经足够满足需求,因为互斥锁在资源被占用时会让调用者进入睡眠状态,从而减少对 CPU 的占用。
    优化锁的设计:考虑使用更高级的同步机制,如读写锁(rwmutex),它允许多个读操作同时进行,而写操作则是互斥的。这可以显著减少锁的竞争,从而降低自旋的需求。
  2. 优化自旋锁的实现
    设置自旋次数限制:在自旋锁的实现中加入自旋次数的限制,当自旋达到一定次数后,如果仍未获取到锁,则让 Goroutine 进入睡眠状态。这样可以避免长时间的无效自旋,浪费 CPU 资源。
    利用 Go 的调度器特性:Go 的调度器在检测到 Goroutine 长时间占用 CPU 而没有进展时,会主动进行抢占式调度,将 Goroutine 暂停并让出 CPU。这可以在一定程度上缓解自旋锁带来的资源占用问题。
  3. 监控和调整系统资源
    监控系统性能:通过工具(如 pprof、statsviz 等)监控 Go 程序的运行时性能,包括 CPU 使用率、内存占用等指标。这有助于及时发现和解决资源占用过高的问题。
    调整 Goroutine 数量:根据系统的负载情况动态调整 Goroutine 的数量。例如,在高并发场景下适当增加 Goroutine 的数量以提高处理能力,但在负载降低时及时减少 Goroutine 的数量以避免资源浪费。
  4. 利用 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()释放锁并阻塞,直到被 SignalBroadcast 唤醒(唤醒后重新获取锁)
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.Mutexsync.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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值