信号量是一个变量或者抽象数据类型,被用于控制在并发系统临界区问题多个线程对公共资源的访问。与自旋锁、栅栏一起作为同步手段。一个普通的信号量是单纯取决于程序员定义条件而改变的变量
允许任意资源计数的信号量称为计数信号量,而限制为0和1(或锁定/解锁,不可用/可用)值的信号量称为二进制信号量,用于实现锁。
我们可以将信号量看作记录特定资源的数量记录,并耦合调整该记录的安全操作。
数量记录,即意味着信号量并不记录哪些资源可以操作
使用信号量时,有很多行为需要注意
- 请求资源但不释放
- 释放一个从来没有获取的资源
- 长期持有不需要的资源
- 不请求的使用资源
- 使用资源但不先请求
机制与实现
信号量S的值是当前可用资源的单元数。P操作浪费时间或休眠,直到受信号量保护的资源可用,此时立即声明该资源。V操作是相反的:它在进程使用完资源后使其再次可用。信号量S的一个重要属性是它的值不能被改变,除非使用V和P操作。
为了避免饥饿,信号量有一个相关的进程队列(通常使用FIFO语义)。如果一个进程对一个值为0的信号量执行P操作,该进程将被添加到该信号量的队列中,并暂停执行。当另一个进程通过执行V操作增加信号量时,队列上有进程,其中一个进程将从队列中移除并继续执行。当进程具有不同的优先级时,队列可以按优先级排序,以便优先级最高的进程首先从队列中取出。
如果实现不能确保自增、自减和比较操作的原子性,那么就存在被遗忘自增或自减的风险,或者信号量值变为负值的风险。原子性可以通过使用能够在单个操作中读取、修改和写入信号量的机器指令来实现。在没有这种硬件指令的情况下,可以通过使用软件互斥算法来合成原子操作。在单处理器系统上,可以通过暂时暂停抢占或禁用硬件中断来确保原子操作。这种方法不适用于多处理器系统,在多处理器系统中,共享一个信号量的两个程序可能同时在不同的处理器上运行。为了在多处理器系统中解决这个问题,可以使用锁定变量来控制对信号量的访问。使用test-and-set-lock命令操作锁定变量。
与互斥锁的区别
虽然二元信号量可以实现于互斥锁。但是严格来说他们还是有区别的
- 优先级反转:当高优先级任务等待互斥锁时就可以提升该任务优先级
- 过早终止任务:互斥还可以提供删除安全性,其中持有互斥的任务不会被意外删除
- 终止死锁:若持有互斥锁的任务终止了,那么OS会释放该互斥锁和信号等待任务
- 递归死锁:互斥锁可重入
- 意外释放:如果释放互斥锁的任务不是它的所有者,则会在释放互斥锁时引发错误。
Golang的信号量
在golang中提供了基于权重的信号量并发原语
结构
type Weighted struct {
// 资源总数
size int64
// 已用资源数
cur int64
mu sync.Mutex
// 等待资源者队列
waiters list.List
}
// 等待者
type waiter struct {
// 需要的资源数
n int64
// 通知等待者
ready chan<- struct{} // Closed when semaphore acquired.
}
方法
-
func NewWeighted(n int64) *Weighted
创建一个当前最大权重为n的权重信号量
-
func (s *Weighted) Acquire(ctx context.Context, n int64) error
获取信号量权重n,若n资源无法满足就会堵塞,在资源满足时或ctx被取消,则会释放。成功的时候会返回nil,失败会返回crx.Err()
若ctx已经done,Acquire可能会无堵塞成功
-
func (s *Weighted) Release(n int64)
释放权重n资源
-
func (s *Weighted) TryAcquire(n int64) bool
与Acquire方法不同在于,会返回是否成功
Acquire源码解析
func (s *Weighted) Acquire(ctx context.Context, n int64) error {
s.mu.Lock()
// 1. 若存在n资源可用且没有等待者就直接返回成功,并更新已用资源
if s.size-s.cur >= n && s.waiters.Len() == 0 {
s.cur += n
s.mu.Unlock()
return nil
}
// 2. 若需要资源大于总量直接堵塞,直到context done
if n > s.size {
// Don't make other Acquire calls block on one that's doomed to fail.
s.mu.Unlock()
<-ctx.Done()
return ctx.Err()
}
// 3. 将当前请求者加入到等待队列中
ready := make(chan struct{})
w := waiter{n: n, ready: ready}
elem := s.waiters.PushBack(w)
s.mu.Unlock()
// 4. 堵塞该请求者,直到context done或者资源就绪
select {
case <-ctx.Done():
err := ctx.Err()
s.mu.Lock()
// 在context done之后还要判断是否资源就绪了
// 资源就绪,那么err就是nil
// 仍然未就绪,在当前请求者是等待队列首位且资源未全部占用情况下就通知所有的等待者
select {
case <-ready:
// Acquired the semaphore after we were canceled. Rather than trying to
// fix up the queue, just pretend we didn't notice the cancelation.
err = nil
default:
isFront := s.waiters.Front() == elem
s.waiters.Remove(elem)
// If we're at the front and there're extra tokens left, notify other waiters.
if isFront && s.size > s.cur {
s.notifyWaiters()
}
}
s.mu.Unlock()
return err
case <-ready:
return nil
}
}
Release以及notifyWaiters源码解析
// 更新占用资源,并通知所有等待者
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()
}
// 通知所有等待者,从前往后遍历,若满足资源请求,则从等待队列移除,并更新占用资源数量
// 若不满足资源请求就终止通知
func (s *Weighted) notifyWaiters() {
for {
next := s.waiters.Front()
if next == nil {
break // No more waiters blocked.
}
w := next.Value.(waiter)
// 相比于找到下一个资源需求符合的等待者,golang选择了直接让所有等待者都被堵塞掉,此举是为了解决前面有一个需求量大的等待者可能一直无法获取从而产生饥饿
// 并且官方还举出多个reader和一个writer的例子。writer可以通过请求N来堵塞所有reader。当然这只是官方预想的场景,我们也可以根据自己的场景来自我实现
if s.size-s.cur < w.n {
break
}
s.cur += w.n
s.waiters.Remove(next)
close(w.ready)
}
}
Ref
- https://en.wikipedia.org/wiki/Semaphore_(programming)