Go 程序的生前死後

作者: 絕了

https://juejin.cn/post/7202149834362585145

最近想寫一篇 GC 的文章,寫着寫着發現沒有 GMP 調度模型和 Memory Model 的知識,gc 就很難寫下去,然後就又去看 GMP 模型,然後發現 GMP 的起點還是得看 g0、m0 這幾個關鍵數據,這幾個數據又是在程序啓動時初始化的,然後發現 GC 和 Memory Model 其實在初始化時也有很多操作,所以先嚐試把 go 程序啓動到銷燬的過程講清楚,再去一步一步的去看 runtime 相關的內容

開發者的視角

從使用者視角來看,go 程序對我們可見的就是這幾部分。

  1. 全局常量

  2. 全局變量

  3. init 函數

  4. main.main 函數

先明確下這幾個步驟的分別執行的次數。

  1. 全局常量,同一個 package 中可以有多個全局常量,同一個 go 文件中也可以有多個全局常量。

  2. 全局變量,同一個 package 中可以有多個全局變量,同一個 go 文件中也可以有多個全局變量。

  3. init 函數,同一個 package 中可以有多個 init 函數,同一個 go 文件中也可以有多個 init 函數(也是比較特殊的,常規的函數是不可以重名的,但是 init 函數可以有多個)。

  4. main.main 函數,main 包中,僅可以定義一個 main 函數,其他包也可以定義 main 函數,但是不可以調用,執行完 main 函數,程序也就退出了。

明確好執行次數後,先說執行順序的結論

  1. 整體上的順序是全局常量 -> 全局變量 ->init 函數 ->main 函數。

  2. 如果有多個 package,整體的 import 順序會從左到右、從上到下構成一棵樹狀結構,越靠左 / 上,代表 import 的順序越靠前,對於不同層的 package,優先執行更深的,對於同層的 package,優先執行更左的,也是多叉樹後序 遍歷的順序。

  3. 如果同一個 package 中有多個 go 文件,文件中的執行順序是根據文件名排序,越小的字典序文件,越先被執行。有一個特殊的點是,在執行 package 級時,會先執行完 package 裏每一個文件的全局常量和全局變量,執行完成後,再去執行 init 函數。

  4. 如果一個 go 文件中有多個 init 函數,會根據 init 的定義順序執行,先定義的先執行。

拿一個實際例子看一下,有如下的文件結構,每個文件中都有 init 函數、全局變量和全局常量

.
├── aa
│   └── a1.go
├── bb
│   └── b1.go
├── cc
│   ├── c1.go
│   └── c2.go
├── dd
│   └── d1.go
├── go.mod
└── main.go

其中 import 順序如下

a1.go import cc
b1.go import dd
main.go import aa bb

構成的 import package 樹是這個結構

  main
 |   |
aa   bb
 |   |
cc   dd

那麼最終的執行順序就是

1. c1 全局常量
2. c1 全局變量
3. c2 全局常量
4. c2 全局變量
5. c1 init
6. c2 init
7. a1 全局常量
8. a1 全局變量
9. a1 init
10.d1 全局常量
11.d1 全局變量
12.d1 init
13.b1 全局常量
14.b1 全局變量
15.b1 init
16.main 全局常量
17.main 全局變量
18.main init

整體的順序也就是下圖表示的,其中 file 根據文件名大小執行,package 根據後續遍歷順序執行。

不過我們在編程時千萬不要依賴各個 init 間執行順序的,首先 init 的順序 go 官方是不做保證的,其次依賴 init 的順序會讓我們的程序邏輯變得很複雜,還需要注意文件命名大小啥的,完全得不償失,建議的方案是各個 init 間不要依賴,如果有依賴關係,那就不要用 init 函數,比如自定義 INIT 函數,然後自己在程序中指定各個 package 的 INIT 順序。

甚至就實際的工程實踐而言,我們在工程上是嚴格避免使用 init 函數的,隱式的初始化會帶來各種隱藏的問題,如果需要初始化,那就顯示的去調用,顯示的調用會讓執行順序更客觀而且避免隱式調用帶來的隱藏問題。

runtime 的視角

以下代碼基於 go 1.19,linux amd64 環境

入口

上邊我們介紹了開發者的視角下程序的初始化,在 runtime 視角下,就有一些更細緻的原因,首先我們先 gdb 打個斷點看一下 go 程序的入口在哪裏,然後直接去看 runtime 的源碼即可。實例程序很簡單,就一個空的 main 函數。

//m1.go
package main

func main() {

}

