Go sync 包

2.1 Mutex 和 RWMutex

1、Mutex 可以看做是锁,而 RWMutex 则是读写锁。 一般的用法是将 Mutex 或者 RWMutex 和需要被保住的资源封装在一个结构体内。

  • 如果有多个 goroutine 同时读写的资源,就一定要保护起来;
  • 如果多个 goroutine 只读某个资源,那就不需要保护。
  • 使用锁的时候,优先使用 RWMutex
  • RWMutex:核心就是四个方法,RLock、 RUnlock、Lock、Unlock
  • Mutex:Lock 和 Unloc
package syncpg

import "sync"

// PublicResource 你永远不知道你的用户拿了它会干啥
// 即便不用 PublicResourceLock 你也毫无办法
// 如果用这个resource,一定要用锁
var PublicResource interface{}
var PublicResourceLock sync.Mutex


// privateResource 要好一点,祈祷用户会来看你的注释,知道要用锁
// 很多库都是这么写的,我也写了很多类似的代码=。=
var privateResource interface{}
var privateResourceLock sync.Mutex


// safeResource 很棒,所有的期望对资源的操作都只能通过定义在上 safeResource 上的方法来进行
type safeResource struct {
   resource interface{}
   lock sync.Mutex
}

func (s *safeResource) DoSomethingToResource()  {
   s.lock.Lock()
   defer s.lock.Unlock()
}

复制代码

**代码演示: **

(1) SafeMap 的 LoadOrStore 写法

  • 例子:SafeMap 可以看做是 map 的一个线程安全的封装。我们为它增加一个 LoadOrStore 的方法
type SafeMap[K comparable, V any] struct {
   m     map[K]V
   lock sync.RWMutex
}

// LoadOrStore loaded 代表是返回老的对象,还是返回了新的对象
// g1 (key1, 123)  g2 (key1, 456)
func (s *SafeMap[K, V]) LoadOrStore(key K, newVal V) (val V, loaded bool) {
}

复制代码

double-check 写法

  • 使用 RWMutex 实现 double-check:
    • 加读锁先检查一遍
    • 释放读锁
    • 加写锁
    • 再检查一遍
    • 所有操作结束释放写锁
import (
   "errors"
   "fmt"
   "sync"
)

type SafeMap[K comparable, V any] struct {
   m    map[K]V
   lock sync.RWMutex
   zero V
}


func (s *SafeMap[K, V]) LoadOrStore(key K, val V) (V, bool) {
   oldVal, ok := s.Get(key)
   if ok {
      return oldVal, true
   }
   s.lock.Lock()
   defer s.lock.Unlock()
   oldVal, ok = s.m[key]  // double-check
   if ok {
      return oldVal, true
   }
   s.m[key] = val
   return val, false
}

func (s *SafeMap[K, V]) Get(key K) (V, bool) {
   s.lock.RLock()
   defer s.lock.RUnlock()
   val, ok := s.m[key]
   return val, ok
}

func (s *SafeMap[K, V]) Put(key K, newVal V) (V, error) {
   s.lock.Lock()
   defer s.lock.Unlock()
   _, ok := s.m[key]
   if !ok {
      exc := fmt.Sprintf("the key %v not exists", key)
      return s.zero, errors.New(exc)
   }
   s.m[key] = newVal
   return newVal, nil
}

func (s *SafeMap[K, V]) Delete(key K) error {
   s.lock.Lock()
   defer s.lock.Unlock()
   _, ok := s.m[key]
   if !ok {
      exc := fmt.Sprintf("the key %v not exists", key)
      return errors.New(exc)
   }
   delete(s.m, key)
   return nil
}
复制代码

(2) 实现一个线程安全的 ArrayList

  • 例子:实现一个线程安全的 ArrayLis
  • 思路:切片本身不是线程安全的,所以最简单的做法就是利用读写锁封装一 下。这也是典型的装饰器模式的应用;
  • 如果考虑扩展性,那么需要预先定义一个 List 接口,后续可以有 ArrayList, LinkedList,锁实现的线程安全 List,以及无锁实现的线程安全 List;
  • 任何非线程安全的类型、接口都可以利用读写锁 + 装饰器模式无侵入式地改 造为线程安全的类型、接口;
