golang中的errgroup

slowquery發表於2022-10-08

0.1、索引

waterflow.link/articles/1665239900...

1、序列執行

假如我們需要查詢一個課件列表,其中有課件的資訊,還有課件建立者的資訊,和課件的縮圖資訊。但是此時我們已經對服務做了拆分,假設有課件服務使用者服務還有檔案服務

我們通常的做法是,當我們查詢課件列表時,我們首先呼叫課件服務,比如查詢10條課件記錄,然後獲取到課件的建立人ID,課件的縮圖ID;再透過這些建立人ID去使用者服務查詢使用者資訊,透過縮圖ID去檔案服務查詢檔案資訊;然後再寫到這10條課件記錄中返回給前端。

像下面這樣:

package main

import (
    "fmt"
    "time"
)

type Courseware struct {
    Id         int64
    Name       string
    Code       string
    CreateId   int64
    CreateName string
    CoverId   int64
    CoverPath string
}

type User struct {
    Id   int64
    Name string
}

type File struct {
    Id   int64
    Path string
}

var coursewares []Courseware
var users map[int64]User
var files map[int64]File
var err error

func main() {
    // 查詢課件
    coursewares, err = CoursewareList()
    if err != nil {
        fmt.Println("獲取課件錯誤")
        return
    }

    // 獲取使用者ID、檔案ID
    userIds := make([]int64, 0)
    fileIds := make([]int64, 0)
    for _, courseware := range coursewares {
        userIds = append(userIds, courseware.CreateId)
        fileIds = append(fileIds, courseware.CoverId)
    }

    // 批次獲取使用者資訊
    users, err = UserMap(userIds)
    if err != nil {
        fmt.Println("獲取使用者錯誤")
        return
    }

    // 批次獲取檔案資訊
    files, err = FileMap(fileIds)
    if err != nil {
        fmt.Println("獲取檔案錯誤")
        return
    }

    // 填充
    for i, courseware := range coursewares {
        if user, ok := users[courseware.CreateId]; ok {
            coursewares[i].CreateName = user.Name
        }

        if file, ok := files[courseware.CoverId]; ok {
            coursewares[i].CoverPath = file.Path
        }
    }
    fmt.Println(coursewares)
}

func UserMap(ids []int64) (map[int64]User, error) {
    time.Sleep(3 * time.Second) // 模擬資料庫請求
    return map[int64]User{
        1: {Id: 1, Name: "liu"},
        2: {Id: 2, Name: "kang"},
    }, nil
}

func FileMap(ids []int64) (map[int64]File, error) {
    time.Sleep(3 * time.Second) // 模擬資料庫請求
    return map[int64]File{
        1: {Id: 1, Path: "/a/b/c.jpg"},
        2: {Id: 2, Path: "/a/b/c/d.jpg"},
    }, nil
}

func CoursewareList() ([]Courseware, error) {
    time.Sleep(3 * time.Second)
    return []Courseware{
        {Id: 1, Name: "課件1", Code: "CW1", CreateId: 1, CreateName: "", CoverId: 1, CoverPath: ""},
        {Id: 2, Name: "課件2", Code: "CW2", CreateId: 2, CreateName: "", CoverId: 2, CoverPath: ""},
    }, nil
}

2、併發執行

但我們獲取課件之後,填充使用者資訊和檔案資訊是可以並行執行的,我們可以修改獲取使用者和檔案的程式碼,把他們放到協程裡面,這樣就可以並行執行了:

...

    // 此處放到協程裡
    go func() {
        // 批次獲取使用者資訊
        users, err = UserMap(userIds)
        if err != nil {
            fmt.Println("獲取使用者錯誤")
            return
        }
    }()

    // 此處放到協程裡
    go func() {
        // 批次獲取檔案資訊
        files, err = FileMap(fileIds)
        if err != nil {
            fmt.Println("獲取檔案錯誤")
            return
        }
    }()

    ...

但是當你執行的時候你會發現這樣是有問題的,因為下面的填充資料的程式碼有可能會在這兩個協程執行完成之前去執行。也就是說最終的資料有可能沒有填充使用者資訊和檔案資訊。那怎麼辦呢?這是我們就可以使用golang的waitgroup了,主要作用就是協程的編排。

我們可以等2個協程都執行完成再去走下面的填充邏輯

我們繼續修改程式碼成下面的樣子

...

// 初始化一個sync.WaitGroup
var wg sync.WaitGroup

