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

2023-07-11 15:00:34

一、前言

今天玩點不一樣的,我想試試看 GPT-4 能不能當好一個程式設計老師,循循善誘,由淺入深,深入淺出…… 給我講解下 Golang 裡的並行機制。

今天倒掉腹中半杯水,當回白紙,GPT 為筆,程式碼為顏料,揮筆潑墨,且看能繪出怎樣的世界。

二、開聊

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

2.1 Golang 裡的並行機制介紹

  • 在 DevChat 裡和 GPT-4 互動的體驗:

下文主要貼文字,避免過多使用截圖。

  • 我問 GPT:

能不能介紹下 Golang 裡的並行機制

  • GPT 說:

在 Golang 中,主要的並行機制是通過 Goroutines 和 Channels 實現的。

  1. Goroutines:Goroutines 是 Go 語言中的輕量級執行緒實現,由 Go 執行時環境管理。Goroutines 相比於執行緒的優勢在於其建立和銷燬的開銷小,可以輕鬆建立上百萬個而不會導致系統資源耗盡。建立一個 Goroutine 只需要在函數呼叫前加上關鍵字 go 即可。

  2. Channels:Channels 是 Go 中提供的一種強大的並行同步機制。它可以在多個 Goroutine 之間傳遞資料並同步執行。你可以把它想象成一個 Goroutines 可以傳送和接收值的管道。通過 make 函數建立 Channels,通過 <- 操作符傳送和接收資料。

以下是一個簡單的範例,展示瞭如何在 Go 中使用 Goroutines 和 Channels:

package main

import (
	"fmt"
	"time"
)

func say(s string, c chan string) {
	for i := 0; i < 5; i++ {
		time.Sleep(1 * time.Second)
		c <- s
	}
	close(c)
}

func main() {
	c := make(chan string)
	go say("Hello", c)
	for msg := range c {
		fmt.Println(msg)
	}
}

在這個範例中,我們建立了一個 Goroutine say,它每秒向 Channel c 傳送一次 "Hello",然後在 main 函數中,我們從 Channel c 中接收並列印這些訊息。當 Channel c 被關閉並且沒有更多的值可以接收時,range c 迴圈將結束。

GPT 給了一段範例程式碼,並且在最後給出了這段程式碼的解釋,整體看來還是挺通俗易懂。接下來我想讓 GPT 解釋下 Goroutine 和「執行緒」的關係。我大概記得 Goroutine 是使用者級執行緒,Go 的 runtime 自己管理和排程的,或者簡單地叫做「協程」;而執行緒在這裡指的是核心級執行緒,OS 完成的管理和排程。且看下 GPT 怎麼說吧。

2.2 Goroutine 與執行緒

  • 我問 GPT:

Goroutines 和執行緒的關係是什麼?Goroutines 排程和執行緒排程分別是什麼原理?

  • GPT 說:

Goroutines 和執行緒的關係:

  • Goroutines 是 Go 語言中的並行體,它是一種輕量級的執行緒,由 Go 執行時(runtime)管理和排程,而不是由作業系統直接管理。
  • Goroutines 相比於作業系統的執行緒,其建立和銷燬的開銷更小,記憶體佔用更少,可以輕鬆建立和管理上百萬個 Goroutines。
  • 一個 Go 程式在執行時會有多個系統執行緒,每個系統執行緒在執行多個 Goroutine。Go 執行時會負責在這些系統執行緒上排程 Goroutines 的執行。

Goroutines 的排程:

  • Go 語言執行時包含了自己的排程器,這個排程器使用了 M:N 的排程模型,即 M 個 Goroutines 對映到 N 個系統執行緒上。
  • Go 排程器的工作方式和作業系統的排程器類似,但是在使用者態完成排程,不需要切換到核心態,因此排程開銷小。
  • Go 排程器使用了一種叫做分段式的搶卻式排程策略,當一個 Goroutine 在執行時,會定期檢查是否需要進行排程(例如,是否有更高優先順序的 Goroutine 等待執行),這樣可以保證 Goroutines 的公平排程。

執行緒的排程:

  • 執行緒的排程是由作業系統完成的,通常使用一種叫做搶佔式的排程策略,當一個執行緒在執行時,作業系統會定期中斷它,儲存它的狀態,然後選擇另一個執行緒執行。
  • 執行緒的建立、銷燬和切換都需要在核心態完成,因此開銷比 Goroutines 大。
  • 執行緒可以利用多核處理器的平行計算能力,但是過多的執行緒會導致排程開銷增大,影響程式的效能。

2.3 Goroutine 與執行緒的排程開銷

  • 我問 GPT:

為什麼執行緒排程的開銷更大?

  • GPT 說:

執行緒排程的開銷主要來自以下幾個方面:

  1. 上下文切換:當作業系統決定停止執行一個執行緒並執行另一個執行緒時,它需要儲存當前執行緒的狀態(包括程式計數器,暫存器,記憶體對映等),然後載入另一個執行緒的狀態。這個過程叫做上下文切換,它需要在使用者態和核心態之間切換,這是一個相對耗時的操作。

  2. 核心態和使用者態的切換:執行緒是由作業系統管理的,所以執行緒的建立、銷燬和排程都需要在核心態完成。每次從使用者態切換到核心態,都需要儲存和恢復大量的狀態資訊,這也會增加額外的開銷。

  3. 資源消耗:每個執行緒都有自己的堆疊,通常大小為幾百KB到幾MB。當執行緒數量增多時,這些記憶體的消耗會變得非常顯著。此外,作業系統需要維護所有執行緒的狀態資訊,這也會消耗一定的系統資源。

相比之下,Goroutines 的排程是在使用者態完成的,不需要頻繁地在使用者態和核心態之間切換,因此開銷更小。同時,Goroutines 的堆疊大小可以從很小(如幾KB)開始,根據需要動態地增長和縮小,因此記憶體利用率更高。

這裡提到了三方面的原因,其中「核心態和使用者態的切換」稍稍不好理解一些,我想繼續追問。

2.4 使用者態和核心態

  • 我問 GPT:

可以解釋下使用者態和核心態以及其切換的原因和原理嗎?

  • GPT 說:

在作業系統中,為了保護系統的安全和穩定,通常會將執行環境分為使用者態(User Mode)和核心態(Kernel Mode)兩種模式。

  1. 使用者態:在使用者態下執行的程式不能直接存取作業系統內部的資源。這些程式只能通過系統呼叫來請求作業系統的服務,系統呼叫的過程實際上是一個使用者態到核心態的切換。

  2. 核心態:在核心態下,程式碼有權存取系統的任何資源,沒有任何限制。作業系統的核心部分,如排程器、記憶體管理等,都在核心態下執行。

使用者態和核心態的切換通常發生在以下情況:

  • 系統呼叫:當用戶態的程式需要作業系統提供服務時,如讀寫檔案、建立執行緒等,它需要通過系統呼叫來請求。這時,程式會從使用者態切換到核心態,執行相應的系統呼叫,然後再切換回使用者態。

  • 中斷處理:當外部裝置(如鍵盤、滑鼠、網路卡等)發出中斷請求時,CPU 會暫停當前的任務,切換到核心態處理中斷。

  • 例外處理:當程式執行出錯,如除以零、存取非法記憶體等,CPU 會切換到核心態,進行例外處理。

使用者態和核心態的切換涉及到大量的狀態儲存和恢復,是一種相對耗時的操作。因此,頻繁的切換會影響系統的效能。這也是為什麼在設計並行模型時,如 Go 的 Goroutines,會盡量減少使用者態和核心態的切換,以提高效能。

OK,到此基本知道了 Goroutine 和「執行緒」的區別。下一步我也不知道該學點啥,讓 GPT 自己說吧。

2.5 Golang 並行程式設計相關的知識點

  • 我問 GPT:

除了 Goroutines 和 Channel,還有哪些和 Golang 並行程式設計相關的知識點?

  • GPT 說:

除了 Goroutines 和 Channels,以下是一些與 Go 語言並行程式設計相關的其他重要知識點:

  1. Select:Select 語句用於在多個傳送/接收 channel 操作中進行選擇。Select 會阻塞,直到條件滿足,然後執行相應的操作。如果有多個條件滿足,Select 會隨機選擇一個執行。

  2. Sync 包:Go 語言的標準庫中有一個 sync 包,提供了一些並行控制的功能,如互斥鎖(Mutex)、讀寫鎖(RWMutex)、條件變數(Cond)、等待組(WaitGroup)等。

  3. Atomic 包:Atomic 包提供了一些原子操作的函數,可以在多個 goroutine 之間安全地運算元據。

  4. Context 包:Context 包用於在 goroutine 之間傳遞上下文資訊,包括取消訊號、超時時間、鍵值對等。

  5. Race Detector:Go 提供了一個 Race Detector,用於檢測並行程式中的資料競爭問題。

  6. 協程洩露:協程洩露是 Go 程式中常見的問題,如果一個 goroutine 啟動後永遠不會結束,那麼它就會一直佔用記憶體,這就是協程洩露。我們需要確保每個啟動的 goroutine 在不需要時都能正確地結束。

  7. 記憶體模型:Go 語言的記憶體模型定義了在並行程式中,如何以及何時對共用資料的讀寫操作對其他 goroutine 可見。理解這個模型對於編寫正確的並行程式是非常重要的。