package syn

import (
   "fmt"
   "sync"
)

// List 接口
// 该接口只定义清楚各个方法的行为和表现
type List[T any] interface {
   // Get 返回对应下标的元素,
   // 在下标超出范围的情况下,返回错误
   Get(index int) (T, error)
   // Append 在末尾追加元素
   Append(t T) error
   // Add 在特定下标处增加一个新元素
   // 如果下标超出范围,应该返回错误
   Add(index int, t T) error
   // Set 重置 index 位置的值
   // 如果下标超出范围,应该返回错误
   Set(index int, t T) error
   // Delete 删除目标元素的位置,并且返回该位置的值
   // 如果 index 超出下标,应该返回错误
   Delete(index int) (T, error)
   // Len 返回长度
   Len() int
   // Cap 返回容量
   Cap() int
   // Range 遍历 List 的所有元素
   Range(fn func(index int, t T) error) error
   // AsSlice 将 List 转化为一个切片
   // 不允许返回nil,在没有元素的情况下,
   // 必须返回一个长度和容量都为 0 的切片
   // AsSlice 每次调用都必须返回一个全新的切片
   AsSlice() []T
}

// newErrIndexOutOfRange 创建一个代表
func newErrIndexOutOfRange(length int, index int) error {
   return fmt.Errorf("ekit: 下标超出范围,长度 %d, 下标 %d", length, index)
}

// ArrayList 基于切片的简单封装
type ArrayList[T any] struct {
   vals []T
   zero T
}

func (a *ArrayList[T]) Get(index int) (T, error) {
   if index < 0 || index > len(a.vals) {
      return a.zero, newErrIndexOutOfRange(len(a.vals), index)
   }
   res := a.vals[index]
   return res, nil
}

// Add 在ArrayList下标为index的位置插入一个元素
// 当index等于ArrayList长度等同于append
func (a *ArrayList[T]) Add(index int, t T) error {
   if index < 0 || index > len(a.vals) {
      return newErrIndexOutOfRange(len(a.vals), index)
   }
   a.vals = append(a.vals, t)
   copy(a.vals[index+1:], a.vals[index:])
   a.vals[index] = t
   return nil
}

func (a *ArrayList[T]) Append(t T) error {
   // 复杂些可以考虑 维护一个size, 来判断是否需要处理扩容
   a.vals = append(a.vals, t)
   return nil
}

func (a *ArrayList[T]) Set(index int, t T) error {
   if index < 0 || index > len(a.vals) {
      return newErrIndexOutOfRange(len(a.vals), index)
   }
   a.vals[index] = t
   return nil
}

func (a *ArrayList[T]) Delete(index int) (T, error) {
   if index < 0 || index > len(a.vals) {
      return a.zero, newErrIndexOutOfRange(len(a.vals), index)
   }
   res := a.vals[index]
   a.vals = append(a.vals[:index], a.vals[index+1:]...)
   return res, nil
}

func (a *ArrayList[T]) Cap() int {
   return cap(a.vals)
}

func (a *ArrayList[T]) Len() int {
   return len(a.vals)
}

func (a *ArrayList[T]) Range(fn func(index int, t T) error) error {
   for key, value := range a.vals {
      e := fn(key, value)
      if e != nil {
         return e
      }
   }
   return nil
}

func (a *ArrayList[T]) AsSlice() []T {
   slice := make([]T, len(a.vals))
   copy(slice, a.vals)
   return slice
}

func NewArrayList[T any](initCap int) *ArrayList[T] {
   return &ArrayList[T]{
      vals: make([]T, 0, initCap),
   }
}

// NewArrayListOf 直接使用 ts,而不会执行复制
func NewArrayListOf[T any](ts []T) *ArrayList[T] {
   return &ArrayList[T]{
      vals: ts,
   }
}

// 线程安全的 ArrayList
type SafeArrayList[T any] struct {
   list List[T]
   lock sync.RWMutex
}

func (a *SafeArrayList[T]) Get(index int) (T, error) {
   a.lock.RLock()
   defer a.lock.RUnlock()
   return a.list.Get(index)
}

func (a *SafeArrayList[T]) Append(t T) error {
   a.lock.Lock()
   defer a.lock.Unlock()
   return a.list.Append(t)
}