編譯,使用 gdb 調試

go build -o m1 m1.go

gdb m1

info files

可以看到程序的入口在0x453860, 我們再看下對應的是哪個文件。

b *0x453860

這樣我們就找到了 go 程序的入口rt0_linux_amd64.s,這是在 linux 系統下程序的入口,其實 go 程序在不同系統和 cpu 架構下,會有不同的入口,比如 mac os 的程序入口就分別有:rt0_darwin_amd64.srt0_darwin_arm64.s

入口文件很簡單,直接執行了 JMP 指令到_rt0_amd64

//rt0_darwin_amd64.s

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

#include "textflag.h"

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
   JMP    _rt0_amd64(SB)

TEXT _rt0_amd64_linux_lib(SB),NOSPLIT,$0
   JMP    _rt0_amd64_lib(SB)

g0 初始化

_rt0_amd64的邏輯也很簡單,初始化了argcargv(也就是命令行傳參)之後就去調用runtime·rt0_go,也就是 go 程序的根 goroutine:m0 的 g0。

TEXT _rt0_amd64(SB),NOSPLIT,$-8
   MOVQ   0(SP), DI  // argc,argument count,傳遞給main函數的參數個數,也就是我們在命令行輸入的
   LEAQ   8(SP), SI  // argv,argument vector,傳遞給main函數的參數數組,字符串類型
   JMP    runtime·rt0_go(SB)

我們繼續看runtime.rt0_go的實現,rt0_go是對 g0 的初始化,在 golang 的 gmp 模型中,每一個M都會有一個g0用於執行 runtime 相關的代碼,其中有一個比較特殊的m0(即 go 進程的主線程,每個 go 進程僅一個m0),而下邊這段彙編代碼就是初始化m0g0棧,其他g0棧會通過runtime.newm執行。

TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0

   // copy arguments forward on an even stack
   // 將參數向前複製到偶數棧上
   MOVQ   DI, AX    // argc
   MOVQ   SI, BX    // argv
   SUBQ   $(5*8), SP    // 3args 2auto
   ANDQ   $~15, SP //減小SP,使寄存器按16字節對齊,
   // CPU 有一組 SSE 指令,這些指令中出現的內存地址必須是 16 的倍數。
   MOVQ   AX, 24(SP)
   MOVQ   BX, 32(SP)

   // create istack out of the given (operating system) stack.
   // _cgo_init may update stackguard.
   // 從操作系統線程棧內存中分配g0的棧,分配64KB + 104B
   MOVQ   $runtime·g0(SB), DI
   LEAQ   (-64*1024+104)(SP), BX
   MOVQ   BX, g_stackguard0(DI)
   MOVQ   BX, g_stackguard1(DI)
   MOVQ   BX, (g_stack+stack_lo)(DI)
   MOVQ   SP, (g_stack+stack_hi)(DI)

   // find out information about the processor we're on
   // 確定處理器的信息,CPUID命令會將cpu信息放到AX寄存器
   MOVL   $0, AX
   CPUID 
   CMPL   AX, $0
   JE nocpuinfo

   ...(各種操作系統和cpu型號的處理)...

tls 初始化

初始化堆棧和 cpu 型號後,接下來就是線程本地存儲(thread local storage,tls)的設置

TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0

   ...(g0的初始化代碼)...

#ifndef GOOS_windows
   JMP ok
#endif

// 在windows上會跳過tls的初始化
   ... (其他操作系統跳過tls初始化的代碼)...

   //初始化m0的tls
   LEAQ   runtime·m0+m_tls(SB), DI
   CALL   runtime·settls(SB)

   // store through it, to make sure it works
   // 通過tls進行一次存儲,保證tls是可以工作的,
   // 對0x123的存儲和讀取,如果和預期不一致的話,則使用INT進行中斷
   get_tls(BX)
   MOVQ   $0x123, g(BX)
   MOVQ   runtime·m0+m_tls(SB), AX
   CMPQ   AX, $0x123
   JEQ 2(PC)
   CALL   runtime·abort(SB)
ok:
   // set the per-goroutine and per-mach "registers"
   // 將g0保存到m0的tls中
   // 將m0保存到AX中
   get_tls(BX) //等價於        MOVQ  TLS, BX
   LEAQ   runtime·g0(SB), CX
   MOVQ   CX, g(BX)
   LEAQ   runtime·m0(SB), AX

   // save m->g0 = g0
   MOVQ   CX, m_g0(AX)
   // save m0 to g0->m
   MOVQ   AX, g_m(CX)

   CLD             // convention is D is always left cleared

   // Check GOAMD64 reqirements
   // We need to do this after setting up TLS, so that
   // we can report an error if there is a failure. See issue 49586.
   
   
   CALL    runtime·check(SB)
    
   ...(後續的code)...

