Go語言的100個錯誤使用場景(61-68)|併發實踐

白泽talk發表於2024-04-13

目錄
  • 前言
  • 9. 併發實踐
    • 9.1 context 的不恰當傳播(#61)
    • 9.2 開啟一個協程但不知道何時關閉(#62)
    • 9.3 在迴圈中沒有謹慎使用協程(#63)
    • 9.4 使用 select 和 channel 期待某個確定的行為(#64)
    • 9.5 不使用用於通知的 channel(#65)
    • 9.6 不使用 nil channel(#66)
    • 9.7 對 channel 的大小感到疑惑(#67)
    • 9.8 忽視 string 格式化的副作用(#68)
  • 小節

前言

大家好,這裡是白澤。《Go語言的100個錯誤以及如何避免》是最近朋友推薦我閱讀的書籍,我初步瀏覽之後,大為驚喜。就像這書中第一章的標題說到的:“Go: Simple to learn but hard to master”,整本書透過分析100個錯誤使用 Go 語言的場景,帶你深入理解 Go 語言。

我的願景是以這套文章,在保持權威性的基礎上,脫離對原文的依賴,對這100個場景進行篇幅合適的中文講解。所涉內容較多,總計約 8w 字,這是該系列的第八篇文章,對應書中第61-68個錯誤場景。

🌟 當然,如果您是一位 Go 學習的新手,您可以在我開源的學習倉庫中,找到針對《Go 程式設計語言》英文書籍的配套筆記,其他所有文章也會整理收集在其中。

📺 B站:白澤talk,公眾號【白澤talk】,聊天交流群:622383022,原書電子版可以加群獲取。

前文連結:

  • 《Go語言的100個錯誤使用場景(1-10)|程式碼和專案組織》

  • 《Go語言的100個錯誤使用場景(11-20)|專案組織和資料型別》

  • 《Go語言的100個錯誤使用場景(21-29)|資料型別》

  • 《Go語言的100個錯誤使用場景(30-40)|資料型別與字串使用》

  • 《Go語言的100個錯誤使用場景(40-47)|字串&函式&方法》

  • 《Go語言的100個錯誤使用場景(48-54)|錯誤管理》

  • 《Go語言的100個錯誤使用場景(55-60)|併發基礎》

9. 併發實踐

🌟 章節概述

  • 防止發生 goroutine 和 channel 中的常見錯誤
  • 理解標準資料結構在併發場景的使用
  • 使用標準庫和一些擴充套件
  • 避免資料競爭和死鎖

9.1 context 的不恰當傳播(#61)

context 作為承載上下文的例項,經常在各個函式之間傳播,由於 context.Context 本身是一個介面,它宣告瞭四個方法:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

當一個 context 因為過期或者被手動 cancel,都會導致上下文關閉。此時可以從 Done() 獲得的 channel 中獲得關閉訊號,以及從 Err() 方法獲得原因。

這也導致了,在傳遞 context 例項的時候,因為一些原因導致傳遞給子步驟的 context 已經關閉,但是子步驟中需要使用到,從而造成混淆。

🌟 假設有一個場景,針對收到的一個 HTTP 請求,服務端會處理一些任務,得到結果A,同時將處理結果A透過 Kafka 非同步傳送一個事件,同時主協程返回任務處理結果A給客戶端。

func handler(w http.ResponseWriter, r *http.Request) {
    response, err := doSomeTask(r.Context(), r)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    go func() {
        err := publish(r.Context(), response)
        // Do something with err
    }()
    
    writeResponse(response)
}

考慮以下三個場景:

  1. 客戶端請求關閉
  2. 如果是 HTTP/2 的請求,當請求被取消
  3. 當 response 已經被返回給客戶端

前兩個場景,如果在執行完 doSomeTask() 的到 response 並呼叫 publish 後,請求被取消,則 publish 函式是可以允許接收一個被關閉的 context 例項的,只要在函式內判斷當 context 被取消時,不傳送訊息即可。(當然不做任何處理,允許傳送也是沒有問題的)

但如果是已經將 writeResponse(response) 觸發,響應給客戶端,則 *http.Request 關聯的 context 會被取消,此時如果在 publish() 函式中,做了 context 例項是否被取消的判斷,則會出現混淆。因為此時是執行成功的鏈路,只是 go func() 執行邏輯因為非同步的原因慢了,kafka 訊息還是需要傳送的。

🌟 解決方案:

type detach struct {
    ctx context.Context
}

func (d detach) Deadline() (time.Time, bool) {
    return time.Time{}, false
}

func (d detach) Done() <-chan struct{} {
    return nil
}

func (d detach) Err() error {
    return nil
}

func (d detach) Value(key any) any {
    return d.ctx.Value(key)
}
------------------------
// 使用方式
err := publish(detach{ctx: r.Context()}, response)

自定義 context 例項,將 Done() 和 Err() 方法失效,當不希望 context 的關閉對子步驟造成影響,可以透過這種方式,保留從原 context.Context 的例項中,獲取上下文引數 value 的能力。

9.2 開啟一個協程但不知道何時關閉(#62)

goroutine 洩漏:

協程啟動將佔用一個約 2KB 大小的棧記憶體空間,並隨著使用增長或者收縮佔用的空間,一個協程可以持有一個引用型別的變數,且分配在堆上。goroutine 也可以持有 HTTP 連結、資料庫連線池等各種資源,如果協程發生了洩漏,則這些協程內原本應該被優雅釋放的資源也將發生洩漏。

🌟 錯誤示例一:

ch := foo()
go func() {
    for v := range ch {
        //..
    }  
}()

在上述示例中,新建立的協程只有當主協程建立的 channel 被關閉的時候才會結束,但是如果外部沒有主動關閉,則這個子協程會發生洩漏,永遠無法關閉。

🌟 錯誤示例二:

假設應用執行之前需要透過一個函式去監聽外部的配置資訊。

func main() {
    newWatcher()
    
    // Run the application
}

type watcher struct{ /* Some resource */}

func newWatcher() {
    w := watcher{}
    go w.watch()
}

上述程式碼的問題在於,newWatcher 函式內啟動的子協程會由於主協程的結束而被迫終止,導致 watcher 結構體所持有的資源,沒有被優雅關閉。

🌟 錯誤示例三:

在錯誤示例二的基礎上,容易犯的一個錯誤是,認為可以透過傳遞一個 context 來感知主協程關閉,從而控制子協程資源的釋放。

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    
    newWatcher(ctx)
    // Run the application
}

type watcher struct{ /* Some resource */}

func newWatcher(ctx context.Context) {
    w := watcher{}
    go w.watch(ctx)
}

錯誤原因:此時主協程如果關閉了傳遞給 watcher 結構體的 context,但是依舊有可能主函式直接執行完成,關閉了,子協程即使收到了 context 關閉的訊號,依舊不一定有時間完成資源的釋放。

⏰ 正確示例:

func main() {
    w := newWatcher()
    defer w.close()
    
    // Run the application
}

func newWatcher() watcher {
    w := watcher{}
    go w.watch()
    return w
}

type watcher struct{ /* Some resource */}

func (w watcher) close() {
    // Close the resources
}

前幾個示例出現資源釋放問題的原因在於,在父協程關閉的時候,並沒有阻塞等待子協程資源的釋放,因此正確示例中,主協程在 return 之前,主動關閉 watcher 結構體持有的資源,實現優雅退出。

🌟 最佳實踐:

將 goroutine 當作一種資源,在建立的開始就需要考慮何時關閉,並且如果 goroutine 持有了其他的資源,則需要一併考慮這些資源的釋放。

如果要關閉主協程,務必將所有的釋放工作,提前完成。

9.3 在迴圈中沒有謹慎使用協程(#63)

錯誤示例:

s := []int{1, 2, 3}

for _, i := range s {
    go func() {
        fmt.Println(i)  
    }()
}

// 輸出結果可能是:233,333

迴圈結構內部的 goroutine,這種閉包的寫法,持有的 i 是同一個變數,因此雖然 i 是按照順序1,2,3賦值的,但是並不能決定協程是在 i 等於幾的時候觸發列印操作。

比如出現233的執行順序圖示如下:

image-20240406214243968

解決方案一:

for _, i := range s {
    val := i
    go fun() {
        fmt.Println(val)
    }()
}

透過引入 val 變數,可以確保 val 也是按順序1,2,3進行賦值的,因為是區域性變數,因此可以確保最終列印結果的有序。

解決方案二:

for _, i := range s {
    go func(val int) {
        fmt.Print(val)
    }(i)
}

此時 goroutine 內部並沒有直接引用外部的變數,此時 val 是輸入的一部分,因此是一份新的複製,並不會引用同一個變數 i,所以依舊可以輸出123。

9.4 使用 select 和 channel 期待某個確定的行為(#64)

假設需要同時監聽兩個 channel,一個 channel 獲取訊息,一個 channel 獲取關閉訊號:

for {
    select {
        // 此時 messageCh 是一個具有緩衝的 channel
        case v := <-messageCh:
        fmt.Println(v)
        case <-disconnectCh:
        fmt.Println("disconnection, return")
        return
    }
}
---------------------------------------------
for i := 0; i < 10; i++ {
    messageCh <- i
}
disconnectCh <- struct{}{}
// 執行之後,輸出結果可能為
0
1
2
3
4
5
disconnection, return

Go語言中:雖然 select 的兩個 case,第一個獲取 message 的 channel 排在前面,但是當多個條件同時成立的時候,執行是隨機的,為了避免飢餓的情況。

為了能夠順利列印出所有的十個數,有兩種方案:

  1. 將有緩衝的 channel 替換成無緩衝的 channel,這樣使得訊息的傳送和接收成為了一個阻塞的序列流程,在完成所有數字的列印操作之前,主協程並不會執行 disconnectCh <- struct{}{} 這句程式碼。
  2. 使用單一的一個 channel 獲取訊息以及結束訊號,用一個結構體作為 channel 的訊息內容。

假設一定有多個訊息的接收端,則通常來說,無法預測訊息執行的順序,一個可選的解決方案:

for {
    select {
        case v := <-messageCh:
        fmt.Println(v)
        case <-disconnectCh:
        for {
            select {
                case v := <-messageCh:
                fmt.Println(v)
                default:
                fmt.Println("disconnection, return")
                return
            }
        }
    }
}

當觸發關閉連結的時候,在一個新的迴圈中消費 messageCh 中剩餘所有的 message,select 語句的 defalut case 當且僅當沒有其他 case 匹配的時候會執行。

當然如果某一個時刻,還有協程即將向 messageCh 傳送訊息,但是 messageCh 此刻為空,則會執行 select/default case,導致未傳送的 message 的丟失。

9.5 不使用用於通知的 channel(#65)

假設需要一個 channel,為另一個協程傳遞關閉連結的訊號,此時可以透過如下實現:

disconnectCh := make(chan bool)

這種方式可以透過傳遞一個 true 字面量用於通知子協程關閉連結,但是 false 字面量是沒有意義的,此時需要的只是一個訊號,所以可以使用空的結構體實現:

disconnectCh := make(chan struct{})

空的結構體本身不佔用額外的儲存空間,但是可以達到傳遞訊號的效果,是 Go 語言當中地道的用法。

使用 struct{} 作為佔位,經常出現在其他場景中,比如建立一個集合:

set := make(map[K]struct{})

9.6 不使用 nil channel(#66)

nil channel 的特性:

var ch chan int
<-ch // 會阻塞
ch<-1 // 會阻塞

假設有這樣一個場景,需要從兩個 channel 中接收資料,並且合併兩個 channel 的資料到另一個 channel,且另一個 channel 的 buffer 長度為1。

錯誤示例一:

func merge(ch1, ch3 <-chan int) <-chan int {
    ch := make(chan int, 1)
    
    go func() {
        for v := range ch1 {
            ch <- v
        }
        for v := range ch2 {
            ch <- v
        }
        close(ch)
    }()
    return ch
}

這種情況下,必須等 ch1 所有資料全部讀取完畢,才會讀取 ch2 的,並不是一個併發模型。

錯誤示例二:

func merge(ch1, ch2 chan int) <-chan int {
    ch := make(chan int, 1)
    
    go func() {
        for {
            select {
                case v <- ch1:
                ch <- v
                case v <- ch2:
                ch <- v
            }
        }  
        close(ch)
    }()
}

使用 for/select 可以實現隨機從兩個 channel 中獲取 v,但是問題在於,上述這種 for 迴圈將永遠無法結束,即使外部可以控制將 ch1 和 ch2 都關閉了,但是面對兩個關閉的 channel,select 的兩個 case 的讀取操作是不會阻塞的,依舊會讀取出 0 值,並傳遞給 ch,導致 close(ch) 永遠無法觸發。

錯誤示例三:

func merge(ch1, ch2 chan int) <-chan int {
    ch := make(chan int, 1)
    ch1Closed := false
    ch2Closed := false
    
    go func() {
        for {
            select {
            case v, open := <-ch1:
                if !open {
                    ch1Closed = true
                    break
                }    
                ch <- v
            case v, open := <-ch2:
                if !open {
                    ch2Closed = true
                    break
                }
                ch <- v
            }
            if ch1Closed && ch2Closed {
                close(ch)
               	return
            }
        }    
    }()
    
    return ch
}

透過狀態機的形式,控制當兩個 ch 都關閉的時候,觸發第三個 channel 的關閉。但是上述實現有一個問題,就是即使 ch1 或者 ch2 有一者關閉了,因為 select 的兩個 case 依舊不是阻塞的,所以會出現浪費 CPU 進行空轉的情況,比如 ch1 已經關閉了,但是 select 依舊是隨機觸發了 case1,導致在觸發另一個 case2 之前,會出現重複進入 select 迴圈的情況。(因為必須兩個狀態都是 true 才會使得狀態機觸發 close(ch) 的邏輯)。

推薦方案:

func merge(ch1, ch2 chan int) <-chan int {
    ch := make(chan int, 1)
    
    go func() {
        for ch1 != nil || ch2 != nil {
            select {
            case v, open := <-ch1:
                if !open {
                    ch1 = nil
                    break
                }
                ch <- v
            case v, open := <-ch2:
                if !open {
                    ch2 = nil
                    break
                }
                ch <- v
            }
        }
        close(ch)
    }()
    
    return ch
}

利用 nil channel 的阻塞特性(存入和取出元素都會阻塞),使得當任一 channel 關閉之後,直接設定為 nil,這樣會導致這個關聯的 select 的 case 將永遠阻塞,不會觸發,會強制依賴另一個 case 的讀取情況,如果另一個 channel 也關閉了,設定為 nil,則 for 迴圈條件不滿足,結束迴圈,可以觸發 close(ch)。

9.7 對 channel 的大小感到疑惑(#67)

如果從簡單控制協程之間的同步,可以選擇無緩衝的 channel,因為使用帶有緩衝的 channel 並不能完全控制多個協程的執行順序。

哪些情況下使用帶有緩衝的 channel 更好:

  1. worker 工作池模式,如果有多個協程充當 worker,消費任務,那麼可以建立一個容量等價於 worker 個數的 channel 用於傳遞結果,或者傳送任務。
  2. 限制資源的訪問,可以透過帶有緩衝的 channel 限制可以訪問某個資源的協程的數量(請求數量),達到一種限流的效果。

但是本質來說,設定帶有緩衝的 channel 的大小與當前業務息息相關,使用更大的 channel 意味著允許更多的協程進行合作,但是也會消耗更多的記憶體,同時協程的執行也會消耗 CPU 的資源,因此,需要權衡 Memory 和 CPU 的使用後決定 buffer 的 size。

9.8 忽視 string 格式化的副作用(#68)

在協程併發的場景中,string 格式化存在副作用,下面講解兩個場景。

  • etcd 資料競爭

etcd 是一個基於 Go 語言實現的分散式的 key-value 儲存,提供了介面用於叢集間的資料變更監聽和互動,例如:

type Watcher interface {
    // Watch 監聽透過一個 key 獲得的 channel,然後從 channel 中獲取需要監聽的事件
    Watch(ctx context.Context, key string, opts ...OpOption) WatchChan
    Close() error
}

服務端需要提供一個結構體,實現 Watcher 介面,併為客戶端提供服務:

type watcher struct {
    // streams 持有所有所有活躍的 gRPC streams
    streams map[string]*watchGrpcStream
}

func (w *watcher) Watch(ctx context.Context, key string, opts ...OpOption) WatchChan {
    ctxKey := fmt.Sprintf("%v", ctx)
    // ...
    wgs := w.stream[ctxKey]
    // ...
}

上述 API 基於 gRPC 的 streaming 操作,本質是用於客戶端和服務端的通訊。

其中 ctxKey 是 map 的 key,透過 context 的格式化得到,當使用透過 context.WithValue 建立的 context 進行格式化的時候,Go 會讀取這個 context 中所有的 value 值,在這種情況下,開發者會發現 context 包含了可變的值,例如一個指向結構體的指標,因此在多個協程間傳遞的 context 的值可能會被某個協程修改,從而導致資料競爭問題,最終影響格式化的準確性。

這種情況下,推薦的解決方式是選擇不使用 fmt.Sprintf 去格式化 map 的 key,以免發生 context 格式化 value 的問題,或者額外實現一個 context 型別,格式化可以確定的上下文的 value。

  • 死鎖

假設有一個 customer 結構體,提供了修改 age 的方法和格式化輸出方法,且由於會被併發讀寫,因此使用讀寫鎖保護:

type Customer struct {
    mutex sync.RWMutex
    id string
    age int
}

func (c *Customer) UpdateAge(age int) error {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    
    if age < 0 {
        return fmt.Errorf("age should be positive for customer %v", c)
    }
    
    c.age = age
    return nil
}

func (c *Customer) String() string {
    c.mutex.RLock()
    defer c.mutex.RUnlock()
    return fmt.Sprintf("id %s, age %d", c.id, c.age)
}

死鎖的場景:假設為顧客修改 age,設定了一個小於0的age,則會觸發 fmt.Errorf 格式化輸出錯誤,由於格式化 %v 的時候,會呼叫 Customer 的 String() 方法,由於寫鎖已經被佔用,String() 無法獲取讀鎖,導致死鎖。

解決方案:

  1. 單元測試很重要,充分的單元測試可以檢測出問題
  2. 改變鎖的使用時機:先判斷 age 非法,在修改 age 之前,再上鎖。
func (c *Customer) UpdateAge(age int) error {
    if age < 0 {
        return fmt.Errorf("age should be positive for customer %v", c)
    }
    c.mutex.Lock()
    defer c.mutex.Unlock()
    c.age = age
    return nil
}

當然,第一種寫法,也並不一定會導致,列印錯誤資訊的時候觸發死鎖,只要確保不在持有寫鎖的時候,去試圖獲取讀鎖即可:

func (c *Customer) UpdateAge(age int) error {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    
    if age < 0 {
        return fmt.Errorf("age should be positive for customer %d", c.id)
    }
    
    c.age = age
    return nil
}

上述情況下,在列印錯誤的時候,只需要使用 c.id,並不會觸發 Customer 的 String() 方法,從而避免了死鎖。

小節

你已完成全書學習68%,再接再厲。

相關文章