Go語言goroutine調度器概述(11)


本文是《go調度器源代碼情景分析》系列的第11篇,也是第二章的第1小節。

goroutine簡介

goroutine是Go語言實現的用戶態線程,主要用來解決操作系統線程太“重”的問題,所謂的太重,主要表現在以下兩個方面:

  1. 創建和切換太重:操作系統線程的創建和切換都需要進入內核,而進入內核所消耗的性能代價比較高,開銷較大;

  2. 內存使用太重:一方面,為了盡量避免極端情況下操作系統線程棧的溢出,內核在創建操作系統線程時默認會為其分配一個較大的棧內存(虛擬地址空間,內核並不會一開始就分配這么多的物理內存),然而在絕大多數情況下,系統線程遠遠用不了這么多內存,這導致了浪費;另一方面,棧內存空間一旦創建和初始化完成之后其大小就不能再有變化,這決定了在某些特殊場景下系統線程棧還是有溢出的風險。

而相對的,用戶態的goroutine則輕量得多:

  1. goroutine是用戶態線程,其創建和切換都在用戶代碼中完成而無需進入操作系統內核,所以其開銷要遠遠小於系統線程的創建和切換;

  2. goroutine啟動時默認棧大小只有2k,這在多數情況下已經夠用了,即使不夠用,goroutine的棧也會自動擴大,同時,如果棧太大了過於浪費它還能自動收縮,這樣既沒有棧溢出的風險,也不會造成棧內存空間的大量浪費。

正是因為Go語言中實現了如此輕量級的線程,才使得我們在Go程序中,可以輕易的創建成千上萬甚至上百萬的goroutine出來並發的執行任務而不用太擔心性能和內存等問題。

 

注意:為了避免混淆,從現在開始,后面出現的所有的線程一詞均是指操作系統線程,而goroutine我們不再稱之為什么什么線程而是直接使用goroutine這個詞。

 

線程模型與調度器

 

第一章討論操作系統線程調度的時候我們曾經提到過,goroutine建立在操作系統線程基礎之上,它與操作系統線程之間實現了一個多對多(M:N)的兩級線程模型

 

這里的 M:N 是指M個goroutine運行在N個操作系統線程之上,內核負責對這N個操作系統線程進行調度,而這N個系統線程又負責對這M個goroutine進行調度和運行。

 

所謂的對goroutine的調度,是指程序代碼按照一定的算法在適當的時候挑選出合適的goroutine並放到CPU上去運行的過程,這些負責對goroutine進行調度的程序代碼我們稱之為goroutine調度器。用極度簡化了的偽代碼來描述goroutine調度器的工作流程大概是下面這個樣子:

 

// 程序啟動時的初始化代碼
......
for i = 0; i < N; i++ { // 創建N個操作系統線程執行schedule函數
    create_os_thread(schedule) // 創建一個操作系統線程執行schedule函數
}

//schedule函數實現調度邏輯
schedule() {
   for { //調度循環
         // 根據某種算法從M個goroutine中找出一個需要運行的goroutine
         g = find_a_runnable_goroutine_from_M_goroutines()
         run_g(g) // CPU運行該goroutine,直到需要調度其它goroutine才返回
         save_status_of_g(g) // 保存goroutine的狀態,主要是寄存器的值
    }
}

 

 

 

這段偽代碼表達的意思是,程序運行起來之后創建了N個由內核調度的操作系統線程(為了方便描述,我們稱這些系統線程為工作線程)去執行shedule函數,而schedule函數在一個調度循環中反復從M個goroutine中挑選出一個需要運行的goroutine並跳轉到該goroutine去運行,直到需要調度其它goroutine時才返回到schedule函數中通過save_status_of_g保存剛剛正在運行的goroutine的狀態然后再次去尋找下一個goroutine。

 

需要強調的是,這段偽代碼對goroutine的調度代碼做了高度的抽象、修改和簡化處理,放在這里只是為了幫助我們從宏觀上了解goroutine的兩級調度模型,具體的實現原理和細節將從本章開始進行全面介紹。

 

調度器數據結構概述

第一章我們討論操作系統線程及其調度時還說過,可以把內核對系統線程的調度簡單的歸納為:在執行操作系統代碼時,內核調度器按照一定的算法挑選出一個線程並把該線程保存在內存之中的寄存器的值放入CPU對應的寄存器從而恢復該線程的運行。

萬變不離其宗,系統線程對goroutine的調度與內核對系統線程的調度原理是一樣的,實質都是通過保存和修改CPU寄存器的值來達到切換線程/goroutine的目的

 

