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

白泽talk發表於2024-03-02

目錄
  • 前言
  • 8. 併發基礎
    • 8.1 混淆併發與並行的概念(#55)
    • 8.2 認為併發總是更快(#56)
    • 8.3 分不清何時使用互斥鎖或 channel(#57)
    • 8.4 不理解競態問題(#58)
    • 8.5 不瞭解工作負載型別對併發效能的影響(#59)
    • 8.6 不懂得使用 Go contexts(#60)
  • 小結

前言

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

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

🌟 當然,如果您是一位 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)|錯誤管理》

8. 併發基礎

🌟 章節概述

  • 理解併發和並行
  • 為什麼併發並不總是更快
  • cup 負載和 io 負載的影響
  • 使用 channel 對比使用互斥鎖
  • 理解資料競爭和競態條件的區別
  • 使用 Go context

8.1 混淆併發與並行的概念(#55)

以一家咖啡店的運作為例講解一下併發和並行的概念。

image-20240224103434660

  • 並行:強調執行,如兩個咖啡師同時在給咖啡拉花
  • 併發:兩個咖啡師競爭一個咖啡研磨機器的使用

8.2 認為併發總是更快(#56)

  • 執行緒:OS 排程的基本單位,用於排程到 CPU 上執行,執行緒的切換是一個高昂的操作,因為要求將當前 CPU 中執行態的執行緒上下文儲存,切換到可執行態,同時排程一個可執行態的執行緒到 CPU 中執行。
  • 協程:執行緒由 OS 上下文切換 CPU 核心,而 Goroutine 則由 Go 執行時上下文切換協程。Go 協程佔用記憶體比執行緒少(2KB/2MB),協程的上下文切換比執行緒快80~90%。

🌟 GMP 模型:

  • G:Goroutine
    • 執行態:被排程到 M 上執行
    • 可執行態:等待被排程
    • 等待態:因為一些原因被阻塞
  • M:OS thread
  • P:CPU core
    • 每個 P 有一個本地 G 佇列(任務佇列)
    • 所有 P 有一個公共 G 佇列(任務佇列)

協程排程規則:每一個 OS 執行緒(M)被排程到 P 上執行,然後每一個 G 執行在 M 上。

image-20240224111521402

🌟 上圖中展示了一個4核 CPU 的機器排程 Go 協程的場景:

此時 P2 正在閒置因為 M3 執行完畢釋放了對 P2 的佔用,雖然 P2 的 Local queue 中已經空了,沒有 G 可以排程執行,但是每隔一定時間,Go runtime 會去 Global queue 和其他 P 的 local queue 偷取一些 G 用於排程執行(當前存在6個可執行的G)。

特別的,在 Go1.14 之前,Go 協程的排程是合作形式的,因此 Go 協程發生切換的只會因為阻塞等待(IO/channel/mutex等),但 Go1.14 之後,執行時間超過 10ms 的協程會被標記為可搶佔,可以被其他協程搶佔 P 的執行。

🌟 為了印證有時候多協程並不一定會提高效能,這裡以歸併排序為例舉三個例子:

image-20240224232145909

示例一:

func sequentialMergesort(s []int) {
    if len(s) <= 1 {
    	return
    }
    middle := len(s) / 2
    sequentialMergesort(s[:middle])
    sequentialMergesort(s[middle:])
    merge(s, middle)
}

func merge(s []int, middle int) {
    // ...
}

示例二:

func sequentialMergesortV1(s []int) {
    if len(s) <= 1 {
    	return
    }
    middle := len(s) / 2
    
    var wg sync.WaitGroup()
    wg.Add(2)
    
    go func() {
        defer wd.Done()
        parallelMergesortV1(s[:middle])
    }()
    go func() {
        defer wd.Done()
        parallelMergesortV1(s[middle:])
    }()
    wg.Wait()
    merge(s, middle)
}

示例三:

const max = 2048

func sequentialMergesortV2(s []int) {
    if len(s) <= 1 {
    	return
    }
    if len(s) < max {
        sequentialMergesort(s)
    } else {
    	middle := len(s) / 2
    
        var wg sync.WaitGroup()
        wg.Add(2)

        go func() {
            defer wd.Done()
            parallelMergesortV2(s[:middle])
        }()
        go func() {
            defer wd.Done()
            parallelMergesortV2(s[middle:])
        }()
        
        wg.Wait()
        merge(s, middle)   
    }
}

由於建立協程和排程協程本身也有開銷,第二種情況無論多少個元素都使用協程去進行並行排序,導致歸併很少的元素也需要建立協程和排程,開銷比排序更多,導致效能還比不上第一種順序歸併。

而在本臺電腦上,經過除錯第三種方式可以獲得比第一種方式更優的效能,因為它在元素大於2048個的時候,選擇並行排序,而少於則使用順序排序。但是2048是一個魔法數,不同電腦上可能不同。這裡這是為了證明,完全依賴併發/並行的機制,並不一定會提高效能,需要注意協程本身的開銷。

8.3 分不清何時使用互斥鎖或 channel(#57)

image-20240225155219930

  • mutex:針對 G1 和 G2 這種並行執行的兩個協程,它們可能會針對同一個物件進行操作,比如切片。此時是一個發生資源競爭的場景,因此適合使用互斥鎖。
  • channel:而上游的 G1 或者 G2 中任何一個都可以在執行完自己邏輯之後,通知 G3 開始執行,或者傳遞給 G3 某些處理結果,此時使用 channel,因為 Go 推薦使用 channel 作為協程間通訊的手段。

8.4 不理解競態問題(#58)

🌟 資料競爭:多個協程同時訪問一塊記憶體地址,且至少有一次寫操作。

假設有兩個併發協程對 i 進行自增操作:

i := 0

go func() {
	i++
}()

go func() {
	i++
}()

因為 i++ 操作可以被分解為3個步驟:

  1. 讀取 i 的值
  2. 對應值 + 1
  3. 將值寫會 i

當併發執行兩個協程的時候,i 的最終結果是無法預計的,可能為1,也可能為2。

修正方案一:

var i int64

go func() {
    atomic.AddInt64(&i, 1)  
}()

go func() {
    atomic.AddInt64(&i, 1)  
}()

使用 sync/atomic 包的原子運算,因為原子運算不能被打斷,因此兩個協程無法同時訪問 i,因為客觀上兩個協程按順序執行,因此最終的結果為2。

但是因為 Go 語言只為幾種型別提供了原子運算,無法應對 slices、maps、structs。

修正方案二:

i := 0
mutex := sync.Mutex{}

go func() {
    mutex.Lock()
    i++
    mutex.UnLock()
}()

go func() {
    mutex.Lock()
    i++
    mutex.UnLock()
}()

此時被 mutex 包裹的部分,同一時刻只能允許一個協程訪問。

修正方案三:

i := 0
ch := make(chan int)

go func() {
    ch <- 1
}

go func() {
    ch <- 1
}

i += <-ch
i += <-ch

使用阻塞的 channel,主協程必須從 ch 中讀取兩次才能執行結束,因此結果必然是2。

🌟 Go 語言的記憶體模型

我們使用 A < B 表示事件 A 發生在事件 B 之前。

i := 0
go func() {
    i++
}()

因為建立協程發生在協程的執行,因此讀取變數 i 並給 i + 1在這個例子中不會造成資料競爭。

i := 0
go func() {
    i++
}()
fmt.Println(i)

協程的退出無法保證一定發生在其他事件之前,因此這個例子會發生資料競爭。

i := 0
ch := make(chan struct{})
go func() {
    <-ch
    fmt.Println(i)
}()
i++
ch <- struct{}{}

這個例子由於列印 i 之前,一定會執行 i++ 的操作,並且子協程等待主協程的 channel 的解除阻塞訊號。

i := 0
ch := make(chan struct{})
go func() {
    <-ch
    fmt.Println(i)
}()
i++
close()

和上一個例子有點像,channel 在關閉事件發生在從 channel 中讀取訊號之前,因此不會發生資料競爭。

i := 0
ch := make(chan struct{}, 1)
go func() {
    i = 1
    <-ch
}()
ch <- struct{}{}
fmt.Println(i)

主協程向 channel 放入值的操作執行,並不能確保與子協程的執行事件順序,因此會發生資料競爭。

i := 0
ch := make(chan struct{})
go func() {
    i = 1
    <-ch
}()
ch <- struct{}{}
fmt.Println(i)

主協程的存入 channel 的事件,必然發生在子協程從 channel 取出事件之前,因此不會發生資料競爭。

i := 0
ch := make(chan struct{})
go func() {
    i = 1
    <-ch
}()
ch <- struct{}{}
fmt.Println(i)

無無緩衝的 channel 確保在主協程執行列印事件之前,必須會執行 i = 1 的賦值操作,因此不會發生資料競爭。

8.5 不瞭解工作負載型別對併發效能的影響(#59)

🌟 工作負載執行時間受到下述條件影響:

  • CPU 執行速度:例如執行歸併排序,此時工作負載稱作——CPU約束。
  • IO 執行速度:對DB進行查詢,此時工作負載稱作——IO約束。
  • 可用記憶體:此時工作負載稱作——記憶體約束。

🌟 接下來透過一個場景講解為何討論併發效能,需要區分負載型別:假設有一個 read 函式,從迴圈中每次讀取1024位元組,然後將獲得的內容傳遞給一個 task 函式執行,返回一個 int 值,並每次迴圈對這個 int 進行求和。

序列實現:

func read(r io.Reader) (int, error) {
    count := 0
    for {
        b := make([]byte, 1024)
        _, err := r.Read(b)
        if err != nil {
            if err == io.EOF {
				break
            }
            return 0, err
        }
        count += task(b)
    }
    return count, nil
}

併發實現:Worker pooling pattern(工作池模式)是一種併發設計模式,用於管理一組固定數量的工作執行緒(worker threads)。這些工作執行緒從一個共享的工作佇列中獲取任務,並執行它們。這個模式的主要目的是提高併發效能,透過減少執行緒的建立和銷燬,以及透過限制併發執行的任務數量來避免資源競爭。

image-20240227225024934

func read(r io.Reader) (int, error) {
    var count int64
    wg := sync.WaitGroup{}
    var n = 10
    
    ch := make(chan []byte, n)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for b := range ch {
                v := tasg(b)
                atomic.AddInt64(&count, int64(v))
            }
        }()
    }
    for {
        b := make([]byte, 1024)
        ch <- b
    }
    close(ch)
    wg.Wait()
    return int(count), nil
}

這個例子中,關鍵在於如何確定 n 的大小:

  • 如果工作負載被 IO 約束:則 n 取決於外部系統,使得系統獲得最大吞吐量的併發數。
  • 如果工作負載被 CPU 約束:最佳實踐是取決於 GOMAXPROOCS,這是一個變數存放系統允許分配給執行協程的最大執行緒數量,預設情況下,這個變數用於設定邏輯 CPU 的數量,因為理想狀態下,只能允許最大執行緒數量的協程同時執行,

image-20240228214951485

8.6 不懂得使用 Go contexts(#60)

🌟 A Context carries a deadline, a cancellation signal, and other values across API boundaries.

截止時間

  • time.Duration(250ms)
  • time.Time(2024-02-28 00:00:00 UTC)

當截止時間到達的時候,一個正在執行的行為將停止。(如IO請求,等待從 channel 中讀取訊息)

假設有一個雷達程式,每隔四秒鐘,向其他應用提供座標座標資訊,且只關心最新的座標。

type publisher interface {
    Publish(ctx context.Content, position flight.Position) error
}

type publishHandler struct {
    pub publisher
}

func (h publishHandler) publishPosition(position flight.Position) error {
    ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
    defer cancel()
    return h.pub.Publish(ctx, position)
}

透過上述程式碼,建立一個過期時間4秒中的 context 上下文,則應用可以透過判斷 ctx.Done() 判斷這個上下文是否過期或者被取消,從而判斷是否為4秒內的有效座標。

cancel() 在 return 之前呼叫,則可以透過 cancel 方法關閉上下文,避免記憶體洩漏。

取消訊號

func main() {
    ctx. cancel := context.WithCancel(context.Background())
    defer cacel()
    
    go func() {
        CreateFileWatcher(ctx, "foo.txt") 
    }()
}

在 main 方法執行完之前,透過呼叫 cancel 方法,將 ctx 的取消訊號傳遞給 CreateFileWatcher() 函式。

上下文傳遞值

ctx := context.WithValue(context.Background(), "key", "value")
fmt.Println(ctx.Value("key"))

# value

key 和 value 是 any 型別的。

package provider

type key string

const myCustomKey key = "key"

func f(ctx context.Context) {
    ctx = context.WithValue(ctx, myCustomKey, "foo")
    // ...
}

為了避免兩個不同的 package 對同一個 ctx 存入同樣的 key 導致衝突,可以將 key 設定成不允許匯出的型別。

一些用法:

  1. 在藉助 ctx 在函式之間傳遞同一個 id,實現鏈路追蹤。
  2. 藉助 ctx 在多箇中介軟體之間傳遞,存放處理資訊。
type key string

const inValidHostKey key = "isValidHost"

func checkValid(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        validHost := r.Host == "came"
        ctx := context.WithValue(r.Context(), inValidHostKey, validHost)
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

checkValid 作為一箇中介軟體,優先處理 http 請求,將處理結果存放在 ctx 中,傳遞給下一個處理步驟。

捕獲 context 取消

context.Context 型別提供了一個 Done 方法,返回了一個接受關閉訊號的 channel:<-chan struct{},觸發條件如下:

  • 如果 ctx 透過 context.WithCancel 建立,則可以透過 cancel 函式關閉。
  • 如果 ctx 透過 context.WithDeadline 建立,當過期的時候 channel 關閉。

此外,context.Context 提供了一個 Err 方法,將返回導致 channel 關閉的原因,如果沒有關閉,呼叫則返回 nil。

  • 返回 context.Canceled error 如果 channel 被 cancel 方法關閉。
  • 返回 context.DeadlineExceeded 如果達到 deadline 過期。
func handler(ctx context.Context, ch chan Message) error {
    for {
        select {
            case msg := <-ch:
            // Do something with msg
            case <-ctx.Done():
            return ctx.Err()
        }
    }
}

小結

你已完成《Go語言的100個錯誤》全書學習進度60%,歡迎追更。

相關文章