【Go】扩展并发原语之errgroup.Group的实战与源码解读

Go 语言是如今高并发服务的首选编程语言之一,其自带的go关键字、channel 类型以及标准库syncsync/atomic中较为丰富的并发原语,使得 Gopher 们能够很顺手地编写出并发程序。Go 官方也利用了这些特性,维护了一个扩展的并发原语库,这里面提供了功能更丰富、使用更便利、封装性更好的并发原语,该扩展并发库的地址为golang.org/x/sync

本文就将对 Go 官方扩展并发库golang.org/x/syncerrgroup.Group的使用与实现进行讲解。在阅读本文时,希望你已经对 Go 基础(如 channel)以及标准库并发原语(如WaitGroupOnceContext)有一定的了解。

回顾 sync.WaitGroup

在开始了解errgroup.Group之前,我们先回顾一下 Go 标准库sync包中WaitGroup在并发编程中的使用。

如同其名字一样,sync.WaitGroup用于等待一组 goroutine 的执行结束。如下面的一段代码中,我们在main函数的主 goroutine 中启动了 4 个 goroutine,使用sync.WaitGroup中的Wait方法,等待 4 个 goroutine 的执行结束,否则main函数执行结束程序会直接退出,并不会等待启动的 4 个 goroutine 执行完成:

go 复制代码
package main

import (
 "fmt"
 "sync"
 "time"
)

func main() {
 var wg sync.WaitGroup
 wg.Add(4) // WaitGroup计数加4,启动4个goroutines
 for i := 0; i < 4; i++ {
  go func(i int) {
   defer wg.Done() // WaitGroup计数减1,表示一个goroutine执行完成
   time.Sleep(500 * time.Millisecond)
   fmt.Println("goroutine", i, "work done.")
  }(i)
 }

 wg.Wait() // 阻塞,等待所有goroutine执行完成
}

// OUTPUT:
// goroutine 1 work done.
// goroutine 2 work done.
// goroutine 3 work done.
// goroutine 0 work done.

简述sync.WaitGroup的原理就是在其内部有着一个计数器,用于记录当前未执行完成的 goroutine 数量,使用者使用封装的AddDone方法分别对这个计数器进行加减,当计数器为减为0时,唤醒调用Wait方法阻塞的 goroutine。

errgroup.Group 的使用

Go 扩展并发库中errgroup.Group原语有着与标准库sync.WaitGroup同样是对一组goroutine进行管理,且有着相似的 API,但前者提供了功能更强大、使用更便捷的封装:

  • 封装AddDone方法,无需手动调用;
  • 更方便的错误捕获与处理;
  • 可使用Context取消 goroutine 的执行;
  • 限制同一时刻并行执行的 goroutine 数量。

下面来通过一个简单的例子看一下errgroup.Group的基础使用:

go 复制代码
package main

import (
 "errors"
 "fmt"
 "time"

 "golang.org/x/sync/errgroup"
)

func main() {
 var eg errgroup.Group
 for i := 0; i < 4; i++ {
        // 注意闭包中的变量捕获问题 https://golang.org/doc/faq#closures_and_goroutines
  i := i
  eg.Go(func() error {
   if i == 2 {
    // 其中一个 goroutine 返回错误
    return errors.New("goroutine 2 got an error")
   }
   time.Sleep(500 * time.Millisecond)
   fmt.Println("goroutine", i, "work done.")
   return nil
  })
 }

 if err := eg.Wait(); err != nil {
  fmt.Println(err.Error())
  return
 }
}

// OUTPUT:
// goroutine 3 work done.
// goroutine 1 work done.
// goroutine 0 work done.
// goroutine 2 got an error

