简介
信号量 是一个同步对象,用于保持在 0 至指定最大值之间的一个计数值。当线程完成一次对该 semaphore 对象的等待(wait)时,该计数值减一;当线程完成一次对semaphore对象的释放(release)时,计数值加一。当计数值为0,则线程等待该 semaphore对象不再能成功直至该semaphore对象变成signaled状态。semaphore对象的计数值大于 0,为signaled状态;计数值等于0,为nonsignaled状态。
简单的理解,信号量是一种同步手段,就是一个计数值,信号量定义了2个操作 P 和 V,P 操作(Wait)减少信号量的计数值,V 操作(Signal)增加信号量的计数值。
初始化信号量相当于指定数量为 n 的资源,它就像是一个有 n 个资源的池子,P 操作相当于请求资源,如果资源可用,就立即返回;如果没有资源或者不够,那么,它可以不断尝试或者阻塞等待。V 操作会释放自己持有的资源,把资源返还给信号量。信号量的值除了初始化的操作以外,只能由 P/V 操作改变。
Golang 拓展包说明
Go 标准库中并没有提供开箱即用的信号量包,扩展包golang.org/x/sync/semaphore提供了一种带权重的信号量实现方式。
数据结构
Weighted 结构就是信号量,之所以叫 Weighted,是因为是一个带权重的信号量。主要是变量有 信号量资源总数(size)、当前已申请资源数(cur)、锁实例和 waiters。
type Weighted struct {
size int64 // 信号量资源总数
cur int64 // 当前已申请资源数
mu sync.Mutex // 锁
waiters list.List // 等待者,链表存储
}
方法
-
Acquire 方法:相当于
P操作,可以一次获取多个资源,如果没有足够多的资源,调用者就会被阻塞,第一个参数是Context,相当于可以通过Context增加超时或者cancel的机制。如果是正常获取了资源,就返回nil;否则,就返回ctx.Err()。 -
Release 方法:相当于
V操作,可以将n个资源释放,返还给信号量。 -
TryAcquire 方法:尝试获取
n个资源,但是它不会阻塞,要么成功获取n个资源,返回true,要么一个也不获取,返回false。
案例
下面来看一下官方提供的 example 案例吧,我修改了一些代码,主要是 worker 执行内容上做了简单的调整,但是思路是一样的。这里创建和 CPU 核数一样多的 Worker,去处理 32 个任务,最终将任务结果输出。
sem.Acquire(ctx, 1) 就是信号量的 P 操作,1 就是请求信号量的资源数量,可以同时请求多个。sem.Release(1) 就类型信号量的 V 操作,1 代表增加信号量的资源数量,可以同时增加多个。
在输出前的 sem.Acquire(ctx, int64(maxWorkers)) 语句值得说一下,这样在请求 最大核数 的信号量资源得话,如果成功的话,就代表之前的 worker 工作全部做完。就可以正常输出了,我们在实际使用信号量时也可以进行使用。
package main
import (
"context"
"fmt"
"golang.org/x/sync/semaphore"
"log"
"runtime"
"time"
)
func main() {
ctx := context.TODO()
var (
maxWorkers = runtime.GOMAXPROCS(0) // 获取CPU核数作为worker数量
sem = semaphore.NewWeighted(int64(maxWorkers)) // 信号量
out = make([]int, 32) // 输出
)
for i := range out {
// 如果没有worker可用,会阻塞在这里,直到某个worker被释放
if err := sem.Acquire(ctx, 1); err != nil {
log.Printf("Failed to acquire semaphore: %v", err)
break
}
// 启动 worker goruntine
go func(i int) {
defer sem.Release(1)
time.Sleep(time.Second * 1) // 模拟耗时操作
out[i] = i
}(i)
}
// 请求所有的worker,这样能确保前面的worker都执行完
if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil {
log.Printf("Failed to acquire semaphore: %v", err)
}
fmt.Println(out)
}
输出结果:
[0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31]
源码分析
- Acquire 方法
如果有足够的资源,就直接申请成功;不成功就创建 waiter,然后调用 select 变成等待者,等待 waiter 的 ready 被唤醒,就获得资源了。如果 waiter 被取消了,需要删除该 waiter ,如果删除的链表头,调用 notifyWaiters 来尝试唤醒其他的 waiter 。这段代码里面有 2 个 select,大家看的时候注意一下。
这里 s.size-s.cur >= n && s.waiters.Len() == 0 既判断了富余的资源数量,也判断了是否有等待者。说明在有等待者的情况下,即使有富余的资源可以被新等待者申请也不能进行申请,需要进行排队等待。
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock() // 加锁
// 没有人在等待,并且有富余资源可以被申请,就增加已申请资源数,并退出
if s.size-s.cur >= n && s.waiters.Len() == 0 {
s.cur += n // 增加已申请资源数
s.mu.Unlock()
return nil
}
// 不能申请比size大的资源数量
if n > s.size {
s.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
ready := make(chan struct{}) // 创建ready chan
w := waiter{n: n, ready: ready} // 创建等待者,等待者有等待资源数量和ready chan
elem := s.waiters.PushBack(w) // 添加到链表的尾部
s.mu.Unlock() // 解锁
// 阻塞
select {
case <-ctx.Done(): // context的Done被取消
err := ctx.Err()
s.mu.Lock() // 加锁
select {
case <-ready: // 被唤醒了
err = nil
default:
isFront := s.waiters.Front() == elem // 是否是第一个等待者
s.waiters.Remove(elem) // 删除取消的等待者
if isFront && s.size > s.cur { // 如果是第一个等待者,并且size大于cur,通知其他等待者
s.notifyWaiters()
}
}
s.mu.Unlock() // 解锁
return err
case <-ready: // 被唤醒,代表获取到资源了
return nil
}
}
- Release 方法
Release 逻辑比较简单,就是将 cur 的值减去 n ,然后尝试通知其他等待者。
func (s *Weighted) Release(n int64) {
s.mu.Lock() // 加锁
s.cur -= n // 释放n个占用的资源
if s.cur < 0 { // 如果当前可用资源小于0,就panic,可能n值传的不对
s.mu.Unlock()
panic("semaphore: released more than held")
}
s.notifyWaiters() // 尝试通知其他等待者
s.mu.Unlock() // 解锁
}
- 内部的 notifyWaiters 方法
循环检测可以被唤醒的 waiter ,如果没有等待者就退出,获取第一个等待的 waiter ,资源数量不够则不进行分配,继续等待;够的话就增加已获得的资源数量,溢出等待者,并唤醒 waiter。这个方法没有加锁的原因是因为调用这个函数前都进行加锁处理了。
func (s *Weighted) notifyWaiters() {
for { // 循环,一次调用可能会唤醒多个waiter
next := s.waiters.Front() // 获取链表第一个waiter
// 没有等待者,退出
if next == nil {
break
}
w := next.Value.(waiter) // 获取waiter
// 当前资源数量还不足以分配给该waiter,退出,waiter继续等待
if s.size-s.cur < w.n {
break
}
s.cur += w.n // 资源数够分配,增加已获得的资源数量
s.waiters.Remove(next) // 移除等待者
close(w.ready) // close 该 waiter 的 ready 的 chan,唤醒该 waiter
}
}
- TryAcquire 方法
这个方法的实现也比较简单,可以看下注释。
func (s *Weighted) TryAcquire(n int64) bool {
s.mu.Lock() // 加锁
success := s.size-s.cur >= n && s.waiters.Len() == 0 // 资源数量够,并且没有等待者,代表可以进行申请
if success {
s.cur += n // 增加已申请资源数量
}
s.mu.Unlock() // 解锁
return success
}
总结
Weighted semaphore实现的是先等待先获得的资源申请方式,实现的是公平模式- 注意请求多少资源,就释放多少资源