因此,為了實現對goroutine的調度,需要引入一個數據結構來保存CPU寄存器的值以及goroutine的其它一些狀態信息,在Go語言調度器源代碼中,這個數據結構是一個名叫g的結構體,它保存了goroutine的所有信息,該結構體的每一個實例對象都代表了一個goroutine,調度器代碼可以通過g對象來對goroutine進行調度,當goroutine被調離CPU時,調度器代碼負責把CPU寄存器的值保存在g對象的成員變量之中,當goroutine被調度起來運行時,調度器代碼又負責把g對象的成員變量所保存的寄存器的值恢復到CPU的寄存器。

 

要實現對goroutine的調度,僅僅有g結構體對象是不夠的,至少還需要一個存放所有(可運行)goroutine的容器,便於工作線程尋找需要被調度起來運行的goroutine,於是Go調度器又引入了schedt結構體,一方面用來保存調度器自身的狀態信息,另一方面它還擁有一個用來保存goroutine的運行隊列。因為每個Go程序只有一個調度器,所以在每個Go程序中schedt結構體只有一個實例對象,該實例對象在源代碼中被定義成了一個共享的全局變量,這樣每個工作線程都可以訪問它以及它所擁有的goroutine運行隊列,我們稱這個運行隊列為全局運行隊列

 

既然說到全局運行隊列,讀者可能猜想到應該還有一個局部運行隊列。確實如此,因為全局運行隊列是每個工作線程都可以讀寫的,因此訪問它需要加鎖,然而在一個繁忙的系統中,加鎖會導致嚴重的性能問題。於是,調度器又為每個工作線程引入了一個私有的局部goroutine運行隊列,工作線程優先使用自己的局部運行隊列,只有必要時才會去訪問全局運行隊列,這大大減少了鎖沖突,提高了工作線程的並發性。在Go調度器源代碼中,局部運行隊列被包含在p結構體的實例對象之中,每一個運行着go代碼的工作線程都會與一個p結構體的實例對象關聯在一起。

 

除了上面介紹的g、schedt和p結構體,Go調度器源代碼中還有一個用來代表工作線程的m結構體,每個工作線程都有唯一的一個m結構體的實例對象與之對應,m結構體對象除了記錄着工作線程的諸如棧的起止位置、當前正在執行的goroutine以及是否空閑等等狀態信息之外,還通過指針維持着與p結構體的實例對象之間的綁定關系。於是,通過m既可以找到與之對應的工作線程正在運行的goroutine,又可以找到工作線程的局部運行隊列等資源。下面是g、p、m和schedt之間的關系圖:

 

 

上圖中圓形圖案代表g結構體的實例對象,三角形代表m結構體的實例對象,正方形代表p結構體的實例對象,其中紅色的g表示m對應的工作線程正在運行的goroutine,而灰色的g表示處於運行隊列之中正在等待被調度起來運行的goroutine。

從上圖可以看出,每個m都綁定了一個p,每個p都有一個私有的本地goroutine隊列,m對應的線程從本地和全局goroutine隊列中獲取goroutine並運行之。

 

前面我們說每個工作線程都有一個m結構體對象與之對應,但並未詳細說明它們之間是如何對應起來的,工作線程執行的代碼是如何找到屬於自己的那個m結構體實例對象的呢?

 

如果只有一個工作線程,那么就只會有一個m結構體對象,問題就很簡單,定義一個全局的m結構體變量就行了。可是我們有多個工作線程和多個m需要一一對應,怎么辦呢?還記得第一章我們討論過的線程本地存儲嗎?當時我們說過,線程本地存儲其實就是線程私有的全局變量,這不正是我們所需要的嗎?!只要每個工作線程擁有了各自私有的m結構體全局變量,我們就能在不同的工作線程中使用相同的全局變量名來訪問不同的m結構體對象,這完美的解決我們的問題。

 

具體到goroutine調度器代碼,每個工作線程在剛剛被創建出來進入調度循環之前就利用線程本地存儲機制為該工作線程實現了一個指向m結構體實例對象的私有全局變量,這樣在之后的代碼中就使用該全局變量來訪問自己的m結構體對象以及與m相關聯的p和g對象。

有了上述數據結構以及工作線程與數據結構之間的映射機制,我們可以把前面的調度偽代碼寫得更豐滿一點:

 

// 程序啟動時的初始化代碼
......
for i = 0; i < N; i++ { // 創建N個操作系統線程執行schedule函數
     create_os_thread(schedule) // 創建一個操作系統線程執行schedule函數
}


