【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 语言中的并发编程。

相关推荐
mtngt114 小时前
AI DDD重构实践
go
Grassto2 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto3 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室4 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题4 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉6 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想
asaotomo7 天前
一款 AI 驱动的新一代安全运维代理 —— DeepSentry(深哨)
运维·人工智能·安全·ai·go
码界奇点7 天前
基于Gin与GORM的若依后台管理系统设计与实现
论文阅读·go·毕业设计·gin·源代码管理
迷迭香与樱花7 天前
Gin 框架
go·gin
只是懒得想了8 天前
用Go通道实现并发安全队列:从基础到最佳实践
开发语言·数据库·golang·go·并发安全