Go必知必會:深入解析 Go 語言 GMP 模型和并發編程的核心機制
自Go語言問世以來,其以簡潔性和高效的并發處理能力廣受贊譽。這一特性的核心在于Go的并發模型——GMP模型。深入理解GMP模型的發展對于精通Go的并發編程至關重要。Goroutine作為Go語言中的關鍵概念,極大地降低了并發編程的門檻。本文旨在詳盡地介紹Go語言中GMP模型的演進歷程,剖析其設計哲學及其優勢,并全面講解Goroutine的基礎知識、優勢和應用方法。同時,文章將通過具體的代碼實例,展示Goroutine在實際開發中的應用。
關鍵/核心題目
在深入探究Go語言的GMP模型之前,我們先來思考幾個關鍵的題目,這些問題將引導我們更深入地理解和掌握GMP模型的精髓。
- 什么是GMP模型?請解釋其基本概念。
- 回答要點:解釋G、M、P的概念及其在調度模型中的角色。
- 如何理解GMP模型中線程的內核態和用戶態?
回答要點:區分內核態線程和用戶態線程,并說明它們在GMP模型中的作用。
Go語言中的Goroutine與線程的映射關系是怎樣的?為什么選擇這種映射方式?
回答要點:解釋Goroutine與線程的多對多映射關系及其優點。
GMP模型如何解決線程調度中的鎖競爭問題?
回答要點:介紹全局隊列和本地隊列的使用,以及G的分配機制。
GMP模型中的Stealing機制是什么?它如何工作?
回答要點:描述Stealing機制的原理及其在Goroutine調度中的應用。
什么是Hand off機制?在什么情況下會使用該機制?
回答要點:解釋Hand off機制及其在阻塞和系統調用中的應用。
如何理解GMP模型中的搶占式調度?它解決了哪些問題?
回答要點:說明搶占式調度的原理及其在防止協程餓死中的作用。
什么是G0和M0?它們在GMP模型中扮演什么角色?
回答要點:描述G0和M0的定義及其在Goroutine調度中的功能。
請詳細說明GMP模型中的調度策略。
回答要點:逐步解釋Goroutine的創建、喚醒、偷取、切換、自旋、系統調用和阻塞處理策略。
如何在實際項目中調優GMP調度模型?
回答要點:討論如何通過調整GOMAXPROCS等參數來優化調度性能。
通過這些問題的思考,將能夠系統地掌握GMP模型的核心概念,理解其調度機制,并在工作中展現出對Go并發模型的深刻理解。
基本概念
在單進程時代,一個進程就是一個運行中的程序。 計算機系統在執行程序時,會從頭到尾依次執行完一個程序,然后再執行下一個程序。在這種模型中,不需要復雜的調度機制,因為只有一個執行流程。
面臨的兩個問題如下。
- 單一執行流程:由于只能一個個執行程序,無法同時處理多個任務,這大大限制了CPU的利用率。
- 進程阻塞:當一個進程遇到I/O操作等阻塞情況時,CPU資源會被浪費,等待進程完成阻塞操作后再繼續執行,導致效率低下。
多進程/線程并發時代
基本概念
為了解決單進程時代的效率問題,引入了多進程和多線程并發模型。 在這種模型中,當一個進程阻塞時,CPU可以切換到另一個準備好的進程繼續執行,這樣可以充分利用CPU資源,提高系統的并發處理能力。
兩個問題
- 高開銷:進程擁有大量資源,進程的創建、切換和銷毀都需要消耗大量的時間和資源。這導致CPU很大一部分時間都在處理進程調度,而不是實際的任務執行。
- 高內存占用:在32位機器下,進程的虛擬內存占用為4GB,線程占用為4MB。大量的線程和進程會導致高內存消耗,限制了系統的擴展性。
協程的引入
為了解決多進程和多線程帶來的高開銷和高內存占用問題,引入了協程(Coroutine)。協程是一種比線程更輕量級的執行單元。協程在用戶態進行調度,避免了頻繁的上下文切換帶來的開銷。Go語言的GMP模型正是基于協程的設計。
協程的基本概念
在深入了解Goroutine之前,先來了解一下協程(Coroutine)的基本概念。
內核態和用戶態
- 內核態線程:由操作系統管理和調度,CPU只負責處理內核態線程。
- 用戶態線程:由用戶程序管理,需綁定到內核態線程上執行,協程即為用戶態線程的一種。
圖片
內核態和用戶態線程關系圖
- Kernel Space (內核空間):上半部分的灰色區域,表示操作系統管理的內核空間。
- User Space (用戶空間):下半部分的白色區域,表示用戶程序運行的空間。
- Kernel Thread 1 和 Kernel Thread 2 (內核線程):由操作系統管理的內核線程,CPU直接處理這些線程。
- User Thread 1、User Thread 2 和 User Thread 3 (用戶線程):由用戶程序管理的用戶線程(協程),需綁定到內核線程上執行。
執行流程如下。
- 用戶態線程:用戶程序創建多個用戶線程(如協程),如圖中的“User Thread 1”、“User Thread 2”和“User Thread 3”。
- 內核態線程:用戶線程需綁定到內核態線程上執行,如圖中的“Kernel Thread 1”和“Kernel Thread 2”。
- CPU處理:
CPU只處理內核態線程,通過綁定關系,用戶態線程的執行也依賴于內核態線程的調度;
圖中的紅色箭頭表示CPU正在處理內核線程,從而間接處理綁定的用戶線程。
線程和協程的映射關系
- 單線程綁定所有協程
問題1:無法利用多核CPU的能力。
問題2:如果某個協程阻塞,整個線程和進程都將阻塞,導致其他協程無法執行,喪失并發能力。
- 一對一映射
將每個協程綁定到一個線程上,退回到多進程/線程的模式,協程的創建、切換、銷毀均需CPU完成,效率低下。
多對多映射
允許多個協程綁定到多個線程上,形成M:N的關系,這樣可以充分利用多核CPU,并通過協程調度器高效管理協程的執行。
圖片
Goroutine
Goroutine是Go語言中的協程,實現了輕量級并發。與傳統的線程相比,Goroutine具有以下顯著特點。
輕量級
Goroutine非常輕量,初始化時僅占用幾KB的棧內存,并且棧內存可以根據需要動態伸縮。這使得我們可以在Go程序中創建成千上萬個Goroutine,而不會消耗過多的系統資源。
高效調度
Goroutine的調度由Go語言的運行時(runtime)負責,而不是操作系統。Go運行時在用戶態進行調度,避免了頻繁的上下文切換帶來的開銷,使得調度更加高效。
Goroutine的使用示例
下面是一個簡單的示例,展示了如何在Go語言中使用Goroutine進行并發編程。
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("Hello")
go say("World")
time.Sleep(1 * time.Second)
fmt.Println("Done")
}
在這個示例中,兩個Goroutine同時執行,分別打印"Hello"和"World"。通過使用go關鍵字,我們可以輕松地啟動一個新的Goroutine。
需要注意的事項
- 主Goroutine的結束:在Go程序中,main函數本身也是一個Goroutine,稱為主Goroutine。當主Goroutine結束時,所有其他Goroutine也會隨之終止。因此,需要確保主Goroutine等待所有子Goroutine執行完畢。
- 同步和共享數據:雖然Goroutine之間共享內存空間,但需要通過同步機制(如通道和鎖)來避免競爭條件。Go語言推薦使用通道(channel)進行Goroutine之間的通信,以保證數據的安全性和同步性。
示例:使用通道進行同步
下面的示例展示了如何使用通道來同步多個Goroutine的執行。
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
// 模擬工作
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("All workers done")
}
在這段代碼中,使用sync.WaitGroup來同步多個Goroutine。主Goroutine啟動多個子Goroutine并等待它們完成,每個子Goroutine在完成任務后調用wg.Done()減少計數,主Goroutine調用wg.Wait()阻塞等待所有子Goroutine完成。
執行流程如下。
- 主Goroutine啟動多個子Goroutine(Goroutine 1、2、3)。
- 各個Goroutine并發執行它們的任務。
- 每個Goroutine在完成任務后,向通道發送信號表示已完成。
- 主Goroutine通過通道接收所有子Goroutine的完成信號,然后繼續執行。
圖片
Goroutine執行與同步流程圖
這張圖展示了多個Goroutine同時執行的流程,以及如何通過通道(Channel)進行同步。
- Goroutine 1、2、3:代表多個并發執行的Goroutine,分別標記為“Goroutine 1”、“Goroutine 2”和“Goroutine 3”。
- Main Goroutine:主Goroutine,它負責啟動其他Goroutine并等待它們完成。
- Channel:用于同步Goroutine的通道。
關于waitgroup我會在下一篇中進行詳細講解。
Goroutine調度器
基本概念
在Go語言中,線程是運行Goroutine的實體,而調度器的功能是將可運行的Goroutine分配到工作線程上。Go語言采用了一種高效的Goroutine調度機制,使得程序能夠在多核處理器上高效運行。
被廢棄的調度器
早期的調度器采用了簡單的設計,存在多個缺陷。
- 概念:用大寫的G表示協程,用大寫的M表示線程。
- 問題
鎖競爭:每個M(線程)想要執行、放回G(協程)都必須訪問一個全局G隊列,因此對G的訪問需要加鎖以保證并發安全。當有很多線程時,鎖競爭激烈,影響系統性能。
局部性破壞:M轉移G會造成延遲和額外的系統負載。例如,當一個G內創建另一個G'時,為了繼續執行G,需要將G'交給另一個M'執行,這會破壞程序的局部性。
- 系統開銷:CPU在線程之間頻繁切換導致頻繁的系統調用,增加了系統開銷。
GMP模型的設計思想
為了克服上述問題,Go引入了GMP模型。
基本概念
Go語言使用GMP模型來管理并發執行,GMP模型由三個核心組件組成:G(Goroutine)、M(Machine)、P(Processor)。
- G(Goroutine):Goroutine是Go語言中的協程,代表一個獨立的執行單元。Goroutine比線程更加輕量級,啟動一個Goroutine的開銷非常小。Goroutine的調度由Go運行時在用戶態進行。
- M(Machine):M代表操作系統的線程,負責實際執行Go代碼。一個M可以執行多個Goroutine,但同一時間只能執行一個Goroutine。M與操作系統的線程直接對應,Go運行時通過M來利用多核CPU的并行計算能力。
- P(Processor):P代表執行上下文(Processor)。P管理著可運行的Goroutine隊列,并負責與M進行綁定。P的數量決定了可以并行執行的Goroutine的數量。Go運行時會根據系統的CPU核數設置P的數量。
GMP模型的組成
- 全局G隊列:存放等待運行的G。
- P的本地G隊列:存放不超過256個G,當新建協程時優先將G存放到本地隊列,本地隊列滿了后將一半的G移動到全局隊列。
- M:內核態線程,線程想要運行協程需要先獲取一個P,從P的本地G隊列中獲取G。當本地隊列為空時,會嘗試從全局隊列或其他P的本地G列表中偷取G。
- P列表:程序啟動時創建GOMAXPROCS個P,并保存在數組中。
- 調度器與OS調度器結合:Go的Goroutine調度器與操作系統調度器結合,OS調度器負責將線程分配給CPU執行。
圖片
設計策略
復用線程的兩個策略
- Work Stealing機制:當本線程沒有可執行的G時,優先從全局G隊列中獲取一批G。如果全局隊列中沒有,則嘗試從其他P的G隊列中偷取G。
- Hand Off機制:當本線程因G進行系統調用等阻塞時,線程會釋放綁定的P,把P轉移給其他空閑的M執行。
利用并行:有GOMAXPROCS個P,則可以有同樣數量的線程并行執行。
搶占式調度:Goroutine是協作式的,一個協程只有讓出CPU才能讓下一個協程執行,而Goroutine執行超過10ms就會強制讓出CPU,防止其他協程餓死。
特殊的G0和M0
- G0:每次啟動一個M都會創建的第一個Goroutine,僅用于調度,不指向任何可執行的函數。每個M都有一個自己的G0,在調度或系統調用時使用G0的棧空間。
- M0:啟動程序后的第一個主線程,負責執行初始化操作和啟動第一個Goroutine,此后與其他M一樣。
調度策略
創建兩步:
- 通過go func()創建一個協程;
- 新創建的協程優先保存在P的本地G隊列,如果本地隊列滿了,會將P本地隊列中的一半G打亂順序移入全局隊列。
圖片
喚醒獲取:創建G時運行的G會嘗試喚醒其他的PM組合去執行。假設G2喚醒了M2,M2綁定了P2,但P2本地隊列沒有G,此時M2為自旋線程。M2便會嘗試從全局隊列中獲取G。
偷取:假設P的本地隊列和全局隊列都空了,會從其他P偷取一半G到自己的本地隊列執行。
切換邏輯:G1運行完后,M上運行的協程切換回G0,G0負責調度時協程的切換。先從P的本地隊列獲取G2,從G0切換到G2,從而實現M的復用。
自旋:自旋線程會占用CPU時間,但創建銷毀線程也會消耗CPU時間,系統最多有GOMAXPROCS個自旋線程,其余的線程會在休眠M隊列里。
系統調用:當G進行系統調用時會進入內核態被阻塞,GM會綁定在一起進行系統調用。M會釋放綁定的P,把P轉移給其他空閑的M執行。當系統調用結束時,GM會嘗試獲取一個空閑的P。
阻塞處理:當G因channel或network I/O阻塞時,不會阻塞M,當超過10ms時M會尋找其他可運行的G。
公平性:調度器每調度61次時,會嘗試從全局隊列里取出待運行的Goroutine來運行,如果沒有找到,就去其他P偷一些Goroutine來執行。
GMP模型的優勢
- 高效的資源利用:通過在用戶態進行調度,避免了頻繁的上下文切換帶來的開銷,充分利用CPU資源。
- 輕量級并發:Goroutine比線程更加輕量級,可以啟動大量的Goroutine而不會消耗大量內存。
- 自動調度:Go運行時自動管理Goroutine的調度,無需程序員手動干預,簡化了并發編程的復雜度。
關鍵題
GMP調度模型
在日常工作中,如果被問到GMP調度模型,建議全面地回答以下內容。如果能完整且詳細地講述這些內容,將會展示你對GMP調度模型的深刻理解和熟練掌握。
基本概念
- 線程的內核態和用戶態
線程分為“內核態”和“用戶態”,用戶態線程即協程,必須綁定一個內核態線程,CPU只負責處理內核態線程。
- 調度器
在Go中,線程是運行Goroutine的實體,調度器的功能是將可運行的Goroutine分配到工作線程上。
映射關系
在Go語言中,線程與協程的映射關系是多對多的,這樣避免了多個協程對應一個線程時出現的無法使用多核和并發的問題。Go的協程是協作式的,只有讓出CPU資源才能調度。如果一個協程阻塞,只有一個線程在運行,其他協程也會被阻塞。
三個概念
- 全局隊列:
- 存放等待運行的Goroutine。
- 本地隊列:
每個P(處理器)都有一個本地隊列,存放不超過256個Goroutine。新建協程時優先放入本地隊列,本地隊列滿了則將一半的G移入全局隊列。
GMP:
G:Goroutine,Go語言中的協程。
M:Machine,內核態線程,運行Goroutine的實體。
P:Processor,處理器,包含運行Goroutine的資源和本地隊列。
設計策略
- 復用線程
Stealing機制:當一個線程沒有可執行的G時,會從全局隊列或其他P的本地隊列中偷取G來執行。
Hand off機制:當一個線程因G進行系統調用等阻塞時,線程會釋放綁定的P,把P轉移給其他空閑的M執行。
- P并行
有GOMAXPROCS個P,代表最多有這么多個線程并行執行。
- 搶占式調度
Goroutine執行超過10ms就會強制讓出CPU,防止其他協程餓死。
- 特殊的G0和M0
G0:每個M啟動時創建的第一個Goroutine,僅用于調度,不執行用戶代碼。每個M都有一個G0。
M0:程序啟動后的第一個主線程,負責初始化操作和啟動第一個Goroutine。
調度策略
- 創建
通過go func()創建一個協程。新創建的協程優先保存在P的本地G隊列,如果本地隊列滿了,會將P本地隊列中的一半G移入全局隊列。
- 喚醒
創建G時,當前運行的G會嘗試喚醒其他PM組合執行。若喚醒的M綁定的P本地隊列為空,M會嘗試從全局隊列獲取G。
- 偷取
如果P的本地隊列和全局隊列都為空,會從其他P偷取一半G到自己的本地隊列執行。
- 切換
G1運行完后,M上運行的Goroutine切換回G0,G0負責調度協程的切換。G0從P的本地隊列獲取G2,實現M的復用。
- 自旋
自旋線程會占用CPU時間,但創建銷毀線程也消耗CPU時間。系統最多有GOMAXPROCS個自旋線程,其他線程在休眠M隊列里。
- 系統調用
當G進行系統調用時進入內核態被阻塞,M會釋放綁定的P,把P轉移給其他空閑的M執行。當系統調用結束,GM會嘗試獲取一個空閑的P。
- 阻塞處理
當G因channel或network I/O阻塞時,不會阻塞M。超過10ms時,M會尋找其他可運行的G。
- 公平性
調度器每調度61次時,會嘗試從全局隊列中取出待運行的Goroutine來運行。如果沒有找到,就去其他P偷一些Goroutine來執行。本文轉載自微信公眾號「王中陽」,作者「王中陽」,可以通過以下二維碼關注。
轉載本文請聯系「王中陽」公眾號。