調度流程

堆棧和 tls 初始化完成後,就開始調度器的初始化和循環調度了。

TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
   ...(前置的code)...
    MOVL   24(SP), AX    // copy argc
    MOVL   AX, 0(SP)
    MOVQ   32(SP), AX    // copy argv
    MOVQ   AX, 8(SP)
    CALL   runtime·args(SB)      //參數初始化
    CALL   runtime·osinit(SB)    //系統核心數初始化
    CALL   runtime·schedinit(SB) //調度器初始化
    
    // create a new goroutine to start program
    // 創建 main goroutine,用於執行runtime.main函數
    MOVQ   $runtime·mainPC(SB), AX       // entry
    PUSHQ  AX  //將mainPC壓棧,也就是runtime.newproc的第一個參數就是runtime.main
    CALL   runtime·newproc(SB) // 創建main goroutine,也就是跑我們runtime.main函數的goroutine
    POPQ   AX
    
    // start this M
    // 開始調度循環,此時只有剛纔創建的main gorountine,開始執行
    CALL   runtime·mstart(SB)
    
    //理論上runtime.mstart不會退出,作爲兜底,如果mstart返回了,直接退出進程
    CALL   runtime·abort(SB)  // mstart should never return
    RET
    
mainPC的定義
// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA   runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL  runtime·mainPC(SB),RODATA,$8

//new proc的定義,第一個參數就是新創建的goroutine要執行的函數funvalue
func newproc(fn *funcval) {
   gp := getg()
   pc := getcallerpc()
   systemstack(func() {
      newg := newproc1(fn, gp, pc)

      _p_ := getg().m.p.ptr()
      runqput(_p_, newg, true)

      if mainStarted {
         wakep()
      }
   })
}

到此 runtime 的彙編代碼初始化部分也就結束了,沒有展開說的是runtime.argsruntime.osinitruntime.schedinit我們繼續深入去看一下runtime.args,後續的 bootstrap 我們放到下一章去看。

命令行參數

命令行參數的處理是通過runtime.args執行。

var (
   argc int32
   argv **byte
)

func args(c int32, v **byte) {
//argc和argc是全局變量,在這裏去做賦值操作
   argc = c
   argv = v
   sysargs(c, v)
}

//go:linkname executablePath os.executablePath
var executablePath string
//初始化executablePath,
//executablePath就是執行當前命令的完整字符串,"./mian -n 10"就是一個例子
func sysargs(argc int32, argv **byte) {
   // skip over argv, envv and the first string will be the path
   n := argc + 1
   // 
   for argv_index(argv, n) != nil {
      n++
   }
   executablePath = gostringnocopy(argv_index(argv, n+1))

   // strip "executable_path=" prefix if available, it's added after OS X 10.11.
   const prefix = "executable_path="
   if len(executablePath) > len(prefix) && executablePath[:len(prefix)] == prefix {
      executablePath = executablePath[len(prefix):]
   }
}

bootstrap

go 程序的命令行參數初始化完成後,後續執行的順序分別是:

  1. 調用osinit,初始化 cpu 核心數和內存頁大小。

  2. 調用schedinit,初始化調度器,準備調度。

  3. make & queue new G,創建一個新的 G(也就是 main goroutine),調用runtime.main函數,並加入調度隊列。

  4. 調用mstart,開始調度循環,調度循環函數mstart永遠不會返回。

核心數

cpu 核心數的處理是通過runtime.osinit執行的。

func osinit() {
   ncpu = getproccount() //獲取cpu的核心數
   physHugePageSize = getHugePageSize() 
   //獲取操作系統重一個物理大頁的大小,是2的冪次,
   //在虛擬內存的管理場景下,使用大頁,可以使得大大減少虛擬地址映射表的加載量
   //減少內核需要加載的頁表數量,提高性能
   
   //架構初始化
   osArchInit()
}

//在源碼中沒有看到具體的實現,由編譯器注入
func osArchInit() {}

調度器初始化

sched 的初始化是調度器的初始化,也是 golang 調度器中最重要的一部分。