上述程序与上小节sync.WaitGroup例子基本一致,只不过令i=2时启动的 goroutine 返回一个错误,以展示errgroup.Group的错误处理。errgroup.Group可以直接通过var声明变量进行使用(称之为零值初始化),使用Go方法启动一个 goroutine。在主 goroutine 中调用Wait方法等待所有 goroutine 执行完成,不同与sync.WaitGroup中的Wait方法,errgroup.GroupWait会返回一个error,其值是所有启动的 goroutine 中返回的第一个错误。这里值得注意的是,例如这段示例代码,即使 goroutine 2 已经返回了错误,Wait也会等待其它启动的 goroutine 结束才会结束阻塞,返回error

除了零值初始化,errgroup提供了另外一种初始化errgroup.Group的函数------WithContext,向该函数传入一个Context,然后会返回一个errgroup.Group实例,以及一个从传入的Context派生出来的可以被取消的Context(称为cancelCtx):

go 复制代码
func WithContext(ctx context.Context) (*Group, context.Context)

通过这种方式初始化的errgroup.Group可以通过返回的cancelCtx对 goroutine 的执行进行控制,且如果某个执行的 goroutine 返回了错误,那么都会调用这个cancelCtx对应的cancel函数,即触发cancelCtx的取消。还是以上面的实例代码为基础,这次我们希望当i=2的 goroutine 返回错误后,其它 goroutine 立刻终止并返回:

go 复制代码
package main

import (
 "context"
 "errors"
 "fmt"
 "time"

 "golang.org/x/sync/errgroup"
)

func main() {
 eg, ctx := errgroup.WithContext(context.Background())
 for i := 0; i < 4; i++ {
  i := i
  eg.Go(func() error {
   if i == 2 {
    return errors.New("goroutine 2 got an error")
   }
   select {
   case <-time.After(500 * time.Millisecond):
   case <-ctx.Done():
    return ctx.Err()
   }
   fmt.Println("goroutine", i, "work done.")
   return nil
  })
 }

 if err := eg.Wait(); err != nil {
  fmt.Println(err.Error())
  return
 }
}

// OUTPUT:
// goroutine 2 got an error

上述代码使用了一个timer------time.After(500 * time.Millisecond)来模拟了 goroutine 的一次具体工作,使用select同时对timercancelCtx进行了监听。由于i=2的 goroutine 会立刻返回错误,执行时间肯定远快于500ms,因此其它 goroutine 会首先监听到cancelCtx事件,并在timer结束前返回。

errgroup.Group默认可以通过Go方法启动无限多个 goroutine,但在处理数据量比较大时,无节制地启用 goroutine 很可能会将主机资源耗尽,因此errgroup.Group还提供了SetLimit(n int)方法,可以限制当前errgroup.Group实例下活跃 goroutine 的数量最大为n,当传入值为负数则代表没有限制。若当前活跃的 goroutine 数量已经达到了n,此时再调用Go方法便会被阻塞住,直到活跃 goroutine 数量降至n以下。

然而在实际开发场景中,我们并不是都期望阻塞的,因此配合SetLimit方法,errgroup.Group同样提供了一个TryGo方法:

go 复制代码
func (g *Group) TryGo(f func() error) bool

TryGo会尝试执行传入的f函数,其仅会在当前活跃 goroutine 数量符合限制时才会启动 goroutine 去执行f,否则直接非阻塞地返回false。这里要注意的是,TryGo返回的布尔值只是用于代表该 goroutine 是否被成功启动,而执行成功与否是该返回值是无法体现的。

errgroup.Group虽然提供了对错误的返回,但是只能返回所有启动的 goroutine 产生的第一个错误,如果不做额外扩展的话后面所产生的错误都会被丢弃,然而在很多时候,获取每个 goroutine 的错误情况又是很必要的。解决思路其实也不复杂,我们可以创建一个长度与所启动 goroutine 数量相同的切片(如果数量固定也可以使用数组,这样性能更好)用于存放每个 goroutine 的错误情况。每个 goroutine 在执行完成后,将其错误值存放到切片对应的索引位置。

下面这段代码中启动了 10 个 goroutine,我们令i为偶数时的 goroutine 返回错误:

go 复制代码
package main

import (
 "errors"
 "fmt"

 "golang.org/x/sync/errgroup"
)

