今天玩點不一樣的,我想試試看 GPT-4 能不能當好一個程式設計老師,循循善誘,由淺入深,深入淺出…… 給我講解下 Golang 裡的並行機制。
今天倒掉腹中半杯水,當回白紙,GPT 為筆,程式碼為顏料,揮筆潑墨,且看能繪出怎樣的世界。
下文通過 DevChat 和 GPT-4 互動,如果你對 DevChat 感興趣,可以跳轉下文瀏覽:
下文主要貼文字,避免過多使用截圖。
能不能介紹下 Golang 裡的並行機制
在 Golang 中,主要的並行機制是通過 Goroutines 和 Channels 實現的。
Goroutines:Goroutines 是 Go 語言中的輕量級執行緒實現,由 Go 執行時環境管理。Goroutines 相比於執行緒的優勢在於其建立和銷燬的開銷小,可以輕鬆建立上百萬個而不會導致系統資源耗盡。建立一個 Goroutine 只需要在函數呼叫前加上關鍵字
go
即可。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
,它每秒向 Channelc
傳送一次 "Hello",然後在 main 函數中,我們從 Channelc
中接收並列印這些訊息。當 Channelc
被關閉並且沒有更多的值可以接收時,range c
迴圈將結束。
GPT 給了一段範例程式碼,並且在最後給出了這段程式碼的解釋,整體看來還是挺通俗易懂。接下來我想讓 GPT 解釋下 Goroutine 和「執行緒」的關係。我大概記得 Goroutine 是使用者級執行緒,Go 的 runtime 自己管理和排程的,或者簡單地叫做「協程」;而執行緒在這裡指的是核心級執行緒,OS 完成的管理和排程。且看下 GPT 怎麼說吧。
Goroutines 和執行緒的關係是什麼?Goroutines 排程和執行緒排程分別是什麼原理?
Goroutines 和執行緒的關係:
- Goroutines 是 Go 語言中的並行體,它是一種輕量級的執行緒,由 Go 執行時(runtime)管理和排程,而不是由作業系統直接管理。
- Goroutines 相比於作業系統的執行緒,其建立和銷燬的開銷更小,記憶體佔用更少,可以輕鬆建立和管理上百萬個 Goroutines。
- 一個 Go 程式在執行時會有多個系統執行緒,每個系統執行緒在執行多個 Goroutine。Go 執行時會負責在這些系統執行緒上排程 Goroutines 的執行。
Goroutines 的排程:
- Go 語言執行時包含了自己的排程器,這個排程器使用了 M:N 的排程模型,即 M 個 Goroutines 對映到 N 個系統執行緒上。
- Go 排程器的工作方式和作業系統的排程器類似,但是在使用者態完成排程,不需要切換到核心態,因此排程開銷小。
- Go 排程器使用了一種叫做分段式的搶卻式排程策略,當一個 Goroutine 在執行時,會定期檢查是否需要進行排程(例如,是否有更高優先順序的 Goroutine 等待執行),這樣可以保證 Goroutines 的公平排程。
執行緒的排程:
- 執行緒的排程是由作業系統完成的,通常使用一種叫做搶佔式的排程策略,當一個執行緒在執行時,作業系統會定期中斷它,儲存它的狀態,然後選擇另一個執行緒執行。
- 執行緒的建立、銷燬和切換都需要在核心態完成,因此開銷比 Goroutines 大。
- 執行緒可以利用多核處理器的平行計算能力,但是過多的執行緒會導致排程開銷增大,影響程式的效能。
為什麼執行緒排程的開銷更大?
執行緒排程的開銷主要來自以下幾個方面:
上下文切換:當作業系統決定停止執行一個執行緒並執行另一個執行緒時,它需要儲存當前執行緒的狀態(包括程式計數器,暫存器,記憶體對映等),然後載入另一個執行緒的狀態。這個過程叫做上下文切換,它需要在使用者態和核心態之間切換,這是一個相對耗時的操作。
核心態和使用者態的切換:執行緒是由作業系統管理的,所以執行緒的建立、銷燬和排程都需要在核心態完成。每次從使用者態切換到核心態,都需要儲存和恢復大量的狀態資訊,這也會增加額外的開銷。
資源消耗:每個執行緒都有自己的堆疊,通常大小為幾百KB到幾MB。當執行緒數量增多時,這些記憶體的消耗會變得非常顯著。此外,作業系統需要維護所有執行緒的狀態資訊,這也會消耗一定的系統資源。
相比之下,Goroutines 的排程是在使用者態完成的,不需要頻繁地在使用者態和核心態之間切換,因此開銷更小。同時,Goroutines 的堆疊大小可以從很小(如幾KB)開始,根據需要動態地增長和縮小,因此記憶體利用率更高。
這裡提到了三方面的原因,其中「核心態和使用者態的切換」稍稍不好理解一些,我想繼續追問。
可以解釋下使用者態和核心態以及其切換的原因和原理嗎?
在作業系統中,為了保護系統的安全和穩定,通常會將執行環境分為使用者態(User Mode)和核心態(Kernel Mode)兩種模式。
使用者態:在使用者態下執行的程式不能直接存取作業系統內部的資源。這些程式只能通過系統呼叫來請求作業系統的服務,系統呼叫的過程實際上是一個使用者態到核心態的切換。
核心態:在核心態下,程式碼有權存取系統的任何資源,沒有任何限制。作業系統的核心部分,如排程器、記憶體管理等,都在核心態下執行。
使用者態和核心態的切換通常發生在以下情況:
系統呼叫:當用戶態的程式需要作業系統提供服務時,如讀寫檔案、建立執行緒等,它需要通過系統呼叫來請求。這時,程式會從使用者態切換到核心態,執行相應的系統呼叫,然後再切換回使用者態。
中斷處理:當外部裝置(如鍵盤、滑鼠、網路卡等)發出中斷請求時,CPU 會暫停當前的任務,切換到核心態處理中斷。
例外處理:當程式執行出錯,如除以零、存取非法記憶體等,CPU 會切換到核心態,進行例外處理。
使用者態和核心態的切換涉及到大量的狀態儲存和恢復,是一種相對耗時的操作。因此,頻繁的切換會影響系統的效能。這也是為什麼在設計並行模型時,如 Go 的 Goroutines,會盡量減少使用者態和核心態的切換,以提高效能。
OK,到此基本知道了 Goroutine 和「執行緒」的區別。下一步我也不知道該學點啥,讓 GPT 自己說吧。
除了 Goroutines 和 Channel,還有哪些和 Golang 並行程式設計相關的知識點?
除了 Goroutines 和 Channels,以下是一些與 Go 語言並行程式設計相關的其他重要知識點:
Select:Select 語句用於在多個傳送/接收 channel 操作中進行選擇。Select 會阻塞,直到條件滿足,然後執行相應的操作。如果有多個條件滿足,Select 會隨機選擇一個執行。
Sync 包:Go 語言的標準庫中有一個 sync 包,提供了一些並行控制的功能,如互斥鎖(Mutex)、讀寫鎖(RWMutex)、條件變數(Cond)、等待組(WaitGroup)等。
Atomic 包:Atomic 包提供了一些原子操作的函數,可以在多個 goroutine 之間安全地運算元據。
Context 包:Context 包用於在 goroutine 之間傳遞上下文資訊,包括取消訊號、超時時間、鍵值對等。
Race Detector:Go 提供了一個 Race Detector,用於檢測並行程式中的資料競爭問題。
協程洩露:協程洩露是 Go 程式中常見的問題,如果一個 goroutine 啟動後永遠不會結束,那麼它就會一直佔用記憶體,這就是協程洩露。我們需要確保每個啟動的 goroutine 在不需要時都能正確地結束。
記憶體模型:Go 語言的記憶體模型定義了在並行程式中,如何以及何時對共用資料的讀寫操作對其他 goroutine 可見。理解這個模型對於編寫正確的並行程式是非常重要的。
我去,博大精深啊…… 給人一種從入門到放棄的衝動。
請繼續介紹一下 Select 語句相關知識點,結合相關程式碼範例
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 在什麼場景下使用呢?
select 語句的 default 在什麼場景下有用?請結合程式碼範例解釋。
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 可以接收資料。
我希望再直觀一點。
能不能構造一個真實的業務場景來進一步解釋 default 的作用?
當然可以。假設我們正在開發一個網路伺服器,它需要從多個使用者端接收資料。我們可以為每個使用者端建立一個 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 解釋這樣寫的優缺點。
這段程式碼中的 main 如果改成這樣,相比於使用 default,哪一種寫法更好?
這段程式碼中,你建立了一個新的 goroutine 來執行 "其他工作"。這個 goroutine 會無限迴圈,每秒列印一次 "No data received. Doing other work.",然後休眠一秒。
這種寫法和使用
default
的主要區別在於,無論是否有資料從ch1
或ch2
接收,"其他工作" 都會每秒執行一次。而使用default
的版本中,"其他工作" 只有在沒有資料接收時才會執行。哪種寫法更好取決於你的具體需求。如果 "其他工作" 是可以並行執行,並且不依賴於資料接收的,那麼你的寫法> 是可以的。但是,如果 "其他工作" 需要在沒有資料接收時才執行,或者它需要存取一些共用資源,那麼使用
default
可能更合適,因為它可以確保在執行 "其他工作" 時,不會有新的資料被接收。此外,你的寫法中,"其他工作" 的 goroutine 是無法結束的,這可能會導致 goroutine 洩露。如果你需要在某個條件下結束這個 goroutine,你需要新增相應的邏輯,例如使用一個額外的 channel 來傳送結束訊號。
這裡用了一個小技巧,當需要就某段程式碼向 DevChat 提問時,可以通過這種方式:
到此,我知道兩種寫法的主要區別了。在 select 裡的 default 只有當 case 不被執行的時候才會觸發,換言之這裡是可以加 break 邏輯結束迴圈的。而我這種改法就不好直接達到這種效果了。由此我想到了一個更合適的場景來解釋 select-default 語法,我讓 GPT 來幫我表述:
我明白了。請重新構造一個 select, default 的例子,在 case 中滿足條件時結束迴圈,程式結束;在等待的過程中,default 裡實現每秒列印一次紀錄檔的邏輯。
以下是一個滿足你要求的範例:
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 洩露和記憶體模型等等話題沒聊完。不過,篇幅有限(其實主要是犯困了),剩下的話題咱改日再聊。(預催更,關注公眾號「胡說雲原生」!)
相關文章