func (a *SafeArrayList[T]) Set(index int, t T) error {
   a.lock.Lock()
   defer a.lock.Unlock() // 尽量用defer解锁, 避免panic
   return a.Set(index, t)
}

func NewSafeArrayList[T any](initCap int) *SafeArrayList[T] {
   return &SafeArrayList[T]{
      list: NewArrayList[T](initCap),
   }
}
复制代码

2、Mutex 细节

锁的一般实现都是依赖于:

  • 自旋作为快路径;
    • 自旋可以通过控制次数或者时间来退出循环。
  • 等待队列作为慢路径;
    • 慢路径:跟语言特性有关,有些依赖于操作系统线程调度,如 Java,有些是自己管,如 goroutine。

下面代码我称为锁实现模板,Mutex 的源码还是很难理解的;

Go 的 Mutex 大致符合模板,但是做了针对性的优化。

理解关键点:

  • state 就是用来控制锁状态的核心,所谓加锁,就是 把 state 修改为某个值,解锁也是类似;
  • sema 是用来处理沉睡、唤醒的信号量,依赖于两 个 runtime 调用:
    • runtime_Semacquire:sema 加 1 并且挂起 goroutine
    • runtime_Semarelease:sema 减 1 并且唤醒 sema 上等待的一个 goroutine

一把锁,如果没有人持有它,也没有人抢,那么一个 CAS 操作就能成功。 (一次性的自旋)

在锁实现模板里面,我们说自旋是快路径。Go 把这个归到了慢路径里面。实际上在这个片段里面,还是很快的,因为没有进入等待队列的环节。所以:理论上的自旋 = Go 的快路径 + Go 慢路径的

自旋部分, 源码:

后半部分的代码就是控制锁的两种模式,以及进队列,被唤醒的部分了。为什么 Go 的锁有所谓的两种模式?我们的锁实现模板里面根本就没有这种东西。

  • 正常模式
  • 饥饿模式

(1) 如果一个新的 goroutine 进来争夺锁,而且队列里面也有等待的 goroutine,你是设计者,你会把锁给谁?

  • 给 G2:毕竟我们要保证公平,先到先得是规矩,不能破坏;
  • G1 和 G2 竞争:保证效率。G1 肯定已经占着了 CPU,所以大概率能够拿到锁; 所以正常模式的核心优势是避免 goroutine 调度,(所谓的正常模式,就是 G1 和 G2 竞争的模式) G1能获得锁。

(2) 那如果要是每次 G2 想要拿到锁的时候,都被新来的G1 给抢走了,那么 G2 和其它队列的不就是饥饿了吗?

  • G2 每次没抢到锁,都要退回去队列头;
  • 所以如果等待时间超过 1ms,那么锁就会变成饥饿模式;
  • 在饥饿模式下,锁会优先选择队列中的 goroutine。

因此对应的退出饥饿模式,要么队列中只剩下一个goroutine,要么 G2 的等待时间小于 1ms。

  • 步骤总结:
  1. 先上来一个 CAS 操作,如果这把锁正空闲,并且 没人抢,那么就直接成功;
  2. 否则,自旋几次,如果这个时候成功了,也不用加 入队列;
  3. 否则,加入队列;
  4. 从队列中被唤醒: (1) 正常模式:和新来的一起抢锁,但是大概率失败; (2) 饥饿模式:肯定拿到锁。

解锁:

  • 上来就是一个 atomic 操作,解锁。理论上来说这 也应该是一个 CAS 操作,即必须是加锁状态才能解锁,Go 这种写法效果是一样的;

  • 解锁失败则是步入慢路径(其实就是先把 locked 设置 为0,再释放一个信号量去唤醒阻塞的协程,阻塞协程被唤醒后将locked 设置为1),也就是要唤醒等待队列里面的 goroutine;

    左边这里释放锁就会唤醒右边阻塞的 goroutine

3、Mutex 和 RWMutex 注意项

  • RWMutex适合于读多写少的场景
  • 写多读少不如直接加写锁
func (s *SafeMap[K, V]) WriteOrRead() {
   s.lock.Lock()
   defer s.lock.Unlock()
   // 写多读少
}