func main() {
 var g struct {
  eg   errgroup.Group
  errs []error
 }

 const jobCount = 10 // 启动goroutine的数量

 g.errs = make([]error, jobCount) // 用于保存每个goroutine的错误情况
 for i := 0; i < jobCount; i++ {
  i := i
  g.eg.Go(func() (err error) {
   defer func() {
    g.errs[i] = err // 保存每个goroutine的错误情况至errs对应的位置
   }()
   if i%2 == 0 {
    err = fmt.Errorf("goroutine %d got an error", i)
   }
   return
  })
 }

 if err := g.eg.Wait(); err != nil {
  fmt.Println(errors.Join(g.errs...).Error())
 }
}

// OUTPUT:
// goroutine 0 got an error
// goroutine 2 got an error
// goroutine 4 got an error
// goroutine 6 got an error
// goroutine 8 got an error

除了对多个错误进行捕获外,同样的思路也可以用于保存 goroutine 所产生的计算结果。

errgroup.Group 实战

在本节我们将利用errgroup.Group完成一个单词数量统计的小项目。需求是实现一个二进制程序(这里起名wc,意为 word count),可以读取文本文件,并对其中的单词进行词频统计,并打印在标准输出,例如:

shell 复制代码
$ ./wc -f article.txt
a                 9
above             3
abundantly        6
after             3
be                4
bearing           2
beast             4

为了实现该需求,我们采用的是 MapReduce 的思想,对输入的文本内容主要进行以下几个步骤的处理:

  1. Map 阶段:将输入的文本进行单词分割,将每个映射为"单词------频数"的键值对(表示为(word: count)),每个单词初始频次都是1
  2. Sort 阶段:将所有(word: count)word进行排序;
  3. Reduce 阶段:将排序好的(word: count)按照word进行count的合并。

MapReduce 是一种处理数据的方式,最早是由 Google 公司研究提出的一种面向大规模数据处理的并行计算模型和方法,开源的版本是 Hadoop。本节项目的实现就是采用了 MapReduce 的思想,实现了一个单机单进程的 MapReduce。

由于篇幅限制,文章中将只展示核心部分代码,完整代码请详见 GitHub 仓库:wc-example

我们先来看一下主函数main的大致代码:

go 复制代码
func main() {
 // 省略了部分非核心代码,详见代码仓库

 f, err := os.Open(inputFile)
 if err != nil {
  _, _ = fmt.Fprintf(os.Stderr, "failed to open file: %s\n", err.Error())
  os.Exit(1)
 }
 defer f.Close()

 eg, ctx := errgroup.WithContext(ctx)
 eg.SetLimit(runtime.GOMAXPROCS(0)) // 设置 goroutine 数量为 CPU 核心数
 input := getInputStream(ctx, eg, f)
 mapped := mapper(ctx, eg, input, mapFn)
 sorted := sorter(ctx, eg, mapped)
 reduced := reducer(ctx, eg, sorted)

 eg.Go(func() error {
  for wc := range reduced {
   if _, err := fmt.Printf("%-15s%4d\n", wc.word, wc.count); err != nil {
    return err
   }
  }
  return nil
 })

 if err := eg.Wait(); err != nil {
  _, _ = fmt.Fprintf(os.Stderr, "failed to process file: %s\n", err.Error())
  os.Exit(1)
 }
}

主函数中比较核心的代码就是初始化errgroup.Group实例eg后,对getInputStreammappersorterreducer函数的调用了。其中getInputStream是将输入文本转化为可读 channel,后三者则分别对应了 MapReduce 的三个步骤,它们每个步骤所产生的 channel 都作为下一步操作的输入 channel,reducer产生的可读 channel 最终由eg启动的另一 goroutine 进行读取与输出。以上正是采用了 Go 官方推荐的并发编程范式之一:管道模式

下面我们将分别对这四个关键函数进行展开。

读取文件

