【Go并发编程】Semaphore

97 阅读2分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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

image.png

  • 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 个资源。这样做的目的是避免饥饿,否则的话,资源可能总是被那些请求资源数小的调用者获取,这样一来,请求资源数巨大的调用者,就没有机会获得资源了。