func (s *SafeMap[K, V]) ReadOrWrite() {
   s.lock.RLock()
   //  读的操作 例如第一次检查
   s.lock.RUnlock()

   s.lock.Lock()
   defer s.lock.Unlock()
}
复制代码
  • 可以考虑使用函数式写法,如延迟初始化
type valProvider[V any] func() V

func (s *SafeMap[K, V]) LoadOrStoreJHeavy(key K, p valProvider[V]) (val interface{}, loaded bool) {
   oldVal, ok := s.Get(key)
   if ok {
      return oldVal, false
   }
   s.lock.Lock()
   defer s.lock.Unlock()
   val, ok = s.m[key]
   if ok {
      return val, true
   }
   newVal := p() // 延迟初始化
   s.m[key] = newVal
   return newVal, false

}
复制代码
  • Mutex 和 RWMutex 都是不可重入的
var l = sync.RWMutex{}

func RecursiveA() {
   l.Lock()
   defer l.Unlock()
   RecursiveB()
}

func RecursiveB() {
   RecursiveC()
}

func RecursiveC() {
   l.Lock()
   defer l.Unlock()
   RecursiveA()
}
复制代码
  • 尽可能用 defer 来解锁,避免 panic
func (a *SafeArrayList[T]) Set(index int, t T) error {
   a.lock.Lock()
   defer a.lock.Unlock() // 尽量用defer解锁, 避免panic
   return a.Set(index, t)
}
复制代码

4、Mutex 面试要点

  • Mutex 的公平性:Go 的锁是不公平锁。为什么它不设计为公平锁?
  • Mutex 的两种模式,以及两种模式的切换时机:
    • 正常模式
    • 饥饿模式
  • 为什么 Mutex 要设计出来这两种模式?这个问题基本等价于为什么它不设计为公平锁。
  • 如果队列里面有 goroutine 在等待锁,那么新来的 goroutine 有可能拿到锁吗?当然,而且大概率。
  • Mutex 是不是可重入的锁?显然不是。
  • RWMutex 和 Mutex 有什么区别?如何选择这两个?几乎完全是写操作的选 Mutex,其它时候优先 选择RWMutex。
  • Mutex 是怎么做到挂起 goroutine 的,以及是如何唤醒 goroutine 的?在这个语境下,只需要回答 sema 这个字段以及 runtime_Semacquire 和 runtime_Semrelease 两个调用就可以。

2.2 Once

sync.Once 一般就是用来确保某个动作至多执行一 次; 普遍用于初始化资源和单例模式。

type Singleton struct {
   data string
}

var singleInstance *Singleton
var once sync.Once

func GetSingletonObj() *Singleton {
   once.Do(func() {   // 只执行一次实现单例模式
      fmt.Println("Create Obj")
      singleInstance = new(Singleton)
   })
   return singleInstance
}
复制代码

1、Once 细节

源码如下:

这是一种 double-check 的变种。 没有直接利用读写锁,而是利用原子操作来扮演读锁的角色。 是值得学习的做法。

2.Once 例子:

  • Beego 用 Once 来初始化 Web 模块

initBeforeHTTPRun 可能会在多个地方被调用,所 以需要使用 Once 来确保只会执行一次。

从 TODO 标记也可以看出来,在一些情况下, Once 可能暗示着代码可以放到包初始化方法 init 里面调用。

2.3 Pool

1、Pool 介绍

一般情况下,如果要考虑缓存资源,比如说创建好的对象,那么可以使用 sync.Pool。

    • sync.Pool 会先查看自己是否有资源,有则直接返回, 没有则创建一个新的;
  • sync.Pool 会在 GC 的时候释放缓存的资源;

一般是用 sync.Pool 都是为了复用内存:

  • 减少了内存分配,也减轻了 GC 压力(最主要)
  • 减少消耗 CPU 资源(内存分配和 GC 都是 CPU 密集操作)

