Go 并行性和并發性:有什么不同?
大家好,我是程序員幽鬼。
并發和并行,Go 剛發布時,官方就不斷強調這兩點的不同??赡苄率忠廊幻院?。這次給大家弄一個系列,詳細講解并發和并行。
軟件中的并行性是同時執行指令。每種編程語言要么實現自己的庫,要么提供語言級支持,如 Go。并行性允許軟件工程師通過在多個處理器上并行執行任務來回避硬件的物理限制。
由于正確利用并行構建的復雜性,應用程序的并行性取決于構建軟件的工程師的技能 。
并行任務示例:
- 多人在餐廳點單
- 雜貨店的多個收銀員
- 多核 CPU
實際上,任何應用程序都存在多層并行性。有應用程序本身的并行度,由應用程序開發者定義,還有 CPU 在操作系統編排的物理硬件上執行的指令的并行度(或多路復用)。
1、并行性的構建
應用程序開發人員利用抽象來描述應用程序的并行性。這些抽象在實現并行性但概念相同的每種語言中通常是不同的。例如,在 C 中,并行性是通過使用 pthreads 來定義的 ,而在 Go 中,并行性是通過使用 goroutines 來定義的 。
進程
進程是一個執行單元,包括它自己的“程序計數器、寄存器和變量。從概念上講,每個進程都有自己的虛擬 CPU”。理解這一點很重要,因為創建和管理進程會產生開銷。除了創建進程的開銷外,每個進程只能訪問自己的內存。這意味著該進程無法訪問其他進程的內存。
如果有多個執行線程(并行任務)需要訪問某些共享資源,這將是一個問題。
線程
引入線程是為了在同一進程內但在不同的并行執行單元上授予對共享內存的訪問權限。線程幾乎是它們自己的進程,但可以訪問父進程的共享地址空間。
線程的開銷遠低于進程,因為它們不必為每個線程創建一個新進程,并且資源可以共享或重用。
以下是 Ubuntu 18.04 的示例,比較了 fork 進程和創建線程的開銷:
- # Borrowed from https://stackoverflow.com/a/52231151/834319
- # Ubuntu 18.04 start_method: fork
- # ================================
- results for Process:
- count 1000.000000
- mean 0.002081
- std 0.000288
- min 0.001466
- 25% 0.001866
- 50% 0.001973
- 75% 0.002268
- max 0.003365
- Minimum with 1.47 ms
- ------------------------------------------------------------
- results for Thread:
- count 1000.000000
- mean 0.000054
- std 0.000013
- min 0.000044
- 25% 0.000047
- 50% 0.000051
- 75% 0.000058
- max 0.000319
- Minimum with 43.89 µs
- ------------------------------------------------------------
- Minimum start-up time for processes takes 33.41x longer than for threads.
臨界區
臨界區是進程中各種并行任務所需的共享內存區。這些部分可能是共享數據、類型或其他資源。
并行的復雜性
由于進程的線程在相同的內存空間中執行,因此存在多個線程同時訪問臨界區的風險。這可能會導致應用程序中的數據損壞或其他意外行為。
當多個線程同時訪問共享內存時,會出現兩個主要問題。
競態條件
競態條件是多個并行執行線程在沒有任何保護的情況下直接讀取或寫入共享資源。這可能導致存儲在資源中的數據可能被損壞或導致其他意外行為的情況。
例如,想象一個進程,其中單個線程正在從共享內存位置讀取值,而另一個線程正在將新值寫入同一位置。如果第一個線程在第二個線程寫入值之前讀取該值,則第一個線程將讀取舊值。
這會導致應用程序未按預期運行的情況。
死鎖
當兩個或多個線程互相等待做某事時,就會發生死鎖。這可能導致應用程序掛起或崩潰。
例如,一個線程針對一個臨界區執行等待滿足條件,而另一個線程針對同一臨界區執行并等待來自另一個線程的條件滿足。如果第一個線程正在等待滿足條件,而第二個線程正在等待第一個線程,則兩個線程將永遠等待。
當試圖通過使用互斥鎖來防止競爭條件時,可能會發生第二種形式的死鎖。
屏障(Barriers)
屏障是同步點,用于管理進程內多個線程對共享資源或臨界區的訪問。
這些屏障允許應用程序開發人員控制并行訪問,以確保不會以不安全的方式訪問資源。
互斥鎖
互斥鎖是一種屏障,它一次只允許一個線程訪問共享資源。這對于在讀取或寫入共享資源時通過鎖定解鎖來防止競爭條件很有用。
- // Example of a mutex barrier in Go
- import (
- "sync"
- "fmt"
- )
- var shared string
- var sharedMu sync.Mutex
- func main() {
- // Start a goroutine to write to the shared variable
- go func() {
- for i := 0; i < 10; i++ {
- write(fmt.Sprintf("%d", i))
- }
- }()
- // read from the shared variable
- for i := 0; i < 10; i++ {
- read(fmt.Sprintf("%d", i))
- }
- }
- func write(value string) {
- sharedMu.Lock()
- defer sharedMu.Unlock()
- // set a new value for the `shared` variable
- shared = value
- }
- func read() {
- sharedMu.Lock()
- defer sharedMu.Unlock()
- // print the critical section `shared` to stdout
- fmt.Println(shared)
- }
如果我們查看上面的示例,可以看到 shared 變量受到互斥鎖的保護。這意味著一次只有一個線程可以訪問該 shared 變量。這確保了shared 變量不會被破壞并且行為可預測。
注意:使用互斥鎖時,確保在函數返回時釋放互斥鎖至關重要。例如,在 Go 中,可以通過使用defer關鍵字來完成。這確保了其他線程(goroutine)可以訪問共享資源。
信號量
信號量是一種屏障,它一次只允許一定數量的線程訪問共享資源。這與互斥鎖的不同之處在于,可以訪問資源的線程數不限于一個。
Go 標準庫中沒有信號量實現。但是可以使用通道來實現。
忙等待(busy waiting)
忙等待是一種線程等待滿足條件的技術。通常用于等待計數器達到某個值。
- // Example of Busy Waiting in Go
- var x int
- func main() {
- go func() {
- for i := 0; i < 10; i++ {
- x = i
- }
- }()
- for x != 1 { // Loop until x is set to 1
- fmt.Println("Waiting...")
- time.Sleep(time.Millisecond * 100)
- }
- }
所以,忙等待需要一個循環,該循環等待滿足讀取或寫入共享資源的條件,并且必須由互斥鎖保護以確保正確的行為。
上述示例的問題是循環訪問不受互斥鎖保護的臨界區。這可能導致循環訪問該值但它可能已被進程的另一個線程更改的競態條件。事實上,上面的例子也是競態條件的一個很好的例子。這個應用程序可能永遠不會退出,因為不能保證循環足夠快以讀取 x=1 時的值,這意味著循環永遠不會退出。
如果我們用互斥鎖保護變量x,循環將被保護,應用程序將退出,但這仍然不完美,循環設置x仍然足夠快,可以在讀取值的循環執行之前兩次命中互斥鎖(雖然不太可能)。
- import "sync"
- var x int
- var xMu sync.Mutex
- func main() {
- go func() {
- for i := 0; i < 10; i++ {
- xMu.Lock()
- x = i
- xMu.Unlock()
- }
- }()
- var value int
- for value != 1 { // Loop until x is set to 1
- xMu.Lock()
- value = x // Set value == x
- xMu.Unlock()
- }
- }
一般來說,忙等待不是一個好辦法。最好使用信號量或互斥鎖來確保臨界區受到保護。我們將介紹在 Go 中處理此問題的更好方法,但它說明了編寫“正確”可并行代碼的復雜性。
WaitGroup
WaitGroup 是確保所有并行代碼路徑在繼續之前已完成處理的方法。在 Go 中,這是通過使用標準庫中 sync.WaitGroup 來完成的。
- // Example of a `sync.WaitGroup` in Go
- import (
- "sync"
- )
- func main() {
- var wg sync.WaitGroup
- var N int = 10
- wg.Add(N)
- for i := 0; i < N; i++ {
- go func() {
- defer wg.Done()
- // do some work
- }()
- }
- // wait for all of the goroutines to finish
- wg.Wait()
- }
在上面的示例中,wg.Wait() 是一個阻塞調用。這意味著主線程將不會繼續,直到所有 goroutine 中的 defer wg.Done() 都調用。在內部,WaitGroup 是一個計數器,對于添加到wg.Add(N)調用的 WaitGroup 中的每個 goroutine,它都會加一。當計數器為零時,主線程將繼續處理,或者在這種情況下應用程序將退出。
2、什么是并發?
并發性和并行性經常混為一談。為了更好地理解并發和并行之間的區別,讓我們看一個現實世界中的并發示例。
如果我們以一家餐館為例,那么就有幾組不同的工作類型(或可復制的程序)發生在一家餐館中。
- 主管(負責安排客人入座)
- 服務員(負責接單和提供食物)
- 廚房(負責烹飪食物)
- Bussers(負責清理桌子)
- 洗碗機(負責清理餐具)
這些小組中的每一個都負責不同的任務,所有這些最終都會導致顧客吃到一頓飯,這稱為并發。 專門的工作中心可以專注于單個任務,這些任務結合起來會產生結果。
如果餐廳每項任務只雇用一個人,餐廳的效率就會受到限制。這稱為序列化。如果餐廳只有一個服務員,那么一次只能接受一個訂單。
并行性是處理并發任務并將它們分布在多個資源中的能力。在餐廳,這將包括服務員、食物準備和清潔。如果有多個服務器,則可以一次接受多個訂單。
每個小組都能夠專注于他們的特定工作中心,而不必擔心上下文切換、最大化吞吐量或最小化延遲。
具有并行工作中心的行業的其他示例包括工廠工人和裝配線工人。本質上,任何可以分解為更小的可重復任務的過程都可以被認為是并發的,因此在使用適當的并發設計時可以并行化。
TL;DR: 并發可以實現正確的并行性,但并行代碼不需要并行性。
原文鏈接:https://benjiv.com/parallelism-vs-concurrency/
本文轉載自微信公眾號「幽鬼」,可以通過以下二維碼關注。轉載本文請聯系幽鬼公眾號。