- 前言
- 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)
以一家咖啡店的運作為例講解一下併發和並行的概念。
- 並行:強調執行,如兩個咖啡師同時在給咖啡拉花
- 併發:兩個咖啡師競爭一個咖啡研磨機器的使用
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 上。
🌟 上圖中展示了一個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 的執行。
🌟 為了印證有時候多協程並不一定會提高效能,這裡以歸併排序為例舉三個例子:
示例一:
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)
- 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個步驟:
- 讀取 i 的值
- 對應值 + 1
- 將值寫會 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)。這些工作執行緒從一個共享的工作佇列中獲取任務,並執行它們。這個模式的主要目的是提高併發效能,透過減少執行緒的建立和銷燬,以及透過限制併發執行的任務數量來避免資源競爭。
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 的數量,因為理想狀態下,只能允許最大執行緒數量的協程同時執行,
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 設定成不允許匯出的型別。
一些用法:
- 在藉助 ctx 在函式之間傳遞同一個 id,實現鏈路追蹤。
- 藉助 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%,歡迎追更。