Golang - errGroup 用法及適用情境

今天來講講滿常會用的 errGroup 的用法及適用情境,為什麼好用呢?一般我們在用 goroutine 的時候都不能夠 return value,你要將 goroutine 執行後的結果傳出去,通常就要使用 channel 的方式才可以,而 errGroup 的套件則適用於如果你想要知道你開的 goroutine 執行的時候如果遇到 error 就停止工作,並且我需要知道 error value 的情況。

先來看 errGroup 的用法

先來 download 該套件:go get -u golang.org/x/sync,這個套件裡面還有許多好用的,之後有空也會來寫文章介紹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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
}
  1. 首先建立 group struct,這個 struct 本身沒有 field 需要設值,所以單純建立 empty struct 就可以了。
  2. group struct 本身只有提供兩個 function,分別是 GoWait,其實跟 waitGroup 是有點相似的。
  3. Go function 接受的參數是一個 function 並且 return error,其實也就是指放進行你想要執行的 goroutine 的 function,以上面的例子,放了 getPage,getPage 本身的實現很簡單,就是 http.Get url,如果 statusCode 不是 200 就會 return error,否則就寫下 log,代表執行成功。
  4. 最後呼叫 Wait,代表會開始 block,很像 waitGroup Wait 的做法,會等待你所開啟的 goroutine 執行完畢才會跳脫 Wait。而還有一個差別是 Wait 會回傳 error,這個 error 來自於你其中一個 goroutine 所回傳的 error。

以上面的例子而言,Wait () 本身並不會回傳 error,因為都可以正常訪問網頁,如果你將其中一個 url 改成不存在的 url 就可以看到效果:

1
2
3
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:

1
2
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

你會發現最後印出的只有一個 error,因為其實 errGroup 只會存放一個 goroutine 的 error,至於會存放誰的?就看誰最先遇到 error 就會存進去,後續 goroutine 如果遇到 error,其 error 就不會存起來。

你可能會覺得你想要的是能夠知道所有的 error 的結果,只給我其中一個 goroutine 的錯誤感覺沒什麼幫助,以上面的例子確實是不好的,你並不會知道哪些 url 是無法訪問成功的。所以我個人認為適用的情境是,當你要的執行的任務是要做同一件事情或者是同一個性質,比如說你同時會有多個 goroutine 都要存取同一個服務獲得不同的資訊之類的,但是訪問的都是同一個 service。而當其中一個 goroutine 如果失敗了,最有可能的原因是 network issue,那麼其他 goroutine 存取同一個 service 也是無法存取成功的。再來,如果你想要的是當有一個 goroutine 失敗了,其他 goroutine 即便成功做完也沒有幫助,就很適合用 errGroup。

但是你單純用 empty Group struct,是不會有幫助的。

errGroup 預設不會取消其他 goroutine 的運作

errGroup 在運作的時候即便你其中一個 goroutine 遇到錯誤了,也是不會 cancel 其他 goroutine,那這樣就無法及時 cancel 其他 goroutine,也無法知道 goroutine 是否有正確退出。

errGroup 有想到這樣的情況,所以它選擇用 context 的做法來結束其他 goroutine

來看個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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)
}
}
  1. errgroup 提供 WithContext 放入你的 parent context,並且 return group struct 及 context,這個 context 事實上是一個 cancel context
  2. 拿到 cancel context 後就可以放在 Go function 裡面去 select <- ctx.Done () 來得知是否要被 cancel,藉此結束 goroutine。以這邊的情境我訪問十次這個 url,如果有一次有錯我就 return err,而如果接收到 ctx.Done () 就 return nil 結束 goroutine。

我把其中一個 goroutine 訪問錯誤的 url,來看效果:

1
2
3
4
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 成功訪問一次,但它要在成功訪問第二次前因為收到 ctx.Done,因此印出了 log,結束了第二個 goroutine。而最後 Wait error 印出了第一個 goroutine error。

這樣的方式可以確保其中一個 goroutine 一但有錯,也通知其他 goroutine 結束工作,也能保證 goroutine 能正常退出。

來看 errGroup 程式碼怎麼實作的

那 errGroup 怎麼達到上面說的效果,來一步一步看它的程式碼理解原理。

1
2
3
4
5
6
7
8
type Group struct {
cancel func()

wg sync.WaitGroup

errOnce sync.Once
err error
}

這個是 Group struct 的結構,可以看出有 cancel func (),這個作用是為了前面說的 WithContext 而來的,而出現了 WaitGroup,可以知道其實 errGroup 也是透過 WaitGroup 來實作的,Once 這個是為了因為只會接受一個 error 而存在的,err 則是最後 Wait 回傳的 error 值。

再來看 Go function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 去執行我們傳進去的 function,並且會去看 return 是否有 error,如果有 error 就會透過 errOnce.Do,去存放 error,因為 Once 的特性就是不管妳傳什麼 function,Once 只會執行一次,所以儘管有多個 goroutine 都傳出 error,這邊也只會執行一次,所以一但 g.err = err 被設值成功後,就不會再被改變值了。

而最後也會看 cancel 是否為 ni,如果不為 nil,代表什麼?代表有使用 WithContext 去建立 errGroup,所以 cancel 才會不為 nil,這時候會幫你呼叫 g.cancel () 幫你去取消其他 goroutine,ctx.Done () 才會有值。

然後由於透過 WaitGroup 實作的,當每一個 goroutine 完成工作後要呼叫 g.wg.Done () 來減一。

再來看 Wait function:

1
2
3
4
5
6
7
func (g *Group) Wait() error {
g.wg.Wait()
if g.cancel != nil {
g.cancel()
}
return g.err
}

一樣呼叫 g.wg.Wait () 所以才會造成 block 的效果,而當全部 goroutine 都結束後,會判斷 cancel 是否為 nil,一樣會去取消其他 goroutine,這邊之所以這樣做的原因就比較偏向是為了取消這次的 context,畢竟這個 cancel context 是為了這次 errorGroup 而存在的,當任務都完成後也應該要重置。

來看 WithContext:

1
2
3
4
func WithContext(ctx context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(ctx)
return &Group{cancel: cancel}, ctx
}

可以看到的確是建立 cancel context,並且將 ctx 回傳給 client 端來使用,並且存放 cancel func。

總結

其實 errGroup 的設計很單純,巧妙的應用了 WaitGroup 及 Context,達到既可以等待所有 goroutine 也可以得到 error,並透過 Context 來讓 client 端可以設計退出 goroutine 的實現。