我去,博大精深啊…… 給人一種從入門到放棄的衝動。

2.6 Select 語句

  • 我問 GPT:

請繼續介紹一下 Select 語句相關知識點,結合相關程式碼範例

  • GPT 說:

Select 語句在 Go 語言中用於處理多個 channel 的傳送和接收操作。Select 會阻塞,直到滿足以下條件之一:

  • 某個 case 語句可以執行,這通常意味著某個 channel 可以進行傳送或接收操作。
  • 如果存在 default 語句,那麼在沒有其他 case 語句可以執行時,執行 default 語句。

如果有多個 case 語句同時滿足條件,那麼 select 會隨機選擇一個執行。

以下是一個使用 select 的範例:

package main

import (
	"fmt"
	"time"
)

func server1(ch chan string) {
	time.Sleep(2 * time.Second)
	ch <- "from server1"
}

func server2(ch chan string) {
	time.Sleep(1 * time.Second)
	ch <- "from server2"
}

func main() {
	output1 := make(chan string)
	output2 := make(chan string)

	go server1(output1)
	go server2(output2)

	select {
	case s1 := <-output1:
		fmt.Println(s1)
	case s2 := <-output2:
		fmt.Println(s2)
	}
}

在這個範例中,我們有兩個伺服器 server1 和 server2,它們分別在不同的時間向各自的 channel 傳送一個訊息。在 main 函數中,我們使用 select 語句等待這兩個伺服器的響應,並列印第一個收到的響應。

注意,如果沒有 case 語句可以執行,並且沒有提供 default 語句,那麼 select 語句將永遠阻塞。這在某些情況下可能是有用的,例如,我們可以建立一個永遠阻塞的 goroutine,只有當接收到特定的訊號時才退出。

這個例子中沒有用到 default,儘管我可以理解 default 的語法含義,但是一時間也沒有特別直觀地想到哪個真實的場景下需要用到 default 來「搶奪 case 的阻塞態」。那麼 default 在什麼場景下使用呢?

2.7 Select 語句的 default 使用場景

  • 我問 GPT:

select 語句的 default 在什麼場景下有用?請結合程式碼範例解釋。

  • GPT 說:

default 語句在 select 中的作用是當其他 case 語句都不滿足條件(即所有的 channel 都不能傳送或接收資料)時,執行 default 語句。這樣,select 語句就不會阻塞,而是繼續執行。

這在你希望 select 語句在無法立即處理 I/O 操作時不阻塞,而是執行其他操作的場景下非常有用。

以下是一個使用 default 語句的範例:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		time.Sleep(2 * time.Second)
		ch <- "message"
	}()

	select {
	case msg := <-ch:
		fmt.Println(msg)
	default:
		fmt.Println("no message received")
	}

	// Do some other work
	// ...
}

在這個範例中,我們建立了一個 goroutine,它在 2 秒後向 channel 傳送一個訊息。然後我們在 select 語句中嘗試從 channel 接收訊息。如果訊息已經傳送,我們就列印這個訊息;如果訊息還沒有傳送(即 channel 還不能接收資料),我們就執行 default 語句,列印 "no message received",然後繼續執行其他的工作。

注意,如果我們去掉 default 語句,select 語句就會阻塞,直到 channel 可以接收資料。

我希望再直觀一點。

  • 我又問 GPT:

能不能構造一個真實的業務場景來進一步解釋 default 的作用?

  • GPT 說:

當然可以。假設我們正在開發一個網路伺服器,它需要從多個使用者端接收資料。我們可以為每個使用者端建立一個 goroutine 和一個 channel,然後使用 select 語句來處理這些 channel 的資料。如果所有的 channel 都沒有資料,我們不希望伺服器阻塞,而是希望它能繼續處理其他任務,如清理記憶體、更新統計資訊等。這時,我們就可以使用 default 語句。

以下是一個簡化的範例:

package main

import (
	"fmt"
	"time"
)

func client(ch chan string, id int) {
	for {
		time.Sleep(time.Duration(id) * time.Second)
		ch <- fmt.Sprintf("client %d data", id)
	}
}

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go client(ch1, 1)
	go client(ch2, 2)

	for {
		select {
		case data := <-ch1:
			fmt.Println(data)
		case data := <-ch2:
			fmt.Println(data)
		default:
			fmt.Println("No data received. Doing other work.")
			time.Sleep(1 * time.Second) // simulate work
		}
	}
}