func schedinit() {
  // 省略lock的過程

   // raceinit must be the first call to race detector.
   // In particular, it must be done before mallocinit below calls racemapshadow.
   // 獲取m0的g0
   _g_ := getg()
   if raceenabled {
      _g_.racectx, raceprocctx0 = raceinit()
   }

   //最多啓動1萬個工作線程
   sched.maxmcount = 10000

   // The world starts stopped.
   // stw,停止gc工作
   worldStopped()

   moduledataverify()
   
   //初始化棧空間和棧內存分配
   stackinit()
   mallocinit()
   
   //獲取GODEBUG環境變量,進行cpu參數初始化
   cpuinit()      // must run before alginit
   //判斷是否可以使用aes的hash,並進行初始化,map、rand等會依賴
   alginit()      // maps, hash, fastrand must not be used before this call
   fastrandinit() // must run before mcommoninit
   
   //初始化m0
   mcommoninit(_g_.m, -1)
   //執行go module的初始化
   modulesinit()   // provides activeModules
   //內置map的依賴
   typelinksinit() // uses maps, activeModules
   //也是go module相關的邏輯
   itabsinit()     // uses activeModules
   stkobjinit()    // must run before GC starts

   sigsave(&_g_.m.sigmask)
   initSigmask = _g_.m.sigmask

   if offset := unsafe.Offsetof(sched.timeToRun); offset%8 != 0 {
      println(offset)
      throw("sched.timeToRun not aligned to 8 bytes")
   }

   // 根據argc和argv,將命令行參數拷貝到一個切片中
   goargs()
   //
   goenvs()
   parsedebugvars()
   //gc的初始化
   gcinit()

   lock(&sched.lock)
   sched.lastpoll = uint64(nanotime())
   procs := ncpu
   if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
      procs = n
   }
   if procresize(procs) != nil {
      throw("unknown runnable goroutine during bootstrap")
   }
   unlock(&sched.lock)

   // World is effectively started now, as P's can run.
   // stw結束,調度器可以繼續執行調度任務
   worldStarted()

   // For cgocheck > 1, we turn on the write barrier at all times
   // and check all pointer writes. We can't do this until after
   // procresize because the write barrier needs a P.
   // cgo和寫屏障相關的邏輯
   if debug.cgocheck > 1 {
      writeBarrier.cgo = true
      writeBarrier.enabled = true
      for _, p := range allp {
         p.wbBuf.reset()
      }
   }

   if buildVersion == "" {
      // Condition should never trigger. This code just serves
      // to ensure runtime·buildVersion is kept in the resulting binary.
      buildVersion = "unknown"
   }
   if len(modinfo) == 1 {
      // Condition should never trigger. This code just serves
      // to ensure runtime·modinfo is kept in the resulting binary.
      modinfo = ""
   }
}

sysmon

第一步就是啓動 sysmonsystem monitor,系統監控)線程,是系統級的 daemon 線程,在 wasm 架構下由於沒有線程,所以不會啓動 sysmon,sysmon 函數的運行也是比較特殊的,沒有綁定的 P,直接運行在系統棧上,不會由 GMP 調度模型進行調度,所以通過 go 的 trace 工具也跟蹤不到這個線程,sysmon 的主要功能如下:

  1. 搶佔式調度(在 go 1.13 加入),長時間運行(超過 10ms)的 goroutine 會被搶佔調度。

  2. 強制 GC,如果 GC 長時間(2min)沒有運營,則 sysmon 會強制開啓一輪 GC。

  3. NetPooler 監控,在 G 由於網絡調用阻塞時,會把 G 放入網絡輪詢器(NetPooler)中,由網絡輪詢器處理異步網絡調用,從而使得 P 可以繼續調度,待 NetPooler 處理完異步網絡調用後,再由 sysmon 監控線程將 G 切換回來繼續調度。

  4. 切換 P 系統調用,如果 P 因爲系統調用導致阻塞(_Psyscall),則會讓 P 切換出去,重新找 G 和 M 進行執行(防止 P 都阻塞在系統調用上,導致大量的 G 得不到調度)。

與此同時,sysmon 的調度週期並不是固定的,是動態的,取決於進程的活動情況。

(。。。)
    // Allow newproc to start new Ms.
    mainStarted = true
    
    if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
       systemstack(func() {
          newm(sysmon, nil, -1)
       })
    }
