今天,让我们讨论一下经常使用的errGroup
的用法和适用场景。为什么它有用?通常,当我们使用goroutine时,我们无法返回值。如果想传递goroutine执行的结果,通常需要使用channel。如果想在打开的goroutine执行过程中遇到错误并停止工作时知道,并且需要知道错误值,那么errGroup
包就适合了。
errGroup用法
首先需要下载并安装该包。
go get -u golang.org/x/sync
一个使用示例如下
package main
import (
"fmt"
"log"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
eg := errgroup.Group{}
eg.Go(func() error {
return getPage("https://blog.kennycoder.io")
})
eg.Go(func() error {
return getPage("https://google.com")
})
if err := eg.Wait(); err != nil {
log.Fatalf("get error: %v", err)
}
}
func getPage(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fail to get page: %s, wrong statusCode: %d", url, resp.StatusCode)
}
log.Printf("success get page %s", url)
return nil
}
- 首先,创建一个group结构体。这个结构体本身不需要设置任何字段,所以创建一个空结构体就可以了。
- group结构体只提供两个函数,
Go
和Wait
,实际上和waitGroup非常类似。 - Go函数接受一个函数作为参数并返回一个错误。这个函数实际上就是你想在goroutine中执行的函数。在上面的例子中,
getPage
被放在Go
中。getPage
的实现很简单,它调用http.Get(url)
,如果statusCode
不是200则返回错误。否则,它会写入日志以指示执行成功。 - 最后,调用
Wait
意味着它将开始阻塞,类似于waitGroup的Wait
方法。它将等待你打开的所有goroutine完成执行后再退出Wait
。不同之处在于Wait
会返回一个错误,这个错误来自你的goroutine之一返回的错误。
在上面的例子中,Wait()
本身没有返回错误,因为所有网页都可以正常访问。如果将其中一个URL更改为不存在的URL,则会看到效果。
2021/10/03 11:52:23 success get page https://google.com
2021/10/03 11:52:23 get error: Get "https://kenny.example.com": dial tcp: lookup kenny.example.com: no such host
exit status 1
如果你的两个goroutine都尝试访问不存在的URL:
2021/10/03 11:53:52 get error: Get "https://kenny.example.com": dial tcp: lookup kenny.example.com: no such host
exit status 1
你会发现最后只打印了一个错误,因为errGroup
只存储一个goroutine的错误。哪个goroutine的错误被存储取决于哪个goroutine先遇到错误并被存储。后续goroutine的错误将不会被存储。
你可能会觉得你想要的是知道所有错误的结果,只给我一个goroutine的错误并没有帮助。在上面的例子中,它确实不是理想的,因为你不知道哪些URL无法访问。所以个人认为,errGroup
适合的场景是你想执行的任务相同或性质相同。例如,如果你有多个goroutine需要访问同一个服务来获取不同的信息。因此,当其中一个goroutine失败时,最可能的原因是网络问题,其他访问同一服务的goroutine也将无法成功访问。此外,如果你想要的是当一个goroutine失败时,即使其他goroutine已经成功完成,也没有帮助,那么errGroup
非常适合。
但是,仅仅使用一个空的Group结构体并没有帮助。
当errGroup
运行时,即使你的goroutine之一遇到错误,它也不会取消其他goroutine。这意味着其他goroutine无法及时取消,也无法知道goroutine是否正确退出。
errGroup考虑到了这种情况,所以它选择使用context方法来取消其他goroutine。
一个例子
func main() {
eg, ctx := errgroup.WithContext(context.Background())
eg.Go(func() error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
log.Printf("goroutine should cancel")
return nil
default:
if err := getPage("https://blog.kennycoder.io"); err != nil {
return err
}
time.Sleep(1 * time.Second)
}
}
return nil
})
eg.Go(func() error {
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
log.Printf("goroutine should cancel")
return nil
default:
if err := getPage("https://google.com"); err != nil {
return err
}
time.Sleep(1 * time.Second)
}
}
return nil
})
if err := eg.Wait(); err != nil {
log.Fatalf("get error: %v", err)
}
}
- errGroup提供WithContext将你的父context放入,并返回一个group结构体和context。这个context实际上是一个cancel context。
- 获得cancel context后,可以将其放入
Go
函数中,并使用select <-ctx.Done()
来知道是否被取消从而结束goroutine。在这个场景中,我访问URL十次。如果有一次出错,我就返回err,如果我收到ctx.Done()
,我就返回nil来结束goroutine。我错误地访问其中一个URL来查看效果:
2021/10/03 12:11:40 success get page https://google.com
2021/10/03 12:11:41 goroutine should cancel
2021/10/03 12:11:41 get error: Get "https:kenny.example.com": http: no Host in request URL
exit status 1
可以看到,第二个goroutine成功访问了一次URL,但是它必须打印出日志并结束第二个goroutine,因为它在第二次成功访问URL之前收到了ctx.Done
。最后,Wait
错误打印出了第一个goroutine的错误。
这种方法确保当一个goroutine遇到错误时,它也会通知其他goroutine结束它们的工作,并确保goroutine可以正常退出。
errGroup内部是如何工作的
让我们来看看它的结构。
type Group struct {
cancel func()
wg sync.WaitGroup
errOnce sync.Once
err error
}
这是Group
结构体的结构,它显示有一个cancel func()
用于前面提到的WithContext
。WaitGroup
的出现表明errGroup也是通过WaitGroup
实现的,Once
是为了只接受一个错误的存在。err
是最终Wait
返回的错误值。
以下是Go
函数
func (g *Group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.errOnce.Do(func() {
g.err = err
if g.cancel != nil {
g.cancel()
}
})
}
}()
}
通过wg.Add(1)
,内部打开一个goroutine来执行传入的函数,并检查返回值是否有错误。如果有错误,则使用errOnce.Do
来存储错误。因为Once
的特点是无论你传入什么函数,Once
都只会执行一次,即使多个goroutine返回错误。因此,g.err = err
只会设置一次,并且一旦成功设置,它的值就不会改变。
最后,它还会检查cancel
是否为nil。如果它不是nil,这意味着什么?这意味着errGroup
是使用WithContext
创建的,所以cancel
不会是nil。这时,g.cancel()
将被调用来为你取消其他goroutine,并且ctx.Done()
将会有一个值。
并且因为它通过WaitGroup
实现,所以每个goroutine完成工作后都需要调用g.wg.Done()
来递减它。
以下是Wait
函数:
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel()
}
return g.err
}
同样,调用g.wg.Wait()
,这会导致阻塞效果。所有goroutine完成后,它将检查cancel
是否为nil,并以相同的方式取消其他goroutine。这样做的原因更多的是为了取消这个context。毕竟,这个cancel
context是为这个errGroup存在的,所有任务完成后应该重置它。
func WithContext(ctx context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(ctx)
return &Group{cancel: cancel}, ctx
}
可以看到,确实建立了一个cancel context,并将ctx
返回给客户端使用,并将cancel
func存储起来。
总而言之,errGroup
的设计非常简单,巧妙地利用WaitGroup
和Context
实现了等待所有goroutine并获取错误的能力,并利用Context
允许客户端设计goroutine退出的实现。