2-8.sync.Semaphore的实现

Go 语言的 sync 包中提供了带权重的信号量 semaphore.Weight,能够控制并发访问的资源数量,比如协程数。先看个例子:

func main() {
    urls := []string{"1", "2", "3", "4"}

    s := semaphore.NewWeighted(3)               // 声明配额为3的信号量
    var w sync.WaitGroup
    for _, u := range urls {
        w.Add(1)
        go func(u string) {
            s.Acquire(context.Background(), 1)  // 占用1个信号量资源
            doSomething(u)
            s.Release(Weight)                   // 解除信号量占用
            w.Done()
        }(u)
    }
    w.Wait()
    
    fmt.Println("All Done")
}

这样就可以限制最多3个协程在跑了。

实现原理

这个结构体对外暴露了四个方法:

  • NewWeighted() 用于创建新的信号量

  • Acquire() 阻塞地获取指定权重的资源,如果当前没有空闲资源,会陷入休眠等待,即 P 操作

  • TryAcquire() 非阻塞地获取指定权重的资源,如果当前没有空闲资源,会直接返回 false

  • Release() 用于释放指定权重的资源,即 V 操作

Acquire() 方法用途类似于 Mutex 结构体的 Lock() ,都是申请资源、加锁,但是可以 Acquire 指定数量的资源;Release() 方法类似于 Unlock(),都是释放资源、解锁。

我们来看一下信号量 semaphore.Weighted 的数据结构:

Acquire 获取信号量

TryAcquire

其实就是 Acquire 代码里的逻辑1,去除了等待的逻辑。

Release 归还信号量资源

Release 方法会将占用资源放回,并调用 notifyWaiters 方法,唤醒等待队列中的调用者

notifyWaiters 通知等待者

notifyWaiters 方法会逐个检查队列里等待的调用者,如果现存资源够等待者请求的数量n,或者是没有等待者了,就返回。

notifyWaiters 方法是按照先入先出的方式唤醒调用者。当释放 100 个资源的时候,如果第一个等待者需要 101 个资源,那么,队列中的所有等待者都会继续等待,即使队列后面有的等待者只需要 1 个资源。这样做的目的是避免饥饿,否则的话,资源可能总是被那些请求资源数小的调用者获取,这样一来,请求资源数巨大的调用者,就没有机会获得资源了。

总结

Go 语言中信号量有时候也会被 Channel 类型所取代,因为一个 buffered chan 也可以代表 n 个资源。不过既然 Go 语言通过golang.orgx/sync 扩展库对外提供了 semaphore.Weight 这一种信号量实现,遇到使用信号量的场景时还是尽量使用官方提供的实现。在使用的过程中我们需要注意以下的几个问题:

  • AcquireTryAcquire 方法都可以用于获取资源,前者会阻塞地获取信号量。后者会非阻塞地获取信号量,如果获取不到就返回 false

  • Release 归还信号量后,会以先进先出的顺序唤醒队列中的等待者。如果现有资源不够队头的调用者请求的资源数,所有等待者会继续等待。

  • 如果一个 goroutine 申请较多的资源,由于上面说的归还后唤醒等待者的策略,它可能会等待比较长的时间。

参考

KevinYan11 - 信号量的使用方法和其实现原理

Last updated