「Go語言進(jìn)階」并發(fā)編程詳解
并發(fā) VS 并行
在講解并發(fā)概念時,總會涉及另外一個概念并行。下面讓我們來聊聊并發(fā)和并行之間的區(qū)別。
- 并發(fā)(concurrency):把任務(wù)在不同的時間點(diǎn)交給處理器進(jìn)行處理。在同一時間點(diǎn),任務(wù)并不會同時運(yùn)行。
- 并行(parallelism):把每一個任務(wù)分配給每一個處理器獨(dú)立完成。在同一時間點(diǎn),任務(wù)一定是同時運(yùn)行。
并發(fā)不是并行。并行是讓不同的代碼片段同時在不同的物理處理器上執(zhí)行。并行的關(guān)鍵是同時做很多事情,而并發(fā)是指同時管理很多事情,這些事情可能只做了一半就被暫停去做別的事情了。
在很多情況下,并發(fā)的效果比并行好,因?yàn)椴僮飨到y(tǒng)和硬件的總資源一般很少,但能支持系統(tǒng)同時做很多事情。這種“使用較少的資源做更多的事情”的哲學(xué),也是指導(dǎo) Go語言設(shè)計的哲學(xué)。
如果希望讓 goroutine 并行,必須使用多于一個邏輯處理器。當(dāng)有多個邏輯處理器(CPU)時,調(diào)度器會將 goroutine 平等分配到每個邏輯處理器上。這會讓 goroutine 在不同的線程上運(yùn)行。不過要想真的實(shí)現(xiàn)并行的效果,用戶需要讓自己的程序運(yùn)行在有多個物理處理器的機(jī)器上。否則,哪怕 Go語言運(yùn)行時使用多個線程,goroutine 依然會在同一個物理處理器上并發(fā)運(yùn)行,達(dá)不到并行的效果。
下圖展示了在一個邏輯處理器上并發(fā)運(yùn)行 goroutine 和在兩個邏輯處理器上并行運(yùn)行兩個并發(fā)的 goroutine 之間的區(qū)別。 調(diào)度器包含一些聰明的算法,這些算法會隨著Go語言的發(fā)布被更新和改進(jìn),所以不推薦盲目修改語言運(yùn)行時對邏輯處理器的默認(rèn)設(shè)置。如果真的認(rèn)為修改邏輯處理器的數(shù)量可以改進(jìn)性能,也可以對語言運(yùn)行時的參數(shù)進(jìn)行細(xì)微調(diào)整。
并發(fā)與并行的區(qū)別
Go 可以充分發(fā)揮多核優(yōu)勢,高效運(yùn)行。 Go語言在 GOMAXPROCS 數(shù)量與任務(wù)數(shù)量相等時,可以做到并行執(zhí)行,但一般情況下都是并發(fā)執(zhí)行。
目錄
- 1.1 Goroutine
- 1.2 CSP
- 1.3 Channel
- 1.4 Lock
- 1.5 WaitGroup
1.1 Goroutine
由誰創(chuàng)建?
- 線程是操作系統(tǒng)分配給應(yīng)用程序的獨(dú)立執(zhí)行單元,它們可以在多核處理器中并行執(zhí)行。線程的調(diào)度是由操作系統(tǒng)內(nèi)核負(fù)責(zé)的,并且線程之間有獨(dú)立的地址空間。
- 協(xié)程是由程序員編寫的,它是一種輕量級的線程,并由Go語言運(yùn)行時管理。協(xié)程之間沒有獨(dú)立的地址空間,而是共享一個地址空間。協(xié)程的調(diào)度是由Go語言運(yùn)行時負(fù)責(zé)的,并且可以在單個線程中并行執(zhí)行。
線程的創(chuàng)建和銷毀的開銷比較大,而協(xié)程的創(chuàng)建和銷毀開銷很小,因此在需要高并發(fā)的場景中,使用協(xié)程更加高效。
大小比較?
線程棧是由操作系統(tǒng)分配的,它通常有一個固定的大小,并且在線程創(chuàng)建時分配。它存儲著線程的狀態(tài)信息和調(diào)用棧。線程棧的大小取決于操作系統(tǒng)的限制,一般在幾百KB到幾MB之間。
而協(xié)程的棧是由Go語言運(yùn)行時管理的,它通常有一個較小的默認(rèn)大小,并在協(xié)程創(chuàng)建時分配。它也存儲著協(xié)程的狀態(tài)信息和調(diào)用棧。協(xié)程棧的大小可以通過Golang的runtime包中的函數(shù)來調(diào)整,一般在幾KB到幾MB之間。
由于協(xié)程的棧比線程棧小,所以協(xié)程能夠創(chuàng)建的數(shù)量比線程多得多。但是由于協(xié)程棧比線程棧小,所以在調(diào)用深度較深的程序中,協(xié)程可能會爆棧。
1.2 CSP
CSP:Communicating Sequential Processes
Go語言提倡:通過通信共享內(nèi)存,而不是通過共享內(nèi)存而實(shí)現(xiàn)通信。
有緩沖通道
緩沖通道中的數(shù)字表示該通道可以在沒有接收者阻塞的情況下緩存多少個元素。
加入容量為1,所以只能緩存一個元素。如果一個新的元素試圖被發(fā)送到已經(jīng)滿了的通道中,發(fā)送者將會阻塞直到接收者從通道中讀取一個元素。
阻塞并不一定意味著數(shù)據(jù)丟失,這取決于阻塞的原因和應(yīng)用程序的設(shè)計:
在 Go 語言中,通道是一種同步機(jī)制,發(fā)送者和接收者之間可以通過通道來進(jìn)行通信。 如果發(fā)送者試圖向一個滿的緩沖通道發(fā)送數(shù)據(jù),那么發(fā)送者將會阻塞直到緩沖區(qū)有空間可用。同樣,如果接收者試圖從一個空的通道接收數(shù)據(jù),那么接收者將會阻塞直到通道中有數(shù)據(jù)可用。這種情況下,數(shù)據(jù)不會丟失,而是在緩沖區(qū)中等待被取出。
無緩沖通道
但是,如果通道是無緩沖的,那么發(fā)送者和接收者之間將是同步的。如果發(fā)送者在接收者準(zhǔn)備好之前發(fā)送了數(shù)據(jù),那么發(fā)送者將會阻塞直到接收者準(zhǔn)備好。
如果接收者在數(shù)據(jù)可用之前就開始接收,那么接收者將會阻塞直到數(shù)據(jù)可用。在這種情況下,如果發(fā)送者和接收者之間的時間差較大,那么可能會導(dǎo)致數(shù)據(jù)丟失。
所以阻塞并不一定意味著數(shù)據(jù)丟失,而是取決于程序是否設(shè)計了阻塞的處理方式,以及阻塞的類型。
下面是一個示例代碼,其中兩個 goroutine 通過緩沖通道共享內(nèi)存:
執(zhí)行效果:
在這個示例中,第一個 goroutine 會循環(huán)發(fā)送 0 到 9 的整數(shù),而第二個 goroutine 會接收這些整數(shù)并打印。這兩個 goroutine 都會共享同一個通道來傳遞數(shù)據(jù)。
注意,在生產(chǎn)環(huán)境中,通常需要使用同步機(jī)制來等待 goroutine 結(jié)束,而不是使用 fmt.Scanln()。
1.3 Channel
make(chan 元素類型,[緩沖大小])
- 無緩沖通道 make(chan int) 同步
- 有緩沖通道 make(chan int,2) 不同步
無緩沖通道是在發(fā)送者和接收者之間同步地傳遞消息。 發(fā)送者會在接收者準(zhǔn)備好接收消息之前阻塞,接收者會在接收到消息之前阻塞。這種方式可以保證消息的順序和每個消息只被接收一次。
緩沖通道具有一個固定大小的緩沖區(qū),發(fā)送者和接收者之間不再是同步的。 如果緩沖區(qū)已滿,發(fā)送者會繼續(xù)執(zhí)行而不會阻塞;如果緩沖區(qū)為空,接收者會繼續(xù)執(zhí)行而不會阻塞。這種方式可以提高程序的性能,但是可能會導(dǎo)致消息的丟失或重復(fù)。
執(zhí)行效果:
在這個程序中,A子協(xié)程循環(huán)發(fā)送0~9的數(shù)字,B子協(xié)程接收并計算數(shù)字的平方,最后主協(xié)程等待所有子協(xié)程完成后輸出所有數(shù)字的平方。
注意:
- 在這個程序中我們使用了兩個通道ch, ch_squared來傳遞數(shù)據(jù),以避免數(shù)據(jù)丟失。
- 在最后輸出結(jié)果時,主協(xié)程要等待所有子協(xié)程完成,因此我們使用了 for i := range ch_squared來等待子協(xié)程的完成
- 在生產(chǎn)環(huán)境中,通常需要使用同步機(jī)制來等待子協(xié)程結(jié)束,而不是使用 for i := range ch_squared。
- 可以把ch_squared改為帶緩沖的channe,以解決生產(chǎn)比消費(fèi)快的執(zhí)行效率問題。
1.4 并發(fā)安全 Lock
在并發(fā)編程中,當(dāng)多個 goroutine 同時訪問共享資源時,可能會出現(xiàn)競爭條件,導(dǎo)致數(shù)據(jù)不一致或錯誤。為了避免這種情況,我們可以使用 Lock(鎖)來保證并發(fā)安全。
Lock 是一種同步機(jī)制,可以防止多個 goroutine 同時訪問共享資源。當(dāng)一個 goroutine 獲取鎖時,其他 goroutine 將被阻塞,直到鎖被釋放。
Go語言標(biāo)準(zhǔn)庫中提供了 sync.Mutex 來實(shí)現(xiàn)鎖。
一個簡單的例子:
執(zhí)行效果:
在上面的示例中,main函數(shù)中啟動了10個goroutine,每個goroutine都會嘗試去獲取鎖,并對共享變量count進(jìn)行修改。在獲取鎖后才能進(jìn)行修改,其他goroutine在等待鎖時將被阻塞。
這樣就能保證并發(fā)安全了,使得共享變量count在多個goroutine之間可以安全地訪問。但是,使用鎖也需要注意避免死鎖的情況,需要在適當(dāng)?shù)臅r候釋放鎖。并發(fā)安全問題難以定位。
1.5 WaitGroup
Go語言標(biāo)準(zhǔn)庫中提供了 sync.WaitGroup 來管理多個 goroutine 的執(zhí)行。
- Add(delta int): 使用該方法來增加等待組中 goroutine 的數(shù)量。當(dāng)我們需要等待一些 goroutine 執(zhí)行完畢時,就可以使用該方法來增加等待組中 goroutine 的數(shù)量。
- Done(): 使用該方法來通知等待組,一個 goroutine 執(zhí)行完畢。當(dāng)一個 goroutine 執(zhí)行完畢后,我們需要調(diào)用該方法來通知等待組。
- Wait(): 使用該方法來等待等待組中的所有 goroutine 執(zhí)行完畢。當(dāng)我們需要等待所有 goroutine 執(zhí)行完畢時,就可以使用該方法。
下面是一個例子,演示了如何使用 sync.WaitGroup 來管理多個 goroutine 的執(zhí)行:
執(zhí)行效果:
在上面的代碼中,我們使用了 sync.WaitGroup 來管理三個 goroutine 的執(zhí)行。我們先使用 wg.Add(3) 來增加等待組中 goroutine 的數(shù)量。然后在每個 goroutine 中調(diào)用 wg.Done() 來通知等待組,該 goroutine 執(zhí)行完畢。最后使用 wg.Wait() 來等待所有 goroutine 執(zhí)行完畢。
注意:
- 如果沒有 wg.Wait(),主協(xié)程可能會在其他協(xié)程還沒有執(zhí)行完成的情況下結(jié)束,這樣的話其他協(xié)程的執(zhí)行結(jié)果就沒有機(jī)會被獲取。
- 如果Add的數(shù)量和done的數(shù)量不對應(yīng),wait永遠(yuǎn)不會返回,這也叫死鎖。
在線運(yùn)行
上面分享的代碼都支持,訪問下方鏈接運(yùn)行測試:https://1024code.com/codecubes/GB47x7u
本文轉(zhuǎn)載自微信公眾號「 程序員升級打怪之旅」,作者「王中陽Go」,可以通過以下二維碼關(guān)注。
轉(zhuǎn)載本文請聯(lián)系「 程序員升級打怪之旅」公眾號。