// 定義一個線程私有全局變量,注意它是一個指向m結構體對象的指針
// ThreadLocal用來定義線程私有全局變量
ThreadLocal self *m
//schedule函數實現調度邏輯
schedule() {
    // 創建和初始化m結構體對象,並賦值給私有全局變量self
    self = initm()   
    for { //調度循環
          if(self.p.runqueue is empty) {
                 // 根據某種算法從全局運行隊列中找出一個需要運行的goroutine
                 g = find_a_runnable_goroutine_from_global_runqueue()
          } else {
                 // 根據某種算法從私有的局部運行隊列中找出一個需要運行的goroutine
                 g = find_a_runnable_goroutine_from_local_runqueue()
          }
          run_g(g) // CPU運行該goroutine,直到需要調度其它goroutine才返回
          save_status_of_g(g) // 保存goroutine的狀態,主要是寄存器的值
     }
} 

 

 

 

僅僅從上面這個偽代碼來看,我們完全不需要線程私有全局變量,只需在schedule函數中定義一個局部變量就行了。但真實的調度代碼錯綜復雜,不光是這個schedule函數會需要訪問m,其它很多地方還需要訪問它,所以需要使用全局變量來方便其它地方對m的以及與m相關的g和p的訪問。

 

在簡單的介紹了Go語言調度器以及它所需要的數據結構之后,下面我們來看一下Go的調度代碼中對上述的幾個結構體的定義。

 

重要的結構體

下面介紹的這些結構體中的字段非常多,牽涉到的細節也很龐雜,光是看這些結構體的定義我們沒有必要也無法真正理解它們的用途,所以在這里我們只需要大概了解一下就行了,看不懂記不住都沒有關系,隨着后面對代碼逐步深入的分析,我們也必將會對這些結構體有越來越清晰的認識。為了節省篇幅,下面各結構體的定義略去了跟調度器無關的成員。另外,這些結構體的定義全部位於Go語言的源代碼路徑下的runtime/runtime2.go文件之中。

 

stack結構體

stack結構體主要用來記錄goroutine所使用的棧的信息,包括棧頂和棧底位置:

 

// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
//用於記錄goroutine使用的棧的起始和結束位置
type stack struct{  
    lo uintptr   // 棧頂,指向內存低地址
    hi uintptr   // 棧底,指向內存高地址
}

 

gobuf結構體

gobuf結構體用於保存goroutine的調度信息,主要包括CPU的幾個寄存器的值:

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  // 保存CPU的rsp寄存器的值
    pc  uintptr  // 保存CPU的rip寄存器的值
    g   guintptr // 記錄當前這個gobuf對象屬於哪個goroutine
    ctxt unsafe.Pointer
 
   // 保存系統調用的返回值,因為從系統調用返回之后如果p被其它工作線程搶占,
   // 則這個goroutine會被放入全局運行隊列被其它工作線程調度,其它線程需要知道系統調用的返回值。
    ret sys.Uintreg 
    lr  uintptr
 
    // 保存CPU的rip寄存器的值
    bp  uintptr// for GOEXPERIMENT=framepointer
}

 

g結構體

g結構體用於代表一個goroutine,該結構體保存了goroutine的所有信息,包括棧,gobuf結構體和其它的一些狀態信息:

// 前文所說的g結構體,它代表了一個goroutine
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).
 
   // 記錄該goroutine使用的棧
    stack      stack  // offset known to runtime/cgo
    // 下面兩個成員用於棧溢出檢查,實現棧的自動伸縮,搶占調度也會用到stackguard0
    stackguard0 uintptr// offset known to liblink
    stackguard1 uintptr// offset known to liblink

    ......
 
    // 此goroutine正在被哪個工作線程執行
    m             *m // current m; offset known to arm liblink
    // 保存調度信息,主要是幾個寄存器的值
    sched         gobuf
 
    ......
    // schedlink字段指向全局運行隊列中的下一個g,
    //所有位於全局運行隊列中的g形成一個鏈表
    schedlink     guintptr

    ......
    // 搶占調度標志,如果需要搶占調度,設置preempt為true
    preempt  bool // preemption signal, duplicates stackguard0 = stackpreempt

   ......
}

 

m結構體

m結構體用來代表工作線程,它保存了m自身使用的棧信息,當前正在運行的goroutine以及與m綁定的p等信息,詳見下面定義中的注釋:

 