func TestPool(t *testing.T) {
   p := sync.Pool{
      // 创建函数,sync.Pool 会回调
      New: func() interface{} {
         return "111"
      },
   }

   obj := p.Get()

   fmt.Println(obj) //  111
   // 在这里使用取出来的对象
   // 用完再还回去
   p.Put(obj)

}
复制代码
func SyncPool() {
   pool := &sync.Pool{
      New: func() interface{} {
         fmt.Println("create a new obj")
         return 100
      },
   }

   v := pool.Get().(int) // 对象没有则创建, 并将此对象返回弹出 sync.Pool
   fmt.Println(v)
   pool.Put(3)  // 将 3 代替 100 put
   runtime.GC() // GC 会清除 sync.Pool 中缓存的对象
   v1, _ := pool.Get().(int)
   fmt.Println(v1)
   v2, _ := pool.Get().(int)
   fmt.Println(v2)
}
复制代码

2、Pool 细节 假如说要实现一个类似功能的Pool,可以考虑用什么方案?

最简单的方案,就是用队列,而且是并发安全的队列。队头取,队尾放回去。在队列为空的时候创建一个新的。

问题:队头和队尾都是竞争点,依赖于锁。

沿着这种思路,就可以用 channel来实现一个简单的连接池:

很显然,既然全局锁会成为瓶颈,我们就避免全局锁,逼不得已的时候再去尝试全局锁。 那么可以考虑的方案就是 TLB(thread-local-buffer)。每个线程自己搞一个队列,再来一个共享的队列。

Go有更好的选择。Go本身的GMP调度模型,其中P是一个神奇的东西,代表的是处理器(Processor)。P的优点:任何数据绑定在P上,都不需要竞争,因为P同一时间只有一个G在运行。

同时,Go并没有采用全局共享队列的方案,而是采用了窃取的方案

Go 的设计:

  • 每个 P 一个 poolLocal 对象
  • 每个 poolLocal 有一个 private 和 shared
  • shared 指向的是一个 poolChain。poolChain 的数据会被别的 P 给偷走
  • poolChain 是一个链表 + ring buffer 的双重结构
  • 从整体上来说,它是一个双向链表
  • 从单个节点来说,它指向了一个 ring buffer。后一个节点的 ring buffer 都是前一个节点的两倍

ring buffer 优势(实际上也可以说是数组的优势):

  • 一次性分配好内存,循环利用
  • 对缓存友好

(1) Pool GET 步骤

所以,稍微思考一下就可以总结 Get 的步骤:

  • 看 private 可不可用,可用就直接返回,
  • 不可用则从自己的 poolChain 里面尝试获取一个;
    • 从头开始找。注意,头指向的其实是最近创建的 ringbuffer;
    • 从队头往队尾找;
  • 找不到则尝试从别的 P 的 poolChain 里面偷一个出来。偷的过程就是全局并发,因为理论上来说,其它 P 都可能恰好一起来偷了;
    • 偷是从队尾偷的
  • 如果偷也偷不到,那么就会去找缓刑(victim)的;
  • 连缓刑的也没有,那就去创建一个新的。

(2) Pool PUT 步骤

  • private 要是没放东西,就直接放 private, 否则,准备放 poolChain;
    • 如果 poolChain 的 HEAD 还没创建,就创建一个HEAD,然后创建一个 8 容量的 ring buffer,把数据丢过去;
    • 如果 poolChain 的 HEAD 指向的 ring buffer 没满,则丢过去 ring buffer;
    • 如果 poolChain 的 HEAD 指向的 ring buffer 已经满了,就创建一个新的节点,并且创建一个两倍容量的ring buffer,把数据丢过去;

3、Pool 与 GC

正常情况下,我们设计一个 Pool 都要考虑容量和淘汰问题(基本类似于缓存):

  • 我们希望能够控制住 Pool 的内存消耗量
  • 在这个前提下,我们要考虑淘汰的问题

Go 的 sync.Pool 就不太一样。它纯粹依赖于 GC,用户完全没办法手工控制。 sync.Pool 的核心机制是依赖于两个

  • locals
  • victim:缓刑

    GC 的过程也很简单:
  • locals 会被挪过去变成 victim
  • victim 会被直接回收掉

复活:如果 victim 的对象再次被使用,那么它就会被丢回去 locals,逃过了下一轮被 GC 回收掉的命运

优点:防止 GC 引起性能抖动

(1) poolLocal 和 false sharding

每一个 poolLocal 都有一个 pad 字段,是用于将poolLocal 所占用的内存补齐到 128 的整数倍。在并发下:所有的对齐基本上都是为了独占 CPU 高速缓存的 CacheLine

