Go 高階併發

watermelon發表於2020-02-26

如果你曾經使用過 Go 一段時間,那麼你可能瞭解一些 Go 中的併發原語:

  • go 關鍵字用來生成 goroutines
  • channel 用於 goroutines 之間通訊
  • context 用於傳播取消
  • syncsync/atomic 包用於低階別的原語,例如互斥鎖和記憶體的原子操作

這些語言特性和包組合在一起,為構建高併發的應用程式提供了豐富的工具集。你可能還沒有發現在擴充套件庫 golang.org/x/sync 中,提供了一系列更高階別的併發原語。我們將在本文中來談談這些內容。

singleflight 包

正如文件中所描述,這個包提供了一個重複函式呼叫抑制的機制。

如果你正在處理計算量大(也可能僅僅是慢,比如網路訪問)的使用者請求時,這個包就很有用。例如,你的資料庫中包含每個城市的天氣資訊,並且你想將這些資料以 API 的形式提供服務。在某些情況下,可能同時有多個使用者想查詢同一城市的天氣。

在這種場景下,如果你只查詢一次資料庫然後將結果共享給所有等待的請求,這樣不是更好嗎?這就是 singleflight 提供的功能。

在使用的時候,我們需要要建立一個 singleflight.Group。它需要在所有請求中共享才能工作。然後將緩慢或者開銷大的操作包裝到 group.Do(key, fn) 的呼叫中。對同一個 key 的多個併發請求將僅呼叫 fn 一次,並且將 fn 的結果返回給所有呼叫者。

實際中的使用如下:

package weather

type Info struct {
    TempC, TempF int // temperature in Celsius and Farenheit
    Conditions string // "sunny", "snowing", etc
}

var group singleflight.Group

func City(city string) (*Info, error) {
    results, err, _ := group.Do(city, func() (interface{}, error) {
        info, err := fetchWeatherFromDB(city) // 慢操作
        return info, err
    })
    if err != nil {
        return nil, fmt.Errorf("weather.City %s: %w", city, err)
    }
    return results.(*Info), nil
}

需要注意的是,我們傳遞給 group.Do 的閉包必須返回 (interface{}, error) 才能和 Go 型別系統一起使用。上面的例子中忽略了 group.Do 的第三個返回值,該值是用來表示結果是否在多個呼叫方之間共享。

如果需要檢視更多完整的例子,可以檢視 Encore Playground 中的程式碼。

errgroup 包

另一個有用的包是 errgroup package。它和 sync.WaitGroup 比較相似,但是會將任務返回的錯誤回傳給阻塞的呼叫方。

當你有多個等待的操作,但又想知道它們是否都已經成功完成時,這個包就很有用。還是以上面的天氣為例,假如你要一次查詢多個城市的天氣,並且要確保其中所有的查詢都成功返回。

首先定義一個 errgroup.Group,然後為每個城市都使用 group.Go(fn func() error) 方法。該方法會生成一個 goroutine 來執行這個任務。當生成你想執行的所有任務時,使用 group.Wait() 等待它們完成。需要注意和 sync.WaitGroup 有一點不同的是,該方法會返回錯誤。當且僅當所有任務都返回 nil 時,才會返回一個 nil 錯誤。

實際中的使用如下:

func Cities(cities ...string) ([]*Info, error) {
    var g errgroup.Group
    var mu sync.Mutex
    res := make([]*Info, len(cities)) // res[i] corresponds to cities[i]

    for i, city := range cities {
        i, city := i, city // 為下面的閉包建立區域性變數
        g.Go(func() error {
            info, err := City(city)
            mu.Lock()
            res[i] = info
            mu.Unlock()
            return err
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return res, nil
}

這裡我們使用一個 res 切片來儲存每個 goroutine 執行的結果。儘管上面的程式碼沒有使用 mu 互斥鎖也是執行緒安全的,但是每個 goroutine 都是在切片中自己的位置寫入結果,因此我們不得不使用一個切片,以防程式碼變化。

限制併發

上面的程式碼將同時查詢給定城市的天氣資訊。如果城市數量比較少,那還不錯,但是如果城市數量很多,可能會導致效能問題。在這種情況下,就應該引入限制併發了。

在 Go 中使用 semaphores 訊號量讓實現限制併發變得非常簡單。訊號量是你學習電腦科學中可能已經遇到過的併發原語,如果沒有遇到也不用擔心。你可以出於多種目的來使用訊號量,但是這裡我們只使用它來追蹤執行中的任務的數量,並阻塞直到有空間可以執行其他任務。

在 Go 中,我們可以使用 channel 來實現訊號量的功能。如果我們一次需要最多執行 10 個任務,則需要建立一個容量為 10 的 channelsemaphore := make(chan struct{}, 10)。你可以想象它為一個可以容納 10 個球的管道。

如果想執行一個新的任務,我們只需要給 channel 傳送一個值:semaphore <- struct{}{},如果已經有很多工在執行的話,將會阻塞。這類似於將一個球推入管道,如果管道已滿,則需要等待直到有空間為止。

當通過 <-semaphore 能從該 channel 中取出一個值時,這表示一個任務完成了。這類似於在管道另一端拿出一個球,這將為塞入下一個球提供了空間。

如描述一樣,我們修改後的 Cities 程式碼如下:

func Cities(cities ...string) ([]*Info, error) {
    var g errgroup.Group
    var mu sync.Mutex
    res := make([]*Info, len(cities)) // res[i] corresponds to cities[i]
    sem := make(chan struct{}, 10)
    for i, city := range cities {
        i, city := i, city // create locals for closure below
        sem <- struct{}{}
        g.Go(func() error {
            info, err := City(city)
            mu.Lock()
            res[i] = info
            mu.Unlock()
            <-sem
            return err
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return res, nil
}

加許可權制併發

最後,當你想要限制併發的時候,並不是所有任務優先順序都一樣。在這種情況下,我們消耗的資源將依據高、低優先順序任務的分佈以及它們如何開始執行而發生變化。

在這種場景下使用加許可權制併發是一種不錯的解決方式。它的工作原理很簡單:我們不需要為同時執行的任務數量做預估,而是為每個任務提供一個 "cost",並從訊號量中獲取和釋放它。

我們不再使用 channel 來做這件事,因為我們需要立即獲取並釋放 "cost"。幸運的是,"擴充套件庫" golang.org/x/sync/sempahore 實現了加權訊號量。

sem <- struct{}{} 操作叫 "獲取",<-sem 操作叫 "釋放"。你可能會注意到 semaphore.Acquire 方法會返回錯誤,那是因為它可以和 context 包一起使用來控制提前結束。在這個例子中,我們將忽略它。

實際上,天氣查詢的例子比較簡單,不適用加權訊號量,但是為了簡單起見,我們假設 cost 變數隨城市名稱長度而變化。然後,我們修改如下:

func Cities(cities ...string) ([]*Info, error) {
    ctx := context.TODO() // 需要的時候,可以用 context 替換 
    var g errgroup.Group
    var mu sync.Mutex
    res := make([]*Info, len(cities)) // res[i] 對應 cities[i]
    sem := semaphore.NewWeighted(100) // 併發處理 100 個字元
    for i, city := range cities {
        i, city := i, city // 為閉包建立區域性變數
        cost := int64(len(city))
        if err := sem.Acquire(ctx, cost); err != nil {
            break
        }
        g.Go(func() error {
            info, err := City(city)
            mu.Lock()
            res[i] = info
            mu.Unlock()
            sem.Release(cost)
            return err
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    } else if err := ctx.Err(); err != nil {
        return nil, err
    }
    return res, nil
}

結論

上面的例子展示了在 Go 中通過微調來實現需要的併發模式是多麼簡單。

更多原創文章乾貨分享,請關注公眾號
  • Go 高階併發
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章