type m struct{
    // g0主要用來記錄工作線程使用的棧信息,在執行調度代碼時需要使用這個棧
    // 執行用戶goroutine代碼時,使用用戶goroutine自己的棧,調度時會發生棧的切換
    g0     *g    // goroutine with scheduling stack

    // 通過TLS實現m結構體對象與工作線程之間的綁定
    tls      [6]uintptr  // thread-local storage (for x86 extern register)
    mstartfn func()
    // 指向工作線程正在運行的goroutine的g結構體對象
    curg     *g      // current running goroutine
 
    // 記錄與當前工作線程綁定的p結構體對象
    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
   
    // spinning狀態:表示當前工作線程正在試圖從其它工作線程的本地運行隊列偷取goroutine
    spinning  bool// m is out of work and is actively looking for work
    blocked   bool// m is blocked on a note
   
    // 沒有goroutine需要運行時,工作線程睡眠在這個park成員上,
    // 其它線程通過這個park喚醒該工作線程
    park         note
    // 記錄所有工作線程的一個鏈表
    alllink      *m// on allm
    schedlink    muintptr

    // Linux平台thread的值就是操作系統線程ID
    thread       uintptr// thread handle
    freelink     *m     // on sched.freem

    ......
}

  

p結構體

p結構體用於保存工作線程執行go代碼時所必需的資源,比如goroutine的運行隊列,內存分配用到的緩存等等。

 

type p struct {
    lock mutex

    status      uint32// one of pidle/prunning/...
    link        puintptr
    schedtick   uint32    // incremented on every scheduler call
    syscalltick uint32    // incremented on every system call
    sysmontick  sysmontick// last tick observed by sysmon
    m           muintptr  // back-link to associated m (nil if idle)

    ......

    // Queue of runnable goroutines. Accessed without lock.
    //本地goroutine運行隊列
    runqhead uint32 // 隊列頭
    runqtail uint32    // 隊列尾
    runq    [256]guintptr //使用數組實現的循環隊列
    // runnext, if non-nil, is a runnable G that was ready'd by
    // the current G and should be run next instead of what's in
    // runq if there's time remaining in the running G's time
    // slice. It will inherit the time left in the current time
    // slice. If a set of goroutines is locked in a
    // communicate-and-wait pattern, this schedules that set as a
    // unit and eliminates the (potentially large) scheduling
    // latency that otherwise arises from adding the ready'd
    // goroutines to the end of the run queue.
    runnextg uintptr

    // Available G's (status == Gdead)
    gFree struct{
        gList
        nint32
    }

    ......
}

 

schedt結構體

schedt結構體用來保存調度器的狀態信息和goroutine的全局運行隊列:

 

type schedt struct {
    // accessed atomically. keep at top to ensure alignment on 32-bit systems.
    goidgen   uint64
    lastpoll  uint64

    lock mutex

    // When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
    // sure to call checkdead().

   // 由空閑的工作線程組成鏈表
    midle       muintptr // idle m's waiting for work
   // 空閑的工作線程的數量
    nmidle        int32 // number of idle m's waiting for work
    nmidlelocked  int32 // number of locked m's waiting for work
    mnext         int64 // number of m's that have been created and next M ID
   // 最多只能創建maxmcount個工作線程
    maxmcount    int32 // maximum number of m's allowed (or die)
    nmsys        int32 // number of system m's not counted for deadlock
    nmfreed      int64 // cumulative number of freed m's

    ngsys        uint32 // number of system goroutines; updated atomically

    // 由空閑的p結構體對象組成的鏈表
    pidle     puintptr // idle p's
   // 空閑的p結構體對象的數量
    npidle     uint32
    nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.

    // Global runnable queue.
   // goroutine全局運行隊列
    runq       gQueue
    runqsize   int32

    ......

    // Global cache of dead G's.
   // gFree是所有已經退出的goroutine對應的g結構體對象組成的鏈表
   // 用於緩存g結構體對象,避免每次創建goroutine時都重新分配內存
    gFree struct{
        lock        mutex
        stack       gList // Gs with stacks
        noStack     gList // Gs without stacks
        n           int32
    }
 
    ......
}

 

重要的全局變量

allgs    []*g   // 保存所有的g
allm     *m     // 所有的m構成的一個鏈表,包括下面的m0
allp     []*p  // 保存所有的p,len(allp) == gomaxprocs

ncpu         int32  // 系統中cpu核的數量,程序啟動時由runtime代碼初始化
gomaxprocs   int32  // p的最大值,默認等於ncpu,但可以通過GOMAXPROCS修改

sched     schedt    // 調度器結構體對象,記錄了調度器的工作狀態

m0 m        // 代表進程的主線程
g0  g       // m0的g0,也就是m0.g0 = &g0

 

在程序初始化時,這些全變量都會被初始化為0值,指針會被初始化為nil指針,切片初始化為nil切片,int被初始化為數字0,結構體的所有成員變量按其本類型初始化為其類型的0值。所以程序剛啟動時allgs,allm和allp都不包含任何g,m和p。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM