跟著 GPT-4 從0到1學習 Golang 並行機制(三)

2023-07-13 15:01:00

一、前言

話接上回《跟著 GPT-4 從0到1學習 Golang 並行機制(二)》,我想試試看 GPT-4 能不能當好一個程式設計老師,循循善誘,由淺入深,深入淺出…… 給我講解下 Golang 裡的並行機制。

今天繼續當白紙,GPT 為筆,程式碼為顏料,揮筆潑墨,接著作畫。

二、開聊

下文通過 DevChat 和 GPT-4 互動,如果你對 DevChat 感興趣,可以跳轉下文瀏覽:

2.1 關於 goroutine 洩露問題

  • 我問 GPT:

可以介紹下 goroutine 洩露問題嗎

  • GPT 說:

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 洩露。

寥寥幾句,似乎已經講完了這個話題。好吧,那繼續看記憶體模型去。

2.2 記憶體模型

  • 我又問 GPT:

繼續介紹下 Go 的記憶體模型吧

  • GPT 說:

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 對 adone 的寫入操作可能對 main goroutine 不可見,即使我們在 setupmain 之間新增了 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 對 adone 的寫入操作對 main goroutine 可見。

這個結果看起來並不好,我不滿意。

  • 我接著問 GPT:

這個例子其實並不能太好地解釋「記憶體模型」,因為不管執行多少次,main 函數中都會讀到 done 為 true。我希望你更深入地介紹 happens before 以及 Golang 哲學中的「Do not communicate by sharing memory; instead, share memory by communicating」

  • GPT 說:

你的觀察是正確的,我之前的例子可能並不能很好地展示 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 之間傳遞了資料,而不是通過共用記憶體。

  • 我接著問 GPT:

看了這個新的例子,我大概理解「happens before」以及「share memory by communicating」了。在前面一個例子中,你通過共用記憶體來實現 goroutine 之間的通訊,其實是不可靠的,因為讀取操作可以發生在寫入操作之前。如果你沒有加上1秒鐘的等待,那麼完全可能發生讀取到的 done 為 false;而通過 channel 通訊來實現共用記憶體就避免了這個問題,因為 done 的讀取會等待寫入完成。總之這裡的關鍵就是儘量用 channel 實現 goroutine 之間的通訊,避免使用共用記憶體;同時在並行程式設計時儘量使用 sync 包提供的各種同步鎖和條件變數等等機制,避免使用類似「time.Sleep()」這種不可靠的方式去實現 goroutine 同步。我的理解對嗎?

  • GPT 說:

你的理解是完全正確的。在 Go 中,我們應該優先使用 channel 來在 goroutine 之間傳遞資料,而不是通過共用記憶體。這樣可以避免資料競爭,使得程式碼更容易理解和維護。

同時,你也正確地指出了在並行程式設計中,我們應該使用 sync 包提供的同步原語,如 Mutex、WaitGroup 和 Cond,來同步 goroutine 的執行。這些同步原語可以幫助我們建立明確的 happens-before 關係,確保我們的程式按照預期的順序執行。

最後,你也正確地指出了,我們應該避免使用 time.Sleep() 這樣的方式來同步 goroutine。因為 time.Sleep() 並不能保證 happens-before 關係,所以它不能確保 goroutine 的執行順序。在實際的並行程式中,我們應該使用 channel、Mutex、WaitGroup 或者其他同步原語來同步 goroutine,而不是依賴於 time.Sleep()

2.3 Race Detector 檢測資料競爭

  • 我問 GPT:

最後,我還想知道 Race Detector 是什麼

  • GPT 說:

Go 語言提供了一個名為 Race Detector 的工具,用於檢測程式碼中的資料競爭條件。資料競爭發生在當兩個或更多的 goroutine 並行存取同一塊記憶體區域,且至少有一個存取是寫入操作時。

Race Detector 可以在執行時檢測到這些資料競爭,幫助你找到並修復它們。你可以通過在 go rungo testgo 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

到這裡,我還想追問一句:

  • 我問 GPT:

所以 Race Detector 其實並不是為了檢測並行存取一段資料,而是檢測哪些並行存取資料時沒有正確地使用合適的同步原語對嗎?

  • GPT 說:

是的,你的理解是正確的。Race Detector 的主要目的是檢測資料競爭,也就是多個 goroutine 同時存取同一塊資料,且至少有一個 goroutine 在進行寫操作,而這些操作之間沒有進行適當的同步。

如果你的程式碼中存在資料競爭,那麼你的程式碼的行為就是未定義的,可能會導致各種難以預料和難以偵錯的問題。通過使用 Race Detector,你可以在開發和測試階段就發現這些問題,然後通過新增適當的同步原語(如互斥鎖、channel 等)來修復它們。

所以,Race Detector 並不是用來檢測並行存取資料本身,而是用來檢測並行存取資料時是否存在資料競爭,即是否存在沒有進行適當同步的並行讀寫操作。

三、總結

三篇《跟著 GPT-4 從0到1學習 Golang 並行機制》文章到此寫完。溫故知新,收穫頗豐,隨便總結下:

  1. Goroutine:Goroutine 是 Go 語言的並行單元,屬於輕量級的執行緒,由 Go runtime 管理,也可稱協程。
  2. Channel:Channel 是 Go 語言中在 goroutines 之間通訊的主要方式。
  3. Selectselect 語句可以用於在多個 channel 操作之間進行選擇。當多個 case 都可以執行時,select 會隨機選擇一個。
  4. Mutexes 和 sync 包sync 包提供了一些同步原語,如 Mutex(互斥鎖)、RWMutex(讀寫鎖)、WaitGroup(等待組)和 Cond(條件變數)。這些原語可以用於保護共用資料,或者同步 goroutines 的執行。
  5. 原子操作:Go 語言的 sync/atomic 包提供了一些原子操作函數,這些函數可以在多個 goroutine 之間安全地運算元據,而不需要使用互斥鎖。原子操作包括 AddInt32、AddInt64、CompareAndSwapInt32、CompareAndSwapInt64 等等。這些函數可以保證在任何時刻只有一個 goroutine 能夠對資料進行操作,從而避免資料競爭。
  6. Context 包context 包提供了一種在 API 邊界之間傳遞請求範圍的值、取消訊號和超時資訊的方式。你可以使用 context.WithCancelcontext.WithDeadlinecontext.WithTimeoutcontext.WithValue 函數來建立新的 Context。
  7. 記憶體模型:Go 的記憶體模型定義了在並行程式中,如何以及何時對變數的讀寫操作對其他 goroutine 可見。你應該使用 channel 或者 sync 包中的原語來同步資料,以確保 happens-before 關係。
  8. Race Detector:Race Detector 是一個用於檢測資料競爭的工具。你可以在 go rungo testgo build 命令後新增 -race 選項來啟用 Race Detector。
  9. Go 並行哲學:"Do not communicate by sharing memory; instead, share memory by communicating." 這是 Go 並行哲學的核心原則。它鼓勵我們使用 channel 來在 goroutine 之間傳遞資料,而不是通過共用記憶體。這樣可以避免資料競爭,使得程式碼更容易理解和維護。

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.

相關文章

(關注我的個人公眾號「胡說雲原生」吧)