(。。。)

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
   lock(&sched.lock)
   sched.nmsys++
   checkdead()
   unlock(&sched.lock)

   lasttrace := int64(0)
   idle := 0 // how many cycles in succession we had not wokeup somebody
   delay := uint32(0)

   for {
   //動態的輪詢週期
      if idle == 0 { // start with 20us sleep...
         delay = 20
      } else if idle > 50 { // start doubling the sleep after 1ms...
         delay *= 2
      }
      if delay > 10*1000 { // up to 10ms
         delay = 10 * 1000
      }
      usleep(delay)

      // sysmon should not enter deep sleep if schedtrace is enabled so that
      // it can print that information at the right time.
      //
      // It should also not enter deep sleep if there are any active P's so
      // that it can retake P's from syscalls, preempt long running G's, and
      // poll the network if all P's are busy for long stretches.
      //
      // It should wakeup from deep sleep if any P's become active either due
      // to exiting a syscall or waking up due to a timer expiring so that it
      // can resume performing those duties. If it wakes from a syscall it
      // resets idle and delay as a bet that since it had retaken a P from a
      // syscall before, it may need to do it again shortly after the
      // application starts work again. It does not reset idle when waking
      // from a timer to avoid adding system load to applications that spend
      // most of their time sleeping.
      now := nanotime()
      if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {
         lock(&sched.lock)
         if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {
            syscallWake := false
            next := timeSleepUntil()
            if next > now {
               atomic.Store(&sched.sysmonwait, 1)
               unlock(&sched.lock)
               // Make wake-up period small enough
               // for the sampling to be correct.
               sleep := forcegcperiod / 2
               if next-now < sleep {
                  sleep = next - now
               }
               shouldRelax := sleep >= osRelaxMinNS
               if shouldRelax {
                  osRelax(true)
               }
               syscallWake = notetsleep(&sched.sysmonnote, sleep)
               if shouldRelax {
                  osRelax(false)
               }
               lock(&sched.lock)
               atomic.Store(&sched.sysmonwait, 0)
               noteclear(&sched.sysmonnote)
            }
            if syscallWake {
               idle = 0
               delay = 20
            }
         }
         unlock(&sched.lock)
      }

      lock(&sched.sysmonlock)
      // Update now in case we blocked on sysmonnote or spent a long time
      // blocked on schedlock or sysmonlock above.
      now = nanotime()

      // trigger libc interceptors if needed
      if *cgo_yield != nil {
         asmcgocall(*cgo_yield, nil)
      }
      // poll network if not polled for more than 10ms
      // 超過10ms未執行pool操作,則強制執行一次
      lastpoll := int64(atomic.Load64(&sched.lastpoll))
      if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
         atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
         list := netpoll(0) // non-blocking - returns list of goroutines
         if !list.empty() {
            // Need to decrement number of idle locked M's
            // (pretending that one more is running) before injectglist.
            // Otherwise it can lead to the following situation:
            // injectglist grabs all P's but before it starts M's to run the P's,
            // another M returns from syscall, finishes running its G,
            // observes that there is no work to do and no other running M's
            // and reports deadlock.
            incidlelocked(-1)
            injectglist(&list)
            incidlelocked(1)
         }
      }
      if GOOS == "netbsd" && needSysmonWorkaround {
         // netpoll is responsible for waiting for timer
         // expiration, so we typically don't have to worry
         // about starting an M to service timers. (Note that
         // sleep for timeSleepUntil above simply ensures sysmon
         // starts running again when that timer expiration may
         // cause Go code to run again).
         //
         // However, netbsd has a kernel bug that sometimes
         // misses netpollBreak wake-ups, which can lead to
         // unbounded delays servicing timers. If we detect this
         // overrun, then startm to get something to handle the
         // timer.
         //
         // See issue 42515 and
         // https://gnats.netbsd.org/cgi-bin/query-pr-single.pl?number=50094.
         if next := timeSleepUntil(); next < now {
            startm(nil, false)
         }
      }
      if scavenger.sysmonWake.Load() != 0 {
         // Kick the scavenger awake if someone requested it.
         scavenger.wake()
      }
      // retake P's blocked in syscalls
      // and preempt long running G's
      // 從系統調用中,將P切換出來
      if retake(now) != 0 {
         idle = 0
      } else {
         idle++
      }
      // check if we need to force a GC
      // 判斷是否需要強制執行GC
      if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
         lock(&forcegc.lock)
         forcegc.idle = 0
         var list gList
         list.push(forcegc.g)
         injectglist(&list)
         unlock(&forcegc.lock)
      }
      if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
         lasttrace = now
         schedtrace(debug.scheddetail > 0)
      }
      unlock(&sched.sysmonlock)
   }
}

runtime.main

