Golang并發(fā)編程之GMP模型詳解
0. 簡介
傳統(tǒng)的并發(fā)編程模型是基于線程和共享內(nèi)存的同步訪問控制的,共享數(shù)據(jù)受鎖的保護(hù),線程將爭奪這些鎖以訪問數(shù)據(jù)。通常而言,使用線程安全的數(shù)據(jù)結(jié)構(gòu)會(huì)使得這更加容易。Go的并發(fā)原語(goroutine
和channel
)提供了一種優(yōu)雅的方式來構(gòu)建并發(fā)模型。Go鼓勵(lì)在goroutine
之間使用channel
來傳遞數(shù)據(jù),而不是顯式地使用鎖來限制對(duì)共享數(shù)據(jù)的訪問。
Do not communicate by sharing memory; instead, share memory by communicating.
這就是Go的并發(fā)哲學(xué),它依賴CSP(Communicating Sequential Processes)
模型,它經(jīng)常被認(rèn)為是 Go 在并發(fā)編程上成功的關(guān)鍵因素。
本文將介紹Go并發(fā)編發(fā)編程的的第一個(gè)議題:goroutine
的實(shí)現(xiàn)及其調(diào)度原理。
1. 進(jìn)程、線程和協(xié)程
進(jìn)程,是一段程序的執(zhí)行過程,是指令、數(shù)據(jù)及其組織形式的描述,進(jìn)程是正在執(zhí)行的程序的實(shí)例。進(jìn)程擁有自己的獨(dú)立空間。
傳統(tǒng)的操作系統(tǒng)中,每個(gè)進(jìn)程有一個(gè)地址空間和至少一個(gè)控制線程,這幾乎可以認(rèn)為是進(jìn)程的定義。而這個(gè)地址空間中,可以存在多個(gè)控制線程的情形,這些線程可以理解為輕量級(jí)的進(jìn)程,除了他們共享地址空間。多線程有以下好處:
- 在許多應(yīng)用中同時(shí)發(fā)生著多種活動(dòng),其中某些活動(dòng)會(huì)被阻塞,比如I/O操作,而某些程序則需要響應(yīng)迅速,比如界面請(qǐng)求,因此多線程的程序設(shè)計(jì)模型會(huì)變得更簡單;
- 線程比進(jìn)程更加輕量級(jí),所以其創(chuàng)建、銷毀和上下文切換都更快;
- 在多CPU的系統(tǒng)中,多線程可以實(shí)現(xiàn)真正的并行。
在操作系統(tǒng)中,進(jìn)程是操作系統(tǒng)資源分配的單位;線程是處理器調(diào)度和執(zhí)行的基本單位。
Linux中的進(jìn)程和線程
在Linux中,所有的線程都當(dāng)做進(jìn)程來實(shí)現(xiàn),二者的區(qū)別在于:進(jìn)程擁有自己的頁表(即地址空間),而線程沒有,只能和同一進(jìn)程內(nèi)的其他線程共享同一份頁表。這個(gè)區(qū)別的根本原因在于二者調(diào)用系統(tǒng)時(shí)的傳參不同而已。
在Linux2.3.3開始,glibc的fork()
函數(shù)創(chuàng)建進(jìn)程時(shí)是調(diào)用系統(tǒng)調(diào)用clone(2)時(shí)指定flags
為SIGCHLD
(共享信號(hào)句柄表)。而pthread_create
創(chuàng)建線程時(shí),內(nèi)部也是調(diào)用clone
函數(shù),其指定的flags
如下:
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
clone
的函數(shù)形式如下:
int clone(int (* fn )(void *), void * stack , int flags , void * arg , ... /* pid_t * parent_tid , void * tls , pid_t * child_tid */ );
其實(shí)Docker底層實(shí)現(xiàn)隔離技術(shù),也利用了clone
函數(shù)這一系統(tǒng)調(diào)用。
1.1 線程模型
線程可以分為內(nèi)核線程和用戶線程,用戶線程必須依托于內(nèi)核線程,實(shí)現(xiàn)調(diào)度,這樣就帶來了三種線程模型:多對(duì)一(M:1)、一對(duì)一(1:1)和多對(duì)多(M:N)(用戶線程對(duì)內(nèi)核線程)。一個(gè)用戶線程必須綁定一個(gè)內(nèi)核線程才能執(zhí)行,不過CPU并不知道有用戶線程的存在。
1.1.1 多對(duì)一用戶級(jí)線程模型
這種模型是多個(gè)用戶線程對(duì)應(yīng)一個(gè)內(nèi)核調(diào)度線程,所有的線程的創(chuàng)建、銷毀和調(diào)度都由用戶空間的線程庫實(shí)現(xiàn),內(nèi)核不感知這些線程的切換。優(yōu)點(diǎn)是線程的上下文切換之間不需要陷入內(nèi)核,速度快。缺點(diǎn)是一旦有一個(gè)用戶線程有阻塞性的系統(tǒng)調(diào)用,比如I/O操作時(shí),系統(tǒng)內(nèi)核接管后,會(huì)阻塞所有的線程。另外,在多處理器的機(jī)器上,這種線程模型是沒有意義的,無法發(fā)揮多核系統(tǒng)的優(yōu)勢。
1.1.2 一對(duì)一內(nèi)核級(jí)線程模型
一對(duì)一模型中,每個(gè)用戶線程擁有一個(gè)對(duì)應(yīng)的內(nèi)核調(diào)度線程,也就是說,內(nèi)核會(huì)對(duì)每個(gè)線程進(jìn)行調(diào)度。也因此,線程的創(chuàng)建、銷毀和上下文切換,都會(huì)陷入到內(nèi)核態(tài)。目前,Linux采用的NPTL(Native POSIX Threads Library)
的線程模型就是一對(duì)一模型。比如以下例子:
#include <stdio.h> #include <unistd.h> #include <pthread.h> void *f(void *arg){ if (!arg) { printf("arg is NULL\n"); } else { printf("%s\n", (char *)arg); } sleep(100); return NULL; } int main() { pthread_t p1, p2; int res; char *p2String = "I am p2!"; // 創(chuàng)建p1線程 res = pthread_create(&p1, NULL, f, NULL); if (res != 0) { printf("創(chuàng)建線程1失??!\n"); return 0; } printf("創(chuàng)建線程1\n"); sleep(5); // 創(chuàng)建p1線程 res = pthread_create(&p2, NULL, f, (void *)p2String); if (res != 0) { printf("創(chuàng)建線程2失??!\n"); return 0; } printf("創(chuàng)建線程2\n"); sleep(100); return 0; }
在程序中,我們創(chuàng)建了兩個(gè)線程,執(zhí)行如下:
$ gcc thread.c -o thread_c -lpthread
$ ./thread_c
創(chuàng)建線程1
arg is NULL
創(chuàng)建線程2
I am p2!
然后查看進(jìn)程號(hào)和此進(jìn)程下的線程數(shù)。
$ ps -ef | grep thread_c
chenyig+ 5293 5087 0 19:02 pts/0 00:00:00 ./thread_c
chenyig+ 5459 5347 0 19:03 pts/1 00:00:00 grep --color=auto thread_c
$ cat /proc/5293/status | grep Threads
Threads: 3
之所以線程數(shù)是3,是因?yàn)橄到y(tǒng)啟動(dòng)進(jìn)程的時(shí)候就自帶一個(gè)線程,再加上創(chuàng)建的兩個(gè)線程,所以總數(shù)是3,這也證明了Linux的線程模型是1:1
的。
1.1.3 多對(duì)多兩級(jí)線程模型
在多對(duì)多模型中,結(jié)合了1:1
模型和M:1
模型的優(yōu)點(diǎn),避免了他們的缺點(diǎn)。每個(gè)用戶線程擁有多個(gè)內(nèi)核調(diào)度線程,也可以多個(gè)用戶線程對(duì)應(yīng)一個(gè)調(diào)度實(shí)體。缺點(diǎn)是線程的調(diào)度需要內(nèi)核態(tài)和用戶態(tài)一起實(shí)現(xiàn),導(dǎo)致模型實(shí)現(xiàn)十分復(fù)雜。NPTL
也曾考慮過使用該模型,但是實(shí)現(xiàn)太過復(fù)雜,需要對(duì)內(nèi)核進(jìn)行大范圍的改動(dòng),所以還是采用了1:1
模型?,F(xiàn)階段,Go
中的協(xié)程goroutine
就是采用該模型實(shí)現(xiàn)的。
package main import ( "fmt" "sync" "time" ) func f(i int) { fmt.Printf("I am goroutine %d\n", i) time.Sleep(100 * time.Second) } func main() { wg := sync.WaitGroup{} for i := 0; i < 100; i++ { idx := i wg.Add(1) go func() { defer wg.Done() f(idx) }() } wg.Wait() }
運(yùn)行后:
$ go build -o thread_go goroutine.go
$ ./thread_go
I am goroutine 7
I am goroutine 4
I am goroutine 0
I am goroutine 6
I am goroutine 1
I am goroutine 2
I am goroutine 9
I am goroutine 3
I am goroutine 5
I am goroutine 8
然后查看進(jìn)程號(hào)和此進(jìn)程下的線程數(shù)。
$ ps -ef | grep thread_go
chenyig+ 69705 67603 0 17:17 pts/0 00:00:00 ./thread_go
chenyig+ 69735 68420 0 17:17 pts/2 00:00:00 grep --color=auto thread_go
$ cat /proc/69705/status | grep Threads
Threads: 5
可以看到,用戶線程(goroutine)和內(nèi)核線程并不是一一對(duì)應(yīng)的,而是多對(duì)多的情形。
2. GMP模型
Go在2012年正式引入GMP模型,然后在1.2版本中引入了協(xié)作式的搶占式調(diào)度,在1.14版本中實(shí)現(xiàn)了基于信號(hào)的搶占式調(diào)度,并一直沿用至今。
GMP模型中:
- G:取
Goroutine
的首字母,即用戶態(tài)的線程,也叫協(xié)程; - M:取
Machine
的首字母,和內(nèi)核線程一一對(duì)應(yīng),為簡單理解,我們可以認(rèn)為其就是內(nèi)核線程; - P:取
Processor
的首字母,表示處理器(可以理解成用戶態(tài)的協(xié)程調(diào)度器),是G和M之間的中間層,負(fù)責(zé)協(xié)程調(diào)度。
2.1 G
Goroutine
是Go語言調(diào)度器中執(zhí)行的任務(wù)實(shí)體,其在runtime
調(diào)度器中的地位與線程在操作系統(tǒng)中的差不多。作為更細(xì)粒度的資源調(diào)度單元,和線程相比,其占用更小的內(nèi)存和更低的上下文切換開銷。
Goroutine
在運(yùn)行時(shí)的結(jié)構(gòu)體是runtime.g
,其結(jié)構(gòu)非常復(fù)雜,我們挑選一些重要的字段進(jìn)行介紹。
type g struct { // Stack parameters. // stack describes the actual stack memory: [stack.lo, stack.hi). // stackguard0 is the stack pointer compared in the Go stack growth prologue. // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption. // stackguard1 is the stack pointer compared in the C stack growth prologue. // It is stack.lo+StackGuard on g0 and gsignal stacks. // It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash). stack stack // offset known to runtime/cgo stackguard0 uintptr // offset known to liblink stackguard1 uintptr // offset known to liblink ... }
以上是和Go
運(yùn)行時(shí)棧相關(guān)的字段,其中stack
結(jié)構(gòu)體如下,只有棧頂和棧底的地址。stackguard0
是運(yùn)行用戶協(xié)程g
的執(zhí)行棧(go棧
)擴(kuò)張或者收縮的檢查的搶占標(biāo)記。而stackguard1
是用于g0
和gsignal
(這二者后面會(huì)介紹)的內(nèi)核棧(C棧)的擴(kuò)張或者收縮的檢查的搶占標(biāo)記。
// Stack describes a Go execution stack. // The bounds of the stack are exactly [lo, hi), // with no implicit data structures on either side. type stack struct { lo uintptr hi uintptr }
另外,還有以下三個(gè)字段和搶占息息相關(guān)。
type g struct { ... preempt bool // preemption signal, duplicates stackguard0 = stackpreempt preemptStop bool // transition to _Gpreempted on preemption; otherwise, just deschedule preemptShrink bool // shrink stack at synchronous safe point ... }
此外,以下字段中,m
表示當(dāng)前協(xié)程占用的線程,可能為空。
type g struct { ... m *m // current m; offset known to arm liblink sched gobuf ... }
而sched
字段存儲(chǔ)了Goroutine
調(diào)度相關(guān)的數(shù)據(jù),如下。
type gobuf struct { // The offsets of sp, pc, and g are known to (hard-coded in) libmach. // // ctxt is unusual with respect to GC: it may be a // heap-allocated funcval, so GC needs to track it, but it // needs to be set and cleared from assembly, where it's // difficult to have write barriers. However, ctxt is really a // saved, live register, and we only ever exchange it between // the real register and the gobuf. Hence, we treat it as a // root during stack scanning, which means assembly that saves // and restores it doesn't need write barriers. It's still // typed as a pointer so that any other writes from Go get // write barriers. sp uintptr pc uintptr g guintptr ctxt unsafe.Pointer ret uintptr lr uintptr bp uintptr // for framepointer-enabled architectures }
其中:
sp
:棧頂指針;pc
:程序計(jì)數(shù)器;ctxt
:函數(shù)閉包的上下文信息,即DX
寄存器;bp
:棧底指針;
可以看到,goroutine
的上下文切換需要保留的寄存器很少,無需保留其他的通用寄存器,至于為啥無需保留,我們留待后續(xù)解釋。
2.2 M
M
表示操作系統(tǒng)的線程,Go
語言使用私有結(jié)構(gòu)體runtime.m
表示操作系統(tǒng)線程,和runtime.g
一樣,這個(gè)結(jié)構(gòu)體包含了幾十個(gè)字段,我們也只挑選一些和我們了解其運(yùn)行機(jī)制的介紹。
type m struct { g0 *g // goroutine with scheduling stack ... curg *g // current running goroutine ... }
其中,g0
是持有調(diào)度棧的goroutine
,curg
是當(dāng)前線程上運(yùn)行的用戶goroutine
。g0
比較特殊,其會(huì)深度參與運(yùn)行時(shí)的調(diào)度過程,包括goroutine
的創(chuàng)建、大內(nèi)存分配和CGO
函數(shù)的執(zhí)行。
另外,在runtime.m
中,還有三個(gè)與處理器P
相關(guān)的字段:p
、nextp
和oldp
。另外還是tls
字段,通過tls
實(shí)現(xiàn)m
結(jié)構(gòu)體對(duì)象與工作線程之間的綁定。
type m struct { ... p puintptr // attached p for executing go code (nil if not executing go code) nextp puintptr oldp puintptr // the p that was attached before executing a syscall ... tls [tlsSlots]uintptr // thread-local storage (for x86 extern register) ... }
2.3 P
處理器P
是線程M
和協(xié)程G
之間的中間層,它能提供線程需要的上下文換環(huán)境,也負(fù)責(zé)調(diào)度線程上的等待隊(duì)列,通過處理器P
的調(diào)度,每一個(gè)內(nèi)核線程都能執(zhí)行多個(gè)goroutine
,且在goroutine
陷入系統(tǒng)調(diào)用的時(shí)候及時(shí)讓出計(jì)算資源,提高線程的利用率。
因?yàn)檎{(diào)度器在啟動(dòng)時(shí)就會(huì)創(chuàng)建GOMAXPROCS
個(gè)處理器,所以Go
語言程序的處理器數(shù)量一定會(huì)等于GOMAXPROCS
,這些處理器會(huì)綁定到不同的內(nèi)核線程上。
type p struct { ... m muintptr // back-link to associated m (nil if idle) ... // Queue of runnable goroutines. Accessed without lock. runqhead uint32 runqtail uint32 runq [256]guintptr runnext guintptr ... }
以上,runtime.p
表示P
的私有結(jié)構(gòu),m
表示其綁定的線程。runq
表示其持有的運(yùn)行goroutine
隊(duì)列,最大256,runnext
表示下一個(gè)要執(zhí)行的goroutine
。
以上是GMP
中協(xié)程G
、線程M
和處理器P
的私有結(jié)構(gòu)簡介,下面將介紹Go
語言調(diào)度器的實(shí)現(xiàn)。
3. 基礎(chǔ)調(diào)度過程
上圖簡單描述了GMP
模型的工作原理,在用戶態(tài),處理器P
將自身的運(yùn)行隊(duì)列中的G
交付給線程M
執(zhí)行,通過用戶態(tài)的調(diào)度,實(shí)現(xiàn)goroutine
之間的調(diào)度,每次切換耗費(fèi)的時(shí)間約為~0.2us
,低于線程上下文切換的~1us
;且每次goroutine
的創(chuàng)建,開辟的棧大小為2KB
,而線程的創(chuàng)建,都會(huì)占用1M
以上的內(nèi)存空間。所以說,無論是在時(shí)間上還是空間上,用戶態(tài)的goroutine
的實(shí)現(xiàn)都比內(nèi)核線程的實(shí)現(xiàn)要輕量的多。
在圖中,深色G
表示線程M
正在執(zhí)行的goroutine
,而隊(duì)列中的淺色G
則表示等待執(zhí)行的goroutine
隊(duì)列。而P
的個(gè)數(shù)一般設(shè)置為CPU的核數(shù),當(dāng)然用戶可以通過runtime.GOMAXPROCS
函數(shù)進(jìn)行設(shè)置。而M
的個(gè)數(shù)不一定,當(dāng)在M
上執(zhí)行的G
陷入內(nèi)核調(diào)用而阻塞時(shí),調(diào)度器會(huì)解綁P
和M
,優(yōu)先在空閑M
隊(duì)列中找到一個(gè)M
進(jìn)行執(zhí)行,如果沒有空閑M
,則創(chuàng)建一個(gè)新的M
執(zhí)行剩余隊(duì)列中的G
,充分利用CPU的資源,所以說M
的個(gè)數(shù)不一定。
以上就是Golang并發(fā)編程之GMP模型詳解的詳細(xì)內(nèi)容,更多關(guān)于Golang GMP模型的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang利用Template模板動(dòng)態(tài)生成文本
Go語言中的Go?Template是一種用于生成文本輸出的簡單而強(qiáng)大的模板引擎,它提供了一種靈活的方式來生成各種格式的文本,下面我們就來看看具體如何使用Template實(shí)現(xiàn)動(dòng)態(tài)文本生成吧2023-09-09Go語言實(shí)現(xiàn)二維數(shù)組的2種遍歷方式以及案例詳解
這篇文章主要介紹了Go語言實(shí)現(xiàn)二維數(shù)組的2種遍歷方式以及案例詳解,圖文代碼聲情并茂,有感興趣的可以學(xué)習(xí)下2021-03-03golang中值類型/指針類型的變量區(qū)別總結(jié)
golang的值類型和指針類型receiver一直是大家比較混淆的地方,下面這篇文章主要給大家總結(jié)介紹了關(guān)于golang中值類型/指針類型的變量區(qū)別的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),需要的朋友可以參考下。2017-12-12golang如何通過viper讀取config.yaml文件
這篇文章主要介紹了golang通過viper讀取config.yaml文件,圍繞golang讀取config.yaml文件的相關(guān)資料展開詳細(xì)內(nèi)容,需要的小伙伴可以參考一下2022-03-03golang使用信號(hào)量熱更新的實(shí)現(xiàn)示例
這篇文章主要介紹了golang使用信號(hào)量熱更新的實(shí)現(xiàn)示例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-04-04golang結(jié)構(gòu)化日志log/slog包之slog.Record的用法簡介
這篇文章主要為大家詳細(xì)介紹了golang結(jié)構(gòu)化日志log/slog包中slog.Record結(jié)構(gòu)體的使用方法和需要注意的點(diǎn),文中的示例代碼講解詳細(xì),需要的可以學(xué)習(xí)一下2023-10-10