八. Go併發程式設計--errGroup

failymao發表於2021-11-07

一. 前言

瞭解 sync.WaitGroup的用法都知道

  • 一個 goroutine 需要等待多個 goroutine 完成和多個 goroutine 等待一個 goroutine 幹活時都可以解決問題


WaitGroup 的確是一個很強大的工具,但是使用它相對來說還是有一點小麻煩,

  • 一方面我們需要自己手動呼叫 Add() 和 Done() 方法,一旦這兩個方法有一個多呼叫或者少呼叫,最終都有可能導致程式崩潰,所以我們在使用這兩個方法的時候要格外小心,確保最終計數器能夠達到 0 的狀態;
  • 另一方面就是它不能丟擲錯誤給呼叫者,只要一個 goroutine 出錯我們就不再等其他 goroutine 了,減少資源浪費,所以我們只能通過宣告多個外部變數的方式(或者宣告一個變數然後通過加鎖來更新它的值)來分別接收每個協程的 error 才行,就像下面的程式碼:
    func main() {
     var (
     	wg sync.WaitGroup
     	err1, err2 error  // 通過在外部定義變數用來記錄錯誤
     )
    
     wg.Add(2)
     go func() {
     	defer wg.Done()
     	fmt.Print("task 1")
     	err1 = nil
     }()
     
     go func() {
     	defer wg.Done()
     	fmt.Print("task 2")
     	err2 = fmt.Errorf("task 2 error")
     }()
     wg.Wait()
     
     if err1 != nil || err2 != nil {
     	// TODO
     }
    
     fmt.Print("finish")
    }
    
    

使用WaitGroup 都不能很好的解決。 所以此時可以使用 ErrGroup 就可以解決問題了。


二. Errgroup

Errgroup 是 Golang 官方提供的一個同步擴充套件庫, 程式碼倉庫如下

它和 WaitGroup 的作用類似,但是它提供了更加豐富的功能以及更低的使用成本:

  1. 和context整合;
  2. 能夠對外傳播error,可以把子任務的錯誤傳遞給Wait 的呼叫者.

Errgroup 的程式碼非常簡短,加上註釋一共才 66 行,包含一個結構體以及三個對外暴露的方法,接下來就讓我們走進原始碼,來具體看一下它是如何工作的


2.1 Group

type Group struct {
    // context 的 cancel 方法
	cancel func()

    // 複用 WaitGroup
	wg sync.WaitGroup

	// 用來保證只會接受一次錯誤
	errOnce sync.Once
    // 儲存第一個返回的錯誤
	err     error
}

2.2 WithContext

func WithContext(ctx context.Context) (*Group, context.Context) {
	// 使用 contex.WithCancel建立一個可以取消的 context 將 cancel 賦值給 Group 儲存起來
	ctx, cancel := context.WithCancel(ctx)
	return &Group{cancel: cancel}, ctx
}

WithContext 就是使用 WithCancel建立一個可以取消的 context 將 cancel 賦值給 Group 儲存起來,然後再將 context 返回回去

注意這裡有一個坑,在後面的程式碼中不要把這個 ctx 當做父 context 又傳給下游,因為 errgroup 取消了,這個 context 就沒用了,會導致下游複用的時候出錯
Go


2.3 Go

Go 方法傳入一個 func() error 內部會啟動一個 goroutine 去處理

// Go calls the given function in a new goroutine.
//
// The first call to return a non-nil error cancels the group; its error will be
// returned by Wait.
func (g *Group) Go(f func() error) {
    // wg.Add(1) 計數器加 1
	g.wg.Add(1)

	go func() {
		defer g.wg.Done()

		if err := f(); err != nil {
		  // 這裡使用sync.Once作用,保證傳入的 無入參函式執行一次
		  // 如果有 error,則記錄發生的第一個 error
			g.errOnce.Do(func() {
				g.err = err
				if g.cancel != nil {
					g.cancel()
				}
			})
		}
	}()
}

Go 方法其實就類似於 go 關鍵字,會啟動一個協程,然後利用 waitgroup 來控制是否結束,如果有一個非 nil 的 error 出現就會儲存起來並且如果有 cancel 就會呼叫 cancel 取消掉,使 ctx 返


2.4 Wait