調度器初始化 (schedinit) 完成後會創建main goroutine,執行runtime.main函數(注意區分package,這裏並不是我們寫的main函數,我們寫的main函數是main.main

// The main goroutine.
func main() {
   g := getg()

   // Racectx of m0->g0 is used only as the parent of the main goroutine.
   // It must not be used for anything else.
   g.m.g0.racectx = 0

   // Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
   // Using decimal instead of binary GB and MB because
   // they look nicer in the stack overflow failure message.
   if goarch.PtrSize == 8 {
      maxstacksize = 1000000000
   } else {
      maxstacksize = 250000000
   }

   // An upper limit for max stack size. Used to avoid random crashes
   // after calling SetMaxStack and trying to allocate a stack that is too big,
   // since stackalloc works with 32-bit sizes.
   maxstackceiling = 2 * maxstacksize

   // Allow newproc to start new Ms.
   mainStarted = true

   if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
      systemstack(func() {
         newm(sysmon, nil, -1)
      })
   }

   // Lock the main goroutine onto this, the main OS thread,
   // during initialization. Most programs won't care, but a few
   // do require certain calls to be made by the main thread.
   // Those can arrange for main.main to run in the main thread
   // by calling runtime.LockOSThread during initialization
   // to preserve the lock.
   lockOSThread()

   if g.m != &m0 {
      throw("runtime.main not on m0")
   }

   // Record when the world started.
   // Must be before doInit for tracing init.
   runtimeInitTime = nanotime()
   if runtimeInitTime == 0 {
      throw("nanotime returning zero")
   }

   if debug.inittrace != 0 {
      inittrace.id = getg().goid
      inittrace.active = true
   }

   doInit(&runtime_inittask) // Must be before defer.

   // Defer unlock so that runtime.Goexit during init does the unlock too.
   needUnlock := true
   defer func() {
      if needUnlock {
         unlockOSThread()
      }
   }()

   gcenable()

   main_init_done = make(chan bool)
   if iscgo {
      if _cgo_thread_start == nil {
         throw("_cgo_thread_start missing")
      }
      if GOOS != "windows" {
         if _cgo_setenv == nil {
            throw("_cgo_setenv missing")
         }
         if _cgo_unsetenv == nil {
            throw("_cgo_unsetenv missing")
         }
      }
      if _cgo_notify_runtime_init_done == nil {
         throw("_cgo_notify_runtime_init_done missing")
      }
      // Start the template thread in case we enter Go from
      // a C-created thread and need to create a new thread.
      startTemplateThread()
      cgocall(_cgo_notify_runtime_init_done, nil)
   }

   doInit(&main_inittask)

   // Disable init tracing after main init done to avoid overhead
   // of collecting statistics in malloc and newproc
   inittrace.active = false

   close(main_init_done)

   needUnlock = false
   unlockOSThread()

   if isarchive || islibrary {
      // A program compiled with -buildmode=c-archive or c-shared
      // has a main, but it is not executed.
      return
   }
   fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
   fn()
   if raceenabled {
      racefini()
   }

   // Make racy client program work: if panicking on
   // another goroutine at the same time as main returns,
   // let the other goroutine finish printing the panic trace.
   // Once it does, it will exit. See issues 3934 and 20018.
   if atomic.Load(&runningPanicDefers) != 0 {
      // Running deferred functions should not take long.
      for c := 0; c < 1000; c++ {
         if atomic.Load(&runningPanicDefers) == 0 {
            break
         }
         Gosched()
      }
   }
   if atomic.Load(&panicking) != 0 {
      gopark(nil, nil, waitReasonPanicWait, traceEvGoStop, 1)
   }

   exit(0)
   for {
      var x *int32
      *x = 0
   }
}

gcenable

runtime.main 執行的邏輯是gcenable,這裏就是啓動垃圾收集器(gc),函數內部會啓動 2 個 gouroutine,分別是bgsweep(後臺執行 gc 掃描工作)和bgscavenge(後臺執行 gc 的清理工作),這兩個 goroutine 永遠不會退出。

// gcenable is called after the bulk of the runtime initialization,
// just before we're about to start letting user code run.
// It kicks off the background sweeper goroutine, the background
// scavenger goroutine, and enables GC.
func gcenable() {
   // Kick off sweeping and scavenging.
   c := make(chan int, 2)
   go bgsweep(c)
   go bgscavenge(c)
   <-c
   <-c
   memstats.enablegc = true // now that runtime is initialized, GC is okay
}

doInit

doInit前,就會把init chan準備好,這個函數其實就是執行用戶自定義的各個packageinit函數,通過源碼,我們也可以看到init函數的執行順序,就是我們在上文描述的順序,同時也能看到 main.main 函數在所有的 init 執行完後纔開始執行

