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