話接上回《跟著 GPT-4 從0到1學習 Golang 並行機制(二)》,我想試試看 GPT-4 能不能當好一個程式設計老師,循循善誘,由淺入深,深入淺出…… 給我講解下 Golang 裡的並行機制。
今天繼續當白紙,GPT 為筆,程式碼為顏料,揮筆潑墨,接著作畫。
下文通過 DevChat 和 GPT-4 互動,如果你對 DevChat 感興趣,可以跳轉下文瀏覽:
可以介紹下 goroutine 洩露問題嗎
Goroutine 洩露是 Go 程式中一個常見的問題,它發生在你啟動了一個 goroutine,但是沒有提供一個結束它的方式。這個 goroutine 就會一直執行,佔用記憶體和 CPU,直到整個程式結束。如果你的程式啟動了大量的這樣的 goroutine,它可能會消耗大量的資源,甚至導致程式崩潰。
以下是一個 goroutine 洩露的範例:
package main import ( "fmt" "time" ) func leakyFunction() { go func() { for { fmt.Println("I'm leaking!") time.Sleep(1 * time.Second) } }() } func main() { leakyFunction() time.Sleep(5 * time.Second) }
在這個範例中,
leakyFunction
啟動了一個 goroutine,這個 goroutine 會無限迴圈列印一條訊息。然而,leakyFunction
並沒有提供一個結束這個 goroutine 的方式,所以這個 goroutine 會一直執行,直到整個程式結束。為了避免 goroutine 洩露,你應該總是提供一個結束 goroutine 的方式。這通常可以通過 context 取消、關閉 channel 或者使用
sync.WaitGroup
來實現。以下是一個修改後的範例,避免了 goroutine 洩露:
package main import ( "context" "fmt" "time" ) func notLeakyFunction(ctx context.Context) { go func() { for { select { case <-ctx.Done(): return default: fmt.Println("I'm not leaking!") time.Sleep(1 * time.Second) } } }() } func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() notLeakyFunction(ctx) time.Sleep(10 * time.Second) }
在這個範例中,我們使用了一個可取消的 context。當 context 被取消時,goroutine 會檢測到這個訊號,並結束執行。這樣,我們就避免了 goroutine 洩露。
寥寥幾句,似乎已經講完了這個話題。好吧,那繼續看記憶體模型去。
繼續介紹下 Go 的記憶體模型吧
Go 的記憶體模型定義了在並行程式中,如何以及何時對變數的讀寫操作對其他 goroutine 可見。它是理解並行程式設計中資料同步的關鍵。
在 Go 中,如果一個 goroutine 在沒有使用 channel 或者其他同步原語(如
sync
包中的鎖)的情況下,對一個變數的寫入操作對其他 goroutine 是不可見的。這是因為 Go 執行時並不保證在沒有同步的情況下,一個 goroutine 的寫入操作何時對其他 goroutine 可見。以下是一個範例:
package main import ( "fmt" "time" ) var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() time.Sleep(1 * time.Second) if done { fmt.Println(a) } }
在這個範例中,
setup
goroutine 對a
和done
的寫入操作可能對main
goroutine 不可見,即使我們在setup
和main
之間新增了time.Sleep
。這是因為 Go 的記憶體模型並不保證在沒有同步的情況下,一個 goroutine 的寫入操作何時對其他 goroutine 可見。為了在 goroutine 之間正確同步資料,你應該使用 channel 或者
sync
包中的原語。例如,你可以使用sync.Mutex
來保護對共用變數的存取,或者使用sync.WaitGroup
來等待一個 goroutine 完成。以下是一個修改後的範例,使用了
sync
包來同步資料:package main import ( "fmt" "sync" ) var a string var done bool var mu sync.Mutex func setup() { a = "hello, world" mu.Lock() done = true mu.Unlock() } func main() { go setup() time.Sleep(1 * time.Second) mu.Lock() if done { fmt.Println(a) } mu.Unlock() }
在這個範例中,我們使用了
sync.Mutex
來保護對done
的存取。這樣,我們就可以確保setup
goroutine 對a
和done
的寫入操作對main
goroutine 可見。
這個結果看起來並不好,我不滿意。
這個例子其實並不能太好地解釋「記憶體模型」,因為不管執行多少次,main 函數中都會讀到 done 為 true。我希望你更深入地介紹 happens before 以及 Golang 哲學中的「Do not communicate by sharing memory; instead, share memory by communicating」
你的觀察是正確的,我之前的例子可能並不能很好地展示 Go 的記憶體模型。讓我們更深入地討論一下。
Happens Before
在 Go 的記憶體模型中,"happens before" 是一個非常重要的概念。如果我們說事件 A happens before 事件 B,那麼意味著在所有的 goroutine 中,事件 A 的效果對事件 B 是可見的。也就是說,如果一個 goroutine 對一個變數的寫入操作 happens before 另一個 goroutine 的讀取操作,那麼讀取操作將會看到寫入操作的效果。
在 Go 中,以下幾種情況會建立 happens before 關係:
- 對同一個 channel 的傳送操作 happens before 相應的接收操作。
- 對一個 Mutex 或者 RWMutex 的解鎖操作 happens before 後續的鎖定操作。
- 對一個 WaitGroup 的 Add 操作 happens before 相應的 Wait 操作。
- 對一個 Cond 的 Signal 或者 Broadcast 操作 happens before 相應的 Wait 操作返回。
"Do not communicate by sharing memory; instead, share memory by communicating"
這是 Go 並行哲學的核心原則。它鼓勵我們使用 channel 來在 goroutine 之間傳遞資料,而不是通過共用記憶體。這樣,我們可以通過資料的所有權轉移來避免資料競爭。
以下是一個使用 channel 來在 goroutine 之間傳遞資料的範例:
package main import ( "fmt" "time" ) func worker(done chan bool) { fmt.Print("working...") time.Sleep(time.Second) fmt.Println("done") done <- true } func main() { done := make(chan bool, 1) go worker(done) <-done }
在這個範例中,我們建立了一個 worker goroutine,並給它傳遞了一個 channel。當 worker 完成工作後,它會向這個 channel 傳送一個訊號。主 goroutine 會等待這個訊號,然後結束程式。這樣,我們就通過 channel 在 goroutine 之間傳遞了資料,而不是通過共用記憶體。
看了這個新的例子,我大概理解「happens before」以及「share memory by communicating」了。在前面一個例子中,你通過共用記憶體來實現 goroutine 之間的通訊,其實是不可靠的,因為讀取操作可以發生在寫入操作之前。如果你沒有加上1秒鐘的等待,那麼完全可能發生讀取到的 done 為 false;而通過 channel 通訊來實現共用記憶體就避免了這個問題,因為 done 的讀取會等待寫入完成。總之這裡的關鍵就是儘量用 channel 實現 goroutine 之間的通訊,避免使用共用記憶體;同時在並行程式設計時儘量使用 sync 包提供的各種同步鎖和條件變數等等機制,避免使用類似「time.Sleep()」這種不可靠的方式去實現 goroutine 同步。我的理解對嗎?
你的理解是完全正確的。在 Go 中,我們應該優先使用 channel 來在 goroutine 之間傳遞資料,而不是通過共用記憶體。這樣可以避免資料競爭,使得程式碼更容易理解和維護。
同時,你也正確地指出了在並行程式設計中,我們應該使用
sync
包提供的同步原語,如 Mutex、WaitGroup 和 Cond,來同步 goroutine 的執行。這些同步原語可以幫助我們建立明確的 happens-before 關係,確保我們的程式按照預期的順序執行。最後,你也正確地指出了,我們應該避免使用
time.Sleep()
這樣的方式來同步 goroutine。因為time.Sleep()
並不能保證 happens-before 關係,所以它不能確保 goroutine 的執行順序。在實際的並行程式中,我們應該使用 channel、Mutex、WaitGroup 或者其他同步原語來同步 goroutine,而不是依賴於time.Sleep()
。
最後,我還想知道 Race Detector 是什麼
Go 語言提供了一個名為 Race Detector 的工具,用於檢測程式碼中的資料競爭條件。資料競爭發生在當兩個或更多的 goroutine 並行存取同一塊記憶體區域,且至少有一個存取是寫入操作時。
Race Detector 可以在執行時檢測到這些資料競爭,幫助你找到並修復它們。你可以通過在
go run
、go test
或go build
命令後新增-race
選項來啟用 Race Detector。以下是一個有資料競爭的程式:
package main import ( "fmt" ) var counter int func increment() { counter++ } func main() { go increment() go increment() fmt.Println(counter) }
在這個程式中,兩個 goroutine 都在嘗試增加
counter
的值。這會導致資料競爭,因為counter++
不是一個原子操作。如果你使用 Race Detector 來執行這個程式(
go run -race main.go
),它會報告資料競爭,並給出詳細的報告,包括資料競爭發生的位置和涉及的 goroutine。注意,雖然 Race Detector 是一個非常有用的工具,但它可能會使你的程式執行得更慢,並使用更多的記憶體。因此,你應該只在測試和偵錯時啟用它,而不是在生產環境中使用。
看起來挺有道理的,我試下這段程式碼:
$ go run -race main.go
0
==================
WARNING: DATA RACE
Write at 0x00010269a8a0 by goroutine 7:
main.increment()
/Users/danielhu/go/src/demo/main.go:10 +0x3c
Previous read at 0x00010269a8a0 by main goroutine:
main.main()
/Users/danielhu/go/src/demo/main.go:16 +0x44
Goroutine 7 (running) created at:
main.main()
/Users/danielhu/go/src/demo/main.go:15 +0x34
==================
Found 1 data race(s)
exit status 66
到這裡,我還想追問一句:
所以 Race Detector 其實並不是為了檢測並行存取一段資料,而是檢測哪些並行存取資料時沒有正確地使用合適的同步原語對嗎?
是的,你的理解是正確的。Race Detector 的主要目的是檢測資料競爭,也就是多個 goroutine 同時存取同一塊資料,且至少有一個 goroutine 在進行寫操作,而這些操作之間沒有進行適當的同步。
如果你的程式碼中存在資料競爭,那麼你的程式碼的行為就是未定義的,可能會導致各種難以預料和難以偵錯的問題。通過使用 Race Detector,你可以在開發和測試階段就發現這些問題,然後通過新增適當的同步原語(如互斥鎖、channel 等)來修復它們。
所以,Race Detector 並不是用來檢測並行存取資料本身,而是用來檢測並行存取資料時是否存在資料競爭,即是否存在沒有進行適當同步的並行讀寫操作。
三篇《跟著 GPT-4 從0到1學習 Golang 並行機制》文章到此寫完。溫故知新,收穫頗豐,隨便總結下:
select
語句可以用於在多個 channel 操作之間進行選擇。當多個 case 都可以執行時,select
會隨機選擇一個。sync
包提供了一些同步原語,如 Mutex(互斥鎖)、RWMutex(讀寫鎖)、WaitGroup(等待組)和 Cond(條件變數)。這些原語可以用於保護共用資料,或者同步 goroutines 的執行。context
包提供了一種在 API 邊界之間傳遞請求範圍的值、取消訊號和超時資訊的方式。你可以使用 context.WithCancel
、context.WithDeadline
、context.WithTimeout
和 context.WithValue
函數來建立新的 Context。sync
包中的原語來同步資料,以確保 happens-before 關係。go run
、go test
或 go build
命令後新增 -race
選項來啟用 Race Detector。Do not communicate by sharing memory; instead, share memory by communicating.
Do not communicate by sharing memory; instead, share memory by communicating.
Do not communicate by sharing memory; instead, share memory by communicating.
相關文章
(關注我的個人公眾號「胡說雲原生」吧)