func doInit(t *initTask) {
   switch t.state {
   case 2: // fully initialized
      return
   case 1: // initialization in progress
      throw("recursive call during initialization - linker skew")
   default: // not initialized yet
      t.state = 1 // initialization in progress

      for i := uintptr(0); i < t.ndeps; i++ {
         p := add(unsafe.Pointer(t)(3+i)*goarch.PtrSize)
         t2 := *(**initTask)(p)
         doInit(t2)
      }

      if t.nfns == 0 {
         t.state = 2 // initialization done
         return
      }

      var (
         start  int64
         before tracestat
      )

      if inittrace.active {
         start = nanotime()
         // Load stats non-atomically since tracinit is updated only by this init goroutine.
         before = inittrace
      }

      firstFunc := add(unsafe.Pointer(t)(3+t.ndeps)*goarch.PtrSize)
      for i := uintptr(0); i < t.nfns; i++ {
         p := add(firstFunc, i*goarch.PtrSize)
         f := *(*func())(unsafe.Pointer(&p))
         f()
      }

      if inittrace.active {
         end := nanotime()
         // Load stats non-atomically since tracinit is updated only by this init goroutine.
         after := inittrace

         f := *(*func())(unsafe.Pointer(&firstFunc))
         pkg := funcpkgpath(findfunc(abi.FuncPCABIInternal(f)))

         var sbuf [24]byte
         print("init ", pkg, " @")
         print(string(fmtNSAsMS(sbuf[:], uint64(start-runtimeInitTime)))" ms, ")
         print(string(fmtNSAsMS(sbuf[:], uint64(end-start)))" ms clock, ")
         print(string(itoa(sbuf[:], after.bytes-before.bytes))" bytes, ")
         print(string(itoa(sbuf[:], after.allocs-before.allocs))" allocs")
         print("\n")
      }

      t.state = 2 // initialization done
   }
}

main_main

在 runtime.main 函數中,我們可以看到它在執行完init chan後就開始執行 main_main 函數。

...
    fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
    fn()
    if raceenabled {
       racefini()
    }
...

通過源碼,我們也可以看到main_main函數其實就是我們在 main 包寫的 main 函數,鏈接到了runtime.main_main

//go:linkname main_main main.main
func main_main()

exit

main_main執行完後,就會執行exit函數,退出碼爲 0,這個和 linux 的 exit 函數同理,內部也是調用了 libc 提供的exit_trampoline函數,退出進程。

// This is exported via linkname to assembly in runtime/cgo.
//
//go:nosplit
//go:cgo_unsafe_args
//go:linkname exit
func exit(code int32) {
   libcCall(unsafe.Pointer(abi.FuncPCABI0(exit_trampoline)), unsafe.Pointer(&code))
}
func exit_trampoline()

不過爲了防止 go 程序不能順利退出,最後還有一段經典的空指針來做兜底,直接異常中斷退出程序。

exit(0)
for {
   var x *int32
   *x = 0
}

mstart

runtime.mstart會開始調度器循環

// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()

mstart的實現與系統架構有關,用對應平臺的彙編語言實現,這裏我們就不深入看細節了,在彙編初始化完後,會繼續調用mstart0函數。mstart0 函數就是做了一些 m0 的 g0 棧初始化。

mstart0

// mstart0 is the Go entry-point for new Ms.
// This must not split the stack because we may not even have stack
// bounds set up yet.
//
// May run during STW (because it doesn't have a P yet), so write
// barriers are not allowed.
//
//go:nosplit
//go:nowritebarrierrec
func mstart0() {
   _g_ := getg()

   osStack := _g_.stack.lo == 0
   if osStack {
      // Initialize stack bounds from system stack.
      // Cgo may have left stack size in stack.hi.
      // minit may update the stack bounds.
      //
      // Note: these bounds may not be very accurate.
      // We set hi to &size, but there are things above
      // it. The 1024 is supposed to compensate this,
      // but is somewhat arbitrary.
      size := _g_.stack.hi
      if size == 0 {
         size = 8192 * sys.StackGuardMultiplier
      }
      _g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
      _g_.stack.lo = _g_.stack.hi - size + 1024
   }
   // Initialize stack guard so that we can start calling regular
   // Go code.
   _g_.stackguard0 = _g_.stack.lo + _StackGuard
   // This is the g0, so we can also call go:systemstack
   // functions, which check stackguard1.
   _g_.stackguard1 = _g_.stackguard0
   mstart1()

   // Exit this thread.
   if mStackIsSystemAllocated() {
      // Windows, Solaris, illumos, Darwin, AIX and Plan 9 always system-allocate
      // the stack, but put it in _g_.stack before mstart,
      // so the logic above hasn't set osStack yet.
      osStack = true
   }
   mexit(osStack)
}