go 复制代码
func getInputStream(ctx context.Context, eg *errgroup.Group, r io.Reader) <-chan string {
 ch := make(chan string)

 eg.Go(func() error {
  defer close(ch)
  sc := bufio.NewScanner(r)
  for sc.Scan() {
   line := sc.Text()
   select {
   case ch <- line:
   case <-ctx.Done():
    return ctx.Err()
   }
  }
  return sc.Err()
 })

 return ch
}

getInputStream先初始化了一个chan string类型的 channel ch,随后通过eg.Go启动了一个 goroutine 来读取 r 中的数据。该 goroutine 中的闭包函数使用了bufio.Scanner进行文件读取,每次读取一整行line后就会向ch发送,这里需要使用 select 判断下传入的eg的上下文ctx是否被取消,如果被取消了就直接返回ctx.Err(),这种处理在后续其它函数中是很常见的。

Map 阶段

在 map 阶段,我们需要将输入的文本进行单词分割,并将每个单词映射为"单词------频数"的键值对,即wordCount类型:

go 复制代码
type wordCount struct {
 word  string
 count int
}
go 复制代码
var nonAlpha = regexp.MustCompile("[^a-zA-Z]+")

func mapFn(line string) []wordCount {
 var result []wordCount
 for _, w := range strings.Fields(line) {
  // 通过正则替换掉掉非字母字符,并转换成小写
  w = strings.ToLower(nonAlpha.ReplaceAllString(w, ""))
  if w != "" {
   result = append(result, wordCount{word: w, count: 1})
  }
 }
 return result
}

func mapper(ctx context.Context, eg *errgroup.Group, input <-chan string, fn func(string) []wordCount) <-chan wordCount {
 ch := make(chan wordCount)

 eg.Go(func() error {
  defer close(ch)
  for l := range input {
   for _, wc := range fn(l) {
    select {
    case ch <- wc:
    case <-ctx.Done():
     return ctx.Err()
    }
   }
  }
  return nil
 })

 return ch
}

mapper函数接收文件输入的只读 channel input,并返回一个元素类型为wordCount的 channel chmapper函数启动了一个 goroutine,其闭包函数中使用了fn函数对input中的每一行文本进行处理。

为了使得代码清晰,这里将fn函数的具体实现与mapper解耦。其具体实现是mapFn函数,它将每一行文本分割为独立的单词,使用正则表达式将非字母字符替换为空字符串,并将单词统一转换为小写,最后将每个单词映射为wordCount类型的键值对。

Sort 阶段

mapper函数中,我们将每一行文本映射为了多个wordCount类型的键值对,但是这些键值对是无序的,因此需要在sorter函数中对其进行排序:

go 复制代码
func sorter(ctx context.Context, eg *errgroup.Group, input <-chan wordCount) <-chan wordCount {
 ch := make(chan wordCount)

 eg.Go(func() error {
  defer close(ch)
  wcHeap := new(wordCountHeap)
  for wc := range input {
   select {
   case <-ctx.Done():
    return ctx.Err()
   default:
    heap.Push(wcHeap, wc)
   }
  }

  for wcHeap.Len() > 0 {
   select {
   case ch <- heap.Pop(wcHeap).(wordCount):
   case <-ctx.Done():
    return ctx.Err()
   }
  }

  return nil
 })

 return ch
}

在这段代码中我们从只读 channel input中读取wordCount类型的键值对,然后将其放入一个wordCountHeap类型的最小堆中,最后从最小堆中取出并发送到返回的 channel ch中。我们需要注意这里的同步关系------从堆中读取数据必须发生在向堆中写完所有 map 数据的之后,也就是意味着 reduce 必须发生在 sort 完成之后。

sorter排序所使用的wordCountHeap类型最小堆实现了标准库heap包中的heap.Interface接口。为了实现起来方便,我们这里直接基于 slice 实现了一个最小堆:

go 复制代码
type wordCountHeap []wordCount

func (w *wordCountHeap) Len() int {
 return len(*w)
}