func main() {
    // 查詢課件
    ...
    // 獲取使用者ID、檔案ID
    ...

    // 此處放到協程裡
    wg.Add(1) // 計數器+1
    go func() {
        defer wg.Done() // 計數器-1
        // 批次獲取使用者資訊
        users, err = UserMap(userIds)
        if err != nil {
            fmt.Println("獲取使用者錯誤")
            return
        }
    }()

    // 此處放到協程裡
    wg.Add(1) // 計數器+1
    go func() {
        defer wg.Done() // 計數器-1
        // 批次獲取檔案資訊
        files, err = FileMap(fileIds)
        if err != nil {
            fmt.Println("獲取檔案錯誤")
            return
        }
    }()

  // 阻塞等待計數器小於等於0
    wg.Wait()

    // 填充
    for i, courseware := range coursewares {
        if user, ok := users[courseware.CreateId]; ok {
            coursewares[i].CreateName = user.Name
        }

        if file, ok := files[courseware.CoverId]; ok {
            coursewares[i].CoverPath = file.Path
        }
    }
    fmt.Println(coursewares)
}

...

我們初始化一個sync.WaitGroup,呼叫wg.Add(1)給計數器加一,呼叫wg.Done()計數器減一,wg.Wait()阻塞等待直到計數器小於等於0,結束阻塞,繼續往下執行。

3、errgroup

但是我們現在又有這樣的需求,我們希望如果獲取使用者或者獲取檔案有任何一方報錯了,直接拋錯,不再組裝資料。

我們可以像下面這樣寫

...

var goErr error
var wg sync.WaitGroup

...

func main() {
    ...

    // 此處放到協程裡
    wg.Add(1)
    go func() {
        defer wg.Done()
        // 批次獲取使用者資訊
        users, err = UserMap(userIds)
        if err != nil {
            goErr = err
            fmt.Println("獲取使用者錯誤:", err)
            return
        }
    }()

    // 此處放到協程裡
    wg.Add(1)
    go func() {
        defer wg.Done()
        // 批次獲取檔案資訊
        files, err = FileMap(fileIds)
        if err != nil {
            goErr = err
            fmt.Println("獲取檔案錯誤:", err)
            return
        }
    }()

    wg.Wait()

    if goErr != nil {
        fmt.Println("goroutine err:", err)
        return
    }

    ...
}

...

把錯誤放在goErr中,結束阻塞後判斷協程呼叫是否拋錯。

那golang裡面有沒有類似這樣的實現呢?答案是有的,那就是errgroup。其實和我們上面的方法差不多,但是errgroup包做了一層結構體的封裝,也不需要在每個協程裡面判斷error傳給errGo了。

下面是errgroup的實現

package main

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

type Courseware struct {
    Id         int64
    Name       string
    Code       string
    CreateId   int64
    CreateName string
    CoverId   int64
    CoverPath string
}

type User struct {
    Id   int64
    Name string
}

type File struct {
    Id   int64
    Path string
}

var coursewares []Courseware
var users map[int64]User
var files map[int64]File
var err error
// 定義一個errgroup
var eg errgroup.Group

func main() {
    // 查詢課件
    coursewares, err = CoursewareList()
    if err != nil {
        fmt.Println("獲取課件錯誤:", err)
        return
    }

    // 獲取使用者ID、檔案ID
    userIds := make([]int64, 0)
    fileIds := make([]int64, 0)
    for _, courseware := range coursewares {
        userIds = append(userIds, courseware.CreateId)
        fileIds = append(fileIds, courseware.CoverId)
    }


    // 此處放到協程裡
    eg.Go(func() error {
        // 批次獲取使用者資訊
        users, err = UserMap(userIds)
        if err != nil {
            fmt.Println("獲取使用者錯誤:", err)
            return err
        }
        return nil
    })

    // 此處放到協程裡
    eg.Go(func() error {
        // 批次獲取檔案資訊
        files, err = FileMap(fileIds)
        if err != nil {
            fmt.Println("獲取檔案錯誤:", err)
            return err
        }
        return nil
    })

  // 判斷group中是否有報錯
    if goErr := eg.Wait(); goErr != nil {
        fmt.Println("goroutine err:", err)
        return
    }

    // 填充
    for i, courseware := range coursewares {
        if user, ok := users[courseware.CreateId]; ok {
            coursewares[i].CreateName = user.Name
        }

        if file, ok := files[courseware.CoverId]; ok {
            coursewares[i].CoverPath = file.Path
        }
    }
    fmt.Println(coursewares)
}

