溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶(hù)服務(wù)條款》

Golang并發(fā)編程之GMP模型怎么實(shí)現(xiàn)

發(fā)布時(shí)間:2023-05-11 17:30:30 來(lái)源:億速云 閱讀:132 作者:iii 欄目:開(kāi)發(fā)技術(shù)

本文小編為大家詳細(xì)介紹“Golang并發(fā)編程之GMP模型怎么實(shí)現(xiàn)”,內(nèi)容詳細(xì),步驟清晰,細(xì)節(jié)處理妥當(dāng),希望這篇“Golang并發(fā)編程之GMP模型怎么實(shí)現(xiàn)”文章能幫助大家解決疑惑,下面跟著小編的思路慢慢深入,一起來(lái)學(xué)習(xí)新知識(shí)吧。

    0. 簡(jiǎn)介

    傳統(tǒng)的并發(fā)編程模型是基于線(xiàn)程和共享內(nèi)存的同步訪(fǎng)問(wèn)控制的,共享數(shù)據(jù)受鎖的保護(hù),線(xiàn)程將爭(zhēng)奪這些鎖以訪(fǎng)問(wèn)數(shù)據(jù)。通常而言,使用線(xiàn)程安全的數(shù)據(jù)結(jié)構(gòu)會(huì)使得這更加容易。Go的并發(fā)原語(yǔ)(goroutinechannel)提供了一種優(yōu)雅的方式來(lái)構(gòu)建并發(fā)模型。Go鼓勵(lì)在goroutine之間使用channel來(lái)傳遞數(shù)據(jù),而不是顯式地使用鎖來(lái)限制對(duì)共享數(shù)據(jù)的訪(fǎng)問(wèn)。

    Do not communicate by sharing memory; instead, share memory by communicating.

    這就是Go的并發(fā)哲學(xué),它依賴(lài)CSP(Communicating Sequential Processes)模型,它經(jīng)常被認(rèn)為是 Go 在并發(fā)編程上成功的關(guān)鍵因素。

    1. 進(jìn)程、線(xiàn)程和協(xié)程

    進(jìn)程,是一段程序的執(zhí)行過(guò)程,是指令、數(shù)據(jù)及其組織形式的描述,進(jìn)程是正在執(zhí)行的程序的實(shí)例。進(jìn)程擁有自己的獨(dú)立空間。

    傳統(tǒng)的操作系統(tǒng)中,每個(gè)進(jìn)程有一個(gè)地址空間和至少一個(gè)控制線(xiàn)程,這幾乎可以認(rèn)為是進(jìn)程的定義。而這個(gè)地址空間中,可以存在多個(gè)控制線(xiàn)程的情形,這些線(xiàn)程可以理解為輕量級(jí)的進(jìn)程,除了他們共享地址空間。多線(xiàn)程有以下好處:

    • 在許多應(yīng)用中同時(shí)發(fā)生著多種活動(dòng),其中某些活動(dòng)會(huì)被阻塞,比如I/O操作,而某些程序則需要響應(yīng)迅速,比如界面請(qǐng)求,因此多線(xiàn)程的程序設(shè)計(jì)模型會(huì)變得更簡(jiǎn)單;

    • 線(xiàn)程比進(jìn)程更加輕量級(jí),所以其創(chuàng)建、銷(xiāo)毀和上下文切換都更快;

    • 在多CPU的系統(tǒng)中,多線(xiàn)程可以實(shí)現(xiàn)真正的并行。

    在操作系統(tǒng)中,進(jìn)程是操作系統(tǒng)資源分配的單位;線(xiàn)程是處理器調(diào)度和執(zhí)行的基本單位。

    Linux中的進(jìn)程和線(xiàn)程

    在Linux中,所有的線(xiàn)程都當(dāng)做進(jìn)程來(lái)實(shí)現(xiàn),二者的區(qū)別在于:進(jìn)程擁有自己的頁(yè)表(即地址空間),而線(xiàn)程沒(méi)有,只能和同一進(jìn)程內(nèi)的其他線(xiàn)程共享同一份頁(yè)表。這個(gè)區(qū)別的根本原因在于二者調(diào)用系統(tǒng)時(shí)的傳參不同而已。

    在Linux2.3.3開(kāi)始,glibc的fork()函數(shù)創(chuàng)建進(jìn)程時(shí)是調(diào)用系統(tǒng)調(diào)用clone(2)時(shí)指定flagsSIGCHLD(共享信號(hào)句柄表)。而pthread_create創(chuàng)建線(xiàn)程時(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 線(xiàn)程模型

    線(xiàn)程可以分為內(nèi)核線(xiàn)程和用戶(hù)線(xiàn)程,用戶(hù)線(xiàn)程必須依托于內(nèi)核線(xiàn)程,實(shí)現(xiàn)調(diào)度,這樣就帶來(lái)了三種線(xiàn)程模型:多對(duì)一(M:1)、一對(duì)一(1:1)和多對(duì)多(M:N)(用戶(hù)線(xiàn)程對(duì)內(nèi)核線(xiàn)程)。一個(gè)用戶(hù)線(xiàn)程必須綁定一個(gè)內(nèi)核線(xiàn)程才能執(zhí)行,不過(guò)CPU并不知道有用戶(hù)線(xiàn)程的存在。

    1.1.1 多對(duì)一用戶(hù)級(jí)線(xiàn)程模型

    這種模型是多個(gè)用戶(hù)線(xiàn)程對(duì)應(yīng)一個(gè)內(nèi)核調(diào)度線(xiàn)程,所有的線(xiàn)程的創(chuàng)建、銷(xiāo)毀和調(diào)度都由用戶(hù)空間的線(xiàn)程庫(kù)實(shí)現(xiàn),內(nèi)核不感知這些線(xiàn)程的切換。優(yōu)點(diǎn)是線(xiàn)程的上下文切換之間不需要陷入內(nèi)核,速度快。缺點(diǎn)是一旦有一個(gè)用戶(hù)線(xiàn)程有阻塞性的系統(tǒng)調(diào)用,比如I/O操作時(shí),系統(tǒng)內(nèi)核接管后,會(huì)阻塞所有的線(xiàn)程。另外,在多處理器的機(jī)器上,這種線(xiàn)程模型是沒(méi)有意義的,無(wú)法發(fā)揮多核系統(tǒng)的優(yōu)勢(shì)。

    1.1.2 一對(duì)一內(nèi)核級(jí)線(xiàn)程模型

    一對(duì)一模型中,每個(gè)用戶(hù)線(xiàn)程擁有一個(gè)對(duì)應(yīng)的內(nèi)核調(diào)度線(xiàn)程,也就是說(shuō),內(nèi)核會(huì)對(duì)每個(gè)線(xiàn)程進(jìn)行調(diào)度。也因此,線(xiàn)程的創(chuàng)建、銷(xiāo)毀和上下文切換,都會(huì)陷入到內(nèi)核態(tài)。目前,Linux采用的NPTL(Native POSIX Threads Library)的線(xiàn)程模型就是一對(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線(xiàn)程
        res = pthread_create(&p1, NULL, f, NULL);
        if (res != 0) {
            printf("創(chuàng)建線(xiàn)程1失??!\n");
            return 0;
        }
        printf("創(chuàng)建線(xiàn)程1\n");
        sleep(5);
    
        // 創(chuàng)建p1線(xiàn)程
        res = pthread_create(&p2, NULL, f, (void *)p2String);
        if (res != 0) {
            printf("創(chuàng)建線(xiàn)程2失??!\n");
            return 0;
        }
        printf("創(chuàng)建線(xiàn)程2\n");
        sleep(100);
    
        return 0;
    }

    在程序中,我們創(chuàng)建了兩個(gè)線(xiàn)程,執(zhí)行如下:

    $ gcc thread.c -o thread_c -lpthread

    $ ./thread_c
    創(chuàng)建線(xiàn)程1
    arg is NULL
    創(chuàng)建線(xiàn)程2
    I am p2!

    然后查看進(jìn)程號(hào)和此進(jìn)程下的線(xià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

    之所以線(xiàn)程數(shù)是3,是因?yàn)橄到y(tǒng)啟動(dòng)進(jìn)程的時(shí)候就自帶一個(gè)線(xiàn)程,再加上創(chuàng)建的兩個(gè)線(xiàn)程,所以總數(shù)是3,這也證明了Linux的線(xiàn)程模型是1:1的。

    1.1.3 多對(duì)多兩級(jí)線(xiàn)程模型

    在多對(duì)多模型中,結(jié)合了1:1模型和M:1模型的優(yōu)點(diǎn),避免了他們的缺點(diǎn)。每個(gè)用戶(hù)線(xiàn)程擁有多個(gè)內(nèi)核調(diào)度線(xiàn)程,也可以多個(gè)用戶(hù)線(xiàn)程對(duì)應(yīng)一個(gè)調(diào)度實(shí)體。缺點(diǎn)是線(xiàn)程的調(diào)度需要內(nèi)核態(tài)和用戶(hù)態(tài)一起實(shí)現(xiàn),導(dǎo)致模型實(shí)現(xiàn)十分復(fù)雜。NPTL也曾考慮過(guò)使用該模型,但是實(shí)現(xiàn)太過(guò)復(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)程下的線(xià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

    可以看到,用戶(hù)線(xiàn)程(goroutine)和內(nèi)核線(xiàn)程并不是一一對(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的首字母,即用戶(hù)態(tài)的線(xiàn)程,也叫協(xié)程;

    • M:取Machine的首字母,和內(nèi)核線(xiàn)程一一對(duì)應(yīng),為簡(jiǎn)單理解,我們可以認(rèn)為其就是內(nèi)核線(xiàn)程;

    • P:取Processor的首字母,表示處理器(可以理解成用戶(hù)態(tài)的協(xié)程調(diào)度器),是G和M之間的中間層,負(fù)責(zé)協(xié)程調(diào)度。

    2.1 G

    Goroutine是Go語(yǔ)言調(diào)度器中執(zhí)行的任務(wù)實(shí)體,其在runtime調(diào)度器中的地位與線(xiàn)程在操作系統(tǒng)中的差不多。作為更細(xì)粒度的資源調(diào)度單元,和線(xiàn)程相比,其占用更小的內(nèi)存和更低的上下文切換開(kāi)銷(xiāo)。

    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)行用戶(hù)協(xié)程g的執(zhí)行棧(go棧)擴(kuò)張或者收縮的檢查的搶占標(biāo)記。而stackguard1是用于g0gsignal(這二者后面會(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é)程占用的線(xiàn)程,可能為空。

    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的上下文切換需要保留的寄存器很少,無(wú)需保留其他的通用寄存器,至于為啥無(wú)需保留,我們留待后續(xù)解釋。

    2.2 M

    M表示操作系統(tǒng)的線(xiàn)程,Go語(yǔ)言使用私有結(jié)構(gòu)體runtime.m表示操作系統(tǒng)線(xiàn)程,和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)前線(xiàn)程上運(yùn)行的用戶(hù)goroutine。g0比較特殊,其會(huì)深度參與運(yùn)行時(shí)的調(diào)度過(guò)程,包括goroutine的創(chuàng)建、大內(nèi)存分配和CGO函數(shù)的執(zhí)行。

    另外,在runtime.m中,還有三個(gè)與處理器P相關(guān)的字段:p、nextpoldp。另外還是tls字段,通過(guò)tls實(shí)現(xiàn)m結(jié)構(gòu)體對(duì)象與工作線(xiàn)程之間的綁定。

    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是線(xiàn)程M和協(xié)程G之間的中間層,它能提供線(xiàn)程需要的上下文換環(huán)境,也負(fù)責(zé)調(diào)度線(xiàn)程上的等待隊(duì)列,通過(guò)處理器P的調(diào)度,每一個(gè)內(nèi)核線(xiàn)程都能執(zhí)行多個(gè)goroutine,且在goroutine陷入系統(tǒng)調(diào)用的時(shí)候及時(shí)讓出計(jì)算資源,提高線(xiàn)程的利用率。

    因?yàn)檎{(diào)度器在啟動(dòng)時(shí)就會(huì)創(chuàng)建GOMAXPROCS個(gè)處理器,所以Go語(yǔ)言程序的處理器數(shù)量一定會(huì)等于GOMAXPROCS,這些處理器會(huì)綁定到不同的內(nèi)核線(xiàn)程上。

    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表示其綁定的線(xiàn)程。runq表示其持有的運(yùn)行goroutine隊(duì)列,最大256,runnext表示下一個(gè)要執(zhí)行的goroutine。

    以上是GMP中協(xié)程G、線(xiàn)程M和處理器P的私有結(jié)構(gòu)簡(jiǎn)介,下面將介紹Go語(yǔ)言調(diào)度器的實(shí)現(xiàn)。

    3. 基礎(chǔ)調(diào)度過(guò)程

    Golang并發(fā)編程之GMP模型怎么實(shí)現(xiàn)

    上圖簡(jiǎn)單描述了GMP模型的工作原理,在用戶(hù)態(tài),處理器P將自身的運(yùn)行隊(duì)列中的G交付給線(xiàn)程M執(zhí)行,通過(guò)用戶(hù)態(tài)的調(diào)度,實(shí)現(xiàn)goroutine之間的調(diào)度,每次切換耗費(fèi)的時(shí)間約為~0.2us,低于線(xiàn)程上下文切換的~1us;且每次goroutine的創(chuàng)建,開(kāi)辟的棧大小為2KB,而線(xiàn)程的創(chuàng)建,都會(huì)占用1M以上的內(nèi)存空間。所以說(shuō),無(wú)論是在時(shí)間上還是空間上,用戶(hù)態(tài)的goroutine的實(shí)現(xiàn)都比內(nèi)核線(xiàn)程的實(shí)現(xiàn)要輕量的多。

    在圖中,深色G表示線(xiàn)程M正在執(zhí)行的goroutine,而隊(duì)列中的淺色G則表示等待執(zhí)行的goroutine隊(duì)列。而P的個(gè)數(shù)一般設(shè)置為CPU的核數(shù),當(dāng)然用戶(hù)可以通過(guò)runtime.GOMAXPROCS函數(shù)進(jìn)行設(shè)置。而M的個(gè)數(shù)不一定,當(dāng)在M上執(zhí)行的G陷入內(nèi)核調(diào)用而阻塞時(shí),調(diào)度器會(huì)解綁PM,優(yōu)先在空閑M隊(duì)列中找到一個(gè)M進(jìn)行執(zhí)行,如果沒(méi)有空閑M,則創(chuàng)建一個(gè)新的M執(zhí)行剩余隊(duì)列中的G,充分利用CPU的資源,所以說(shuō)M的個(gè)數(shù)不一定。

    讀到這里,這篇“Golang并發(fā)編程之GMP模型怎么實(shí)現(xiàn)”文章已經(jīng)介紹完畢,想要掌握這篇文章的知識(shí)點(diǎn)還需要大家自己動(dòng)手實(shí)踐使用過(guò)才能領(lǐng)會(huì),如果想了解更多相關(guān)內(nèi)容的文章,歡迎關(guān)注億速云行業(yè)資訊頻道。

    向AI問(wèn)一下細(xì)節(jié)

    免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀(guān)點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

    AI