func (w *wordCountHeap) Less(i int, j int) bool {
 return (*w)[i].word < (*w)[j].word
}
func (w *wordCountHeap) Swap(i int, j int) {
 (*w)[i], (*w)[j] = (*w)[j], (*w)[i]
}

func (w *wordCountHeap) Pop() any {
 v := (*w)[len(*w)-1]
 *w = (*w)[:len(*w)-1]
 return v
}

func (w *wordCountHeap) Push(x any) {
 *w = append(*w, x.(wordCount))
}

基于 slice 的最小堆wordCountHeap会将数据全部存于内存中,在实际生产中可能会遇到很大的文件,这时候我们就需要考虑其它的堆实现方式了,不过这不是本文的重点。

Reduce 阶段

Reduce 阶段会将排序后的 wordCount 键值对按照 word 进行 count 的合并,最终得到每个 word 的总 count,reduce 阶段处理后产生的 channel ch中的数据关于word是有序且唯一的:

go 复制代码
func reducer(ctx context.Context, eg *errgroup.Group, input <-chan wordCount) <-chan wordCount {
 ch := make(chan wordCount)

 eg.Go(func() error {
  defer close(ch)
  var wc wordCount
  for in := range input {
   if wc.word != in.word {
    if wc.word != "" {
     select {
     case ch <- wc:
     case <-ctx.Done():
      return ctx.Err()
     }
    }
    wc = in
   }
   wc.count += in.count
  }
  return nil
 })

 return ch
}

由于篇幅限制,这里只展示了wc的核心代码,主要展示了errgroup.Group实现 MapReduce 的核心思路。完整的项目除了文中数据处理的核心代码,还需要考虑 flag 参数解析、日志打印、程序优雅退出等,这些代码都可以在仓库中找到。

源码走读

Group 结构体

首先来看一下errgroup.Group这个结构体,字段并不复杂,每个字段的意义已经写在了注释当中:

go 复制代码
type Group struct {
 cancel func(error) // cancelCtx的取消函数
 wg sync.WaitGroup  // 等待goroutine执行
 sem chan token     // 基于channel的信号量
 errOnce sync.Once  // 确保err只被赋值一次
 err     error    // 保存启动的goroutine所返回的第一个错误
}

我们可以看到,errgroup.Group内部封装了sync.WaitGroup,用于等待 goroutine 的执行;err则用于保存启动的 goroutine 执行所返回的第一个错误,由于多个 goroutine 并发执行,因此使用了sync.Once确保err只被第一个错误赋值;sem是一个基于 channel 实现的信号量,用于控制 goroutine 的并发执行数量,在下文中会有详细的介绍;cancel是当通过WithContext函数初始化时,才会有的cancelCtx的取消函数。

由此可见,如果我们使用var eg errgroup.Group这样的零值初始化,会默认使cancelsem为空值nil,即不使用cancelCtx去空值 goroutine 的取消,且 goroutine 并发执行数量也没有限制。

WithContext

我们再来看下另一种初始化方式的WithContext函数源码:

go 复制代码
func WithContext(ctx context.Context) (*Group, context.Context) {
 ctx, cancel := withCancelCause(ctx)
 return &Group{cancel: cancel}, ctx
}

//go:build go1.20
func withCancelCause(parent context.Context) (context.Context, func(error)) {
 return context.WithCancelCause(parent) // 该函数是go1.20版本及以上才提供的
}

根据传入的父Context,调用withCancelCause函数生成一个可取消的cancelCtx和对应的取消函数cancel,赋值给返回的errgroup.Group实例。

这个withCancelCause函数根据编译代码所使用的 Go 语言版本,其实现是不同的。例如在上面的代码中是 go1.20 版本及以上的withCancelCause函数实现,它对context.WithCancelCause函数进行了封装。context.WithCancelCause函数是 go1.20 版本更新时加入到标准库context中的,与其前辈context.WithCancel函数不同的是,其返回的cancel函数参数可以传入一个errorcancel函数类型为context.CancelCauseFunc),而后者的cacel函数却无法传参(为context.CancelFunc类型)。context.CancelCauseFunc类型的取消函数能够传入错误信息,在实际开发中更为实用,因此也推荐大家使用新版本的 Go 进行开发。

