本文原文地址:GoLang協程Goroutiney原理與GMP模型詳解
什麼是goroutine
Goroutine是Go語言中的一種輕量級執行緒,也成為協程,由Go執行時管理。它是Go語言併發程式設計的核心概念之一。Goroutine的設計使得在Go中實現併發程式設計變得非常簡單和高效。
以下是一些關於Goroutine的關鍵特性:
- 輕量級:Goroutine的建立和切換開銷非常小。與作業系統級別的執行緒相比,Goroutine佔用的記憶體和資源更少。一個典型的Goroutine只需要幾KB的棧空間,並且棧空間可以根據需要動態增長。
- 併發執行:Goroutine可以併發執行多個任務。Go執行時會自動將Goroutine排程到可用的處理器上執行,從而充分利用多核處理器的能力。
- 簡單的語法:啟動一個Goroutine非常簡單,只需要在函式呼叫前加上go關鍵字。例如,go myFunction()會啟動一個新的Goroutine來執行myFunction函式。
- 通訊和同步:Go語言提供了通道(Channel)機制,用於在Goroutine之間進行通訊和同步。通道是一種型別安全的通訊方式,可以在不同的Goroutine之間傳遞資料。
什麼是協程
協程(Coroutine)是一種比執行緒更輕量級的併發程式設計方式。它允許在單個執行緒內執行多個任務,並且可以在任務之間進行切換,而不需要進行執行緒上下文切換的開銷。協程透過協作式多工處理來實現併發,這意味著任務之間的切換是由程式顯式控制的,而不是由作業系統排程的。
以下是協程的一些關鍵特性:
- 輕量級:協程的建立和切換開銷非常小,因為它們不需要作業系統級別的執行緒管理。
- 非搶佔式:協程的切換是顯式的,由程式設計師在程式碼中指定,而不是由作業系統搶佔式地排程。
- 狀態儲存:協程可以在暫停執行時儲存其狀態,並在恢復執行時繼續從暫停的地方開始。
- 非同步程式設計:協程非常適合用於非同步程式設計,特別是在I/O密集型任務中,可以在等待I/O操作完成時切換到其他任務,從而提高程式的併發性和效率。
Goroutin就是Go在協程這個場景上的實現。
以下是一個簡單的go goroutine例子,展示瞭如何使用協程:
package main
import (
"fmt"
"sync"
"time"
)
// 定義一個簡單的函式,模擬一個耗時操作
func printNumbers(wg *sync.WaitGroup) {
defer wg.Done() // 在函式結束時呼叫Done方法
for i := 1; i <= 5; i++ {
fmt.Printf("Number: %d\n", i)
time.Sleep(1 * time.Second) // 模擬耗時操作
}
}
func main() {
var wg sync.WaitGroup
// 啟動一個goroutine來執行printNumbers函式
wg.Add(1)
go printNumbers(&wg)
// 主goroutine繼續執行其他操作
for i := 'A'; i <= 'E'; i++ {
fmt.Printf("Letter: %c\n", i)
time.Sleep(1 * time.Second) // 模擬耗時操作
}
// 等待所有goroutine完成
wg.Wait()
}
我們定義了一個名為printNumbers的函式,該函式會列印數字1到5,並在每次列印後暫停1秒。然後,在main函式中,我們使用go關鍵字啟動一個新的goroutine來執行printNumbers函式。同時,主goroutine繼續執行其他操作,列印字母A到E,並在每次列印後暫停1秒。
需要注意的是,主goroutine和新啟動的goroutine是併發執行的。為了確保所有goroutine完成,我們使用sync.WaitGroup來等待所有goroutine完成。我們在啟動goroutine之前呼叫wg.Add(1),並在printNumbers函式結束時呼叫wg.Done()。最後,我們在main函式中呼叫wg.Wait(),等待所有goroutine完成。這樣可以確保程式在所有goroutine完成之前不會退出。
協程是一種強大的工具,可以簡化併發程式設計,特別是在處理I/O密集型任務時。
Goroutin實現原理
Goroutine的實現原理包括Goroutine的建立、排程、上下文切換和棧管理等多個方面。透過GPM模型和高效的排程機制,Go執行時能夠高效地管理和排程大量的Goroutine,實現高併發程式設計。
Goroutine的建立
當使用go關鍵字啟動一個新的Goroutine時,Go執行時會執行以下步驟:
- 分配G結構體:Go執行時會為新的Goroutine分配一個G結構體(G表示Goroutine),其中包含Goroutine的狀態資訊、棧指標、程式計數器等。
- 分配棧空間:Go執行時會為新的Goroutine分配初始的棧空間,通常是幾KB。這個棧空間是動態增長的,可以根據需要自動擴充套件。
- 初始化G結構體:Go執行時會初始化G結構體,將Goroutine的入口函式、引數、棧指標等資訊填入G結構體中。
- 將Goroutine加入排程佇列:Go執行時會將新的Goroutine加入到某個P(Processor)的本地執行佇列中,等待排程執行。
Goroutine的排程
Go執行時使用GPM模型(Goroutine、Processor、Machine)來管理和排程Goroutine。排程過程如下:
- P(Processor):P是Go執行時的一個抽象概念,表示一個邏輯處理器。每個P持有一個本地執行佇列,用於儲存待執行的Goroutine。P的數量通常等於機器的CPU核心數,可以透過runtime.GOMAXPROCS函式設定。
- M(Machine):M表示一個作業系統執行緒。M負責實際執行P中的Goroutine。M與P是一對一繫結的關係,一個M只能繫結一個P,但一個P可以被多個M繫結(透過搶佔機制)。M的數量是由Go執行時系統動態管理和確定的。M的數量並不是固定的,而是根據程式的執行情況和系統資源的使用情況動態調整的。透過runtime.NumGoroutine()和runtime.NumCPU()函式,我們可以檢視當前的Goroutine數量和CPU核心數。Go執行時對M的數量有一個預設的最大限制,以防止建立過多的M導致系統資源耗盡。這個限制可以透過環境變數GOMAXPROCS進行調整,但通常不需要手動設定。
- G(Goroutine):代表一個goroutine,它有自己的棧,instruction pointer和其他資訊(正在等待的channel等等),用於排程。
- 排程迴圈:每個P會在一個迴圈中不斷從本地執行佇列中取出Goroutine,並將其分配給繫結的M執行。如果P的本地執行佇列為空,P會嘗試從其他P的本地執行佇列中竊取Goroutine(工作竊取機制)。
從上圖中看,有2個物理執行緒M,每一個M都擁有一個處理器P,每一個也都有一個正在執行的goroutine。P的數量可以透過GOMAXPROCS()來設定,它其實也就代表了真正的併發度,即有多少個goroutine可以同時執行。圖中灰色的那些goroutine並沒有執行,而是出於ready的就緒態,正在等待被排程。P維護著這個佇列(稱之為runqueue),Go語言裡,啟動一個goroutine很容易:go function 就行,所以每有一個go語句被執行,runqueue佇列就在其末尾加入一個goroutine,在下一個排程點,就從runqueue中取出(如何決定取哪個goroutine?)一個goroutine執行。
P的數量可以大於器的CPU核心數?
在Go語言中,P(Processor)的數量通常等於機器的CPU核心數,但也可以透過runtime.GOMAXPROCS函式進行調整。預設情況下,Go執行時會將P的數量設定為機器的邏輯CPU核心數。然而,P的數量可以被設定為大於或小於機器的CPU核心數,這取決於具體的應用需求和效能考慮。
調整P的數量,可以使用runtime.GOMAXPROCS函式來設定P的數量。例如:
package main
import (
"fmt"
"runtime"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
// 模擬工作負載
for i := 0; i < 1000000000; i++ {
}
fmt.Printf("Worker %d done\n", id)
}
func main() {
// 設定P的數量為機器邏輯CPU核心數的兩倍
numCPU := runtime.NumCPU()
runtime.GOMAXPROCS(numCPU * 2)
var wg sync.WaitGroup
// 啟動多個Goroutine
for i := 1; i <= 10; i++ {
wg.Add(1)
go worker(i, &wg)
}
// 等待所有Goroutine完成
wg.Wait()
fmt.Println("All workers done")
}
在這個示例中,我們將P的數量設定為機器邏輯CPU核心數的兩倍。這樣做的目的是為了觀察在不同P數量設定下程式的效能表現。
P的數量大於CPU核心數的影響
- 上下文切換增加:當P的數量大於CPU核心數時,可能會導致更多的上下文切換。因為作業系統需要在有限的CPU核心上排程更多的執行緒(M),這可能會增加排程開銷。
- 資源競爭:更多的P意味著更多的Goroutine可以同時執行,但這也可能導致更多的資源競爭,特別是在I/O密集型任務中。過多的P可能會導致資源爭用,反而降低程式的整體效能。
- 併發性提高:在某些情況下,增加P的數量可以提高程式的併發性,特別是在存在大量阻塞操作(如I/O操作)的情況下。更多的P可以更好地利用CPU資源,減少阻塞時間。
P的數量小於CPU核心數的影響
- CPU利用率降低:當P的數量小於CPU核心數時,可能會導致CPU資源未被充分利用。因為P的數量限制了同時執行的Goroutine數量,可能會導致某些CPU核心處於空閒狀態。
- 減少上下文切換:較少的P數量可以減少上下文切換的開銷,因為作業系統需要排程的執行緒(M)數量減少。這可能會提高CPU密集型任務的效能。
選擇合適的P數量選擇合適的P數量需要根據具體的應用場景和效能需求進行調整。以下是一些建議:
- CPU密集型任務:對於CPU密集型任務,通常將P的數量設定為等於或接近機器的邏輯CPU核心數,以充分利用CPU資源。
- I/O密集型任務:對於I/O密集型任務,可以考慮將P的數量設定為大於CPU核心數,以提高併發性和資源利用率。
- 效能測試和調優:透過效能測試和調優,找到最佳的P數量設定。可以嘗試不同的P數量,觀察程式的效能表現,選擇最優的配置。
Goroutine的上下文切換
Goroutine的上下文切換由Go執行時的排程器管理,主要涉及以下步驟:
- 儲存當前Goroutine的狀態:當一個Goroutine被掛起時,Go執行時會儲存當前Goroutine的狀態資訊,包括程式計數器、棧指標、暫存器等。
- 切換到新的Goroutine:Go執行時會從P的本地執行佇列中取出下一個待執行的Goroutine,並恢復其狀態資訊。
- 恢復新的Goroutine的狀態:Go執行時會將新的Goroutine的狀態資訊載入到CPU暫存器中,並跳轉到新的Goroutine的程式計數器位置,繼續執行。
Goroutine什麼時候會被掛起?Goroutine會在執行阻塞操作、使用同步原語、被排程器排程、建立和銷燬時被掛起。Go執行時透過高效的排程機制管理Goroutine的掛起和恢復,以實現高併發和高效能的程式執行。瞭解這些掛起的情況有助於編寫高效的併發程式,並避免潛在的效能問題。
- 阻塞操作
當Goroutine執行阻塞操作時,它會被掛起,直到阻塞操作完成。常見的阻塞操作包括:
- I/O操作:如檔案讀寫、網路通訊等。
- 系統呼叫:如呼叫作業系統提供的阻塞函式。
- Channel操作:如在無緩衝Channel上進行傳送或接收操作時,如果沒有對應的接收者或傳送者,Goroutine會被掛起。
- 同步原語
使用同步原語(如sync.Mutex、sync.WaitGroup、sync.Cond等)進行同步操作時,Goroutine可能會被掛起,直到條件滿足。例如:
- 互斥鎖(Mutex):當Goroutine嘗試獲取一個已經被其他Goroutine持有的互斥鎖時,它會被掛起,直到鎖被釋放。
- 條件變數(Cond):當Goroutine等待條件變數時,它會被掛起,直到條件變數被通知。
- 排程器排程
Go執行時的排程器會根據需要掛起和恢復Goroutine,以實現高效的併發排程。排程器可能會在以下情況下掛起Goroutine:
- 時間片用完:Go排程器使用協作式排程,當一個Goroutine的時間片用完時,排程器會掛起該Goroutine,並排程其他Goroutine執行。
- 主動讓出:Goroutine可以透過呼叫runtime.Gosched()主動讓出CPU,排程器會掛起該Goroutine,並排程其他Goroutine執行。
- Goroutine的建立和銷燬
- 建立:當一個新的Goroutine被建立時,它會被掛起,直到排程器將其排程執行。
- 銷燬:當一個Goroutine執行完畢或被顯式終止時,它會被掛起並從排程器中移除。
Goroutine的棧管理
Goroutine的棧空間是動態分配的,可以根據需要自動擴充套件。Go執行時使用分段棧(segmented stack)或連續棧(continuous stack)來管理Goroutine的棧空間:
- 分段棧:在早期版本的Go中,Goroutine使用分段棧。每個Goroutine的棧由多個小段組成,當棧空間不足時,Go執行時會分配新的棧段並連結到現有的棧段上。
- 連續棧:在Go 1.3及以後的版本中,Goroutine使用連續棧。每個Goroutine的棧是一個連續的記憶體塊,當棧空間不足時,Go執行時會分配一個更大的棧,並將現有的棧內容複製到新的棧中。