mstart1

在初始化棧完成後,mstart0函數也是調用了mstart1函數(ps:runtime 的函數命名挺隨心的),mstart1 函數在綁定了調度器的一些變量到 g0 上後,繼續調用了真正的調度循環函數:schedule

// The go:noinline is to guarantee the getcallerpc/getcallersp below are safe,
// so that we can set up g0.sched to return to the call of mstart1 above.
//
//go:noinline
func mstart1() {
   _g_ := getg()

   if _g_ != _g_.m.g0 {
      throw("bad runtime·mstart")
   }

   // Set up m.g0.sched as a label returning to just
   // after the mstart1 call in mstart0 above, for use by goexit0 and mcall.
   // We're never coming back to mstart1 after we call schedule,
   // so other calls can reuse the current frame.
   // And goexit0 does a gogo that needs to return from mstart1
   // and let mstart0 exit the thread.
   _g_.sched.g = guintptr(unsafe.Pointer(_g_))
   _g_.sched.pc = getcallerpc()
   _g_.sched.sp = getcallersp()

   asminit()
   minit()

   // Install signal handlers; after minit so that minit can
   // prepare the thread to be able to handle the signals.
   if _g_.m == &m0 {
      mstartm0()
   }

   if fn := _g_.m.mstartfn; fn != nil {
      fn()
   }

   if _g_.m != &m0 {
      acquirep(_g_.m.nextp.ptr())
      _g_.m.nextp = 0
   }
   schedule()
}

schedule

我們繼續看 schedule 的實現,schedule 就是一輪調度,找到一個可以運行的 goroutine 並執行它。

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
    //獲取g0
   _g_ := getg()

   if _g_.m.locks != 0 {
      throw("schedule: holding locks")
   }

   if _g_.m.lockedg != 0 {
      stoplockedm()
      execute(_g_.m.lockedg.ptr(), false) // Never returns.
   }

   // We should not schedule away from a g that is executing a cgo call,
   // since the cgo call is using the m's g0 stack.
   if _g_.m.incgo {
      throw("schedule: in cgo")
   }

top:
   pp := _g_.m.p.ptr()
   pp.preempt = false

   // Safety check: if we are spinning, the run queue should be empty.
   // Check this before calling checkTimers, as that might call
   // goready to put a ready goroutine on the local run queue.
   if _g_.m.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
      throw("schedule: spinning with local work")
   }

    //找到一個可以運行的G
   gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

   // This thread is going to run a goroutine and is not spinning anymore,
   // so if it was marked as spinning we need to reset it now and potentially
   // start a new spinning M.
   if _g_.m.spinning {
      resetspinning()
   }

   if sched.disable.user && !schedEnabled(gp) {
      // Scheduling of this goroutine is disabled. Put it on
      // the list of pending runnable goroutines for when we
      // re-enable user scheduling and look again.
      lock(&sched.lock)
      if schedEnabled(gp) {
         // Something re-enabled scheduling while we
         // were acquiring the lock.
         unlock(&sched.lock)
      } else {
         sched.disable.runnable.pushBack(gp)
         sched.disable.n++
         unlock(&sched.lock)
         goto top
      }
   }

   // If about to schedule a not-normal goroutine (a GCworker or tracereader),
   // wake a P if there is one.
   if tryWakeP {
      wakep()
   }
   if gp.lockedm != 0 {
      // Hands off own p to the locked m,
      // then blocks waiting for a new p.
      startlockedm(gp)
      goto top
   }

   execute(gp, inheritTime)
}

總結

本文先是從開發者的視角介紹了全局變量、全局常量和 init 函數的執行順序,然後從 runtime 的視角,介紹了 linux_amd64 架構下的 go 程序的運行前後的全流程。

參考文檔

  1. go 1.19.2 源碼:https://github.com/golang/go/releases/tag/go1.19.2

  2. https://qcrao91.gitbook.io/go/goroutine-tiao-du-qi/miao-shu-scheduler-de-chu-shi-hua-guo-cheng

  3. https://golang.design/under-the-hood/zh-cn/part1basic/ch02life/boot/

  4. https://liupzmin.com/2022/04/26/theory/stack-insight-03/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/M1iyu_rZohuIKmv1hX7DMg