开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 24 天,点击查看活动详情
Semaphore
大学学过操作系统的朋友们应该都不陌生,Semaphore是一种用于控制并发访问的机制。它是一种计数器,可以用来限制访问某些共享资源的线程数量。
初始化信号量:设定初始的资源的数量。
P 操作:将信号量的计数值减去 1,如果新值已经为负,那么调用者会被阻塞并加入到等待队列中。否则,调用者会继续执行,并且获得一个资源。
V 操作:将信号量的计数值加 1,如果先前的计数值为负,就说明有等待的 P 操作的调用者。它会从等待队列中取出一个等待的调用者,唤醒它,让它继续执行。
- 计数信号量:信号>1
- 二进位信号量:信号=1,相当于一个互斥锁
Go使用信号量
Go 内部使用信号量来控制 goroutine 的阻塞和唤醒。它是 Go 运行时内部使用的,并没有封装暴露成一个对外的信号量并发原语,原则上我们没有办法使用。
channel实现信号量
初始化一个channel,容量就是信号量的计数值。 P操作:向channel插入数据 V操作:从channel取出数据
// Semaphore 数据结构,并且还实现了Locker接口
type semaphore struct {
sync.Locker
ch chan struct{}
}
// 创建一个新的信号量
func NewSemaphore(capacity int) sync.Locker {
if capacity <= 0 {
capacity = 1 // 容量为1就变成了一个互斥锁
}
return &semaphore{ch: make(chan struct{}, capacity)}
}
// 请求一个资源
func (s *semaphore) Lock() {
s.ch <- struct{}{}
}
// 释放资源
func (s *semaphore) Unlock() {
<-s.ch
}
func main() {
sema := NewSemaphore(2) // 信号量大小为 2
for i := 1; i <= 4; i++ { // 创建 4 个 goroutine
go func(id int) {
fmt.Printf("Goroutine %d is waiting...\n", id)
sema.Lock() // 获取信号量
fmt.Printf("Goroutine %d is running...\n", id)
time.Sleep(time.Second) // 模拟任务执行时间
fmt.Printf("Goroutine %d is done.\n", id)
sema.Unlock() // 释放信号量
}(i)
}
time.Sleep(10 * time.Second)
fmt.Println("All done.")
}
semaphore
Go 在它的扩展包中提供了信号量semaphore
- Acquire 方法:相当于 P 操作,你可以一次获取多个资源,如果没有足够多的资源,调用者就会被阻塞。它的第一个参数是 Context,这就意味着,你可以通过 Context 增加超时或者 cancel 的机制。如果是正常获取了资源,就返回 nil;否则,就返回ctx.Err(),信号量不改变。
- Release 方法:相当于 V 操作,可以将 n 个资源释放,返还给信号量。
- TryAcquire 方法:尝试获取 n 个资源,但是它不会阻塞,要么成功获取 n 个资源,返回 true,要么一个也不获取,返回 false。
创建和 CPU 核数一样多的 Worker,让它们去处理一个 4 倍数量的整数 slice。每个Worker 一次只能处理一个整数,处理完之后,才能处理下一个:
var (
maxWorkers = runtime.GOMAXPROCS(0) // worker数量
sema = semaphore.NewWeighted(int64(maxWorkers)) //信号量
task = make([]int, maxWorkers*4) // 任务数,是worker的四
)
func main() {
ctx := context.Background()
for i := range task {
// 如果没有worker可用,会阻塞在这里,直到某个worker被释放
if err := sema.Acquire(ctx, 1); err != nil {
break
}
// 启动worker goroutine
go func(i int) {
defer sema.Release(1)
time.Sleep(100 * time.Millisecond) // 模拟一个耗时操作
task[i] = i + 1
}(i)
} // 请求所有的worker,这样能确保前面的worker都执行完
if err := sema.Acquire(ctx, int64(maxWorkers)); err != nil {
fmt.Printf("获取所有的worker失败: %v", err)
}
fmt.Println(task)
}
semaphore源码阅读
Go 扩展库中的信号量是使用互斥锁 +List 实现的。互斥锁实现其它字段的保护,而 List实现了一个等待队列,等待者的通知是通过 Channel 的通知机制实现的。
type Weighted struct {
size int64
cur int64
mu sync.Mutex
waiters list.List
}
- size:最大资源数
- cur:当前已被使用的资源
- mu:互斥锁
- waiters:等待队列
Acquire 是代码最复杂的一个方法,它不仅仅要监控资源是否可用,而且还要检测 Context 的 Done 是否已关闭。
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock()
// fast path, 如果有足够的资源,都不考虑ctx.Done的状态,将cur加上n就返回
if s.size-s.cur >= n && s.waiters.Len() == 0 {
s.cur += n
s.mu.Unlock()
return nil
}
// 如果是不可能完成的任务,请求的资源数大于能提供的最大的资源数
if n > s.size {
s.mu.Unlock()
// 依赖ctx的状态返回,否则一直等待
<-ctx.Done()
return ctx.Err()
}
// 否则就需要把调用者加入到等待队列中
// 创建了一个ready chan,以便被通知唤醒
ready := make(chan struct{})
w := waiter{n: n, ready: ready}
elem := s.waiters.PushBack(w)
s.mu.Unlock()
// 等待
select {
// context的Done被关闭
case <-ctx.Done():
err := ctx.Err()
s.mu.Lock()
select {
// 如果被唤醒了,忽略ctx的状态
case <-ready:
err = nil
default:
// 通知waiter
isFront := s.waiters.Front() == elem
s.waiters.Remove(elem)
// 通知其它的waiters,检查是否有足够的资源
if isFront && s.size > s.cur {
s.notifyWaiters()
}
}
s.mu.Unlock()
return err
// 被唤醒了
case <-ready:
return nil
}
}
Release 方法将当前计数值减去释放的资源数 n,并唤醒等待队列中的调用者,看是否有足够的资源被获取。
func (s *Weighted) Release(n int64) {
s.mu.Lock()
s.cur -= n
if s.cur < 0 {
s.mu.Unlock()
panic("semaphore: released more than held")
}
s.notifyWaiters()
s.mu.Unlock()
}
notifyWaiters 方法就是逐个检查等待的调用者,如果资源不够,或者是没有等待者了,就返回:
func (s *Weighted) notifyWaiters() {
for {
next := s.waiters.Front()
if next == nil {
break // No more waiters blocked.
}
w := next.Value.(waiter)
if s.size-s.cur < w.n {
break
}
s.cur += w.n
s.waiters.Remove(next)
close(w.ready)
}
}
notifyWaiters 方法是按照先入先出的方式唤醒调用者。当释放 100 个资源的时候,如果第一个等待者需要 101 个资源,那么,队列中的所有等待者都会继续等待,即使有的等待者只需要 1 个资源。这样做的目的是避免饥饿,否则的话,资源可能总是被那些请求资源数小的调用者获取,这样一来,请求资源数巨大的调用者,就没有机会获得资源了。