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