GO 扩展库:errgroup 实现原理与使用

299 阅读5分钟

在平时开发中,如果我们没有使用到errgroup库,那么通常都是自己手动管理sync.WaitGroup 以等待goroutine 结束;要是需要获取协程的错误,那么就要使用 chan error 来收集错误;如果还要限制并发数的话,还要使用个 sem channel 来限制并发数。但是使用errgroup库就可以帮我们实现上述功能。

在 Go 语言中,errgroup 是一个常用于并发编程的工具,它帮助我们管理多个 goroutine 的执行,并确保能够正确处理每个 goroutine 的错误。errgroupgolang.org/x/sync/errgroup 包,它不仅提供了并发任务管理,还可以方便地捕获其中一个任务发生的错误并处理。

1. 基本概念

errgroup 的核心目标是管理并发任务,并捕获第一个错误。我们知道,在并发编程中,多个 goroutine 可能会同时执行,因此我们需要一种方式来等待所有的 goroutine 执行完毕,并且捕获它们的错误。errgroup 就是为了解决这个问题而设计的。

errgroup的实现原理

errgroup 的核心是 Group 结构体,其定义如下:

type Group struct {
    cancel func(error) // 取消函数,用于在多个goroutine之间同步取消信号
    wg sync.WaitGroup  // 用于等待一组goroutine的完成
    sem chan token     // 用于控制并发数量的channel
    errOnce sync.Once  // 保证只接收一个goroutine返回的错误
    err     error      // 记录第一个非nil的错误
}
  • cancel:是一个函数,当某个协程返回错误时,会调用该函数取消其他协程的执行。
  • wg:是 sync.WaitGroup 的实例,用于等待所有协程完成。
  • sem:是一个 token 类型的 channel,用于限制并发数量。当调用 SetLimit 方法时,会创建一个固定大小的 channel,协程在执行前需要从该 channel 中获取一个 token
  • errOnce:是一个 sync.Once 实例,确保错误只被记录一次。
  • err:用于存储第一个非 nil 的错误。

errgroup 的主要方法包括:

  • WithContext:创建一个带有取消功能的 Group 实例。它会返回一个 Group 实例和一个上下文对象。当某个协程返回错误时,会调用上下文的 cancel 方法。
  • Go:启动一个协程并执行传入的函数。如果设置了并发限制,则会通过 sem channel 控制并发数量。
  • Wait:等待所有协程完成,并返回第一个非 nil 的错误。
  • SetLimit:设置并发数量的上限。
  • TryGo:尝试启动一个协程,如果并发数量已达到上限,则返回 false

2. errgroup 的实现原理

errgroup 的实现依赖于 Go 的 goroutine 和 channel。内部维护了一个错误 channel 和一个等待 group 的同步机制。每当一个 goroutine 执行完毕,都会把自己的错误(如果有的话)写入这个 channel,而 errgroup 会阻塞等待所有 goroutine 完成,然后通过 select 语句获取第一个非 nil 错误。

以Go()方法为例,如下是Go()方法的实现,可以看到当你启用 SetLimit(n) 限制了并发上线功能,那么进入Go方法就往sem channel发送信号来阻塞等待,直到sem channel有可用的token,才会执行后面的逻辑。如果当前协程占用信号量成功的话就会调用g.wg.Add(1) 来增加等待计数器,确保所有 goroutine 都执行完毕后,g.Wait() 才会返回。如果 f() 函数执行时返回了错误,errOnce.Do() 会保证仅记录第一个错误,并执行取消操作(如果有定义 cancel 函数)。这也是 errgroup 的一个重要特性,即只要一个 goroutine 发生错误,后续的任务就会被取消。


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)
				}
			})
		}
	}()
}

TryGo 方法与 Go 类似,但它是非阻塞的,如果当前并发数已达到SetLimit(n),它不会等待,而是直接返回 false,表示未能启动新的 goroutine。

func (g *Group) TryGo(f func() error) bool {
    if g.sem != nil {
        select {
        case g.sem <- token{}: // 尝试占用信号量,如果已满直接返回 false
        default:
            return false
        }
    }
    //
    ...
    //
}

3. 示例代码

下面给出一段网络请求的并发示例代码来说明errgroup的使用

package main

import (
	"fmt"
	"golang.org/x/sync/errgroup"
	"net/http"
	"time"
)

func fetchURL(url string) error {
	// 模拟网络请求
	time.Sleep(1 * time.Second)
	resp, err := http.Get(url)
	if err != nil {
		return fmt.Errorf("failed to fetch %s: %v", url, err)
	}
	defer resp.Body.Close()
	fmt.Printf("Successfully fetched %s with status %s\n", url, resp.Status)
	return nil
}

func main() {
	var g errgroup.Group

	// 设置并发限制为 3
	g.SetLimit(3)

	urls := []string{
		"https://www.google.com",
		"https://www.github.com",
		"https://www.reddit.com",
		"https://www.stackoverflow.com",
	}

	// 启动多个并发任务
	for _, url := range urls {
		url := url // 防止闭包问题
		g.Go(func() error {
			return fetchURL(url)
		})
	}

	// 等待所有任务完成并捕获错误
	if err := g.Wait(); err != nil {
		fmt.Printf("Error occurred: %v\n", err)
	} else {
		fmt.Println("All URLs fetched successfully!")
	}
}

4. 代码解析

  • g.SetLimit(3): 这里我们限制了同时并发的 goroutine 数量为 3,避免短时间内创建过多的 goroutine 导致资源过度消耗。
  • g.Go(func() error { ... }): 每次调用 g.Go() 启动一个新的 goroutine,且在其中执行 fetchURL 函数。每个 goroutine 执行完后会把可能出现的错误返回给 errgroup
  • g.Wait(): Wait() 会阻塞直到所有任务都完成,如果某个任务返回了错误,它会立刻返回该错误。

5. 错误处理

errgroup 的一个显著特点是,一旦某个 goroutine 返回错误,其他 goroutine 会继续执行,但最终会返回第一个错误。因此,当我们使用 errgroup 时,需要特别注意如何在并发任务中处理错误。

在上面的示例中,我们启动了多个请求任务,即使某个任务失败了,程序会继续执行剩下的任务,最后如果有错误发生,g.Wait() 会返回该错误。

6. 总结

errgroup 是 Go 并发编程中的一个重要工具,它简化了并发任务的管理和错误处理。通过设置并发限制、等待任务完成并获取错误,errgroup 能帮助我们更高效地处理多个并发任务,并确保错误能够被正确捕获和处理。

在实际开发中,errgroup 适用于以下场景:

  • 同时发起多个网络请求并希望在出现错误时能够及时处理
  • 并发执行多个独立的任务,且需要收集所有任务的执行结果
  • 限制并发数来避免资源过度消耗

希望这篇小技巧能够帮助你更好地理解和使用 Go 语言中的 errgroup