在這個範例中,我們有兩個使用者端,它們分別在不同的時間向各自的 channel 傳送資料。在 main 函數中,我們使用 select 語句等待這兩個使用者端的資料。如果有資料,我們就列印資料;如果沒有資料,我們就執行 default 語句,列印 "No data received. Doing other work.",然後模擬執行其他的工作。

所以 default 可以讓 select 不阻塞,在沒有一個 case 可以立即執行的情況下先處理其他任務。那麼相比於 default 的工作單獨開一個 goroutine 去執行又有什麼區別呢?我腦海中閃過這個想法,於是我寫了這一段:

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go client(ch1, 1)
	go client(ch2, 2)

  go func(){
    for {
      fmt.Println("No data received. Doing other work.")
		  time.Sleep(1 * time.Second) // simulate work
    }
  }()

	for {
		select {
		case data := <-ch1:
			fmt.Println(data)
		case data := <-ch2:
			fmt.Println(data)
	}
}

我嘗試讓 GPT 解釋這樣寫的優缺點。

  • 我又問 GPT:

這段程式碼中的 main 如果改成這樣,相比於使用 default,哪一種寫法更好?

  • GPT 說:

這段程式碼中,你建立了一個新的 goroutine 來執行 "其他工作"。這個 goroutine 會無限迴圈,每秒列印一次 "No data received. Doing other work.",然後休眠一秒。

這種寫法和使用 default 的主要區別在於,無論是否有資料從 ch1ch2 接收,"其他工作" 都會每秒執行一次。而使用 default 的版本中,"其他工作" 只有在沒有資料接收時才會執行。

哪種寫法更好取決於你的具體需求。如果 "其他工作" 是可以並行執行,並且不依賴於資料接收的,那麼你的寫法> 是可以的。但是,如果 "其他工作" 需要在沒有資料接收時才執行,或者它需要存取一些共用資源,那麼使用 default 可能更合適,因為它可以確保在執行 "其他工作" 時,不會有新的資料被接收。

此外,你的寫法中,"其他工作" 的 goroutine 是無法結束的,這可能會導致 goroutine 洩露。如果你需要在某個條件下結束這個 goroutine,你需要新增相應的邏輯,例如使用一個額外的 channel 來傳送結束訊號。

這裡用了一個小技巧,當需要就某段程式碼向 DevChat 提問時,可以通過這種方式:

到此,我知道兩種寫法的主要區別了。在 select 裡的 default 只有當 case 不被執行的時候才會觸發,換言之這裡是可以加 break 邏輯結束迴圈的。而我這種改法就不好直接達到這種效果了。由此我想到了一個更合適的場景來解釋 select-default 語法,我讓 GPT 來幫我表述:

  • 我對 GPT 說:

我明白了。請重新構造一個 select, default 的例子,在 case 中滿足條件時結束迴圈,程式結束;在等待的過程中,default 裡實現每秒列印一次紀錄檔的邏輯。

  • GPT 說:

以下是一個滿足你要求的範例:

package main

import (
	"fmt"
	"time"
)

func worker(done chan bool) {
	fmt.Println("Working...")
	time.Sleep(5 * time.Second) // simulate work
	fmt.Println("Done working.")
	done <- true
}

func main() {
	done := make(chan bool)
	go worker(done)

	for {
		select {
		case <-done:
			fmt.Println("Received done signal. Exiting.")
			return
		default:
			fmt.Println("No done signal received. Waiting...")
			time.Sleep(1 * time.Second)
		}
	}
}

在這個範例中,我們建立了一個 worker goroutine,它在完成工作後向 done channel 傳送一個訊號。在 main 函數中,我們使用 select 語句等待 done 訊號。如果收到 done 訊號,我們列印 "Received done signal. Exiting.",然後結束程式;如果沒有收到 done 訊號,我們就執行 default 語句,列印 "No done signal received. Waiting...",然後休眠一秒。這個過程會一直重複,直到收到 done 訊號。

這會好理解了,當 case 等待的工作沒有完成的時候,default 可以做點其他事。就好比吃飯排隊時每分鐘擡頭看看有沒有輪到自己,沒有輪到就預設低頭繼續玩一分鐘手機一樣。

三、總結

別急別急,我知道你想說還有 Sync、Atomic、Context、Race Detector、Goroutine 洩露和記憶體模型等等話題沒聊完。不過,篇幅有限(其實主要是犯困了),剩下的話題咱改日再聊。(預催更,關注公眾號「胡說雲原生」!)

相關文章