这里也给出 go1.20 版本以下的withCancelCause函数实现,但细节不再赘述:

go 复制代码
//go:build !go1.20
func withCancelCause(parent context.Context) (context.Context, func(error)) {
 ctx, cancel := context.WithCancel(parent)
 return ctx, func(error) { cancel() }
}

Go

下面我们来看最关键的Go函数的实现:

go 复制代码
func (g *Group) Go(f func() error) {
 if g.sem != nil {
  g.sem <- token{} // 尝试获取一个资源
 }

 g.wg.Add(1)
 go func() {
  defer g.done()

  if err := f(); err != nil {
   g.errOnce.Do(func() {
    g.err = err
    if g.cancel != nil {
     g.cancel(g.err)
    }
   })
  }
 }()
}

抛开对信号量sem的操作,Go函数实际上就是对sync.WaitGroup的封装。使用Add(1)增加一次计数后,启动一个 goroutine 去执行传入的函数f。如果函数f的执行返回了错误,那么将在errOnce.Do中尝试以下两步处理:

  • 对内部的err进行赋值。由于sync.Once在并发场景下的单例性,只有所有 goroutine 的第一个错误会被赋值给err
  • 如果内部cancel函数不为空值nil,那么就执行cancel函数,传入err。同样由于sync.Once在并发场景下的单例性,只有所有 goroutine 的第一个错误会被作为cancel的错误原因。

以上处理完成后,会执行defer执行的g.done函数:

go 复制代码
func (g *Group) done() {
 if g.sem != nil {
  <-g.sem // 释放一个资源
 }
 g.wg.Done()
}

g.done函数的实现很简单,忽略信号量sem的操作,就只是执行了wg.Done

Wait

下面我们看下用于等待Go函数所启动的 goroutine 执行完成的Wait函数的实现:

go 复制代码
func (g *Group) Wait() error {
 g.wg.Wait()
 if g.cancel != nil {
  g.cancel(g.err)
 }
 return g.err
}

同样不复杂,Wait的阻塞等待就是依靠wg.Wait,当 goroutine 执行完成,wg.Wait从阻塞中唤醒,此时如果cancel函数不为空,就会执行它。这里要重点注意的是,不论内部的错误err是否为nilcancel都会执行,并传入err,即cancelCtx都会被取消掉,因此通过WithContext初始化errgroup.Group实例时所返回的cancelCtx务必只用于该实例相关的任务执行

基于 channel 的信号量

接下来我们集中对errgroup.Group内部实现的信号量机制进行讲解。

sem为一个chan token类型的 channel,其中token就是个空的结构体,在 Go 语言中空结构体并不占用实际内存,在我们不关心 channel 值时,将 channel 元素类型设为struct{}是个很好的实践:

go 复制代码
type token struct{}

了解 Go 语言 channel 的朋友们应该知道,channel 总共分为无缓冲有缓冲两种类型的 channel,简述其区别:

  • 无缓冲 channel:当向 channel 发送数据时,发送者会被阻塞,直到有接收者接收数据;当从 channel 接收数据时,接收者会被阻塞,直到有发送者发送数据;
  • 有缓冲 channel:当向 channel 发送数据时,如果 channel 缓冲区未满,发送者不会被阻塞,否则发送者会被阻塞,直到有接收者接收数据;当从有缓冲 channel 接收数据时,如果 channel 缓冲区不为空,接收者不会被阻塞,否则接收者会被阻塞,直到有发送者发送数据。

根据 channel 的特性,当我们使用make(chan token, n)初始化一个缓冲区大小为n的 channel,如果把缓冲区的槽位看作资源,相当于初始化了一个资源数量为n的信号量。此时向 channel 发送数据可以被看作获取资源,而接收数据则被看作释放资源。如果没有接收者,则前n次向 channel 发送都不会阻塞,而第n+1次发送则会阻塞,实现了资源的限制,直到有 channel 数据被接收。