(2) Pool 为什么最后采取找 victim 的

前面的步骤,有一个令人困惑的点是:偷不到别的 P时,再去找缓刑(victim)。

那么问题来了:偷是一个全局竞争的过程,但是找victim 不是,找 victim 和找正常的是一样的过程。显然先找 victim 会有更好的性能,那么为什么要偷一把呢?

因为 sync.Pool 希望 victime 里面的对象尽可能被回收掉。

4、代码演示

(1) 利用 Pool 实现简单的 buffer 池

Pool 还是比较简单的,大多数情况下都可以直接使用。但是在一些场景下,我们需要更加精细的控制,那么就会尝试自己封装一下 Pool。

要考虑:

  • 如果一个 buffer 占据了很多内存,要不要放回去?
  • 怎么控制整个池的内存使用量?因为依托于 GC 是比较不可控的:
    • 控制单个 buffer 上限?
    • 控制 buffer 数量?
    • 控制总体内存?
type MyPool struct {
   p      sync.Pool
   maxCnt int32
   cnt    int32
}

func (p *MyPool) Get() any {
   return p.p.Get()
}

func (p *MyPool) Put(val any) {
   // 大对象不放回去
   if unsafe.Sizeof(val) > 1024 {
      return
   }

   p.p.Put(val)
}
复制代码

(2) 利用泛型封装 Pool

  • 唯一要注意的点是这种封装带来的性能损耗。
  • 本身也就是利用装饰器模式给 Pool 加上泛型的功能。
type BigObjHandler struct{}

func (h *BigObjHandler) DiscardBigObj(val any) any {
   if unsafe.Sizeof(val) > 1024 {
      return nil
   }
   return val
}

type MyPool[V any] struct {
   BigObjHandler // 大对象处理装饰器
   p             sync.Pool
}

func (p *MyPool[V]) Get() V {
   return p.p.Get()
}

func (p *MyPool[V]) Put(val V) {
   v := p.DiscardBigObj(val)
   if v == nil {
      return
   }
   p.p.Put(v)
}
复制代码

5、开源实例

(1) bytebufferpool 实现要点

Github:github.com/valyala/byt…

  • 也是依托于 sync.Pool 进行了二次封装
  • defaultSize 是每次创建的 buffer 的默认大小,超过maxSize 的 buffer 就不会被放回去
  • 统计不同大小的 buffer 的使用次数。例如 0-64bytes 的 buffer 被使用了多少次。这个我们称为分组统计使用次数
  • 引入了所谓的校准机制,其实就是动态计算defaultSize 和 maxSize

我们搞 buffer 缓存,就是希望这些buffer 的 size 最好是恰好符合我们希望的。过小会扩容,过大不会浪费内存。所以 bytebufferpool 就根据使用次数来决定:

  • 新创建的多大
  • 超过多大的就没必要放回来

6、面试要点

基本上,sync.Pool 面试的热点就是两个:

  • sync.Pool 和 GC 的关系:数据默认在 local 里面,GC 的时候会被挪过去 victim 里面。如果这时候有P 用了 victim 的数据,那么数据会被放回去 local 里面。
  • poolChain 的设计:核心在于理解 poolChain 是一个双向链表加 ring buffer 的双重结构。

由这两个核心衍生出来的各种问题:

  • 什么时候 P 会用 victim 的数据:偷都偷不到的时候。
  • 为什么 Go 会设计这种结构?一个全局共享队列不好吗?这个问题要结合 TLB 来回答,TLB 解决全局锁竞争的方案,Go 结合自身 P 这么一个优势,设计出来的。
  • 窃取:这个可以作为一个刷亮点的东西,结合 GMP 调度里面的工作窃取,原理都是一样的。
  • 使用 sync.Pool 有什么注意点(缺点、优点)?高版本的 Go 里面的 sync.Pool 没特别大的缺点,硬要说就是内存使用量不可控,以及 GC 之后即便可以用 victim,Get 的速率还是要差点。

2.4 WaitGroup