/ Wait blocks until all function calls from the Go method have returned, then
// returns the first non-nil error (if any) from them.
func (g *Group) Wait() error {
    // wg.Wait() 等待所有任務執行完畢
	g.wg.Wait()
	if g.cancel != nil {
		g.cancel()
	}
	return g.err
}

Wait 方法其實就是呼叫 WaitGroup 等待,如果有 cancel 就呼叫一下


三. 案例

3.1 記錄錯誤

使用 Errgroup,上述程式碼可以改為下面的樣子:

package main

import (
	"fmt"

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

func main() {
	var eg errgroup.Group

	//匿名函式將會通過GO關鍵字啟動一個協程
	eg.Go(func() error {
		fmt.Print("task 1\n")
		return nil
	})

	eg.Go(func() error {
		fmt.Print("task 2\n")
		return fmt.Errorf("task 2 error")
	})

	// 使用Wait 等待所有的協程執行完畢後,再進行後面的邏輯,同時可以記錄兩個協程的錯誤
	if err := eg.Wait(); err != nil {
		fmt.Printf("some error occur: %s\n", err.Error())
	}

	fmt.Print("over")
}

程式碼簡潔了很多


3.2 一個協程出錯,其他協程終止

基於 errgroup 實現一個 http server 的啟動和關閉 ,以及 linux signal 訊號的註冊和處理,要保證能夠 一個退出,全部登出退出。

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

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

func main() {
	g, ctx := errgroup.WithContext(context.Background())

	mux := http.NewServeMux()
	mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("pong"))
	})

	// 模擬單個服務錯誤退出
	serverOut := make(chan struct{})
	mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
		serverOut <- struct{}{}
	})

	server := http.Server{
		Handler: mux,
		Addr:    ":8080",
	}

	// g1
	// g1 退出了所有的協程都能退出麼?
	// g1 退出後, context 將不再阻塞,g2, g3 都會隨之退出
	// 然後 main 函式中的 g.Wait() 退出,所有協程都會退出
	g.Go(func() error {
		return server.ListenAndServe()
	})

	// g2
	// g2 退出了所有的協程都能退出麼?
	// g2 退出時,呼叫了 shutdown,g1 會退出
	// g2 退出後, context 將不再阻塞,g3 會隨之退出
	// 然後 main 函式中的 g.Wait() 退出,所有協程都會退出
	g.Go(func() error {
		select {
		case <-ctx.Done():
			log.Println("errgroup exit...")
		case <-serverOut:
			log.Println("server will out...")
		}

		timeoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		// 這裡不是必須的,但是如果使用 _ 的話靜態掃描工具會報錯,加上也無傷大雅
		defer cancel()

		log.Println("shutting down server...")
		return server.Shutdown(timeoutCtx)
	})

	// g3
	// g3 捕獲到 os 退出訊號將會退出
	// g3 退出了所有的協程都能退出麼?
	// g3 退出後, context 將不再阻塞,g2 會隨之退出
	// g2 退出時,呼叫了 shutdown,g1 會退出
	// 然後 main 函式中的 g.Wait() 退出,所有協程都會退出
	g.Go(func() error {
		quit := make(chan os.Signal, 0)
		signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

		select {
		case <-ctx.Done():
			return ctx.Err()
		case sig := <-quit:
			return fmt.Errorf("get os signal: %v", sig)
		}
	})

	fmt.Printf("errgroup exiting: %+v\n", g.Wait())
}

執行後,使用 ctrl + C 終止程式,終端輸出如下

2021/11/03 10:20:34 errgroup exit...
2021/11/03 10:20:34 shutting down server...
errgroup exiting: get os signal: interrupt

這裡主要用到了 errgroup 一個出錯,其餘取消的能力


四. 總結

當然除了 Golang 官方提供的擴充套件庫之外,還有很多類似的其他優秀開源工具,例如 bilibili/errgroup,支援設定固定數量的協程數以及失敗 cancel 機制和 panic-recover 機制等等,感興趣的同學可以自行去了解一番。


五. 參考

  1. https://lailin.xyz/post/go-training-week3-errgroup.html

  2. https://juejin.cn/post/6996300205989560333

  3. https://github.com/golang/sync/tree/master/errgroup

相關文章