Go方法中,如果sem不为空,首先要向sem中发送一个token{},相当于获取一个资源,只有获取资源成功才可以继续进行 goroutine 的启动:

go 复制代码
func (g *Group) Go(f func() error) {
 if g.sem != nil {
  g.sem <- token{}
 }
    // 省略后续代码
}

Go类似但非阻塞的TryGo方法则使用了select,当获取资源不成功时(sem缓冲区已满无法写入),会走default分支直接返回false,不会被阻塞:

go 复制代码
func (g *Group) TryGo(f func() error) bool {
 if g.sem != nil {
  select {
  case g.sem <- token{}:
  default:
   return false
  }
 }
    // 剩余方法实现与Go方法基本一致,这里不再展示
}

与之对应地,在f执行结束后的done()方法中,会从sem中读取一次,相当于释放一个资源。

errgroup.Group在其SetLimit(n)方法中,进行了基于 channel 的信号量的初始化,将sem赋值为一个资源数量为n的信号量:

go 复制代码
func (g *Group) SetLimit(n int) {
 if n < 0 {
  g.sem = nil
  return
 }
 if len(g.sem) != 0 {
  panic(fmt.Errorf("errgroup: modify limit while %v goroutines in the group are still active", len(g.sem)))
 }
 g.sem = make(chan token, n)
}

根据SetLimit传入的信号量资源个数n的大小,会有以下三种情况:

  • n < 0:将sem置空,相当于取消信号量,解除了 goroutine 并发执行数量限制;
  • n = 0sem将成为一个无缓冲的 channel,此时会阻塞所有资源请求,因此之后必须使用TryGo而非Go,否则会发生死锁导致 panic;
  • n > 0:初始化资源数量为n的信号量。

SetLimit(n)函数是可以多次执行的,比如我们可以先令n = 3,再令n = 10。但在执行SetLimit时,需要通过len(g.sem) == 0确保当前sem缓冲区中没有数据,即没有正在执行的 goroutine,否则在sem被赋新值后,原sem将不会有接收者,最终将导致 goroutine 无法结束执行。根据源码,如果len(g.sem) != 0SetLimit函数会直接触发 panic,因此调用SetLimit时需要确定当前没有活跃的 goroutine。

总结

本文详细介绍了 Go 扩展并发库中errgroup.Group原语的基本使用,并基于errgroup.Group实现了一个单词词频统计的小项目,感受了errgroup.Group编写 Go 并发程序的便利性。同时,本文也对errgroup.Group的源码进行了走读,详细介绍了其内部的实现原理,包括errgroup.Group的结构体、WithContext函数、Go函数、Wait函数以及基于 channel 的信号量机制。希望本文能够帮助大家更好地理解errgroup.Group的使用与原理,以及 Go 语言中的并发编程。

相关推荐
不爱说话郭德纲12 小时前
聚焦 Go 语言框架,探索创新实践过程
go·编程语言
0x派大星2 天前
【Golang】——Gin 框架中的 API 请求处理与 JSON 数据绑定
开发语言·后端·golang·go·json·gin
IT书架2 天前
golang高频面试真题
面试·go
郝同学的测开笔记2 天前
云原生探索系列(十四):Go 语言panic、defer以及recover函数
后端·云原生·go
秋落风声3 天前
【滑动窗口入门篇】
java·算法·leetcode·go·哈希表
0x派大星4 天前
【Golang】——Gin 框架中的模板渲染详解
开发语言·后端·golang·go·gin
0x派大星5 天前
【Golang】——Gin 框架中的表单处理与数据绑定
开发语言·后端·golang·go·gin
三里清风_6 天前
如何使用Casbin设计后台权限管理系统
golang·go·casbin
0x派大星6 天前
【Goland】——Gin 框架中间件详解:从基础到实战
开发语言·后端·中间件·golang·go·gin
0x派大星6 天前
【Goland】——Gin 框架简介与安装
后端·golang·go·gin