WaitGroup 是用于同步多个 goroutine 之间工作的。常见场景是我们会把任务拆分给多个 goroutine 并行完成。在完成之后需要合并这些任务的结果,或者需要等到所有小任务都完成之后才能进入下一步。

  • 要在开启 goroutine 之前先加1
  • 每一个小任务完成就减1
  • 调用 Wait 方法来等待所有子任务完成

容易犯错的地方是 +1 和 -1 不匹配(非常不好测试):

  • 加多了导致 Wait 一直阻塞,引起 goroutine 泄露
  • 减多了直接就 panic
func waitGroup() {
   wg := sync.WaitGroup{}
   var res int64 = 0
   for i := 0; i < 10; i++ {
      wg.Add(1)
      go func(delta int) {
         atomic.AddInt64(&res, int64(delta))
         defer wg.Done()
      }(i)

   }
   wg.Wait()
   fmt.Println(res)
}
复制代码

1、WaitGroup 细节

WaitGroup 从使用方式来看,就知道要实现类似功能,至少需要:

  • 记住当前有多少个任务还没完成
  • 记住当前有多少 goroutine 调用了 wait 方法
  • 然后需要一个东西来协调 goroutine 的行为

所以,按照道理来说,只需要设计三个字段来承载这个功能,然后搞个锁来维护这三个字段就可以了。

(1) WaitGroup 定义:

  • noCopy:主要用于告诉编译器说这个东西不能复制。在 sync 包里面很多结构体有这个字段。我们也可以使用,比如说在 Dubbo-go 的URL 结构体使用了这个技巧。
  • state1:在 64 位下,高 32 位记录了还有多少任务在运行;低 32 位记录了有多少 goroutine在等 Wait() 方法返回
  • state2:信号量,用于挂起或者唤醒goroutine,约等于 Mutex 里面的 sema 字段(要注意横向对比)
    • 本质上,WaitGroup 是一个无锁实现,严重依赖于 CAS 对 state1 的操作。
    • 一大堆的注释就是解释 32 位对齐和 64 位对

齐,WaitGroup 要做一些处理。阅读源码的时

候不必纠结这个细节。

于 CAS 对 state1 的操作。

这是 Dubbo-go里面防止核心类URL 复制的做法

根据这两个字段我们可以进一步猜测 WaitGroup 的实现细节:

  • Add:看上去就是 state1 的高 32 位自增 1,原子操作一把梭
  • Done:看上去就是 state1 的高 32 位自减 1,原子操作一把梭,然后看看是不是要唤醒等待 goroutine,其实 Done 就相当于 Add(-1)
  • Wait:看上去就是 state1 的低 32 位自增 1,同时利用 state2 和runtime_Semacquire调用把当前 goroutine 挂起

WaitGroup 代码难理解,就在于这些问题,要充分考虑各种并发场景。

(2) WaitGroup Add 方法

要唤醒等待的 goroutine 计数器 -1 ,直到为0

唯一要注意的就是这里并没有用原子操作,因为高 32 位可能也在操作。

而前面 Add 方法可以用原子操作,是因为 Add 方法不关心等待者的数量。只有在唤醒 goroutine 的时候才会考虑等待者数量,但是这个数量是从原子操作的返回值里面解析出来。

2、与 errgroup 对比 WaitGroup 和 errgroup.Group 是很相似的,可以认为 errgroup.Group 是对 WaitGroup 的封装。

  • 首先需要引入 golang.org/x/sync 依赖
  • errgroup.Group 会帮我们保持进行中任务计数
  • 任何一个任务返回 error,Wait 方法就会返回error
func Errgroup() {
   eg := errgroup.Group{}
   var res int64 = 0
   for i := 0; i < 10; i++ {
      delta := i
      eg.Go(func() error {
         atomic.AddInt64(&res, int64(delta))
         return nil
      })
   }
   if err := eg.Wait(); err != nil {
      panic(err)
   }
   fmt.Println(res)
}
复制代码

3、WaitGroup 面试要点

  • 面试官可能预设一个场景,比如说他问如何协调 goroutine 之间的工作,那么 WaitGroup 是可以的。
  • WaitGroup 设计里面的 state1 的特点。
  • WaitGroup 里面的 Wait 是怎么做到的?核心就是借助于 state2 这个字段,利用了runtime_Semaquire 和 runtime_Semrelease 两个调用。可以强调 Mutex 也是类似机制。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值