func UserMap(ids []int64) (map[int64]User, error) {
    time.Sleep(3 * time.Second)
    return map[int64]User{
        1: {Id: 1, Name: "liu"},
        2: {Id: 2, Name: "kang"},
    }, errors.New("sql err")
}

func FileMap(ids []int64) (map[int64]File, error) {
    time.Sleep(3 * time.Second)
    return map[int64]File{
        1: {Id: 1, Path: "/a/b/c.jpg"},
        2: {Id: 2, Path: "/a/b/c/d.jpg"},
    }, nil
}

func CoursewareList() ([]Courseware, error) {
    time.Sleep(3 * time.Second)
    return []Courseware{
        {Id: 1, Name: "課件1", Code: "CW1", CreateId: 1, CreateName: "", CoverId: 1, CoverPath: ""},
        {Id: 2, Name: "課件2", Code: "CW2", CreateId: 2, CreateName: "", CoverId: 2, CoverPath: ""},
    }, nil
}

當然,errgroup中也有針對上下文的errgroup.WithContext函式,如果我們想控制請求介面的時間,用這個是最合適不過的。如果請求超時會返回一個關閉上下文的報錯,像下面這樣

package main

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

type Courseware struct {
    Id         int64
    Name       string
    Code       string
    CreateId   int64
    CreateName string
    CoverId    int64
    CoverPath  string
}

type User struct {
    Id   int64
    Name string
}

type File struct {
    Id   int64
    Path string
}

var coursewares []Courseware
var users map[int64]User
var files map[int64]File
var err error

func main() {
    // 查詢課件
    ...

    // 獲取使用者ID、檔案ID
    ...

  // 定義一個帶超時時間的上下文,1秒鐘超時
    ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancelFunc()
  // 定義一個帶上下文的errgroup,使用上面帶有超時時間的上下文
    eg, ctx := errgroup.WithContext(ctx)
    // 此處放到協程裡
    eg.Go(func() error {
        // 批次獲取使用者資訊
        users, err = UserMap(ctx, userIds)
        if err != nil {
            fmt.Println("獲取使用者錯誤:", err)
            return err
        }
        return nil
    })

    // 此處放到協程裡
    eg.Go(func() error {
        // 批次獲取檔案資訊
        files, err = FileMap(ctx, fileIds)
        if err != nil {
            fmt.Println("獲取檔案錯誤:", err)
            return err
        }
        return nil
    })

    if goErr := eg.Wait(); goErr != nil {
        fmt.Println("goroutine err:", err)
        return
    }

    // 填充
    for i, courseware := range coursewares {
        if user, ok := users[courseware.CreateId]; ok {
            coursewares[i].CreateName = user.Name
        }

        if file, ok := files[courseware.CoverId]; ok {
            coursewares[i].CoverPath = file.Path
        }
    }
    fmt.Println(coursewares)
}

func UserMap(ctx context.Context, ids []int64) (map[int64]User, error) {
    result := make(chan map[int64]User)
    go func() {
        time.Sleep(2 * time.Second) // 假裝請求超過1秒鐘
        result <- map[int64]User{
            1: {Id: 1, Name: "liu"},
            2: {Id: 2, Name: "kang"},
        }
    }()

    select {
    case <-ctx.Done(): // 如果上下文結束直接返回錯誤資訊
        return nil, ctx.Err()
    case res := <-result: // 返回正確結果
        return res, nil
    }
}

func FileMap(ctx context.Context, ids []int64) (map[int64]File, error) {
    return map[int64]File{
        1: {Id: 1, Path: "/a/b/c.jpg"},
        2: {Id: 2, Path: "/a/b/c/d.jpg"},
    }, nil
}

func CoursewareList() ([]Courseware, error) {
    time.Sleep(3 * time.Second)
    return []Courseware{
        {Id: 1, Name: "課件1", Code: "CW1", CreateId: 1, CreateName: "", CoverId: 1, CoverPath: ""},
        {Id: 2, Name: "課件2", Code: "CW2", CreateId: 2, CreateName: "", CoverId: 2, CoverPath: ""},
    }, nil
}

執行上面的程式碼:

go run waitgroup.go
獲取使用者錯誤: context deadline exceeded
goroutine err: context deadline exceeded
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章