Semaphore
信号量是在并发编程中比较常见的一种同步机制,它会保证持有的计数器在 0 到初始化的权重之间,每次获取资源时都会将信号量中的计数器减去对应的数值,在释放时重新加回来,当遇到计数器大于信号量大小时就会进入休眠等待其他进程释放信号,我们常常会在控制访问资源的进程数量时用到。
Golang 的扩展包中就提供了带权重的信号量,我们可以按照不同的权重对资源的访问进行管理,这个包对外也只提供了四个方法:
NewWeighted 用于创建新的信号量;
- Acquire 获取了指定权重的资源,如果当前没有『空闲资源』,就会陷入休眠等待;
- TryAcquire 也用于获取指定权重的资源,但是如果当前没有『空闲资源』,就会直接返回 false;
- Release 用于释放指定权重的资源;
结构体
NewWeighted 方法的主要作用创建一个新的权重信号量,传入信号量最大的权重就会返回一个新的 Weighted 结构体指针:
func NewWeighted(n int64) *Weighted {
w := &Weighted{size: n}
return w
}
type Weighted struct {
size int64
cur int64
mu sync.Mutex
waiters list.List
}
Weighted 结构体中包含一个 waiters 列表其中存储着等待获取资源的『用户』,除此之外它还包含当前信号量的上限以及一个计数器 cur,这个计数器的范围就是 [0, size]:
golang-semaphore
信号量中的计数器会随着用户对资源的访问和释放进行改变,引入的权重概念能够帮助我们更好地对资源的访问粒度进行控制,尽可能满足所有常见的用例。
获取
在上面我们已经提到过 Acquire 方法就是用于获取指定权重资源的方法,这个方法总共由三个不同的情况组成:
当信号量中剩余的资源大于获取的资源并且没有等待的 Goroutine 时就会直接获取信号量;
当需要获取的信号量大于 Weighted 的大小时,由于不可能满足条件就会直接返回;
遇到其他情况时会将当前 Goroutine 加入到等待列表并通过 select 等待当前 Goroutine 被唤醒,被唤醒后就会获取信号量;
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
}
if n > s.size {
s.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
ready := make(chan struct{})
w := waiter{n: n, ready: ready}
elem := s.waiters.PushBack(w)
s.mu.Unlock()
select {
case <-ctx.Done():
err := ctx.Err()
s.mu.Lock()
select {
case <-ready:
err = nil
default:
s.waiters.Remove(elem)
}
s.mu.Unlock()
return err
case <-ready:
return nil
}
}
另一个用于获取信号量的方法 TryAcquire 相比之下就非常简单,它只会判断当前信号量是否有充足的资源获取,如果有充足的资源就会直接立刻返回 true 否则就会返回 false:
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
}
与 Acquire 相比,TryAcquire 由于不会等待资源的释放所以可能更适用于一些延时敏感、用户需要立刻感知结果的场景。
释放
最后要介绍的 Release 方法其实也非常简单,当我们对信号量进行释放时,Release 方法会从头到尾遍历 waiters 列表中全部的等待者,如果释放资源后的信号量有充足的剩余资源就会通过 Channel 唤起指定的 Goroutine:
func (s *Weighted) Release(n int64) {
s.mu.Lock()
s.cur -= n
for {
next := s.waiters.Front()
if next == nil {
break
}
w := next.Value.(waiter)
if s.size-s.cur < w.n {
break
}
s.cur += w.n
s.waiters.Remove(next)
close(w.ready)
}
s.mu.Unlock()
}
当然也可能会出现剩余资源无法唤起 Goroutine 的情况,在这时当前方法就会释放锁后直接返回,通过对这段代码的分析我们也能发现,如果一个信号量需要的占用的资源非常多,他可能会长时间无法获取锁,这可能也是 Acquire 方法引入另一个参数 Context 的原因,为信号量的获取设置一个超时时间。
小结
带权重的信号量确实有着更多的应用场景,这也是 Go 语言对外提供的唯一一种信号量实现,在使用的过程中我们需要注意以下的几个问题:
Acquire 和 TryAcquire 方法都可以用于获取资源,前者用于同步获取会等待锁的释放,后者会在无法获取锁时直接返回;
Release 方法会按照 FIFO 的顺序唤醒可以被唤醒的 Goroutine;
如果一个 Goroutine 获取了较多地资源,由于 Release 的释放策略可能会等待比较长的时间;