【一月一本技術書】-【Go語言設計與實現】- 9月

2022-09-20 06:04:35

Go : 2009.11.10
代表作:Docker、k8s、etcd
模仿C語言,目標:網際網路的C語言

講的晦澀難懂。。。。硬板。。放棄了好幾次才讀完。滿分10分,打6分。

下個月:Python資料結構與演演算法分析吧。需要演演算法刷題了。

四大:編譯原理、基礎知識、執行時、進階知識

編譯原理

編譯過程

抽象語法樹 Abstract Syntax Tree\ AST\ 是原始碼語法的結構的一種抽象表示。

用樹狀的方式表示程式語言的語法結構。每一個節點表示原始碼的一個元素。每一顆子樹表示一個語法元素。
2 * 3 + 7

抽象語法樹抹去了原始碼中不重要的一些字元:空格、分號、括號等

靜態單賦值Static Single Assignment\SSA 是中間程式碼的特性。

每個變數只會被賦值一次。 優化

x := 1  # 無效
x := 2  # 有效
y := x

x_1 := 1 # 無效,編譯後,沒有這個玩意了
x_2 := 2
y_1 := x_2

指令集

  • 複雜指令集 CISC: 通過增加指令的型別減少需要執行的指令數
  • 精簡指令集 RISC: 使用更少的指令型別完成目標計算任務

編譯原理

編譯器程式碼:src/cmd/compile目錄中
編譯器分為 前端和後端

  • 前端: 詞法分析、語法分析、型別檢查、中間程式碼生成
  • 後端: 目的碼的生成、優化;將中間程式碼翻譯成目標機器能夠執行的二進位制機器碼

四個階段:詞法和語法分析、型別檢查和AST轉換、通用SSA生成、機器程式碼生成

  • 詞法和語法分析
    解析原始碼檔案開始,詞法分析的作用就是解析原始碼檔案。將字串序列轉換成Token序列。方便後面的處理和解析。
    執行詞法分析的程式稱為 詞法解析器 lexer

語法分析的輸入是詞法分析器輸出的Token序列。根據程式語言定義好的文法 Grammar分析Token序列。
每一個go的原始碼檔案最終會被歸納成一個SourceFile結構。

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .

詞法分析器會返回一個不包含空格、換行等字元的Token序列。 package,json,import,(,io,)...
語法分析器會把Token序列轉換成有意義的結構體---語法樹,AST.

"json.go": SourceFile {
    PackageName: "json",
    ImportDecl: []Import{
        "io",
    },
    TopLevelDecl: ...
}

一個原始檔對應著一個AST. 包含:包名、定義的常數、結構體和函數。
GO使用的語法解析器是LALR(1)的文法。

語法解析的過程中發生的任何語法錯誤都會被語法解析器發現並列印到標準輸出上。

  • 型別檢查
    AST生成之後。編譯器會對語法樹中定義和使用的型別進行檢查。
  1. 常數、型別和函數名及型別
  2. 變數的賦值和初始化
  3. 函數和閉包的主體
  4. 雜湊鍵值對的型別
  5. 匯入函數體
  6. 外部的宣告
    遍歷整顆抽象語法樹,保證節點不存在型別錯誤,
  • 中間程式碼生成
    型別檢查之後就不存在語法錯誤了,編譯器就會將AST轉換成中間程式碼
    會使用gc.compileFunctions編譯整個Go語言專案中的全部函數。並行編譯

  • 機器碼生成
    不同型別的CPU分別使用不同的包生成機器碼,amd64、arm、arm64、mips、mips64、ppc64、s390x、x86、wasm.

Go語言的編譯器能夠生成Wasm WebAssembly 格式的指令,就可以執行在常見的主流瀏覽器中。

編譯器入口

src/cmd/complie/internal/gc/main.go。
抽象語法樹會經歷型別檢查、SSA 中間程式碼生成以及機器碼生成三個階段
檢查常數、型別和函數的型別;
處理變數的賦值;
對函數的主體進行型別檢查;
決定如何捕獲變數;
檢查行內函式的型別;
進行逃逸分析;
將閉包的主體轉換成參照的捕獲變數;
編譯頂層函數;
檢查外部依賴的宣告

詞法分析和語法分析

原始碼對於計算機來說是無法被理解的字串。
第一步:將字串分組。如下分為 make、 chan、 int 和 括號

make(chan int)

詞法分析是將字元序列轉換為標記(token)序列的過程。

  • lex
    lex是用於生成詞法分析器的工具。
    lex生成的程式碼能夠將一個檔案中的字元分解成Token序列。
    lex就是一個正則匹配的生成器。

lex檔案範例:

%{
#include <stdio.h>
%}

%%
package      printf("PACKAGE "); # 解析package
import       printf("IMPORT "); # 解析 import
\.           printf("DOT "); # 解析點
\{           printf("LBRACE "); 
\}           printf("RBRACE ");
\(           printf("LPAREN ");
\)           printf("RPAREN ");
\"           printf("QUOTE ");
\n           printf("\n");
[0-9]+       printf("NUMBER ");
[a-zA-Z_]+   printf("IDENT ");
%%

這個lex檔案就可以解析下面這段程式碼

package main

import (
	"fmt"
)

func main() {
	fmt.Println("Hello")
}

.l結尾的lex程式碼並不能直接執行,通過lex命令將上面的.l展開成C語音程式碼。

$ lex simplego.l
$ cat lex.yy.c
...
int yylex (void) {
	...
	while ( 1 ) {
		...
yy_match:
		do {
			register YY_CHAR yy_c = yy_ec[YY_SC_TO_UI(*yy_cp)];
			if ( yy_accept[yy_current_state] ) {
				(yy_last_accepting_state) = yy_current_state;
				(yy_last_accepting_cpos) = yy_cp;
			}
			while ( yy_chk[yy_base[yy_current_state] + yy_c] != yy_current_state ) {
				yy_current_state = (int) yy_def[yy_current_state];
				if ( yy_current_state >= 30 )
					yy_c = yy_meta[(unsigned int) yy_c];
				}
			yy_current_state = yy_nxt[yy_base[yy_current_state] + (unsigned int) yy_c];
			++yy_cp;
		} while ( yy_base[yy_current_state] != 37 );
		...

do_action:
		switch ( yy_act )
			case 0:
    			...

			case 1:
    			YY_RULE_SETUP
    			printf("PACKAGE ");
    			YY_BREAK
			...
}

lex.yy.c的前600行基本是宏和函數的宣告和定義。後面的程式碼大都是yylex這個函數服務的。
這個函數使用有限自動機 Deterministic Finite Automaton\DFA.的程式結構來分析輸入的字元流。
lex.yy.c編譯成二進位制可執行檔案,就是詞法分析器。
把GO語言程式碼作為輸入傳遞到詞法分析器中。會生成如下內容。

$ cc lex.yy.c -o simplego -ll
$ cat main.go | ./simplego

PACKAGE  IDENT

IMPORT  LPAREN
	QUOTE IDENT QUOTE
RPAREN

IDENT  IDENT LPAREN RPAREN  LBRACE
	IDENT DOT IDENT LPAREN QUOTE IDENT QUOTE RPAREN
RBRACE

lex生成的詞法分析器lexer通過正則匹配的方式將機器原本很難理解的字串分解成很多的Token. 有利於後面的處理。
從.l檔案到二進位制如下。

GO語言的詞法解析是通過scanner.go檔案中的syntax.scanner結構體實現的。

type scanner struct {
	source
	mode   uint
	nlsemi bool

	// current token, valid after calling next()
	line, col uint
	blank     bool // line is blank up to col
	tok       token
	lit       string   // valid if tok is _Name, _Literal, or _Semi ("semicolon", "newline", or "EOF"); may be malformed if bad is true
	bad       bool     // valid if tok is _Literal, true if a syntax error occurred, lit may be malformed
	kind      LitKind  // valid if tok is _Literal
	op        Operator // valid if tok is _Operator, _AssignOp, or _IncOp
	prec      int      // valid if tok is _Operator, _AssignOp, or _IncOp
}

tokens.go定義了go語言中支援的全部Token類。
例如操作符、括號和關鍵字等。

const (
	_    token = iota
	_EOF       // EOF

	// operators and operations
	_Operator // op
	...

	// delimiters
	_Lparen    // (
	_Lbrack    // [
	...

	// keywords
	_Break       // break
	...
	_Type        // type
	_Var         // var

	tokenCount //
)

語言中的元素分成幾個不同的型別,分別是名稱和字面量、操作符、分割符、關鍵字。

語法分析

根據某種特定的形式文法Grammar.對Token序列構成的輸入文字進行分析並確定其語法結構的過程。

  • 文法
    上下文無關文法 是用來形式化、精確描述某種程式語言的工具。
    通過文法定義一種語言的語法。包含一系列用於轉換字串的生產規則 Production Rule.
    上下文無關文法中的每一個生產規則 都會將 規則左側的非終結符 轉換成 右側的字串。

終結符是文法中無法再被展開的符號。比如: ‘id’、 123
文法都由以下四個部分組成

  1. N 有限個非終結符的集合。
    2)Σ 有限個終結符的集合
    3)P 有限個生產規則12的集合;
    4)S 非終結符集合中唯一的開始符號;
    文法被定義成一個四元組 (N,Σ,P,S)
    S→aSb
    S→ab
    S→ϵ
    上述規則構成的文法就能夠表示 ab、aabb 以及 aaa..bbb 等字串,程式語言的文法就是由這一系列的生產規則表示的
SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
PackageClause  = "package" PackageName .
PackageName    = identifier .

ImportDecl       = "import" ( ImportSpec | "(" { ImportSpec ";" } ")" ) .
ImportSpec       = [ "." | PackageName ] ImportPath .
ImportPath       = string_lit .

TopLevelDecl  = Declaration | FunctionDecl | MethodDecl .
Declaration   = ConstDecl | TypeDecl | VarDecl .

每個Go語言程式碼檔案最終都會被解析成一個獨立的抽象語法樹。所以語法樹最頂層的結構或者開始符號都是SourceFile:

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .

每一個檔案都包含一個package的定義 以及可選的 import。 和 其他的頂層宣告 TopLevelDecl。

每一個sourceFile在編譯器中都對應一個syntax.File結構體

type File struct {
	Pragma   Pragma
	PkgName  *Name
	DeclList []Decl
	Lines    uint
	node
}

頂層宣告有5大型別:分別是常數、型別、變數、函數和方法

  • 分析方法
    1)自定向下分析:
    2)自底向上分析

型別檢查

得到抽象語法樹之後開始型別檢查。
術語:強型別、弱型別、靜態型別、動態型別、編譯、解釋

  • 強型別定義:在編譯期間會有嚴格的型別限制。編譯器會在編譯期間發生變數複製、返回值和函數呼叫時的型別錯誤。

  • 弱型別定義:型別錯誤可能出現在執行時 進行隱式的型別轉換,
    java在編譯期間進行型別檢查的程式語言是強型別的
    GO語言一樣。
    型別的轉換是顯示的還是隱式的
    編譯器會幫助我們推斷型別變數嗎。

  • 靜態型別 檢查
    對原始碼的分析來確定 執行程式 型別安全的過程。能夠減少程式在執行時的型別檢查。可以看作是程式碼優化的方式
    靜態型別檢查能夠幫助我們在編譯期間發現程式中出現的型別錯誤。
    一些動態型別的程式語言都會為這些程式語言加入靜態型別檢查。 javascript的Flow.

  • 動態型別 檢查
    執行時確定型別安全的過程。
    只使用動態型別檢查的程式語言叫做動態型別程式設計於洋。 js ruby php.
    靜態和動態型別檢查不是完全衝突和對立的。

Java 不僅在編譯期間提前檢查型別發現型別錯誤,還為物件新增了型別資訊,在執行時使用反射根據物件的型別動態地執行方法增強靈活性並減少冗餘程式碼。

執行過程

GO編譯器 不僅使用靜態型別檢查來保證程式執行的型別安全,還會在程式設計期間引入型別資訊,能夠使用反射來判斷引數和變數的型別。
gc.Main函數

	for i := 0; i < len(xtop); i++ {
		n := xtop[i]
		if op := n.Op; op != ODCL && op != OAS && op != OAS2 && (op != ODCLTYPE || !n.Left.Name.Param.Alias) {
			xtop[i] = typecheck(n, ctxStmt)
		}
	}

	for i := 0; i < len(xtop); i++ {
		n := xtop[i]
		if op := n.Op; op == ODCL || op == OAS || op == OAS2 || op == ODCLTYPE && n.Left.Name.Param.Alias {
			xtop[i] = typecheck(n, ctxStmt)
		}
	}

	...

	checkMapKeys()

這段程式碼分為倆部分。
gc.typecheck()函數檢查常數、型別函數宣告以及變數賦值語句的型別。
gc.checkMapKeys()檢查雜湊中鍵的型別。
cmd/compile/internal/gc.typecheck1 根據傳入節點 Op 的型別進入不同的分支,其中包括加減乘數等操作符、函數呼叫、方法呼叫等 150 多種,因為節點的種類很多,所以這裡只節選幾個典型案例深入分析。

func typecheck1(n *Node, top int) (res *Node) {
	switch n.Op {
	case OTARRAY:
		...

	case OTMAP:
		...

	case OTCHAN:
		...
	}

	...

	return n
}
  • 切片 OTARRATY

如果當前節點的操作型別是OTARRAY.那麼這個分支首先會對右節點,進行型別檢查。

case OTARRAY:
		r := typecheck(n.Right, Etype)
		if r.Type == nil {
			n.Type = nil
			return n
		}

然後根據當前節點的左節點不容。分三種 [] int、 [...] int 、[3] int

第一種直接呼叫 types.NewSlice,直接返回了一個 TSLICE 型別的結構體.元素的型別資訊也會儲存在結構體總。

if n.Left == nil {
	t = types.NewSlice(r.Type)

第二種會呼叫gc.typecheckcomplit處理。

func typecheckcomplit(n *Node) (res *Node) {
	...
	if n.Right.Op == OTARRAY && n.Right.Left != nil && n.Right.Left.Op == ODDD {
		n.Right.Right = typecheck(n.Right.Right, ctxType)
		if n.Right.Right.Type == nil {
			n.Type = nil
			return n
		}
		elemType := n.Right.Right.Type

		length := typecheckarraylit(elemType, -1, n.List.Slice(), "array literal")

		n.Op = OARRAYLIT
		n.Type = types.NewArray(elemType, length)
		n.Right = nil
		return n
	}
	...
}

第三種。呼叫type.NewArray初始化一個儲存著陣列中元素型別和陣列大小的結構體。

} else {
			n.Left = indexlit(typecheck(n.Left, ctxExpr))
			l := n.Left
			v := l.Val()
			bound := v.U.(*Mpint).Int64()
			t = types.NewArray(r.Type, bound)		}

		n.Op = OTYPE
		n.Type = t
		n.Left = nil
		n.Right = nil
  • 雜湊 OTMAP
    如果處理的節點是雜湊,那麼編譯器會分別檢查雜湊的鍵值型別以驗證它們型別的合法性:
case OTMAP:
		n.Left = typecheck(n.Left, Etype)
		n.Right = typecheck(n.Right, Etype)
		l := n.Left
		r := n.Right
		n.Op = OTYPE
		n.Type = types.NewMap(l.Type, r.Type)
		mapqueue = append(mapqueue, n)
		n.Left = nil
		n.Right = nil

中間程式碼生成

經過詞法與語法分析和型別檢查倆個部分之後,AST已經不存在語法錯誤了。
編譯器的後端工作--中間程式碼生成。

中間程式碼

中間程式碼是編譯器或虛擬機器器使用的語言。可以來幫助我們分析計算機程式。
編譯器在將原始碼轉換到機器碼的過程中,先把原始碼換成一種中間的表示形式。 即中間程式碼。

很多編譯器需要將原始碼翻譯成多種機器碼,直接翻譯高階程式語言相對比較困難。拆成中間程式碼生成和機器碼生成。
中間程式碼是更接近機器語言的表示形式。
cmd/compile/internal/gc.funccompile 編譯函數

func Main(archInit func(*Arch)) {
	...

	initssaconfig()

	for i := 0; i < len(xtop); i++ {
		n := xtop[i]
		if n.Op == ODCLFUNC {
			funccompile(n)
		}
	}

	compileFunctions()
}

設定初始化和函數編譯倆部分。

設定初始化

SSA設定的初始化過程是中間程式碼生成之前的準備工作,會快取可能用到的型別指標、初始化SSA設定和一些之後會呼叫的執行時函數。

func initssaconfig() {
	types_ := ssa.NewTypes()

	_ = types.NewPtr(types.Types[TINTER])                             // *interface{}
	_ = types.NewPtr(types.NewPtr(types.Types[TSTRING]))              // **string
	_ = types.NewPtr(types.NewPtr(types.Idealstring))                 // **string
	_ = types.NewPtr(types.NewSlice(types.Types[TINTER]))             // *[]interface{}
	..
	_ = types.NewPtr(types.Errortype)                                 // *error

這個函數分為三部分
1)呼叫ssa.NewTypes()初始化ssa.Types結構體。並呼叫types.NewPtr函數快取型別的資訊。比如Bool Int8 String等。

types.NewPtr函數的主要作用是根據型別生成指向這些型別的指標。同時會根據編譯器的設定將 生成的指標型別快取在當前型別中。優化型別指標的獲取效率。

func NewPtr(elem *Type) *Type {
	if t := elem.Cache.ptr; t != nil {
		if t.Elem() != elem {
			Fatalf("NewPtr: elem mismatch")
		}
		return t
	}

	t := New(TPTR)
	t.Extra = Ptr{Elem: elem}
	t.Width = int64(Widthptr)
	t.Align = uint8(Widthptr)
	if NewPtrCacheEnabled {
		elem.Cache.ptr = t
	}
	return t
}
  1. 根據當前的CPU架構初始化SSA設定。
ssaConfig = ssa.NewConfig(thearch.LinkArch.Name, *types_, Ctxt, Debug['N'] == 0)

輸入引數:CPU架構、ssa.Types結構體、上下文資訊、Debug設定。
生成中間程式碼和機器碼的函數。當前編譯器使用的指標、暫存器大小、可用暫存器列表、掩碼等編譯選項

func NewConfig(arch string, types Types, ctxt *obj.Link, optimize bool) *Config {
	c := &Config{arch: arch, Types: types}
	c.useAvg = true
	c.useHmul = true
	switch arch {
	case "amd64":
		c.PtrSize = 8
		c.RegSize = 8
		c.lowerBlock = rewriteBlockAMD64
		c.lowerValue = rewriteValueAMD64
		c.registers = registersAMD64[:]
		...
	case "arm64":
	...
	case "wasm":
	default:
		ctxt.Diag("arch %s not implemented", arch)
	}
	c.ctxt = ctxt
	c.optimize = optimize

	...
	return c
}

設定一旦建立,整個編譯期間都是唯讀的。並且被全部編譯階段共用。
3)最後,會初始化 一些編譯器可能用到的Go語言執行時函數

	assertE2I = sysfunc("assertE2I")
	assertE2I2 = sysfunc("assertE2I2")
	assertI2I = sysfunc("assertI2I")
	assertI2I2 = sysfunc("assertI2I2")
	deferproc = sysfunc("deferproc")
	Deferreturn = sysfunc("deferreturn")
	...

遍歷和替換

在生成中間程式碼之前,編譯器還需要替換AST中節點的一些元素。go.walk等函數實現。

func walk(fn *Node)
func walkappend(n *Node, init *Nodes, dst *Node) *Node
...
func walkrange(n *Node) *Node
func walkselect(sel *Node)
func walkselectcases(cases *Nodes) []*Node
func walkstmt(n *Node) *Node
func walkstmtlist(s []*Node)
func walkswitch(sw *Node)

這些用於遍歷抽象語法樹的函數會將一些關鍵字和內建函數轉換成函數呼叫
例如: 上述函數會將 panic、recover 兩個內建函數轉換成 runtime.gopanic 和 runtime.gorecover 兩個真正執行時函數,而關鍵字 new 也會被轉換成呼叫 runtime.newobject 函數。

編譯器會將Go語言關鍵字轉換成執行時包中的函數,

SSA生成

經過walk函數處理之後,AST就不會再變了。會使用gc.compileSSA將抽象語法樹轉換成中間程式碼。

func compileSSA(fn *Node, worker int) {
	f := buildssa(fn, worker) # 負責生成具有SSA特色的中間程式碼
	pp := newProgs(fn, worker)
	genssa(f, pp)

	pp.Flush()
}

中間程式碼的生成過程是從 AST 抽象語法樹到 SSA 中間程式碼的轉換過程,在這期間會對語法樹中的關鍵字再進行改寫,改寫後的語法樹會經過多輪處理轉變成最後的 SSA 中間程式碼,相關程式碼中包括了大量 switch 語句、複雜的函數和呼叫棧

機器碼生成

編譯的最後一個階段是根據SSA中間程式碼生成機器碼,這裡的機器碼是在目標CPU架構上能夠執行的二進位制程式碼。
中間程式碼的降級Lower過程。在降級過程中,編譯器將一些值重寫成了目標CPU架構的特定值。

指令集架構

指令集架構是 計算機的抽象模型。它是計算機軟體和硬體之間的介面和橋樑。

每一個指令集架構都定義了 支援 的資料結構、暫存器、管理主記憶體的硬體支援(記憶體一致、地址模型、虛擬記憶體)、支援的指令集合IO模型。
讓同一個二進位制檔案能夠在不同版本的硬體上執行。

機器碼生成

倆部分協同工作
1)負責SSA中間程式碼降級和根據目標架構進行特定處理的ssa包
2)負責生成機器碼的obj.

  • SSA 降級
    SSA 降級是在中間程式碼生成的過程中完成的,其中將近 50 輪處理的過程中,lower 以及後面的階段都屬於 SSA 降級這一過程,這麼多輪的處理會將 SSA 轉換成機器特定的操作


和組合程式碼非常相似。

組合器 #
組合器是將組合語言翻譯為機器語言的程式,Go 語言的組合器是基於 Plan 9 組合器的輸入型別設計的,

資料結構

陣列

陣列和切片是Go語音中常見的資料結構
陣列是由相同型別元素的集合組成的資料結構。會為陣列分配一塊連續的記憶體來儲存其中的元素。
常見是一維的。多維的在數值和圖形領域

倆個維度來描述陣列,
1) 陣列中儲存的元素型別
2) 陣列最大能儲存的元素個數

[10] int 
[200] interface{}

Go語言陣列在初始化之後,大小就無法改變。儲存元素型別相同、大小一致才是同一型別的陣列

func NewArray(elem *Type, bound int64) *Type {
	if bound < 0 {
		Fatalf("NewArray: invalid bound %v", bound)
	}
	t := New(TARRAY)
	t.Extra = &Array{Elem: elem, Bound: bound}
	t.SetNotInHeap(elem.NotInHeap())
	return t
}

編譯期間的陣列型別是types.NewArray函數生成的。elem是元素型別,bound是陣列大小。
當前陣列是否應該在堆疊中初始化在編譯期間就確定了

初始化

倆種不同的建立方式

arr1 := [3] int{1,2,3} # 顯示指定陣列大小
arr2 = [...] int{1,2,3} # 宣告陣列,在編譯期推導陣列的大小

編譯器的推導過程

  • 上限推導
    倆種不同的宣告方式會做出不同的處理
    [10]T 這種。變數型別在進行到型別檢查就會被提取出來。隨後使用types.NewArray建立 包含陣列大小的types.Array結構體
    [...]T這種。會在gc.typecheckcomplit函數中對該陣列的大小進行推導。
func typecheckcomplit(n *Node) (res *Node) {
	...
	if n.Right.Op == OTARRAY && n.Right.Left != nil && n.Right.Left.Op == ODDD {
		n.Right.Right = typecheck(n.Right.Right, ctxType)
		if n.Right.Right.Type == nil {
			n.Type = nil
			return n
		}
		elemType := n.Right.Right.Type

		length := typecheckarraylit(elemType, -1, n.List.Slice(), "array literal")

		n.Op = OARRAYLIT
		n.Type = types.NewArray(elemType, length)
		n.Right = nil
		return n
	}
	...

	switch t.Etype {
	case TARRAY:
		typecheckarraylit(t.Elem(), t.NumElem(), n.List.Slice(), "array literal") # 遍歷計算
		n.Op = OARRAYLIT
		n.Right = nil
	}
}

呼叫typecheckarryalit通過遍歷元素的方式來計算陣列中元素的數量

  • 語句轉換
    由一個字面量組成的陣列,根據陣列元素數量的不同。編譯器會在負責初始化字面量的gc.anylit函數中做倆種不同的優化

1)當元素數量<= 4 ,會直接將陣列中的元素放置在棧上
2)>4 ,會將陣列中的元素放置到靜態區,並在執行時 取出

存取和賦值

無論是在棧上,還是靜態儲存區。 陣列在記憶體中都是一連串的記憶體空間。
指向陣列開頭的指標、元素的數量、元素型別佔的空間大小 三個 維度來表示一個陣列。
陣列存取越界是非常嚴重的錯誤,Go 語言中可以在編譯期間的靜態型別檢查判斷陣列越界。

陣列和字串的一些簡單越界錯誤都會在編譯期間發現。
比如:直接使用整數或者常數存取陣列,但是使用變數去存取陣列或字串時,就無法提前發現錯誤。
需要go語言在執行時阻止不合法的存取

arr[4]: invalid array index 4 (out of bounds for 3-element array)
arr[i]: panic: runtime error: index out of range [4] with length 3

越界操作會由執行時的runtime.panicIndex和runtime.goPanicIndex觸發程式的執行時錯誤,並導致程式崩潰退出

TEXT runtime·panicIndex(SB),NOSPLIT,$0-8
	MOVL	AX, x+0(FP)
	MOVL	CX, y+4(FP)
	JMP	runtime·goPanicIndex(SB)

func goPanicIndex(x int, y int) {
	panicCheck1(getcallerpc(), "index out of range")
	panic(boundsError{x: int64(x), signed: true, y: y, code: boundsIndex})
}

當陣列的存取操作,OINDEX 成功通過編譯器檢查後,會被轉換成幾個SSA指令,

package check

func outOfRange() int {
	arr := [3]int{1, 2, 3}
	i := 4
	elem := arr[i]
	return elem
}

$ GOSSAFUNC=outOfRange go build array.go
dumped SSA to ./ssa.html

start階段生成的SSA程式碼就是優化之前的第一版中間程式碼。
elem := arr[i]中間程式碼如下

b1:
    ...
    v22 (6) = LocalAddr <*[3]int> {arr} v2 v20
    v23 (6) = IsInBounds <bool> v21 v11
If v23 → b2 b3 (likely) (6)

b2: ← b1-
    v26 (6) = PtrIndex <*int> v22 v21
    v27 (6) = Copy <mem> v20
    v28 (6) = Load <int> v26 v27 (elem[int])
    ...
Ret v30 (+7)

b3: ← b1-
    v24 (6) = Copy <mem> v20
    v25 (6) = PanicBounds <mem> [0] v21 v11 v24
Exit v25 (6)

對陣列存取操作生成了判斷陣列上限的指令 IsInBounds 以及當條件不滿足時,觸發程式崩潰的PanicBounds指令。

編譯器會將PanicBounds指令轉換成runtime.panicIndex函數。當陣列下標沒有越界時,編譯器會先獲取陣列的記憶體地址和存取的下標。利用PtrIndex計算出目標元素的地址。最後使用Load操作將指標中的元素載入到記憶體中。

編譯器無法判斷下標是否越界,會將PanicBounds指令交給執行時進行判斷。
改成整數存取,中間程式碼如下

b1:
    ...
    v21 (5) = LocalAddr <*[3]int> {arr} v2 v20
    v22 (5) = PtrIndex <*int> v21 v14
    v23 (5) = Load <int> v22 v20 (elem[int])
    ...

賦值的過程中會先確定目標陣列的地址,再通過 PtrIndex 獲取目標元素的地址,最後使用 Store 指令將資料存入地址中,從上面的這些 SSA 程式碼中我們可以看出 陣列定址和賦值都是在編譯階段完成的,沒有執行時的參與。

b1:
    ...
    v21 (5) = LocalAddr <*[3]int> {arr} v2 v19
    v22 (5) = PtrIndex <*int> v21 v13
    v23 (5) = Store <mem> {int} v22 v20 v19
    ...

切片

陣列在go語言中沒那麼常用,更常用的資料結構是切片, 即動態陣列,長度不固定,可以向切片中追加元素。它會在容量不足時自動擴容。

宣告方式不需要指定切片中的元素個數,只需要指定元素型別

[] int 
[] interface{} 

編譯期生成型別只包含切片中的元素型別。

func NewSlice(elem *Type) *Type {
	if t := elem.Cache.slice; t != nil {
		if t.Elem() != elem {
			Fatalf("elem mismatch")
		}
		return t
	}

	t := New(TSLICE)
	t.Extra = Slice{Elem: elem}
	elem.Cache.slice = t
	return t
}

編譯期間的切片是types.Slice型別的,執行時切片有reflect.SliceHeader結構體表示

type SliceHeader struct {
	Data uintptr  # 指向陣列的指標
	Len  int      # 當前切片的長度
	Cap  int	  # 當前切片的容量
}

Data是一片連續的記憶體空間。這片記憶體空間用於儲存切片中的全部元素。
切片與陣列的關係非常密切。切片引入了一個抽象層。提供了對陣列中部分連續片段的參照。而作為陣列的參照。我們可以在執行區間修改它的長度和範圍。當切片底層陣列長度不足時就會觸發擴容,切片指向的陣列可能會發生變化。但是上層感知不到。上層只與切片打交道。

切片初始化

arr[0:3] or slice[0:3] # 通過下班獲取一部分
slice := [] int {1,2,3} # 字面量初始化
slice := make([]int, 10) # make建立
  • 使用下標
    最接近組合語言的方式。轉換成OpSliceMack操作。
// ch03/op_slice_make.go
package opslicemake

func newSlice() []int {
	arr := [3]int{1, 2, 3}
	slice := arr[0:1]
	return slice
}

slice := arr[0:1] 對應如下的SSA中間程式碼

v27 (+5) = SliceMake <[]int> v11 v14 v17

name &arr[*[3]int]: v11
name slice.ptr[*int]: v11
name slice.len[int]: v14
name slice.cap[int]: v17

SliceMake 操作接收四個引數: 元素型別、陣列指標、切片大小、 容量。
下標初始化不會拷貝原陣列或原切片中的資料,只會建立一個指向原陣列的切片結構體。所以修改新切片的資料也會修改原切片。

  • 字面量
    []int{1,2,3}, 編譯期間會展開如下程式碼片段
var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]
  1. 根據切片中的元素數量對底層陣列的大小進行推斷並建立一個陣列
    2)將這些字面量元素儲存到初始化陣列中。
    3)建立一個同樣指向【3】int型別的陣列指標
    4)將靜態儲存區的陣列vstat 複製給vauto指標所在的地址
    5)通過[:] 操作獲取一個底層使用vauto的切片。
  • 關鍵字
    slice := make([]int, 10)
    使用字面量建立切片,大部分工作在編譯期間完成,使用make關鍵字建立切片時,很多工作需要執行時的參與。
    呼叫方法必須向make函數傳入切片的大小以及可選的容量。gc.typecheck1函數會檢驗入參。檢查len是否傳入,還會保證cap一定大於或等於len.還會將OMAKE節點轉換為OMAKESLICE。
    go.walker會依據來個條件轉換OMAKESLICE型別的節點
    1.切片的大小和容量是否足夠小
    2.切片是否發生了逃逸,最終在堆上初始化。
    當切片發生逃逸或者非常大時,執行時需要runtime.makeslice在堆上初始化切片。
    如果切片不會發生逃逸並且切片非常小的時候,make([] int, 3,4)會直接被轉換成如下程式碼
    var arr [4]int n := arr[:3]
    建立切片的執行時函數runtime.makeslice
func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.size, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

主要工作是計算切片佔用的記憶體空間並在堆上申請一片連續的記憶體。
記憶體空間 = 切片中元素的大小 * 切片容量
雖然編譯期間可以檢查出很多錯誤,但是在建立切片的過程中如果發生了以下錯誤會直接觸發執行時錯誤並崩潰。
1.記憶體空間的大小發生了溢位
2.申請的記憶體大於最大可分配的記憶體
3.傳入的長度小於0 或者大於容量。
mallocgc是用於申請記憶體的函數,這個函數比較複雜,
如果遇到了較小的物件會直接初始化在Go語音排程器裡面的P結構中。而大於32KB的物件會在堆上初始化,

存取元素

使用len和cap獲取長度或者容量是切片最常見的操作。
對應倆個特殊操作 OLEN 和 OCAP.
SSA生成階段會轉換成OpSliceLen 和 OpSliceCap。可能會觸發decompose builtin階段的優化,len(slice) / cap(slice)在一些情況下會直接替換成切片的長度或者容量。不需要在執行時獲取。

(SlicePtr (SliceMake ptr _ _ )) -> ptr
(SliceLen (SliceMake _ len _)) -> len
(SliceCap (SliceMake _ _ cap)) -> cap

除了獲取切片的長度和容量之外,存取切片中元素使用的OINDEX操作也會在中間程式碼生成期間轉換成對地址的直接存取.
切片操作基本都是在編譯期間完成的。除了存取切片的長度、容量或者其中的元素之外。
編譯期間會將包含range關鍵字的遍歷轉換成形式更簡單的迴圈。

追加和擴容

使用append關鍵字向切片中追加元素。
中間程式碼生成階段的gc.state.append方法會根據返回值是否會覆蓋原變數,進入倆種流程。
如果append返回的新切片不需要賦值回原有的變數。進入如下

// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)

如果追加後切片的大小大於容量,那麼就會呼叫 growslice對切片進行擴容。然後依次將新的元素依次加入切片。

如果使用slice = append(slice,1,2,3)。那麼append後的切片會覆蓋原切片。

// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
   newptr, len, newcap = growslice(slice, newlen)
   vardef(a)
   *a.cap = newcap
   *a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3

是否覆蓋原變數的邏輯其實差不多。最大的區別在於得到的新切片是否會賦值回原變數。
如果我們選擇覆蓋原有的變數。就不需要擔心切片發生拷貝影響效能。

切片容量不足的處理流程。growslice.
擴容是為切片分配新的記憶體空間並拷貝原始切片中元素的過程。

func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}

根據不同的容量選擇不同的策略
1.如果期望容量大於當前容量的倆倍,就會使用期望容量
2.如果當前切片的長度小於1024,就會將容量翻倍。
3.如果當前切片的長度大於1024,那麼就增加25%的容量。

拷貝切片

copy(a,b)
gc.copyany分倆種情況進行處理。
如果當前copy不是在執行時呼叫的。直接替換成下面的程式碼

n := len(a)
if n > len(b) {
    n = len(b)
}
if a.ptr != b.ptr {
    memmove(a.ptr, b.ptr, n*sizeof(elem(a))) 
}

執行 時發生,呼叫runtime.slicecopy

func slicecopy(to, fm slice, width uintptr) int {
	if fm.len == 0 || to.len == 0 {
		return 0
	}
	n := fm.len
	if to.len < n {
		n = to.len
	}
	if width == 0 {
		return n
	}
	...

	size := uintptr(n) * width
	if size == 1 {
		*(*byte)(to.array) = *(*byte)(fm.array)
	} else {
		memmove(to.array, fm.array, size)
	}
	return n
}

都通過runtime.memmove將整塊記憶體的內容拷貝到目標的記憶體區域中:

雜湊表

go語言的雜湊的實現原理。
陣列表示元素的序列。
雜湊表示的是鍵值對之間的對映關係。

設計原理

O(1)的讀寫效能。
提供了鍵值之間的對映。想要實現一個效能優異的雜湊表。需要注意倆個關鍵點---雜湊函數和衝突解決方法

  • 雜湊函數

雜湊函數的選擇在很大程度上 能夠決定雜湊表的讀寫效能。
理想的情況下,雜湊函數應該能夠將不同鍵 對映到不同的索引上。這就要求 雜湊函數的輸出範圍 > 輸入範圍
鍵的數量會遠遠大於對映的範圍。理想情況很難存在。

比較實際的方式是讓雜湊函數的結果 儘可能的均勻分佈。然後通過工程上的手段解決雜湊碰撞的問題。
不均勻的雜湊函數

  • 衝突解決
    通常情況下,雜湊函數的輸入範圍一定遠遠大於輸出範圍。
    所以一定會遇到衝突。衝突不一定是雜湊完全相等。可能是部分相等。比如:前4個位元組相同。

  • 開放定址法
    開放定址法 是一種在雜湊表中解決雜湊碰撞的方法。核心思想是依次探測和比較陣列中的元素以判斷目標鍵值對 是否存在於雜湊表中。
    底層資料結構必須是陣列。陣列長度有限。所以向雜湊表寫入(author, draven)會從如下的索引開始遍歷
    index := hash('author') % array.len
    如果發生了衝突。會將鍵值對寫入到下一個索引不為空的位置。

    讀取資料

    開放地址法中對效能影響最大的是裝載因子。它是陣列中元素的數量與 陣列大小的比值。隨著裝載因子的增加。線性探測的平均用時就會逐漸增加。會影響雜湊表的讀寫效能。當裝載率超過70%之後。雜湊表的效能就會急劇下降。達到100%,就會完全失效。

  • 拉鍊法
    拉鍊法是雜湊表最常見的實現方法。 資料結構使用陣列+連結串列。還會引入紅黑樹優化效能

雜湊函數會選擇一個桶,和開放地址法一樣,就是對雜湊返回的結果取模。
選擇了2號桶後就可以遍歷當前桶中的連結串列。在遍歷連結串列的時候會遇到以下倆種情況
1.找到鍵相同的鍵值對- 更新值
2.沒有找到-在連結串列末尾追加新的鍵值對
拉鍊法的裝載因子
裝載因子:= 元素數量 / 桶數量
當裝載因子變大是,讀寫效能也就越差。

資料結構

go語言執行時同時使用了多個資料結構組合表示雜湊表。runtime.hmap是最核心的結構體。

type hmap struct {
	count     int   	# 當前雜湊表中的元素數量
	flags     uint8		# 
	B         uint8		# 表示當前雜湊表持有的buckets 數量。
	noverflow uint16
	hash0     uint32	# 雜湊的種子。為雜湊函數的結果引入 隨機性

	buckets    unsafe.Pointer
	oldbuckets unsafe.Pointer  # 儲存之前的buckets的欄位
	nevacuate  uintptr

	extra *mapextra
}

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}


每一個bmap都能夠儲存8個鍵值對。當雜湊表中儲存的資料過多。單個桶已經裝滿就會使用 extra.nextOverflow中桶儲存移除的資料。
上述倆種不同的桶在記憶體中是連續儲存的。分為正常桶(黃色桶)和溢位桶(綠色桶)
bmap,原始碼中 只包含一個tophash欄位。

type bmap struct {
	tophash [bucketCnt]uint8
}

在執行期間不止包含tophash欄位

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

雜湊表初始化

  • 字面量
    key:value的語法來表示鍵值對
hash := map[string]int{
	"1": 2,
	"3": 4,
	"5": 6,
}

gc.maplit

func maplit(n *Node, m *Node, init *Nodes) {
	a := nod(OMAKE, nil, nil)
	a.Esc = n.Esc
	a.List.Set2(typenod(n.Type), nodintconst(int64(n.List.Len())))
	litas(m, a, init)

	entries := n.List.Slice()
	if len(entries) > 25 {  # 雜湊表數量小於25個時,一次直接加入到雜湊表中
		...
		return
	}

	// Build list of var[c] = expr.
	// Use temporaries so that mapassign1 can have addressable key, elem.
	...
}

超過了25個,會建立倆個陣列分別儲存鍵值。會通過如下for迴圈加入雜湊

hash := make(map[string]int, 26)
vstatk := []string{"1", "2", "3", ... , "26"}
vstatv := []int{1, 2, 3, ... , 26}
for i := 0; i < len(vstak); i++ {
    hash[vstatk[i]] = vstatv[i]
}
  • 執行時
    當建立的雜湊表被分配到棧上,並且容量小於BUCKETSIZE = 8時,GO語言在編譯階段會使用如下方式快速初始化雜湊。
var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()

讀寫操作

雜湊表的存取一般都是通過下標或者遍歷進行的。

_ = hash[key]

for k, v := range hash {
    // k, v
}
  • 存取
    在編譯型別檢查期間,hash[key] 以及類似的 操作都會被轉換成雜湊的 OINDEXMAP操作。
    中間程式碼生成階段會在gc.walkexpr 中間這些OINDEXMAP操作轉換成如下程式碼
v     := hash[key] // => v     := *mapaccess1(maptype, hash, &key)
v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)

runtime.mapaccess1 會先通過雜湊表設定的雜湊函數、、種子獲取當前鍵對應的雜湊。再通過bucketMask和add拿到該鍵值對所在的桶序號和雜湊高位的8位元數位。

小結

Go 語言使用拉鍊法來解決雜湊碰撞的問題實現了雜湊表,它的存取、寫入和刪除等操作都在編譯期間轉換成了執行時的函數或者方法。雜湊在每一個桶中儲存鍵對應雜湊的前 8 位,當對雜湊進行操作時,這些 tophash 就成為可以幫助雜湊快速遍歷桶中元素的快取。

雜湊表的每個桶都只能儲存 8 個鍵值對,一旦當前雜湊的某個桶超出 8 個,新的鍵值對就會儲存到雜湊的溢位桶中。隨著鍵值對數量的增加,溢位桶的數量和雜湊的裝載因子也會逐漸升高,超過一定範圍就會觸發擴容,擴容會將桶的數量翻倍,元素再分配的過程也是在呼叫寫操作時增量進行的,不會造成效能的瞬時巨大抖動。

字串


如果是程式碼中存在的字串,編譯器會將其標記成唯讀資料SRODATA.

$ cat main.go
package main

func main() {
	str := "hello"
	println([]byte(str))
}

$ GOOS=linux GOARCH=amd64 go tool compile -S main.go
...
go.string."hello" SRODATA dupok size=5  # SRODATA標記
	0x0000 68 65 6c 6c 6f                                   hello
...

唯讀意味著字串會被分配到唯讀的記憶體空間。
可以通過在string 和 []byte 型別之間反覆轉換實現修改。
1.先講這段記憶體拷貝到堆或者棧上。
2.將變數的型別轉換成[] byte後,並修改位元組資料
3.將修改後的位元組陣列轉回string.

字串資料結構

type StringHeader struct {
	Data uintptr  # 指向位元組陣列的指標
	Len  int	  #  陣列大小
}

與切片相比,只少了一個表示容量的Cap欄位。字串就是一個唯讀的切片型別。
所有在字串上的寫入操作都是通過拷貝實現的。

字串 解析過程

解析器會在詞法分析階段解析字串。會對原始檔中的字串進行切片和分組。將原有的字元流轉換成Token序列。
倆種宣告

str1 := "this is a string"
str2 := `this is another
string`

雙引號和反引號。
雙引號:只能用於單行字串的初始化。如果內部出現雙引號需要\符合跳脫。
反引號:可以擺脫單行的限制。雙引號不再負責標記字串的開始和結束。在遇到json或者其他複雜資料格式的場景下非常方便。

字串拼接

+符號, 會將該符號對應的OADD節點轉換為OADDSTR型別的節點。然後呼叫gc.addstr函數生成用於拼接字串的程式碼

func walkexpr(n *Node, init *Nodes) *Node {
	switch n.Op {
	...
	case OADDSTR:
		n = addstr(n, init)
	}
}

型別轉換

解析 和 序列化json等資料格式是,需要將資料在string和[]byte之間來回轉換。
從位元組陣列到字串的轉換需要使用runtime.slicebytesostring函數。例如:string(bytes),
長度為0和長度為1 的位元組陣列,處理起來比較簡單。

func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
	l := len(b)
	if l == 0 {
		return ""
	}
	if l == 1 {
		stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])
		stringStructOf(&str).len = 1
		return
	}
	var p unsafe.Pointer
	if buf != nil && len(b) <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(len(b)), nil, false)
	}
	stringStructOf(&str).str = p
	stringStructOf(&str).len = len(b)
	memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))
	return
}

處理過後會根據傳入的緩衝區大小決定是否需要為新字串分配一片記憶體空間。

字串轉換成[]byte型別時,需要使用runtime.stringtoslicebyte函數,

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
	var b []byte
	if buf != nil && len(s) <= len(buf) {
		*buf = tmpBuf{}
		b = buf[:len(s)]
	} else {
		b = rawbyteslice(len(s))
	}
	copy(b, s)
	return b
}

語言基礎

函數呼叫

函數是go語言的一等公民。

呼叫慣例

somefunction(arg0,arg1)
呼叫慣例是呼叫方和被呼叫方對於引數和返回值傳遞的約定。

  • C語言
    範例
// ch04/my_function.c
int my_function(int arg1, int arg2) {
    return arg1 + arg2;
}

int main() {
    int i = my_function(1, 2);
}

編譯成組合

main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp
	movl	$2, %esi  // 設定第二個引數
	movl	$1, %edi  // 設定第一個引數
	call	my_function
	movl	%eax, -4(%rbp)
my_function:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)    // 取出第一個引數,放到棧上
	movl	%esi, -8(%rbp)    // 取出第二個引數,放到棧上
	movl	-8(%rbp), %eax    // eax = esi = 1
	movl	-4(%rbp), %edx    // edx = edi = 2
	addl	%edx, %eax        // eax = eax + edx = 1 + 2 = 3
	popq	%rbp

呼叫過程:
1.呼叫方main函數將my_function的倆個引數分別存到edi和esi暫存器中。
2.在my_function呼叫時,它會將暫存器edi和esi中的資料儲存到eax和edx倆個暫存器中。隨後通過組合指令addl 計算倆個入參之和。
3.在my_fuction呼叫後,使用暫存器eax 傳奇返回值。然後儲存到棧上的i變數中。

當my_function函數的入參增加至8個時。會得到不同的組合程式碼。

main:
	pushq	%rbp
	movq	%rsp, %rbp
	subq	$16, %rsp     // 為引數傳遞申請 16 位元組的棧空間
	movl	$8, 8(%rsp)   // 傳遞第 8 個引數
	movl	$7, (%rsp)    // 傳遞第 7 個引數
	movl	$6, %r9d
	movl	$5, %r8d
	movl	$4, %ecx
	movl	$3, %edx
	movl	$2, %esi
	movl	$1, %edi
	call	my_function

前6個引數會使用edi、esi、edx、ecx、r8d\r9d 六個暫存器傳遞。
最後的倆個引數通過棧傳遞。

rbp暫存器會儲存函數呼叫棧的基址指標。main函數的棧空間的其實地址。而另一個暫存器rsp儲存的是main函數呼叫棧結束的位置。
這倆個暫存器共同表示了函數的棧空間。

在呼叫my_function之前。main函數通過sub1 $16,%rsp指令分配了16個位元組的站地址。隨後將第6個以上的引數按照從右到左的順序存入棧中。餘下的6個引數通過暫存器傳遞。

my_function:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)    // rbp-4 = edi = 1
	movl	%esi, -8(%rbp)    // rbp-8 = esi = 2
	...
	movl	-8(%rbp), %eax    // eax = 2
	movl	-4(%rbp), %edx    // edx = 1
	addl	%eax, %edx        // edx = eax + edx = 3
	...
	movl	16(%rbp), %eax    // eax = 7
	addl	%eax, %edx        // edx = eax + edx = 28
	movl	24(%rbp), %eax    // eax = 8
	addl	%edx, %eax        // edx = eax + edx = 36
	popq	%rbp

my_function會先將暫存器中的全部資料轉移到棧上。然後利用eax暫存器計算所有入參的和並返回結果。
函數的返回值是通過eax暫存器進行傳遞的。由於只使用一個暫存器儲存返回值。所以C語言的函數不能同時返回多個值。

  • Go語言
package main

func myFunction(a, b int) (int, int) {
	return a + b, a - b
}

func main() {
	myFunction(66, 77)
}
"".main STEXT size=68 args=0x0 locals=0x28
	0x0000 00000 (main.go:7)	MOVQ	(TLS), CX
	0x0009 00009 (main.go:7)	CMPQ	SP, 16(CX)
	0x000d 00013 (main.go:7)	JLS	61
	0x000f 00015 (main.go:7)	SUBQ	$40, SP      // 分配 40 位元組棧空間
	0x0013 00019 (main.go:7)	MOVQ	BP, 32(SP)   // 將基址指標儲存到棧上
	0x0018 00024 (main.go:7)	LEAQ	32(SP), BP
	0x001d 00029 (main.go:8)	MOVQ	$66, (SP)    // 第一個引數
	0x0025 00037 (main.go:8)	MOVQ	$77, 8(SP)   // 第二個引數
	0x002e 00046 (main.go:8)	CALL	"".myFunction(SB)
	0x0033 00051 (main.go:9)	MOVQ	32(SP), BP
	0x0038 00056 (main.go:9)	ADDQ	$40, SP
	0x003c 00060 (main.go:9)	RET

"".myFunction STEXT nosplit size=49 args=0x20 locals=0x0
	0x0000 00000 (main.go:3)	MOVQ	$0, "".~r2+24(SP) // 初始化第一個返回值
	0x0009 00009 (main.go:3)	MOVQ	$0, "".~r3+32(SP) // 初始化第二個返回值
	0x0012 00018 (main.go:4)	MOVQ	"".a+8(SP), AX    // AX = 66
	0x0017 00023 (main.go:4)	ADDQ	"".b+16(SP), AX   // AX = AX + 77 = 143
	0x001c 00028 (main.go:4)	MOVQ	AX, "".~r2+24(SP) // (24)SP = AX = 143
	0x0021 00033 (main.go:4)	MOVQ	"".a+8(SP), AX    // AX = 66
	0x0026 00038 (main.go:4)	SUBQ	"".b+16(SP), AX   // AX = AX - 77 = -11
	0x002b 00043 (main.go:4)	MOVQ	AX, "".~r3+32(SP) // (32)SP = AX = -11
	0x0030 00048 (main.go:4)	RET


Go語言使用棧傳遞引數和接收返回值。所以只需要在棧上多分配一些記憶體就可以返回多個值。

引數傳遞

值傳遞還是參照傳遞。

  • 傳值: 函數呼叫會對引數進行拷貝,被呼叫方和呼叫方倆者持有不相關的倆份資料
  • 傳參照:持有相同的資料
    不同的語言會選擇不同的方式傳遞引數。GO語言選擇了傳值得方式。無論是基本型別、結構體、還是指標。都會對傳遞的引數進行拷貝。
  • 整型和陣列

整型變數i和陣列arr.

func myFunction(i int, arr [2]int) {
	fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}

func main() {
	i := 30
	arr := [2]int{66, 77}
	fmt.Printf("before calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
	myFunction(i, arr)
	fmt.Printf("after  calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}

$ go run main.go
before calling - i=(30, 0xc00009a000) arr=([66 77], 0xc00009a010)
in my_funciton - i=(30, 0xc00009a008) arr=([66 77], 0xc00009a020)
after  calling - i=(30, 0xc00009a000) arr=([66 77], 0xc00009a010)

在my_function中修改

func myFunction(i int, arr [2]int) {
	i = 29
	arr[1] = 88
	fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)
}

$ go run main.go
before calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
in my_funciton - i=(29, 0xc000072028) arr=([66 88], 0xc000072040)
after  calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)

Go 語言的整型和陣列型別都是值傳遞的,也就是在呼叫函數時會對內容進行拷貝。需要注意的是如果當前陣列的大小非常的大,這種傳值的方式會對效能造成比較大的影響

  • 結構體和指標
type MyStruct struct {
	i int
}

func myFunction(a MyStruct, b *MyStruct) {
	a.i = 31
	b.i = 41
	fmt.Printf("in my_function - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}

func main() {
	a := MyStruct{i: 30}
	b := &MyStruct{i: 40}
	fmt.Printf("before calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
	myFunction(a, b)
	fmt.Printf("after calling  - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)
}

$ go run main.go
before calling - a=({30}, 0xc000018178) b=(&{40}, 0xc00000c028)
in my_function - a=({31}, 0xc000018198) b=(&{41}, 0xc00000c038)
after calling  - a=({30}, 0xc000018178) b=(&{41}, 0xc00000c028)

結論1:傳遞結構體時:會拷貝結構體中的全部內容
結論2:傳遞結構體指標時:會拷貝結構體指標。
修改結構體指標,是改變了指標指向的結構體。 b.i => (*b).i
儘量使用指標作為引數型別來避免發生資料拷貝進而影響效能。

介面

Go語言中的介面是一組方法的簽名。使用介面能夠讓我們寫出易於測試的程式碼。

介面概述

介面是計算機系統中多個元件共用的邊界。不同的元件能夠在邊界上交換資訊。
介面的本質是引入一個新的中間層。呼叫方可以通過介面與具體的實現分離。解除上下游的耦合。
上層的模組不需要依賴下層的具體模組。只需要依賴一個約定好的介面。

面向介面的程式設計方式有著非常強大的生命力。

Go語言中的介面是一種內建的型別。定義了一組方法的簽名。

  • 隱式介面
    java中的介面,還可以定義變數。可以在類中直接使用。
public interface MyInterface {
    public String hello = "Hello";
    public void sayHello();
}
public class MyInterfaceImpl implements MyInterface {
    public void sayHello() {
        System.out.println(MyInterface.hello);
    }
}

go語言不需要使用上述方式顯示的宣告實現的介面.
一個常見的Go語言介面是這樣的:

type error interface {
	Error() string
}

如果一個型別需要實現error介面。那麼只需要實現Error() string方法。

type RPCError struct {
	Code    int64
	Message string
}

func (e *RPCError) Error() string {
	return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

Go語言中的介面實現都是隱式的。只需要實現Error() String方法就實現了error介面。
在java中:實現介面需要顯示地宣告介面 並實現所有方法
在Go中:實現介面的所有方法就隱式地實現了介面。

上述RPCError結構體並不太關心它實現了哪些介面,Go語言只會在傳遞引數、返回引數以及變數賦值時才會對某個型別是否實現介面進行檢查。

func main() {
	var rpcErr error = NewRPCError(400, "unknown err") // typecheck1
	err := AsErr(rpcErr) // typecheck2
	println(err)
}

func NewRPCError(code int64, msg string) error {
	return &RPCError{ // typecheck3
		Code:    code,
		Message: msg,
	}
}

func AsErr(err error) error {
	return err
}

將 *RPCError 型別的變數賦值給 error 型別的變數 rpcErr;
將 *RPCError 型別的變數 rpcErr 傳遞給簽名中引數型別為 error 的 AsErr 函數;
將 *RPCError 型別的變數從函數簽名的返回值型別為 error 的 NewRPCError 函數中返回;

  • 型別
    介面也是Go語言中的一種型別,能夠出現在變數的定義、函數的入參和返回值中,並對它們進行約束。
    go語言有倆種不同的介面,一種是帶有一組方法的介面。
    另一種是不帶任何方法的interface{}

    runtime.iface表示第一種介面。
    runtime.aface表示不包含任何方法介面的interface{}
    interface{}型別不是任意型別。變數在執行期間的型別會發生變換。
    獲取變數型別時會得到interface{}
package main

func main() {
	type Test struct{}
	v := Test{}
	Print(v)
}

func Print(v interface{}) {
	println(v)
}

上述函數不接受任意型別的引數,只接受interface{}型別的值,在呼叫Print函數時,會對引數v進行型別轉換,將原來的Test型別轉換成interface型別。

  • 指標和介面
    在GO語言中,同時使用指標和介面時 會發生一些讓人困惑的問題。
    介面在定義一組方法時沒有對實現的接收者做限制。
    所以我們會看到某個型別實現介面的倆種方式。

結構體型別和指標型別是不同的。
上圖中倆種實現不可以同時存在,如果同時存在,會報錯:「method redeclared」
對 Cat結構體來說。在實現介面時,可以選擇接受者的型別,即結構體或者結構體指標

type Cat struct {}
type Duck interface { ... }

func (c  Cat) Quack {}  // 使用結構體實現介面
func (c *Cat) Quack {}  // 使用結構體指標實現介面

var d Duck = Cat{}      // 使用結構體初始化變數
var d Duck = &Cat{}     // 使用結構體指標初始化變數

實現介面的型別和初始化返回的型別兩個維度共組成了四種情況,然而這四種情況不是都能通過編譯器的檢查:

使用結構體指標實現介面,結構體初始化變數無法通過編譯。其他三種情況都可以正常執行。

當實現介面的型別和初始化的變數返回的型別相同是,程式碼通過是理所應當的。
方法接受者和初始化型別都是結構體。
方法接受者和初始化型別都是結構體指標
而剩下的倆種方式為什麼一個可以通過編譯,一個不行呢。

可以通過的是:方法的接受者是結構體,初始化變數是結構體指標。

type Cat struct{}

func (c Cat) Quack() {
	fmt.Println("meow")
}

func main() {
	var c Duck = &Cat{}
	c.Quack()
}

作為指標的&Cat{}變數能夠隱式地獲取到指向的結構體。通過指標找到對應的結構體,然後參照Quack()

type Duck interface {
	Quack()
}

type Cat struct{}

func (c *Cat) Quack() {
	fmt.Println("meow")
}

func main() {
	var c Duck = Cat{}
	c.Quack()
}

$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:
	Cat does not implement Duck (Quack method has pointer receiver)

Cat 型別沒有實現 Duck 介面,Quack 方法的接受者是指標。這兩個報錯對於剛剛接觸 Go 語言的開發者比較難以理解,如果我們想要搞清楚這個問題,首先要知道 Go 語言在傳遞引數時都是傳值的。

初始化的變數c是Cat{} 還是 &Cat{}。使用c.Quack()呼叫方法是都會發生值拷貝。

  • 對於&Cat{}來說,這意味著拷貝一個新的&Cat{}指標,這個指標指向一個相同並且唯一的結構體。所以編譯器可以隱式的對變數解除參照 dereference。獲取結構體。

  • 對於Cat{} 來說。意味著,Quack方法會接受一個全新的Cat{}.因為方法的引數是*Cat{}。編譯器不會無中生有建立一個新的指標,即使可以建立新的指標,這個指標也不會指向最初呼叫該方法的結構體。
    上面的分析解釋了指標型別的現象,當我們使用指標實現介面時,只有指標型別的變數才會實現該介面;當我們使用結構體實現介面時,指標型別和結構體型別都會實現該介面。當然這並不意味著我們應該一律使用結構體實現介面,這個問題在實際工程中也沒那麼重要,在這裡我們只想解釋現象背後的原因。

  • nil 和 non-nil

資料結構

go語言根據介面型別是否包含一組方法將介面型別分成了倆類

  • 使用runtime.iface結構體表示包含方法的介面。
  • 使用runtime.eface結構體表示不包含任何方法的interface{}型別。
type eface struct { // 16 位元組
	_type *_type	# 指向底層資料型別的指標
	data  unsafe.Pointer	# 指向底層資料的指標
}
type iface struct { // 16 位元組
	tab  *itab	
	data unsafe.Pointer		# 指向底層資料的指標
}
  • 型別結構體
    runtime._type 是Go語言型別的執行時表示,
type _type struct {
	size       uintptr	#該型別佔用的記憶體空間
	ptrdata    uintptr
	hash       uint32	# 快速確定型別是否相等
	tflag      tflag
	align      uint8
	fieldAlign uint8
	kind       uint8
	equal      func(unsafe.Pointer, unsafe.Pointer) bool  # 當前型別的多個物件是否相等。
	gcdata     *byte
	str        nameOff
	ptrToThis  typeOff
}
  • itab結構體
type itab struct { // 32 位元組
	inter *interfacetype
	_type *_type
	hash  uint32 # 是對 _type.hash 的拷貝
	_     [4]byte
	fun   [1]uintptr # 是一個動態大小的陣列,它是一個用於動態派發的虛擬函式表,儲存了一組函數指標
}

型別轉換

介面型別是如何初始化和傳遞的。

  • 指標型別

型別斷言

具體型別轉換為介面型別
介面型別轉換為具體型別

動態派發

Dynamic dispatch.是在執行期間選擇具體多型操作(方法或函數)執行的過程。
Go語言不是嚴格意義上的物件導向的語言。但是介面的引入為它帶來了動態派發的特性。
編譯期間不能確認介面型別,go語言會在執行期間決定具體呼叫該方法的哪個實現。

func main() {
	var c Duck = &Cat{Name: "draven"}
	c.Quack()	# Duck介面型別的身份呼叫,呼叫時需要經過執行時的動態派發
	c.(*Cat).Quack() # *Cat 具體型別的身份呼叫。編譯期就會確定呼叫的函數。
}

反射

reflect 實現了執行時的反射能力。
能夠讓程式操作不同型別的物件。
reflect.TypeOf 能獲取型別資訊;
reflect.ValueOf 能獲取資料的執行時表示;

型別 reflect.Type 是反射包定義的一個介面,我們可以使用 reflect.TypeOf 函數獲取任意變數的型別,reflect.Type 介面中定義了一些有趣的方法,MethodByName 可以獲取當前型別對應方法的參照、Implements 可以判斷當前型別是否實現了某個介面:

type Type interface {
        Align() int
        FieldAlign() int
        Method(int) Method
        MethodByName(string) (Method, bool)
        NumMethod() int
        ...
        Implements(u Type) bool
        ...
}
type Value struct {
        // 包含過濾的或者未匯出的欄位
}

func (v Value) Addr() Value
func (v Value) Bool() bool
func (v Value) Bytes() []byte
...

三大法則

從 interface{} 變數可以反射出反射物件;
從反射物件可以獲取 interface{} 變數;
要修改反射物件,其值必須可設定;

常用關鍵字

for 和 range

for迴圈編譯成組合指令

package main

func main() {
	for i := 0; i < 10; i++ {
		println(i)
	}
}

"".main STEXT size=98 args=0x0 locals=0x18
	00000 (main.go:3)	TEXT	"".main(SB), $24-0
	...
	00029 (main.go:3)	XORL	AX, AX                   ;; i := 0
	00031 (main.go:4)	JMP	75
	00033 (main.go:4)	MOVQ	AX, "".i+8(SP)
	00038 (main.go:5)	CALL	runtime.printlock(SB)
	00043 (main.go:5)	MOVQ	"".i+8(SP), AX
	00048 (main.go:5)	MOVQ	AX, (SP)
	00052 (main.go:5)	CALL	runtime.printint(SB)
	00057 (main.go:5)	CALL	runtime.printnl(SB)
	00062 (main.go:5)	CALL	runtime.printunlock(SB)
	00067 (main.go:4)	MOVQ	"".i+8(SP), AX
	00072 (main.go:4)	INCQ	AX                       ;; i++
	00075 (main.go:4)	CMPQ	AX, $10                  ;; 比較變數 i 和 10
	00079 (main.go:4)	JLT	33                           ;; 跳轉到 33 行如果 i < 10
	...
  • 神奇的指標
    當我們在遍歷一個陣列時,如果獲取 range 返回變數的地址並儲存到另一個陣列或者雜湊時,會遇到令人困惑的現象
func main() {
	arr := []int{1, 2, 3}
	newArr := []*int{}
	for _, v := range arr {
		newArr = append(newArr, &v)
	}
	for _, v := range newArr {
		fmt.Println(*v)
	}
}

$ go run main.go
3 3 3

正確的做法應該是使用 &arr[i] 替代 &v,我們會在下面分析這一現象背後的原因。

  • 遍歷清空陣列
func main() {
	arr := []int{1, 2, 3}
	for i, _ := range arr {
		arr[i] = 0
	}
}

依次遍歷切片和雜湊看起來是非常耗費效能的, 因為陣列、切片和雜湊佔用的記憶體空間都是連續的,所以最快的方法是直接清空這片記憶體中的內容,當我們編譯上述程式碼時會得到以下的組合指令

  • 隨機遍歷
func main() {
	hash := map[string]int{
		"1": 1,
		"2": 2,
		"3": 3,
	}
	for k, v := range hash {
		println(k, v)
	}
}

每次執行都會不同。
Go 語言在執行時為雜湊表的遍歷引入了不確定性,也是告訴所有 Go 語言的使用者,程式不要依賴於雜湊表的穩定遍歷,我們在下面的小節會介紹在遍歷的過程是如何引入不確定性的。

經典迴圈

Go語言中的經典迴圈在編譯器看來是一個OFOR型別的節點。由下面四個部分組成。
初始化迴圈的 Ninit;
迴圈的繼續條件 Left;
迴圈體結束時執行的 Right;
迴圈體 NBody:

for Ninit; Left; Right {
    NBody
}

範圍迴圈

編譯器會將所有for-range迴圈 變成經典迴圈。
將是將ORANGE節點轉換成OFOR節點

select

select 是作業系統中的系統呼叫。 select \ poll \ epoll 等函數構建I/O多路複用模型提升程式的效能。
Go語言的select與作業系統的select比較相似。
C語言的select系統呼叫可以同時監聽讀個檔案描述符的可讀或者可寫的狀態。
Go語言中的select也能夠讓Goroutine同時等待多個Channel可讀或者可寫。
在多個檔案或者Channel狀態改變之前,select會一直阻塞當前執行緒或者Goroutine.

select 是與switch相似的控制結構。與switch不同的是,select中雖然也有多個case.但是這些case中的表示式必須都是Channel的收發操作。

func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}

c <- x 或者 <- quit 倆個表示式中任意一個返回,無論哪一個表示式返回,都會立即執行case中的程式碼。
同時觸發,會隨機執行其中的一個。

現象

1.select能在Channel上進行非阻塞的收發操作。
2.select在遇到多個Channel同時響應時,會隨機執行一種情況。

  • 非阻塞的收發
    在通常情況下,select語句會阻塞當前Goroutine並等待多個Channel中的一個達到可以收發的狀態。
    但是如果select語句控制結構中包含 default語句,那麼有下列倆種情況
  1. 當存在可以收發的Channel時,直接處理該Channel對應的case.
  2. 當不存在可以收發的Channel時,執行default中的語句。
  • 隨機執行
    避免飢餓問題的發生。

資料結構

select 在Go語言的原始碼中不存在對應的結構體。使用runtime.scase結構體表示 select在控制結構中的case.

type scase struct {
	c    *hchan         // chan,case中使用的Channel
	elem unsafe.Pointer // data element
}

實現原理

select 語句在編譯期會被轉換成OSELECT節點。
每個OSELECT節點都會持有一組OCASE節點,如果OCASE的執行條件是空,那就是一個default節點。

分析4種情況
select 不存在任何的 case;
select 只存在一個 case;
select 存在兩個 case,其中一個 case 是 default;
select 存在多個 case;

  • 直接阻塞
    select結構中不包含任何case.
func walkselectcases(cases *Nodes) []*Node {
	n := cases.Len()

	if n == 0 {
		return []*Node{mkcall("block", nil, nil)}
	}
	...
}

select 語句轉換成呼叫runtime.block函數,

func block() {
	gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1)
}

block函數會呼叫gopark讓出當前Goroutine對處理器的使用權,並傳入等待原因 waitReasonSelectNoCases.
空的select語句會直接阻塞當前的Goroutine。導致Goroutine進入無法被喚醒的永久休眠狀態。

  • 單一管道
    select條件只包含一個case.
    編譯器會改寫成if條件語句。
// 改寫前
select {
case v, ok <-ch: // case ch <- v
    ...    
}

// 改寫後
if ch == nil {
    block()
}
v, ok := <-ch // case ch <- v
...

在處理單操作select語句時,會根據Channel的收發情況生成不同的語句。當case中的Channel是空指標時,會直接掛起當前Goroutine並陷入永久休眠。

  • 非阻塞操作
    當select中僅包含2個case.並且其中一個是default.Go語言的編譯器會認為這是一次非阻塞的 收發操作。
    gc.walkselectcases會對這種情況單獨處理,優化之前會將case中的所有Channel都轉換成指向Channel的地址。

  • 傳送
    當case中表示式的型別是OSEND時,編譯器會使用條件語句和 runtime.selectnbsend函數改寫程式碼。

select {
case ch <- i:
    ...
default:
    ...
}

if selectnbsend(ch, i) {
    ...
} else {
    ...
}

runtime.selectnbsend ,為我們提供了向Channel非阻塞地傳送資料的能力。
我們在Channel一節介紹了向Channel傳送資料的runtime.chansend函數包含一個block引數。該引數會決定這一次的傳送是不是阻塞的。

func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
	return chansend(c, elem, false, getcallerpc())
}
  • 接收
    由於從Channel中接收資料可能返回一個或者倆個值,所以接收資料的情況會比傳送稍顯複雜。
// 改寫前
select {
case v <- ch: // case v, ok <- ch:
    ......
default:
    ......
}

// 改寫後
if selectnbrecv(&v, ch) { // if selectnbrecv2(&v, &ok, ch) {
    ...
} else {
    ...
}

返回值數量不同會導致使用不同的函數。
倆個用於非阻塞接收訊息的函數。 selectnbrecv 和 selectnbrecv2(對chanrecv返回值的處理稍有不同)

func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected bool) {
	selected, _ = chanrecv(c, elem, false)
	return
}

func selectnbrecv2(elem unsafe.Pointer, received *bool, c *hchan) (selected bool) {
	selected, *received = chanrecv(c, elem, false)
	return
}
  • 常見流程
  1. 將所有的case轉換成包含Channel以及型別等資訊的runtime.scase結構體。
  2. 呼叫執行時函數runtime.selectgo從多個準備就緒的Channel中選擇一個可執行的runtime.scase結構體。
  3. 通過for迴圈生成一組if語句,在語句中判斷自己是不是被選中的case.
selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
    c := scase{}
    c.kind = ...
    c.elem = ...
    c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
    ...
    break
}
if chosen == 1 {
    ...
    break
}
if chosen == 2 {
    ...
    break
}

selectgo函數執行過程

  1. 執行一些必要的初始化操作並確定case的處理順序。
  2. 在迴圈中根據case的型別做出不同的處理。
  • 初始化

runtime.selectgo函數首先會執行必要的儲戶和操作,並決定處理case的倆個順序- 輪詢順序 pollOrder 和 加鎖順序 lockOrder

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
	cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
	order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
	
	ncases := nsends + nrecvs
	scases := cas1[:ncases:ncases]
	pollorder := order1[:ncases:ncases]
	lockorder := order1[ncases:][:ncases:ncases]

	norder := 0
	for i := range scases {
		cas := &scases[i]
	}

	for i := 1; i < ncases; i++ {
		j := fastrandn(uint32(i + 1))
		pollorder[norder] = pollorder[j]
		pollorder[j] = uint16(i)
		norder++
	}
	pollorder = pollorder[:norder]
	lockorder = lockorder[:norder]

	// 根據 Channel 的地址排序確定加鎖順序
	...
	sellock(scases, lockorder)
	...
}

輪詢順序: 通過runtime.fastrandn 函數進入隨機性
加鎖順序: 按照Channel的地址排序後確定加鎖順序。

隨機的輪詢順序可以便祕Channel的飢餓問題。保證公平性
根據Channel的地址順序確定加鎖順序能夠避免死鎖的發生。

  • 迴圈
    當我們為select語句鎖定了所有Channel之後,就會進入runtime.selectgo函數的主迴圈,
    分 三個階段查詢或者等待某個channel準備就緒。
  1. 查詢是否已經存在準備就緒的Channel。即可以執行收發操作。
  2. 將當前Goroutine加入Channel對應的收發佇列上,並等待其他Goroutine的喚醒。
  3. 當前Goroutine被喚醒之後找到滿足條件的Changnel並進行處理。

runtime.selectgo函數會根據不同情況通過goto語句跳轉到函數內部的不同標籤執行相應的邏輯,其中包括:

  • bufrecv: 可以從緩衝區讀取資料
  • bufsend: 可以向緩衝區寫入資料
  • recv: 可以從休眠的傳送方獲取資料
  • send: 可以向休眠的傳送方傳送資料
  • rclose: 可以從關閉的Channel讀取EOF
  • sclose: 向關閉的Channel傳送資料
  • retc: 結束呼叫並返回。

分析第一個階段:查詢已經準備就緒的Channel。迴圈遍歷所有case並找到需要被喚起的runtime.sudog結構。

在這個階段,我們會根據case的四種型別分別處理

  1. 當case不包含channel時:會被跳過。

  2. 當case會從Channel 中接收資料時。
    如果當前Channel的sendq上有等待的Goroutine.就會跳到recv標籤並從 緩衝區讀取資料後將等待的Goroutine中的資料放入到緩衝區中相同的位置。
    如果當前Channel的緩衝區不為空,就會跳到bufrecv標籤處從緩衝區獲取資料。
    如果當前Channel已經被關閉,就會跳到rclose做一些清除的收尾工作。

  3. 當case會向Channel傳送資料時
    如果當前Channel已經被關閉,就會直接跳到sclose標籤,觸發panic嘗試中止程式。
    如果當前channel的recvq上有等待的Goroutine.就會跳到send標籤向channel傳送資料。
    如果當前channel的緩衝區存在空閒位置,就會將傳送的資料存入緩衝區。

  4. 當select語句中包含default時:
    表示前面的的所有的case語句都沒有被執行。這裡會解鎖所有channel並返回,意味著當前select結構中的收發都是非阻塞的。

    第一階段的主要職責是查詢所有case中是否有可以立刻被處理的Channel. 無論是在等待的Goroutine上還是緩衝區中。
    只要存在資料滿足條件就會立刻處理。如果不能立刻找到活躍的channel。就會進入迴圈的下一階段。
    按照需要將當前的goroutine加入到channel的sendq或者recvq佇列中。

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
	...
	gp = getg()
	nextp = &gp.waiting
	for _, casei := range lockorder {
		casi = int(casei)
		cas = &scases[casi]
		c = cas.c
		sg := acquireSudog()
		sg.g = gp
		sg.c = c

		if casi < nsends {
			c.sendq.enqueue(sg)
		} else {
			c.recvq.enqueue(sg)
		}
	}

	gopark(selparkcommit, nil, waitReasonSelect, traceEvGoBlockSelect, 1)
	...
}

除了將當前的Goroutine對應的runtime.sudog結構體加入佇列之外,這些結構體都會被串成連結串列附著在Goroutine上。在入隊後會呼叫runtime.gopark掛起當前Goroutine等待排程器的喚醒。
golang select waiting

等待select中的一些Channel準備就緒後,當前Goroutine就會被排程器喚醒。這時會繼續執行runtime.selectgo函數的第三部分。從runtime.sudog中讀取資料。

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
	...
	sg = (*sudog)(gp.param)
	gp.param = nil

	casi = -1
	cas = nil
	sglist = gp.waiting
	for _, casei := range lockorder {
		k = &scases[casei]
		if sg == sglist {
			casi = int(casei)
			cas = k
		} else {
			c = k.c
			if int(casei) < nsends {
				c.sendq.dequeueSudoG(sglist)
			} else {
				c.recvq.dequeueSudoG(sglist)
			}
		}
		sgnext = sglist.waitlink
		sglist.waitlink = nil
		releaseSudog(sglist)
		sglist = sgnext
	}

	c = cas.c
	goto retc
	...
}

第三次遍歷全部case時,我們會獲取當前Goroutine接收到的引數 sudog結構。我們會依次對比所有case對應的sudog結構找到被喚醒的case.獲取該case對應的索引並返回。

由於當前的select結構找到了一個case執行,那麼剩下的case中沒有被用到的sudog就會被忽略並且釋放掉。為了不影響channel的正常使用。我們還是需要將這些廢棄的sudog從channel中出隊。

小結

select結構,優化,根據select中case的不同選擇不同的優化路徑。
四個優化

  1. 空的 select 語句會被轉換成呼叫 runtime.block 直接掛起當前 Goroutine;
  2. 如果 select 語句中只包含一個 case,編譯器會將其轉換成 if ch == nil { block }; n; 表示式;
    首先判斷操作的 Channel 是不是空的;
    然後執行 case 結構中的內容;
  3. 如果 select 語句中只包含兩個 case 並且其中一個是 default,那麼會使用 runtime.selectnbrecv 和 runtime.selectnbsend 非阻塞地執行收發操作;
    在預設情況下會通過 runtime.selectgo 獲取執行 case 的索引,並通過多個 if 語句執行對應 case 中的程式碼;
  4. 在編譯器已經對 select 語句進行優化之後,Go 語言會在執行時執行編譯期間展開的 runtime.selectgo 函數,該函數會按照以下的流程執行:
    執行時3個步驟:
  5. 隨機生成一個遍歷的輪詢順序 pollOrder 並根據 Channel 地址生成鎖定順序 lockOrder;
  6. 根據 pollOrder 遍歷所有的 case 檢視是否有可以立刻處理的 Channel;
    如果存在,直接獲取 case 對應的索引並返回;
    如果不存在,建立 runtime.sudog 結構體,將當前 Goroutine 加入到所有相關 Channel 的收發佇列,並呼叫 runtime.gopark 掛起當前 Goroutine 等待排程器的喚醒;
  7. 當排程器喚醒當前 Goroutine 時,會再次按照 lockOrder 遍歷所有的 case,從中查詢需要被處理的 runtime.sudog 對應的索引;

defer

defer 會在當前函數返回前 執行 傳入的函數。
經常被 用於關閉檔案描述符、關閉資料庫連線、解鎖資源
defer的實現一定是由編譯器和執行時 共同完成的。
使用defer的最常見場景是在函數呼叫結束後完成一些收尾工作。
例如:回滾資料庫事務

func createPost(db *gorm.DB) error {
    tx := db.Begin()
    defer tx.Rollback()
    
    if err := tx.Create(&Post{Author: "Draveness"}).Error; err != nil {
        return err
    }
    
    return tx.Commit().Error
}

defer 現象

  • defer 關鍵字的呼叫時機以及多次呼叫defer時執行順序是如何確定的。

  • defer 關鍵字使用傳值的方式 傳遞引數時會進行預計算,導致不符合預期的結果。

  • 作用域
    向defer關鍵字傳入的函數會在函數返回之前執行,
    假如我們在 for迴圈中 多次呼叫 defer關鍵字

func main() {
	for i := 0; i < 5; i++ {
		defer fmt.Println(i)
	}
}

$ go run main.go
4
3
2
1
0
func main() {
    {
        defer fmt.Println("defer runs")
        fmt.Println("block ends")
    }
    
    fmt.Println("main ends")
}

$ go run main.go
block ends
main ends
defer runs
  • 預計算引數
    GO語言中所有的函數呼叫都是傳值的。
    要計算main函數執行的時間:
func main() {
	startedAt := time.Now()
	defer fmt.Println(time.Since(startedAt))
	
	time.Sleep(time.Second)
}

$ go run main.go
0s

不符合預期,defer關鍵字會立刻拷貝函數中參照的外部引數。所以time.Since(startedAt)的結果不是在main函數退出之前計算的,而是在defer關鍵字呼叫時計算的。導致輸出為0s。

解決辦法:傳入匿名函數

func main() {
	startedAt := time.Now()
	defer func() { fmt.Println(time.Since(startedAt)) }()
	
	time.Sleep(time.Second)
}

$ go run main.go
1s

雖然呼叫 defer 關鍵字時也使用值傳遞,但是因為拷貝的是函數指標,所以 time.Since(startedAt) 會在 main 函數返回前呼叫並列印出符合預期的結果。

defer 資料結構

type _defer struct {
	siz       int32 # 引數和結果的記憶體大小
	started   bool 
	openDefer bool # 是否經過開放編碼的優化
	sp        uintptr  # 棧指標
	pc        uintptr  # 呼叫方的程式計數器
	fn        *funcval # 傳入的函數
	_panic    *_panic # 觸發延遲呼叫的結構體, 可能為空
	link      *_defer  # 
}

runtime._defer結構體是延遲呼叫連結串列上的一個元素。所有的結構體都會通過link欄位 串聯成 連結串列。

執行機制

gc.state.stmt會負責處理程式中的defer,該函數會根據條件的不同,使用三種不同的機制處理該關鍵字。

func (s *state) stmt(n *Node) {
	...
	switch n.Op {
	case ODEFER:
		if s.hasOpenDefers {
			s.openDeferRecord(n.Left) // 開放編碼
		} else {
			d := callDefer // 堆分配
			if n.Esc == EscNever {
				d = callDeferStack // 棧分配
			}
			s.callResult(n.Left, d)
		}
	}
}

堆分配、棧分配和開放編碼是處理 defer 關鍵字的三種方法

堆上分配

堆上分配的runtime._defer 結構體是預設的兜底方案,當方案被啟用時,編譯器會呼叫 callResult和 call.這表示 defer在編譯器看來也是函數呼叫。
call 會負責為所有函數和方法呼叫生成中間程式碼,工作包括以下內容:

  1. 獲取需要執行的函數名、閉包指標、程式碼指標、和函數呼叫的接收方
  2. 獲取棧地址並將函數或者方法的引數寫入棧中
  3. 使用newValue1A以及相關函數生成函數呼叫的中間程式碼
  4. 如果當前呼叫的函數是defer. 那麼會單獨生成相關的結束程式碼塊。
  5. 獲取函數的返回值地址並結束當前呼叫。
func (s *state) call(n *Node, k callKind, returnResultAddr bool) *ssa.Value {
	...
	var call *ssa.Value
	if k == callDeferStack {
		// 在棧上初始化 defer 結構體
		...
	} else {
		...
		switch {
		case k == callDefer:
			aux := ssa.StaticAuxCall(deferproc, ACArgs, ACResults) # 呼叫 deferproc函數,此函數  接收引數的大小和閉包所在的地址倆個引數
			call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, aux, s.mem())
		...
		}
		call.AuxInt = stksize
	}
	s.vars[&memVar] = call
	...
}

編譯器還會通過三個步驟為所有呼叫defer的函數末尾插入 deferreturn 的函數呼叫。

  1. gc.walkstmt在遇到 ODEFER節點時會執行 sethasdefer(true) 設定當前函數的hasdefer屬性。
  2. buildssa會執行 s.hasdefer = fn.FUnc.HasDefer() 更新 state的hasdefer.
  3. gc.state.exit會根據state的hasdefer在函數返回之前插入runtime.deferreturn的函數呼叫。
func (s *state) exit() *ssa.Block {
	if s.hasdefer {
		...
		s.rtcall(Deferreturn, true, nil)
	}
	...
}

當執行時將 runtime._defer 分配到堆上時,Go 語言的編譯器不僅將 defer 轉換成了 runtime.deferproc,還在所有呼叫 defer 的函數結尾插入了 runtime.deferreturn。上述兩個執行時函數是 defer 關鍵字執行時機制的入口,它們分別承擔了不同的工作:

runtime.deferproc 負責建立新的延遲呼叫;
runtime.deferreturn 負責在函數呼叫結束時執行所有的延遲呼叫;

  • 建立延遲呼叫
    deferproc會為defer建立一個新的runtime._defer結構體,設定它的函數指標 fn、pc、sp.並將相關的引數拷貝到相鄰的記憶體空間中。
func deferproc(siz int32, fn *funcval) {
	sp := getcallersp()
	argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn)
	callerpc := getcallerpc()

	d := newdefer(siz) # 想盡辦法 獲得 defer結構體。無論使用哪種方式,
	
	# 只要獲取到 runtime._defer 結構體,它都會被追加到所在 Goroutine _defer 連結串列的最前面。
	if d._panic != nil {
		throw("deferproc: d.panic != nil after newdefer")
	}
	d.fn = fn
	d.pc = callerpc
	d.sp = sp
	switch siz {
	case 0:
	case sys.PtrSize:
		*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
	default:
		memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
	}

	return0()
}

最後呼叫的 runtime.return0 是唯一一個不會觸發延遲呼叫的函數,它可以避免遞迴 runtime.deferreturn 的遞迴呼叫

defer 關鍵字的插入順序是從後向前的,而 defer 關鍵字執行是從前向後的,這也是為什麼後呼叫的 defer 會優先執行

  • 執行延遲呼叫
    runtime.deferreturn 會從 Goroutine的 defer連結串列中取出最前面的defer並呼叫jmpdefer傳入需要執行的函數和引數。
func deferreturn(arg0 uintptr) {
	gp := getg()
	d := gp._defer
	if d == nil {
		return
	}
	sp := getcallersp()
	...

	switch d.siz {
	case 0:
	case sys.PtrSize:
		*(*uintptr)(unsafe.Pointer(&arg0)) = *(*uintptr)(deferArgs(d))
	default:
		memmove(unsafe.Pointer(&arg0), deferArgs(d), uintptr(d.siz))
	}
	fn := d.fn
	gp._defer = d.link
	freedefer(d)
	jmpdefer(fn, uintptr(unsafe.Pointer(&arg0))) # 是一個用組合語言實現的執行時函數
	# 跳轉到 defer 所在的程式碼段並在執行結束之後跳轉回 runtime.deferreturn。
}

棧上分配

預設情況下,defer結構體都會在堆上分配,如果能夠將部分結構體分配到站上就可以節約記憶體分配帶來的額外開銷。

當該關鍵字 在函數 體中最多執行一次時,編譯期間的call會將結構體分配到棧上並呼叫。 deferprocStack:

func (s *state) call(n *Node, k callKind) *ssa.Value {
	...
	var call *ssa.Value
	if k == callDeferStack {
		// 在棧上建立 _defer 結構體
		t := deferstruct(stksize)
		...

		ACArgs = append(ACArgs, ssa.Param{Type: types.Types[TUINTPTR], Offset: int32(Ctxt.FixedFrameSize())})
		aux := ssa.StaticAuxCall(deferprocStack, ACArgs, ACResults) // 呼叫 deferprocStack
		arg0 := s.constOffPtrSP(types.Types[TUINTPTR], Ctxt.FixedFrameSize())
		s.store(types.Types[TUINTPTR], arg0, addr)
		call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, aux, s.mem())
		call.AuxInt = stksize
	} else {
		...
	}
	s.vars[&memVar] = call
	...
}

因為在編譯期間我們已經建立了 runtime._defer 結構體,所以在執行期間 runtime.deferprocStack 只需要設定一些未在編譯期間初始化的欄位,就可以將棧上的 runtime._defer 追加到函數的連結串列上:

除了分配位置的不同,棧上分配和堆上分配的 runtime._defer 並沒有本質的不同,而該方法可以適用於絕大多數的場景,與堆上分配的 runtime._defer 相比,該方法可以將 defer 關鍵字的額外開銷降低 ~30%。

開放編碼

Open Coded實現defer關鍵字。該設計使用程式碼內聯優化defer關鍵的額外開銷並引入函數資料 funcdata管理panic的呼叫。

panic 和 recover

都是go語言的內建函數,提供了互補的功能。

panic 能夠改變程式的控制流。呼叫panic後會立刻停止執行當前函數的剩餘程式碼。並在當前Goroutine中遞迴執行呼叫方的 defer.
recover 可以中止panic造成的程式崩潰,它是一個只能在defer中發揮作用的函數,在其他作用域中呼叫不會發揮作用。

paninc 現象

  • panic 只會觸發當前Goroutine的defer
  • recover只有在 defer中呼叫才會生效
  • panic 允許在defer中巢狀多次呼叫

跨協程失效

func main() {
	defer println("in main")
	go func() {
		defer println("in goroutine")
		panic("")
	}()

	time.Sleep(1 * time.Second)
}

$ go run main.go
in goroutine
panic:
...

in main並沒有列印。 只列印了 in goroutine.

失效的崩潰恢復

func main() {
	defer fmt.Println("in main")
	if err := recover(); err != nil {
		fmt.Println(err)
	}

	panic("unknown err")
}

$ go run main.go
in main
panic: unknown err

goroutine 1 [running]: # 非正常退出 
main.main()
	...
exit status 2

recover 只有在panic之後呼叫才生效,就是要在defer函數中。

巢狀奔潰
panic是可以多次巢狀呼叫的

func main() {
	defer fmt.Println("in main")
	defer func() {
		defer func() {
			panic("panic again and again")
		}()
		panic("panic again")
	}()

	panic("panic once")
}

$ go run main.go
in main
panic: panic once
	panic: panic again
	panic: panic again and again

goroutine 1 [running]:
...
exit status 2

panic 資料結構

runtime._panic 結構體表示。

type _panic struct {
	argp      unsafe.Pointer  # 執行defer呼叫時引數的指標
	arg       interface{} # 呼叫panic時傳入的引數
	link      *_panic  # 指向了更早的panic結構
	recovered bool   # 當前panic是否被recover恢復
	aborted   bool   # 當前的panic是否被強行終止
	pc        uintptr
	sp        unsafe.Pointer
	goexit    bool
}

程式奔潰

編譯器會將panic轉換成gopanic.該函數的執行過程包3個步驟

  1. 建立新的panic並新增到Goroutine的panic連結串列的最前面
  2. 在迴圈中 不斷從當前的Goroutine的defer中連結串列獲取 defer並呼叫 reflectcall執行延遲呼叫函數。
  3. 呼叫runtime.fatalpanic中止整個程式。
func gopanic(e interface{}) {
	gp := getg()
	...
	var p _panic
	p.arg = e
	p.link = gp._panic
	gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

	for {
		d := gp._defer
		if d == nil {
			break
		}

		d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

		reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))

		d._panic = nil
		d.fn = nil
		gp._defer = d.link

		freedefer(d)
		if p.recovered {
			...
		}
	}

	fatalpanic(gp._panic) # 實現了無法被恢復的程式崩潰,它在中止程式之前會通過 
	*(*int)(nil) = 0
}

需要注意的是,上述函數中省略了三部分比較重要的程式碼:

  1. 恢復程式的 recover 分支中的程式碼;
  2. 通過內聯優化 defer 呼叫效能的程式碼3;
    runtime: make defers low-cost through inline code and extra funcdata
  3. 修復 runtime.Goexit 異常情況的程式碼;
    runtime.printpanics 列印出全部的 panic 訊息以及呼叫時傳入的引數
    列印崩潰訊息後會呼叫 runtime.exit 退出當前程式並返回錯誤碼 2,程式的正常退出也是通過 runtime.exit 實現的。
func fatalpanic(msgs *_panic) {
	pc := getcallerpc()
	sp := getcallersp()
	gp := getg()

	if startpanic_m() && msgs != nil {
		atomic.Xadd(&runningPanicDefers, -1)
		printpanics(msgs)
	}
	if dopanic_m(gp, pc, sp) {
		crash()
	}

	exit(2)
}

崩潰恢復

recover是如何中止程式崩潰的。
編譯器會將關鍵字 recover轉換成 runtime.gorecover

func gorecover(argp uintptr) interface{} {
   gp := getg()
   p := gp._panic
   if p != nil && !p.recovered && argp == uintptr(p.argp) {
   	p.recovered = true
   	return p.arg
   }
   return nil
}

該函數的實現很簡單,如果當前 Goroutine 沒有呼叫 panic,那麼該函數會直接返回 nil,這也是崩潰恢復在非 defer 中呼叫會失效的原因。在正常情況下,它會修改 runtime._panic 的 recovered 欄位,runtime.gorecover 函數中並不包含恢復程式的邏輯,程式的恢復是由 runtime.gopanic 函數負責的:

func gopanic(e interface{}) {
	...

	for {
		// 執行延遲呼叫函數,可能會設定 p.recovered = true
		...

		pc := d.pc
		sp := unsafe.Pointer(d.sp)

		...
		if p.recovered {
			gp._panic = p.link
			for gp._panic != nil && gp._panic.aborted {
				gp._panic = gp._panic.link
			}
			if gp._panic == nil {
				gp.sig = 0
			}
			gp.sigcode0 = uintptr(sp)
			gp.sigcode1 = pc
			mcall(recovery)
			throw("recovery failed")
		}
	}
	...
}

make 和 new

初始化一個結構體,會用到 make和new。

make 的作用 是初始化內建的資料結構, 切片、雜湊表 、 channel.
new 的作用 根據傳入的型別分配一片記憶體空間,並返回這片記憶體空間的指標

slice := make([]int, 0, 100)
hash := make(map[int]bool, 10)
ch := make(chan int, 5)
i := new(int)

var v int
i := &v

new 只能接收型別作為引數 然後返回一個指向該型別的指標

make

在編譯期間的型別檢查階段,Go 語言會將代表 make 關鍵字的 OMAKE 節點根據引數型別的不同轉換成了 OMAKESLICE、OMAKEMAP 和 OMAKECHAN 三種不同型別的節點,這些節點會呼叫不同的執行時函數來初始化相應的資料結構。

new

編譯器會在中間程式碼生成階段通過以下兩個函數處理該關鍵字

  1. gc.callnew 會將關鍵字轉換成 ONEWOBJ 型別的節點
  2. gc.state.exper會根據申請空間的大小分倆種情況處理
    • 如果申請的空間為0,就會返回一個表示空指標的 zerobase變數。
    • 在遇到其他情況時會將關鍵字轉換成 runtime.newobject函數。
func callnew(t *types.Type) *Node {
	...
	n := nod(ONEWOBJ, typename(t), nil)
	...
	return n
}

func (s *state) expr(n *Node) *ssa.Value {
	switch n.Op {
	case ONEWOBJ:
		if n.Type.Elem().Size() == 0 {
			return s.newValue1A(ssa.OpAddr, n.Type, zerobaseSym, s.sb)
		}
		typ := s.expr(n.Left)
		vv := s.rtcall(newobject, true, []*types.Type{n.Type}, typ)
		return vv[0]
	}
}

需要注意的是,無論是直接使用 new,還是使用 var 初始化變數,它們在編譯器看來都是 ONEW 和 ODCL 節點。如果變數會逃逸到堆上,這些節點在這一階段都會被gc.walkstmt 轉換成通過 runtime.newobject 函數並在堆上申請記憶體.

func walkstmt(n *Node) *Node {
	switch n.Op {
	case ODCL:
		v := n.Left
		if v.Class() == PAUTOHEAP {
			if prealloc[v] == nil {
				prealloc[v] = callnew(v.Type)
			}
			nn := nod(OAS, v.Name.Param.Heapaddr, prealloc[v])
			nn.SetColas(true)
			nn = typecheck(nn, ctxStmt)
			return walkstmt(nn)
		}
	case ONEW:
		if n.Esc == EscNone {
			r := temp(n.Type.Elem())
			r = nod(OAS, r, nil)
			r = typecheck(r, ctxStmt)
			init.Append(r)
			r = nod(OADDR, r.Left, nil)
			r = typecheck(r, ctxExpr)
			n = r
		} else {
			n = callnew(n.Type.Elem())
		}
	}
}

runtime.newobject 函數會獲取傳入型別佔用空間的大小,呼叫 runtime.mallocgc 在堆上申請一片記憶體空間並返回指向這片記憶體空間的指標:

func newobject(typ *_type) unsafe.Pointer {
	return mallocgc(typ.size, typ, true)
}

執行時

上下文Context

Context 是Go語言中用來設定截止日期、同步訊號、傳遞請求相關值得結構體。
上下文與Goroutine有比較密切的 關係,
是Go語言在1.7版本引入標準庫的介面。定義了4個需要實現的方法

  1. Deadline 返回context.Context被取消的時間,也就是完成工作的截止日期
  2. Done 返回一個Channel.這個Channel會在當前工作完成或者上下文被取消後關閉,多次呼叫返回同一個Channel.
  3. Err 返回Context結束的原因。
    • 如果被取消,會返回Canceled錯誤。
    • 如果超時,會返回DeadlineExceeded錯誤。
  4. Value 從Context中獲取鍵對應的值,對於同一個上下文來說,多次呼叫Value並傳入相同的key.會返回相同的結果。該方法可以用來傳遞請求特定的資料。
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

context 包中提供的BackGround、TODO、WithDeadline和WithValue函數會返回實現該介面的私有結構體。

設計原理

在Goroutine構成的樹形結構中對訊號進行同步以減少計算資源的浪費 是Context的最大作用。
GO服務的每一個請求都是通過單獨的Goroutine處理的。http/rpc請求的處理器會啟動新的Goroutine存取資料庫和其他服務。

會建立多個Goroutine來處理一次請求,Context的作用實在不同Goroutine之間同步請求特定資料、取消訊號、處理請求的截止日期

每一個Context都會從最頂層的Goroutine一層一層傳遞到最下層。
可以在最上層Goroutine執行出現錯誤時,將訊號同步給下層。

建立一個過期時間為1s的上下文。並向上下文傳入handle函數,該方法會使用500ms的時間處理傳入的請求:

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	go handle(ctx, 500*time.Millisecond)
	select {
	case <-ctx.Done():
		fmt.Println("main", ctx.Err())
	}
}

func handle(ctx context.Context, duration time.Duration) {
	select {
	case <-ctx.Done():
		fmt.Println("handle", ctx.Err())
	case <-time.After(duration):
		fmt.Println("process request with", duration)
	}
}

因為過期時間大於處理時間,所以我們有足夠的時間處理該請求,該上述程式碼會列印出下面的內容:

$ go run context.go
process request with 500ms
main context deadline exceeded

handle 函數沒有進入超時的select分支,但是main函數的select卻會等待Context超時,並列印出 main context deadline exceeded

如果我們將處理請求時間增加是1.5s.整個程式都會因為上下文的過期而被終止。

預設上下文

context包中最常用的方法還是Background、TODO.這倆個方法都會返回預先初始化好的私有變數 background 、 todo.它們會在同一個Go程式中被複用。

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

這倆個私有變數都是通過new(emptyCtx)語句初始化的。它們是指向私有結構體context.emptyCtx的指標,

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
	return nil
}

通過空方法實現了Context介面中的所有方法,沒有任何功能

取消訊號

WithCancel函數能從Context中衍生出一個新的上下文並返回用於取消該上下文的函數。
一旦我們指向返回的取消函數,當前上下文以及他的子上下文都會被取消。多有的Goroutine都會同步收到這一取消訊號。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

同步原語與鎖

go語言作為一個原生支援使用者態程序Goroutine的語言, 當提到並行程式設計、多執行緒程式設計時。都離不開鎖這一概念。
鎖是一種並行程式設計中的同步原語 Synchronization Primitives.
它能保證多個Goroutine在存取同一片記憶體時不會出現競爭條件 Race Condition.

基本原語

sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once和sync.Cond:

  • Mutex
type Mutex struct {
	state int32 # 當前互斥鎖的狀態。
	sema  uint32 # 用於控制鎖狀態的號誌
}

倆個加起來只佔8位元組的結構體,表示了go語言中的互斥鎖。

  • 狀態
    互斥鎖的狀態比較複雜,最低三位分別表示mutexLocked、mutexWoken、mutexStarving.
    剩下的位置用來表示多少個Goroutine在等待互斥鎖的釋放

預設情況下,互斥鎖的狀態位都是0. int32中的不同位分別表示了不同的狀態。
1) mutexLocked - 表示互斥鎖的鎖定狀態
2) mutexWoken - 表示從正常模式被從喚醒
3) mutexStarving - 當前的互斥鎖進入飢餓狀態
4) waitersCount - 當前互斥鎖上等待的Goroutine個數。

  • 正常模式和飢餓模式
    Mutex有倆種模式, 正常模式和飢餓模式,
    在正常模式下,鎖的 等待者會按照先進先出的順序獲取鎖,
    但是剛被喚起的Goroutine與新建立的Goroutine競爭時,大概率或獲取不到鎖,為了減少這種情況的出現。
    一旦Goroutine超過1ms沒有獲取到鎖。它就會將當前互斥鎖切換飢餓模式,防止部分Goroutine被【餓死】

    飢餓模式是1.9中引入的優化,為了保證互斥鎖的公平性。
    在飢餓模式中,互斥鎖會直接交給等待佇列最前面的Goroutine.新的Goroutine在該狀態下不能獲取鎖、也不會進入自旋狀態。它們只會在佇列的末尾等待。
    如果一個Goroutine獲得了互斥鎖並且他在佇列的末尾或者他等待的時間少於1ms,那麼當前的互斥鎖就會切回正常模式。

與飢餓模式相比,正常模式下的互斥鎖能夠提供更好的效能,
飢餓模式能避免Goroutine由於陷入等待無法獲取鎖而造成的高尾延時。

  • 加鎖和解鎖
    互斥鎖的加鎖和解鎖的過程。
    分別使用Mutex.lock和unlock方法。

當鎖的狀態是0時,將mutexLocked位置成1

func (m *Mutex) Lock() {
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		return
	}
	m.lockSlow()
}

如果互斥鎖的狀態不是0時,就會呼叫lockSlow嘗試通過自旋 Spinning等方式等待鎖的釋放。該方法的主題是一個非常大的for迴圈。
1、判斷當前Goroutine能否進入自旋
2、通過自旋等待互斥鎖的釋放
3、計算互斥鎖的最新狀態
4、更新互斥鎖的狀態並 獲取鎖。

鎖是如何判斷當前Goroutine能否進入自旋等互斥鎖的釋放。

func (m *Mutex) lockSlow() {
	var waitStartTime int64
	starving := false
	awoke := false
	iter := 0
	old := m.state
	for {
		if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
			if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
				atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
				awoke = true
			}
			runtime_doSpin()
			iter++
			old = m.state
			continue
		}

自旋是多執行緒同步機制,當前的程序在進入自旋的過程中會一直保持CPU的佔用。

持續檢查某個條件是否為真。
在多核的CPU上,自旋可以避免Goroutine的切換。
使用恰當會對效能帶來很大的增益。
Goroutine進入自旋的條件非常苛刻。
1.互斥鎖只有在普通模式才能進入自旋。
2.runtime.sync_runtime_capSpin需要返回true:

  1. 執行在多CPU的機器上
    2)當前Goroutine為了獲取該鎖進入自旋的次數小於4次
    3)當前機器上至少存在一個正在執行的處理器P 並且處理的執行佇列為空

一旦當前Goroutine能夠進入自旋就會呼叫runtime.sync_runtime_doSpin和runtime.procyield並執行30次的PAUS指令。
該指令只會佔用CPU並消耗CPU時間。

func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}

TEXT runtime·procyield(SB),NOSPLIT,$0-0
	MOVL	cycles+0(FP), AX
again:
	PAUSE
	SUBL	$1, AX
	JNZ	again
	RET

處理了自旋相關的特殊邏輯後,互斥鎖會根據上下文計算當前互斥鎖的最新狀態。
幾個不同的條件分別會更新state欄位中儲存的不同資訊。

	new := old
		if old&mutexStarving == 0 {
			new |= mutexLocked
		}
		if old&(mutexLocked|mutexStarving) != 0 {
			new += 1 << mutexWaiterShift
		}
		if starving && old&mutexLocked != 0 {
			new |= mutexStarving
		}
		if awoke {
			new &^= mutexWoken
		}

計算了新的互斥鎖狀態之後,會使用 CAS 函數 sync/atomic.CompareAndSwapInt32 更新狀態:

		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			if old&(mutexLocked|mutexStarving) == 0 {
				break // 通過 CAS 函數獲取了鎖
			}
			...
                #如果沒有通過 CAS 獲得鎖,會呼叫 runtime.sync_runtime_SemacquireMutex 通過號誌保證資源不會被兩個 Goroutine 獲取。
			runtime_SemacquireMutex(&m.sema, queueLifo, 1)
			starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
			old = m.state
			if old&mutexStarving != 0 {
				delta := int32(mutexLocked - 1<<mutexWaiterShift)
				if !starving || old>>mutexWaiterShift == 1 {
					delta -= mutexStarving
				}
				atomic.AddInt32(&m.state, delta)
				break
			}
			awoke = true
			iter = 0
		} else {
			old = m.state
		}
	}
}

sync_runtime_SemacquireMutex 會在方法中不斷嘗試獲取鎖並陷入休眠等待號誌的釋放,一旦當前 Goroutine 可以獲取號誌,它就會立刻返回,sync.Mutex.Lock 的剩餘程式碼也會繼續執行。

在正常模式下,這段程式碼會設定喚醒和飢餓標記、重置迭代次數並重新執行獲取鎖的迴圈;
在飢餓模式下,當前 Goroutine 會獲得互斥鎖,如果等待佇列中只存在當前 Goroutine,互斥鎖還會從飢餓模式中退出;

互斥鎖的解鎖過程 sync.Mutex.Unlock 與加鎖過程相比就很簡單,該過程會先使用 sync/atomic.AddInt32 函數快速解鎖,這時會發生下面的兩種情況:
如果該函數返回的新狀態等於 0,當前 Goroutine 就成功解鎖了互斥鎖;
如果該函數返回的新狀態不等於 0,這段程式碼會呼叫 sync.Mutex.unlockSlow 開始慢速解鎖:

func (m *Mutex) Unlock() {
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		m.unlockSlow(new)
	}
}

sync.Mutex.unlockSlow 會先校驗鎖狀態的合法性 — 如果當前互斥鎖已經被解鎖過了會直接丟擲異常 「sync: unlock of unlocked mutex」 中止當前程式。

在正常情況下會根據當前互斥鎖的狀態,分別處理正常模式和飢餓模式下的互斥鎖:

func (m *Mutex) unlockSlow(new int32) {
	if (new+mutexLocked)&mutexLocked == 0 {
		throw("sync: unlock of unlocked mutex")
	}
	if new&mutexStarving == 0 { // 正常模式
		old := new
		for {
			if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
				return
			}
			new = (old - 1<<mutexWaiterShift) | mutexWoken
			if atomic.CompareAndSwapInt32(&m.state, old, new) {
				runtime_Semrelease(&m.sema, false, 1)
				return
			}
			old = m.state
		}
	} else { // 飢餓模式
		runtime_Semrelease(&m.sema, true, 1)
	}
}

在正常模式下,上述程式碼會使用如下所示的處理過程:
如果互斥鎖不存在等待者或者互斥鎖的 mutexLocked、mutexStarving、mutexWoken 狀態不都為 0,那麼當前方法可以直接返回,不需要喚醒其他等待者;
如果互斥鎖存在等待者,會通過 sync.runtime_Semrelease 喚醒等待者並移交鎖的所有權;
在飢餓模式下,上述程式碼會直接呼叫 sync.runtime_Semrelease 將當前鎖交給下一個正在嘗試獲取鎖的等待者,等待者被喚醒後會得到鎖,在這時互斥鎖還不會退出飢餓狀態;

計時器

全域性四叉堆

Go 1.10 之前的 計時器 都是 最小四叉堆實現

var timers struct {
	lock         mutex
	gp           *g
	created      bool
	sleeping     bool
	rescheduling bool
	sleepUntil   int64
	waitnote     note
	t            []*timer  # 最小四叉堆
}

執行時建立的所有計時器都會加入到四叉堆中
runtime.timerproc Goroutine會執行時間驅動的事件。執行時會在發生以下事件時喚醒計時器

  • 四叉堆中的計數器到期
  • 四叉堆中加入了觸發時間更早的新計時器

全域性四叉堆共用的互斥鎖對計時器的影響非常大。

分片四叉堆

1.10 將全域性的四叉堆分割成了64個更小的四叉堆。
在理想的情況下,四叉堆的數量應該等於處理器的數量。但是需要實現動態分配的過程。
最終選擇初始化64個四叉堆。以犧牲記憶體佔用的代價換取效能的提升。

const timersLen = 64

var timers [timersLen]struct {
	timersBucket
}

type timersBucket struct {
	lock         mutex
	gp           *g
	created      bool
	sleeping     bool
	rescheduling bool
	sleepUntil   int64
	waitnote     note
	t            []*timer
}

如果當前機器上的處理器P的個數超過了64,多個處理器上的計時器就可能儲存在同一個桶中。
每一個計時器桶 都由一個執行runtime.timerproc函數的Goroutine處理。

網路輪詢器

最新版本實現中,計時器桶已被移除,所有的計時器都以最小四叉堆的形式儲存在處理器 runtime.p中。

type p struct {
	...
	timersLock mutex  # 用於保護計時器的互斥鎖
	timers []*timer # 用於儲存計時器的最小四叉堆

	numTimers     uint32  # 計時器中的數量
	adjustTimers  uint32  # 處於timerModifiedEarlier狀態的計時器數量
	deletedTimers uint32  # 處於timerDeleted狀態的計時器數量
	...
}

timer資料結構

type timer struct {
	pp puintptr

	when     int64 # 當前計時器被喚醒的時間 
	period   int64 # 倆次被喚醒的時間間隔 
	f        func(interface{}, uintptr) # 計時器被喚醒都會呼叫的函數
	arg      interface{} # 計時器被喚醒是呼叫f 傳入的引數
	seq      uintptr #
	nextwhen int64  # 計時器處於timerModifiedXX狀態時,用於設定when欄位
	status   uint32 # 計時器的狀態
}

對外暴露的是Timer

type Timer struct {
	C <-chan Time
	r runtimeTimer
}

狀態機

執行時使用狀態機的方式處理全部的計時器,其中包括10種狀態和幾種操作。
由於go語言的計時器支援增刪改和重置操作。

狀態 解釋
timerNoStatus 還沒有設定狀態
timerWaiting 等待觸發
timerRunning 執行計時器函數
timerDeleted 被刪除
timerRemoving 正在被刪除
timerRemoved 已經被停止並從堆中刪除
timerModifying 正在被修改
timerModifiedEarlier 被修改到了更早的時間
timerModifiedLater 被修改到了更晚的時間
timerMoving 已經被修改正在被移動

Channel

作為Go核心的資料結構和Goroutine之間的通訊方式。
Channel是支撐Go語言高效能並行程式設計模型的重要結構。

Channel設計原理

設計模式:不要通過共用記憶體的方式進行通訊,應該通過通訊的方式共用記憶體。
多執行緒使用共用記憶體傳遞資料

Go語言提供了一種不同的並行模型,即交談循序程式 Communicating sequential processes CSP.
Goroutine和Channel分別對應CSP中的實體和傳遞資訊的媒介。

倆個Goroutine,一個會向Channel中傳送資料,另一個會從Channel中接收資料。
它們倆者能夠獨立執行並不存在直接關聯,通過Channel間接完成通訊。

  • 先入先出
    1) 先從Channel讀取資料的Goroutine會先接收到資料
    2) 先向Channel傳送資料的Goroutine會得到先傳送資料的權利

  • 無鎖管道
    鎖是一種常見的並行控制技術,我們一般會將鎖分成樂觀鎖和悲觀鎖。即樂觀並行控制和悲觀並行控制
    無鎖 lock-free佇列更準確的描述是使用樂觀並行控制的佇列。
    樂觀並行控制也叫樂觀鎖。
    樂觀鎖只是一種並行控制的思想。

樂觀並行控制本質上是基於驗證的協定,我們使用原子指令 CAS(compare-and-swap 或者 compare-and-set)在多執行緒中同步資料,無鎖佇列的實現也依賴這一原子指令

Channel 在執行時的內部表示是 runtime.hchan,該結構體中包含了用於保護成員變數的互斥鎖,
Channel 是一個用於同步和通訊的有鎖佇列,使用互斥鎖解決程式中可能存在的執行緒競爭問題是很常見的,我們能很容易地實現有鎖佇列。

然而鎖導致的休眠和喚醒會帶來額外的上下文切換,如果臨界區過大,加鎖解鎖導致的額外開銷就會成為效能瓶頸。
1994 年的論文 Implementing lock-free queues 就研究瞭如何使用無鎖的資料結構實現先進先出佇列,
而 Go 語言社群也在 2014 年提出了無鎖 Channel 的實現方案,該方案將 Channel 分成了以下三種型別:
1)同步channel - 不需要緩衝區,傳送方會直接將資料 交給 handoff 接收方
2)非同步channel - 基於環形快取的傳統生產者消費者模型
3)chan struct{} 型別 的非同步Channel - struct{}型別不佔用記憶體 空間。不需要實現緩衝區和直接傳送handoff的語意。

Channel資料結構

type hchan struct {
	qcount   uint  # Channel中元素的個數
	dataqsiz uint  # Channel中迴圈佇列的長度
	buf      unsafe.Pointer # 緩衝區資料指標
	elemsize uint16  # 當前Channel能夠收發的元素個數
	closed   uint32
	elemtype *_type  # 能夠收發的元素型別。
	sendx    uint   # 傳送操作處理到的位置
	recvx    uint   # 接收操作處理到的位置
	recvq    waitq # 由於緩衝區不足而導致阻塞的Goroutine的雙向連結串列
	sendq    waitq # 由於緩衝區不足而導致阻塞的Goroutine的雙向連結串列

	lock mutex
}
type waitq struct {
	first *sudog
	last  *sudog
}

建立管道

使用make關鍵字。make(chan int, 10) 轉換成 OMAKE型別的節點。
並在型別檢查階段將OMAKE型別的節點轉換成 OMAKECHAN型別。

func typecheck1(n *Node, top int) (res *Node) {
	switch n.Op {
	case OMAKE:
		...
		switch t.Etype {
		case TCHAN:
			l = nil
			if i < len(args) { // 帶緩衝區的非同步 Channel
				...
				n.Left = l
			} else { // 不帶緩衝區的同步 Channel
				n.Left = nodintconst(0)
			}
			n.Op = OMAKECHAN
		}
	}
}

這一階段會對傳入 make 關鍵字的緩衝區大小進行檢查,如果我們不向 make 傳遞表示緩衝區大小的引數,
那麼就會設定一個預設值 0,也就是當前的 Channel 不存在緩衝區。

OMAKECHAN 型別的節點最終都會在 SSA 中間程式碼生成階段之前被轉換成呼叫 runtime.makechan 或者 runtime.makechan64 的函數:

func walkexpr(n *Node, init *Nodes) *Node {
	switch n.Op {
	case OMAKECHAN:
		size := n.Left
		fnname := "makechan64"
		argtype := types.Types[TINT64]

		if size.Type.IsKind(TIDEAL) || maxintval[size.Type.Etype].Cmp(maxintval[TUINT]) <= 0 {
			fnname = "makechan"
			argtype = types.Types[TINT]
		}
		n = mkcall1(chanfn(fnname, 1, n.Type), n.Type, init, typename(n.Type), conv(size, argtype))
	}
}

runtime.makechan 和 runtime.makechan64 會根據傳入的引數型別和緩衝區大小建立一個新的 Channel 結構,
其中後者用於處理緩衝區大小大於 2 的 32 次方的情況,因為這在 Channel 中並不常見,所以我們重點關注 runtime.makechan:

func makechan(t *chantype, size int) *hchan {
	elem := t.elem
	mem, _ := math.MulUintptr(elem.size, uintptr(size))

	var c *hchan
	switch {
	case mem == 0:
		c = (*hchan)(mallocgc(hchanSize, nil, true))
		c.buf = c.raceaddr()
	case elem.kind&kindNoPointers != 0:
		c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
		c.buf = add(unsafe.Pointer(c), hchanSize)
	default:
		c = new(hchan)
		c.buf = mallocgc(mem, elem, true)
	}
	c.elemsize = uint16(elem.size)
	c.elemtype = elem
	c.dataqsiz = uint(size)
	return c
}

根據 Channel 中收發元素的型別和緩衝區的大小初始化 runtime.hchan 和緩衝區:

如果當前 Channel 中不存在緩衝區,那麼就只會為 runtime.hchan 分配一段記憶體空間;
如果當前 Channel 中儲存的型別不是指標型別,會為當前的 Channel 和底層的陣列分配一塊連續的記憶體空間;
在預設情況下會單獨為 runtime.hchan 和緩衝區分配記憶體;
在函數的最後會統一更新 runtime.hchan 的 elemsize、elemtype 和 dataqsiz 幾個欄位。

傳送資料

向Channel傳送資料是,需要使用ch <- i語句,會解析為OSEND節點,並轉換成 runtime.chansend1.
chansend1只是呼叫了chansend,並傳入Channel和需要傳送的資料。
在傳送資料的邏輯執行之前會先為當前 Channel 加鎖,防止多個執行緒並行修改資料。

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	lock(&c.lock)

	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

如果 Channel 已經關閉,那麼向該 Channel 傳送資料時會報 「send on closed channel」 錯誤並中止程式。

因為 runtime.chansend 函數的實現比較複雜,所以我們這裡將該函數的執行過程分成以下的三個部分:

當存在等待的接收者時,通過 runtime.send 直接將資料傳送給阻塞的接收者;
當緩衝區存在空餘空間時,將傳送的資料寫入 Channel 的緩衝區;
當不存在緩衝區或者緩衝區已滿時,等待其他 Goroutine 從 Channel 接收資料;

直接傳送

如果目標 Channel 沒有被關閉並且已經有處於讀等待的 Goroutine,那麼 runtime.chansend 會從接收佇列 recvq 中取出最先陷入等待的 Goroutine 並直接向它傳送資料:

	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

接收資料

i <- ch
變成 ORECV 型別的節點,後者會在型別檢查階段被轉換成 OAS2RECV 型別。資料的接收操作遵循以下的路線圖:

如果當前 Channel 已經被關閉並且緩衝區中不存在任何資料,那麼會清除 ep 指標中的資料並立刻返回。
當存在等待的傳送者時,通過 runtime.recv 從阻塞的傳送者或者緩衝區中獲取資料;
當緩衝區存在資料時,從 Channel 的緩衝區中接收資料;
當緩衝區中不存在資料時,等待其他 Goroutine 向 Channel 傳送資料;

排程器

執行緒是作業系統排程時的最基本單元。
而linux在排程器並不區分程序和執行緒的排程。
大多數執行緒屬於程序。

多個執行緒可以屬於同一個程序並共用記憶體空間。
因為多執行緒不需要建立新的虛擬記憶體空間。
不需要記憶體管理單元處理上下文的切換,執行緒之間的通訊是基於共用的記憶體進行的。

每個執行緒都會佔用1M以上的記憶體空間,在切換執行緒時不止會消耗較多的記憶體。恢復暫存器中的內容還需要向作業系統申請或銷燬資源。
每一次執行緒上下文切換都需要銷燬1us左右的時間。但是GO排程器對Goroutine的上下文切換約為0.2us.減少了80%的開銷。

Go 語言的排程器通過使用與 CPU 數量相等的執行緒減少執行緒頻繁切換的記憶體開銷,
同時在每一個執行緒上執行額外開銷更低的 Goroutine 來降低作業系統和硬體的負載。

排程器設計原理

  • 單執行緒排程器 0.x
    只包含40行程式碼,只能存在一個活躍執行緒,由G-M模型組成。
  • 多執行緒排程器 1.0
    允許允許多執行緒的程式,全域性鎖導致競爭嚴重。
  • 任務竊取排程器 1.1
    引入了處理器P.構成了目前的G-M-P模型。在處理器P的基礎上實現了基於工作竊取的排程器。
    在某些情況下,Goroutine不會讓出執行緒,進而造成飢餓問題。
    時間過長的垃圾回收 會導致 程式長時間無法運作
  • 搶佔式排程器 1.2 - 至今
    1) 基於共同作業的搶佔式排程器 1.2 - 1.13
    通過編譯器在函數呼叫時插入搶佔檢查指令,在函數呼叫時檢查當前 Goroutine 是否發起了搶佔請求,實現基於共同作業的搶佔式排程;
    Goroutine 可能會因為垃圾回收和迴圈長時間佔用資源導致程式暫停;
    2)基於訊號的搶佔式排程器 - 1.14 ~ 至今
    實現基於訊號的真搶佔式排程;
    垃圾回收在掃描棧時會觸發搶佔排程;
    搶佔的時間點不夠多,還不能覆蓋全部的邊緣情況;

網路輪詢器

利用作業系統的I/O多路複用機制。

  • I/O模型
    作業系統中包含阻塞I/O、非阻塞I/O、訊號驅動I/O、非同步I/O、I/O多路複用 5種I/O模型。

檔案描述符 File Descriptor, FD, 適用於存取檔案或者其他I/O資源的抽象控制程式碼。 管道或網路通訊端。
不同的I/O模型會使用不同的方式操作檔案描述符

  • 阻塞I/O

通過read 或者 write系統呼叫讀寫檔案或者網路時,應用程式會被阻塞。

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t nbytes);

應用程式會從使用者態陷入核心態,核心會檢查檔案描述符是否可讀,當檔案描述符中存在資料時,作業系統核心會將準備好的資料拷貝給應用程式,並交回控制權。

  • 非阻塞I/O

當程序把一個檔案描述符設定成非阻塞時,執行read和write等I/O操作會立刻返回,

第一次從檔案描述符中讀取資料會觸發系統呼叫並返回ENGAIN錯誤,ENGAIN意味著該檔案描述符還在等待緩衝區中的資料。
隨後,應用程式會不斷輪詢呼叫read,直到返回值大於0. 這時應用程式就可以對讀取作業系統緩衝區中的資料並進行操作
程序在使用非阻塞I/O操作是,可以在等待過程中執行其他任務。提高CPU的利用率

  • I/O多路複用
    用來處理同一個事件迴圈中的多個I/O事件。
    需要使用特定的系統呼叫。
    select.該函數可以同時監聽最多1024個檔案描述符的可讀或可寫狀態。

除了標準的select之外,作業系統中還提供了一個比較相似的poll函數。使用連結串列儲存檔案描述符,擺脫了1024的數量上限。

多路複用函數會阻塞的監聽一組檔案描述符,當檔案描述符的狀態轉變為可讀或者可寫時,select會返回可讀或者可寫事件的個數。
應用程式可以在輸入的檔案描述符中查詢哪些可讀或者可寫,然後執行相應的操作。

I/O多路複用模型是效率較高的I/O模型,可以同時阻塞監聽一組檔案描述符的狀態。Redis Nginx都使用的I/O多路複用模型。

  • 多模組

Go語言在網路輪詢器中使用I/O多路複用模型處理I/O操作。但是沒有選擇select.
select有較多的限制。
1)監聽能力有限- 最多隻能監聽1024個檔案描述符
2)記憶體拷貝開銷大 - 需要維護一個較大的資料結構儲存檔案描述符,該結構需要拷貝到核心中。
3)時間複雜度O(n) - 返回準備就緒的事件個數後,需要遍歷所有的檔案描述符。

為了提高I/O多路複用的效能,不同的作業系統也都實現了自己的I/O多路複用函數,例如:epoll, kqueue, evport等。
Go語言為了提高在不同作業系統上的I/O操作效能,使用平臺特定的函數實現了多個版本的網路輪詢模組。

  • 介面
    epoll、kqueue、solaries等多路複用模組都要實現以下5個函數,這5個函數構成一個虛擬的介面
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(delta int64) gList
func netpollBreak()
func netpollIsPollDescriptor(fd uintptr) bool

netpollinit() : 初始化網路輪詢器,通過sync.Once和netpollInited變數保證函數只呼叫一次。
netpollopen(): 監聽檔案描述符上的邊緣觸發事件,建立事件並加入監聽
netpoll(): 輪詢網路並返回一組已經準備就緒的Goroutine.傳入的引數會決定他的行為
如果引數小於0:無限期等待檔案描述符就緒
等於0:非阻塞地輪詢網路
大於0:阻塞特定事件輪詢網路
netpollBreak(): 喚醒網路輪詢器,
netpollIsPollDescriptor(): 判斷檔案描述符是否被輪詢器使用。

資料結構

作業系統的I/O多路複用函數會監控檔案描述符的可讀可寫。
Go語言網路輪詢器會監聽runtime.pollDesc結構體的狀態。它會封裝作業系統的檔案描述符。

type pollDesc struct {
	link *pollDesc

	lock    mutex
	fd      uintptr
	...
	rseq    uintptr
	rg      uintptr
	rt      timer
	rd      int64
	wseq    uintptr
	wg      uintptr
	wt      timer
	wd      int64
}

該結構體中包含用於監控可讀 和 可寫狀態的變數。按照功能分成四組。

  • resq\wseq: 表示檔案描述符被重用或者計時器被重置。
  • rg\wg: 表示二進位制的號誌,可能為pdReady、pdWait、等待檔案描述符可讀或者可寫的Goroutine、nil
  • rd\wd: 等待檔案描述符可讀或者可寫的截止日期
  • rt\wt:用於等待檔案描述符的計時器
    還儲存了用於保護資料的互斥鎖,檔案描述符。
    會使用link欄位 串聯成連結串列儲存在runtime.pollCache中:
type pollCache struct {
	lock  mutex
	first *pollDesc
}


pollCache是執行時包中的全域性變數,該結構體包含一個用於保護輪詢資料的互斥鎖和連結串列頭。

執行時會在第一次呼叫pollCache.alloc方法初始化總大小約為4KB的pollDesc結構體,
runtime.persistentAlloc會保證這些資料結構初始化在不會觸發垃圾回收的記憶體中。讓這些資料結構只能被內部的epoll和kqueue模組參照。

func (c *pollCache) alloc() *pollDesc {
	lock(&c.lock)
	if c.first == nil {
		const pdSize = unsafe.Sizeof(pollDesc{})
		n := pollBlockSize / pdSize
		if n == 0 {
			n = 1
		}
		mem := persistentalloc(n*pdSize, 0, &memstats.other_sys)
		for i := uintptr(0); i < n; i++ {
			pd := (*pollDesc)(add(mem, i*pdSize))
			pd.link = c.first
			c.first = pd
		}
	}
	pd := c.first
	c.first = pd.link
	unlock(&c.lock)
	return pd # 返回連結串列頭還沒有被使用的 runtime.pollDesc 
}

這種批次初始化的做法能夠增加網路輪詢器的吞吐量。
Go 語言執行時會呼叫 runtime.pollCache.free 方法釋放已經用完的 runtime.pollDesc 結構,它會直接將結構體插入連結串列的最前面:

func (c *pollCache) free(pd *pollDesc) {
	lock(&c.lock)
	pd.link = c.first
	c.first = pd
	unlock(&c.lock)
}

多路複用

網路輪詢器實際上是對I/O多路複用技術的封裝,

  1. 網路輪詢器的初始化
  2. 如何向網路輪詢器加入待監控的任務
  3. 如何從網路輪詢器獲取觸發的事件

上述三個過程包含了網路輪詢器相關的方方面面。分析遵循倆個規則。
1.因為不同的I/O多路複用模組的實現大同小異,會使用linux作業系統上的epoll實現。
2.因為處理讀事件和寫事件類似,省略寫事件。

  • 初始化
    因為檔案I/O、網路I/O、計時器都依賴網路輪詢器。
    有倆條不同路徑初始化網路輪詢器。
  1. internal/poll.pollDesc.ini - 通過net.netFD.init和os.newFile初始化網路I/O和檔案I/O的輪詢時。
  2. runtime.doaddtimer - 向處理器中增加新的計時器時

網路輪詢器的初始化會使用runtime.poll_runtime_pollServerInit和runtime.netpollGenericInit倆個函數。

func poll_runtime_pollServerInit() {
	netpollGenericInit()
}

func netpollGenericInit() {
	if atomic.Load(&netpollInited) == 0 {
		lock(&netpollInitLock)
		if netpollInited == 0 {
			netpollinit()
			atomic.Store(&netpollInited, 1)
		}
		unlock(&netpollInitLock)
	}
}

runtime.netpollGenericInit 會呼叫平臺上特定實現的 runtime.netpollinit,即 Linux 上的 epoll,它主要做了以下幾件事情:

是呼叫 epollcreate1 建立一個新的 epoll 檔案描述符,這個檔案描述符會在整個程式的生命週期中使用;
通過 runtime.nonblockingPipe 建立一個用於通訊的管道;
使用 epollctl 將用於讀取資料的檔案描述符打包成 epollevent 事件加入監聽;

var (
	epfd int32 = -1
	netpollBreakRd, netpollBreakWr uintptr
)

func netpollinit() {
	epfd = epollcreate1(_EPOLL_CLOEXEC)
	r, w, _ := nonblockingPipe()
	ev := epollevent{
		events: _EPOLLIN,
	}
	*(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd
	epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)
	netpollBreakRd = uintptr(r)
	netpollBreakWr = uintptr(w)
}

初始化的管道為我們提供了中斷多路複用等待檔案描述符中事件的方法。
netpollBreak會向管道中寫入資料喚醒epoll.

func netpollBreak() {
	for {
		var b byte
		n := write(netpollBreakWr, unsafe.Pointer(&b), 1)
		if n == 1 {
			break
		}
		if n == -_EINTR {
			continue
		}
		if n == -_EAGAIN {
			return
		}
	}
}

因為目前的計時器由網路輪詢器管理和觸發,它能夠讓網路輪詢器立刻返回並讓執行時檢查是否有需要觸發的計時器。

  • 輪詢事件
    呼叫pollDesc.init初始化檔案描述符時,不止會初始化網路輪詢器,還會通過poll_runtime_pollOpen重置輪詢資訊pollDesc,並呼叫netpollopen初始化輪詢事件。
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
	pd := pollcache.alloc()
	lock(&pd.lock)
	if pd.wg != 0 && pd.wg != pdReady {
		throw("runtime: blocked write on free polldesc")
	}
	...
	pd.fd = fd
	pd.closing = false
	pd.everr = false
	...
	pd.wseq++
	pd.wg = 0
	pd.wd = 0
	unlock(&pd.lock)

	var errno int32
	errno = netpollopen(fd, pd)
	return pd, int(errno)
}

runtime.netpollopen 它會呼叫 epollctl 向全域性的輪詢檔案描述符 epfd 中加入新的輪詢事件監聽檔案描述符的可讀和可寫狀態:

func netpollopen(fd uintptr, pd *pollDesc) int32 {
	var ev epollevent
	ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
	*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
	return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}

從全域性的 epfd 中刪除待監聽的檔案描述符可以使用 runtime.netpollclose,因為該函數的實現與 runtime.netpollopen 比較相似,所以這裡不展開分析了。

  • 事件迴圈
  1. Goroutine讓出執行緒並等待讀寫事件
  2. 多路複用等待讀寫事件的發生並返回

這倆個步驟連線了作業系統中的I/O多路複用機制和Go語言的執行時,在倆個不同的體系之間構建了橋樑。

  • 等待事件

在檔案描述符上執行讀寫操作時,如果不可讀或者不可寫,當前Goroutine會執行poll_runtime_pollWait檢查pollDesc的狀態,並呼叫netpollblock等待檔案描述符可讀或可寫

func poll_runtime_pollWait(pd *pollDesc, mode int) int {
	...
	for !netpollblock(pd, int32(mode), false) {
		...
	}
	return 0
}

func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
	gpp := &pd.rg
	if mode == 'w' {
		gpp = &pd.wg
	}
	...
	if waitio || netpollcheckerr(pd, mode) == 0 {
		gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
	}
	...
}

runtime.netpollblock 是 Goroutine 等待 I/O 事件的關鍵函數,它會使用執行時提供的 runtime.gopark 讓出當前執行緒,將 Goroutine 轉換到休眠狀態並等待執行時的喚醒。

  • 輪詢等待

Go 語言的執行時會在排程或者系統監控中呼叫 runtime.netpoll 輪詢網路,該函數的執行過程可以分成以下幾個部分:

  1. 根據傳入的delay計算epoll系統呼叫需要等待的時間
  2. 呼叫epollwait等待可讀或者可寫事件的發生。
  3. 在迴圈中依次處理epollevent事件
func netpoll(delay int64) gList {
	var waitms int32
	if delay < 0 {
		waitms = -1
	} else if delay == 0 {
		waitms = 0
	} else if delay < 1e6 {
		waitms = 1
	} else if delay < 1e15 {
		waitms = int32(delay / 1e6)
	} else {
		waitms = 1e9
	}

計算了需要等待的時間之後,runtime.netpoll 會執行 epollwait 等待檔案描述符轉換成可讀或者可寫,如果該函數返回了負值,可能會返回空的 Goroutine 列表或者重新呼叫 epollwait 陷入等待:

	var events [128]epollevent
retry:
	n := epollwait(epfd, &events[0], int32(len(events)), waitms)
	if n < 0 {
		if waitms > 0 {
			return gList{}
		}
		goto retry
	}

當 epollwait 系統呼叫返回的值大於 0 時,意味著被監控的檔案描述符出現了待處理的事件,我們在如下所示的迴圈中會依次處理這些事件:

var toRun gList
	for i := int32(0); i < n; i++ {
		ev := &events[i]
		if *(**uintptr)(unsafe.Pointer(&ev.data)) == &netpollBreakRd {
			...
			continue
		}

		var mode int32
		if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
			mode += 'r'
		}
		...
		if mode != 0 {
			pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
			pd.everr = false
			netpollready(&toRun, pd, mode)
		}
	}
	return toRun
}

處理的事件總共包含兩種,一種是呼叫 runtime.netpollBreak 觸發的事件,該函數的作用是中斷網路輪詢器;另一種是其他檔案描述符的正常讀寫事件,對於這些事件,我們會交給
7.3 棧記憶體管理
第四部分 進階內容
第八章 超程式設計
8.1 外掛系統
8.2 程式碼生成
第九章 標準庫
9.1 JSON
9.2 HTTP
9.3 資料庫
6.6 網路輪詢器 #
各位讀者朋友,很高興大家通過本部落格學習 Go 語言,感謝一路相伴!《Go語言設計與實現》的紙質版圖書已經上架京東,有需要的朋友請點選 連結 購買。

在今天,大部分的服務都是 I/O 密集型的,應用程式會花費大量時間等待 I/O 操作的完成。網路輪詢器是 Go 語言執行時用來處理 I/O 操作的關鍵元件,它使用了作業系統提供的 I/O 多路複用機制增強程式的並行處理能力。本節會深入分析 Go 語言網路輪詢器的設計與實現原理。

6.6.1 設計原理 #
網路輪詢器不僅用於監控網路 I/O,還能用於監控檔案的 I/O,它利用了作業系統提供的 I/O 多路複用模型來提升 I/O 裝置的利用率以及程式的效能。本節會分別介紹常見的幾種 I/O 模型以及 Go 語言執行時的網路輪詢器如何使用多模組設計在不同的作業系統上支援多路複用。

I/O 模型 #
作業系統中包含阻塞 I/O、非阻塞 I/O、訊號驅動 I/O 與非同步 I/O 以及 I/O 多路複用五種 I/O 模型。我們在本節中會介紹上述五種模型中的三種:

阻塞 I/O 模型;
非阻塞 I/O 模型;
I/O 多路複用模型;
在 Unix 和類 Unix 作業系統中,檔案描述符(File descriptor,FD)是用於存取檔案或者其他 I/O 資源的抽象控制程式碼,例如:管道或者網路通訊端1。而不同的 I/O 模型會使用不同的方式操作檔案描述符。

阻塞 I/O #
阻塞 I/O 是最常見的 I/O 模型,在預設情況下,當我們通過 read 或者 write 等系統呼叫讀寫檔案或者網路時,應用程式會被阻塞:

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t nbytes);
C
如下圖所示,當我們執行 read 系統呼叫時,應用程式會從使用者態陷入核心態,核心會檢查檔案描述符是否可讀;當檔案描述符中存在資料時,作業系統核心會將準備好的資料拷貝給應用程式並交回控制權。

blocking-io-mode

圖 6-39 阻塞 I/O 模型

作業系統中多數的 I/O 操作都是如上所示的阻塞請求,一旦執行 I/O 操作,應用程式會陷入阻塞等待 I/O 操作的結束。

非阻塞 I/O #
當程序把一個檔案描述符設定成非阻塞時,執行 read 和 write 等 I/O 操作會立刻返回。在 C 語言中,我們可以使用如下所示的程式碼片段將一個檔案描述符設定成非阻塞的:

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
C
在上述程式碼中,最關鍵的就是系統呼叫 fcntl 和引數 O_NONBLOCK,fcntl 為我們提供了操作檔案描述符的能力,我們可以通過它修改檔案描述符的特性。當我們將檔案描述符修改成非阻塞後,讀寫檔案會經歷以下流程:

non-blocking-io-mode

圖 6-40 非阻塞 I/O 模型

第一次從檔案描述符中讀取資料會觸發系統呼叫並返回 EAGAIN 錯誤,EAGAIN 意味著該檔案描述符還在等待緩衝區中的資料;隨後,應用程式會不斷輪詢呼叫 read 直到它的返回值大於 0,這時應用程式就可以對讀取作業系統緩衝區中的資料並進行操作。程序使用非阻塞的 I/O 操作時,可以在等待過程中執行其他任務,提高 CPU 的利用率。

I/O 多路複用 #
I/O 多路複用被用來處理同一個事件迴圈中的多個 I/O 事件。I/O 多路複用需要使用特定的系統呼叫,最常見的系統呼叫是 select,該函數可以同時監聽最多 1024 個檔案描述符的可讀或者可寫狀態:

int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);
C
除了標準的 select 之外,作業系統中還提供了一個比較相似的 poll 函數,它使用連結串列儲存檔案描述符,擺脫了 1024 的數量上限。

io-multiplexing

圖 6-41 I/O 多路複用函數監聽檔案描述符

多路複用函數會阻塞的監聽一組檔案描述符,當檔案描述符的狀態轉變為可讀或者可寫時,select 會返回可讀或者可寫事件的個數,應用程式可以在輸入的檔案描述符中查詢哪些可讀或者可寫,然後執行相應的操作。

io-multiplexing-mode

圖 6-42 I/O 多路複用模型

I/O 多路複用模型是效率較高的 I/O 模型,它可以同時阻塞監聽了一組檔案描述符的狀態。很多高效能的服務和應用程式都會使用這一模型來處理 I/O 操作,例如:Redis 和 Nginx 等。

多模組 #
Go 語言在網路輪詢器中使用 I/O 多路複用模型處理 I/O 操作,但是他沒有選擇最常見的系統呼叫 select2。雖然 select 也可以提供 I/O 多路複用的能力,但是使用它有比較多的限制:

監聽能力有限 — 最多隻能監聽 1024 個檔案描述符;
記憶體拷貝開銷大 — 需要維護一個較大的資料結構儲存檔案描述符,該結構需要拷貝到核心中;
時間複雜度 O(n) — 返回準備就緒的事件個數後,需要遍歷所有的檔案描述符;
為了提高 I/O 多路複用的效能,不同的作業系統也都實現了自己的 I/O 多路複用函數,例如:epoll、kqueue 和 evport 等。Go 語言為了提高在不同作業系統上的 I/O 操作效能,使用平臺特定的函數實現了多個版本的網路輪詢模組:

src/runtime/netpoll_epoll.go
src/runtime/netpoll_kqueue.go
src/runtime/netpoll_solaris.go
src/runtime/netpoll_windows.go
src/runtime/netpoll_aix.go
src/runtime/netpoll_fake.go
這些模組在不同平臺上實現了相同的功能,構成了一個常見的樹形結構。編譯器在編譯 Go 語言程式時,會根據目標平臺選擇樹中特定的分支進行編譯:

netpoll-modules

圖 6-43 多模組網路輪詢器

如果目標平臺是 Linux,那麼就會根據檔案中的 // +build linux 編譯指令選擇 src/runtime/netpoll_epoll.go 並使用 epoll 函數處理使用者的 I/O 操作。

介面 #
epoll、kqueue、solaries 等多路複用模組都要實現以下五個函數,這五個函數構成一個虛擬的介面:

func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(delta int64) gList
func netpollBreak()
func netpollIsPollDescriptor(fd uintptr) bool
Go
上述函數在網路輪詢器中分別扮演了不同的作用:

runtime.netpollinit — 初始化網路輪詢器,通過 sync.Once 和 netpollInited 變數保證函數只會呼叫一次;
runtime.netpollopen — 監聽檔案描述符上的邊緣觸發事件,建立事件並加入監聽;
runtime.netpoll — 輪詢網路並返回一組已經準備就緒的 Goroutine,傳入的引數會決定它的行為3;
如果引數小於 0,無限期等待檔案描述符就緒;
如果引數等於 0,非阻塞地輪詢網路;
如果引數大於 0,阻塞特定時間輪詢網路;
runtime.netpollBreak — 喚醒網路輪詢器,例如:計時器向前修改時間時會通過該函數中斷網路輪詢器4;
runtime.netpollIsPollDescriptor — 判斷檔案描述符是否被輪詢器使用;
我們在這裡只需要瞭解多路複用模組中的幾個函數,本節的後半部分會詳細分析它們的實現原理。

6.6.2 資料結構 #
作業系統中 I/O 多路複用函數會監控檔案描述符的可讀或者可寫,而 Go 語言網路輪詢器會監聽 runtime.pollDesc 結構體的狀態,它會封裝作業系統的檔案描述符:

type pollDesc struct {
link *pollDesc

lock    mutex
fd      uintptr
...
rseq    uintptr
rg      uintptr
rt      timer
rd      int64
wseq    uintptr
wg      uintptr
wt      timer
wd      int64

}
Go
該結構體中包含用於監控可讀和可寫狀態的變數,我們按照功能將它們分成以下四組:

rseq 和 wseq — 表示檔案描述符被重用或者計時器被重置5;
rg 和 wg — 表示二進位制的號誌,可能為 pdReady、pdWait、等待檔案描述符可讀或者可寫的 Goroutine 以及 nil;
rd 和 wd — 等待檔案描述符可讀或者可寫的截止日期;
rt 和 wt — 用於等待檔案描述符的計時器;
除了上述八個變數之外,該結構體中還儲存了用於保護資料的互斥鎖、檔案描述符。runtime.pollDesc 結構體會使用 link 欄位串聯成連結串列儲存在 runtime.pollCache 中:

type pollCache struct {
lock mutex
first *pollDesc
}
Go
runtime.pollCache 是執行時包中的全域性變數,該結構體中包含一個用於保護輪詢資料的互斥鎖和連結串列頭:

poll-desc-list

圖 6-44 輪詢快取連結串列

執行時會在第一次呼叫 runtime.pollCache.alloc 方法時初始化總大小約為 4KB 的 runtime.pollDesc 結構體,runtime.persistentAlloc 會保證這些資料結構初始化在不會觸發垃圾回收的記憶體中,讓這些資料結構只能被內部的 epoll 和 kqueue 模組參照:

func (c pollCache) alloc() pollDesc {
lock(&c.lock)
if c.first == nil {
const pdSize = unsafe.Sizeof(pollDesc{})
n := pollBlockSize / pdSize
if n == 0 {
n = 1
}
mem := persistentalloc(n
pdSize, 0, &memstats.other_sys)
for i := uintptr(0); i < n; i++ {
pd := (
pollDesc)(add(mem, i*pdSize))
pd.link = c.first
c.first = pd
}
}
pd := c.first
c.first = pd.link
unlock(&c.lock)
return pd
}
Go
每次呼叫該結構體都會返回連結串列頭還沒有被使用的 runtime.pollDesc,這種批次初始化的做法能夠增加網路輪詢器的吞吐量。Go 語言執行時會呼叫 runtime.pollCache.free 方法釋放已經用完的 runtime.pollDesc 結構,它會直接將結構體插入連結串列的最前面:

func (c *pollCache) free(pd *pollDesc) {
lock(&c.lock)
pd.link = c.first
c.first = pd
unlock(&c.lock)
}
Go
上述方法沒有重置 runtime.pollDesc 結構體中的欄位,該結構體被重複利用時才會由 runtime.poll_runtime_pollOpen 函數重置。

6.6.3 多路複用 #
網路輪詢器實際上是對 I/O 多路複用技術的封裝,本節將通過以下的三個過程分析網路輪詢器的實現原理:

網路輪詢器的初始化;
如何向網路輪詢器加入待監控的任務;
如何從網路輪詢器獲取觸發的事件;
上述三個過程包含了網路輪詢器相關的方方面面,能夠讓我們對其實現有完整的理解。需要注意的是,我們在分析實現時會遵循以下兩個規則:

因為不同 I/O 多路複用模組的實現大同小異,本節會使用 Linux 作業系統上的 epoll 實現;
因為處理讀事件和寫事件的邏輯類似,本節會省略寫事件相關的程式碼;
初始化 #
因為檔案 I/O、網路 I/O 以及計時器都依賴網路輪詢器,所以 Go 語言會通過以下兩條不同路徑初始化網路輪詢器:

internal/poll.pollDesc.init — 通過 net.netFD.init 和 os.newFile 初始化網路 I/O 和檔案 I/O 的輪詢資訊時;
runtime.doaddtimer — 向處理器中增加新的計時器時;
網路輪詢器的初始化會使用 runtime.poll_runtime_pollServerInit 和 runtime.netpollGenericInit 兩個函數:

func poll_runtime_pollServerInit() {
netpollGenericInit()
}

func netpollGenericInit() {
if atomic.Load(&netpollInited) == 0 {
lock(&netpollInitLock)
if netpollInited == 0 {
netpollinit()
atomic.Store(&netpollInited, 1)
}
unlock(&netpollInitLock)
}
}
Go
runtime.netpollGenericInit 會呼叫平臺上特定實現的 runtime.netpollinit,即 Linux 上的 epoll,它主要做了以下幾件事情:

是呼叫 epollcreate1 建立一個新的 epoll 檔案描述符,這個檔案描述符會在整個程式的生命週期中使用;
通過 runtime.nonblockingPipe 建立一個用於通訊的管道;
使用 epollctl 將用於讀取資料的檔案描述符打包成 epollevent 事件加入監聽;
var (
epfd int32 = -1
netpollBreakRd, netpollBreakWr uintptr
)

func netpollinit() {
epfd = epollcreate1(_EPOLL_CLOEXEC)
r, w, _ := nonblockingPipe()
ev := epollevent{
events: _EPOLLIN,
}
*(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd
epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)
netpollBreakRd = uintptr(r)
netpollBreakWr = uintptr(w)
}
Go
初始化的管道為我們提供了中斷多路複用等待檔案描述符中事件的方法,runtime.netpollBreak 會向管道中寫入資料喚醒 epoll:

func netpollBreak() {
for {
var b byte
n := write(netpollBreakWr, unsafe.Pointer(&b), 1)
if n == 1 {
break
}
if n == -_EINTR {
continue
}
if n == -_EAGAIN {
return
}
}
}
Go
因為目前的計時器由網路輪詢器管理和觸發,它能夠讓網路輪詢器立刻返回並讓執行時檢查是否有需要觸發的計時器。

輪詢事件 #
呼叫 internal/poll.pollDesc.init 初始化檔案描述符時不止會初始化網路輪詢器,還會通過 runtime.poll_runtime_pollOpen 重置輪詢資訊 runtime.pollDesc 並呼叫 runtime.netpollopen 初始化輪詢事件:

func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
pd := pollcache.alloc()
lock(&pd.lock)
if pd.wg != 0 && pd.wg != pdReady {
throw("runtime: blocked write on free polldesc")
}
...
pd.fd = fd
pd.closing = false
pd.everr = false
...
pd.wseq++
pd.wg = 0
pd.wd = 0
unlock(&pd.lock)

var errno int32
errno = netpollopen(fd, pd)
return pd, int(errno)

}
Go
runtime.netpollopen 的實現非常簡單,它會呼叫 epollctl 向全域性的輪詢檔案描述符 epfd 中加入新的輪詢事件監聽檔案描述符的可讀和可寫狀態:

func netpollopen(fd uintptr, pd *pollDesc) int32 {
var ev epollevent
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}
Go
從全域性的 epfd 中刪除待監聽的檔案描述符可以使用 runtime.netpollclose,因為該函數的實現與 runtime.netpollopen 比較相似,所以這裡不展開分析了。

事件迴圈 #
本節將繼續介紹網路輪詢器的核心邏輯,也就是事件迴圈。我們將從以下的兩個部分介紹事件迴圈的實現原理:

Goroutine 讓出執行緒並等待讀寫事件;
多路複用等待讀寫事件的發生並返回;
上述過程連線了作業系統中的 I/O 多路複用機制和 Go 語言的執行時,在兩個不同體系之間構建了橋樑,我們將分別介紹上述的兩個過程。

等待事件 #
當我們在檔案描述符上執行讀寫操作時,如果檔案描述符不可讀或者不可寫,當前 Goroutine 會執行 runtime.poll_runtime_pollWait 檢查 runtime.pollDesc 的狀態並呼叫 runtime.netpollblock 等待檔案描述符的可讀或者可寫:

func poll_runtime_pollWait(pd *pollDesc, mode int) int {
...
for !netpollblock(pd, int32(mode), false) {
...
}
return 0
}

func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
gpp := &pd.rg
if mode == 'w' {
gpp = &pd.wg
}
...
if waitio || netpollcheckerr(pd, mode) == 0 {
gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
}
...
}
Go
runtime.netpollblock 是 Goroutine 等待 I/O 事件的關鍵函數,它會使用執行時提供的 runtime.gopark 讓出當前執行緒,將 Goroutine 轉換到休眠狀態並等待執行時的喚醒。

輪詢等待 #
Go 語言的執行時會在排程或者系統監控中呼叫 runtime.netpoll 輪詢網路,該函數的執行過程可以分成以下幾個部分:

根據傳入的 delay 計算 epoll 系統呼叫需要等待的時間;
呼叫 epollwait 等待可讀或者可寫事件的發生;
在迴圈中依次處理 epollevent 事件;
因為傳入 delay 的單位是納秒,下面這段程式碼會將納秒轉換成毫秒:

func netpoll(delay int64) gList {
var waitms int32
if delay < 0 {
waitms = -1
} else if delay == 0 {
waitms = 0
} else if delay < 1e6 {
waitms = 1
} else if delay < 1e15 {
waitms = int32(delay / 1e6)
} else {
waitms = 1e9
}
Go
計算了需要等待的時間之後,runtime.netpoll 會執行 epollwait 等待檔案描述符轉換成可讀或者可寫,如果該函數返回了負值,可能會返回空的 Goroutine 列表或者重新呼叫 epollwait 陷入等待:

var events [128]epollevent

retry:
n := epollwait(epfd, &events[0], int32(len(events)), waitms)
if n < 0 {
if waitms > 0 {
return gList{}
}
goto retry
}
Go
當 epollwait 系統呼叫返回的值大於 0 時,意味著被監控的檔案描述符出現了待處理的事件,我們在如下所示的迴圈中會依次處理這些事件:

var toRun gList
for i := int32(0); i < n; i++ {
	ev := &events[i]
	if *(**uintptr)(unsafe.Pointer(&ev.data)) == &netpollBreakRd {
		...
		continue
	}

	var mode int32
	if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
		mode += 'r'
	}
	...
	if mode != 0 {
		pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
		pd.everr = false
		netpollready(&toRun, pd, mode)
	}
}
return toRun

}
Go
處理的事件總共包含兩種,一種是呼叫 runtime.netpollBreak 觸發的事件,該函數的作用是中斷網路輪詢器;另一種是其他檔案描述符的正常讀寫事件,對於這些事件,我們會交給 runtime.netpollready 處理:

func netpollready(toRun *gList, pd *pollDesc, mode int32) {
	var rg, wg *g
	...
	if mode == 'w' || mode == 'r'+'w' {
		wg = netpollunblock(pd, 'w', true)
	}
	...
	if wg != nil {
		toRun.push(wg)
	}
}

runtime.netpollunblock 會在讀寫事件發生時,將 runtime.pollDesc 中的讀或者寫號誌轉換成 pdReady 並返回其中儲存的 Goroutine;如果返回的 Goroutine 不會為空,那麼執行時會將該 Goroutine 會加入 toRun 列表,並將列表中的全部 Goroutine 加入執行佇列並等待排程器的排程。

runtime.netpoll 返回的 Goroutine 列表都會被 runtime.injectglist 注入到處理器或者全域性的執行佇列上。因為系統監控 Goroutine 直接執行線上程上,所以它獲取的 Goroutine 列表會直接加入全域性的執行佇列,其他 Goroutine 獲取的列表都會加入 Goroutine 所在處理器的執行佇列上。

  • 截止日期
    網路輪詢器和計時器的關係非常緊密,這不僅僅是因為網路輪詢器負責計時器的喚醒,還因為檔案和網路 I/O 的截止日期也由網路輪詢器負責處理。截止日期在 I/O 操作中,尤其是網路呼叫中很關鍵,網路請求存在很高的不確定因素,我們需要設定一個截止日期保證程式的正常執行,這時需要用到網路輪詢器中的 runtime.poll_runtime_pollSetDeadline:
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
	rd0, wd0 := pd.rd, pd.wd
	if d > 0 {
		d += nanotime()
	}
	pd.rd = d
	...
	if pd.rt.f == nil {
		if pd.rd > 0 {
			pd.rt.f = netpollReadDeadline
			pd.rt.arg = pd
			pd.rt.seq = pd.rseq
			resettimer(&pd.rt, pd.rd)
		}
	} else if pd.rd != rd0 {
		pd.rseq++
		if pd.rd > 0 {
			modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq)
		} else {
			deltimer(&pd.rt)
			pd.rt.f = nil
		}
	}

該函數會先使用截止日期計算出過期的時間點,然後根據 runtime.pollDesc 的狀態做出以下不同的處理:

如果結構體中的計時器沒有設定執行的函數時,該函數會設定計時器到期後執行的函數、傳入的引數並呼叫 runtime.resettimer 重置計時器;
如果結構體的讀截止日期已經被改變,我們會根據新的截止日期做出不同的處理:
如果新的截止日期大於 0,呼叫 runtime.modtimer 修改計時器;
如果新的截止日期小於 0,呼叫 runtime.deltimer 刪除計時器;

在 runtime.poll_runtime_pollSetDeadline 的最後,會重新檢查輪詢資訊中儲存的截止日期:

var rg *g
	if pd.rd < 0 {
		if pd.rd < 0 {
			rg = netpollunblock(pd, 'r', false)
		}
		...
	}
	if rg != nil {
		netpollgoready(rg, 3)
	}
	...
}

如果截止日期小於 0,上述程式碼會呼叫 runtime.netpollgoready 直接喚醒對應的 Goroutine。

系統監控

很多系統都有守護行程,它們能夠在後天監控系統的執行狀態,在出現意外時及時響應。
系統監控是Go語言執行時的重要組成部分。它會每隔一段時間檢查Go語言執行時,確保程式沒有進入異常狀態。

設計原理

在支援多工的作業系統中,守護行程是在後臺執行的計算機程式,它不會由使用者直接操作。
一般會在作業系統啟動時自動執行。 k8s的DaemonSet和Go語言的系統監控都使用類似設計提供一些通用的功能。

守護行程是很有效的設計,它在整個系統的生命週期中都會存在,會隨著系統的啟動而啟動,系統的結束而結束。在作業系統和 Kubernetes 中,我們經常會將資料庫服務、紀錄檔服務以及監控服務等程序作為守護行程執行。

Go 語言的系統監控也起到了很重要的作用,它在內部啟動了一個不會中止的迴圈,在迴圈的內部會輪詢網路、搶佔長期執行或者處於系統呼叫的 Goroutine 以及觸發垃圾回收,通過這些行為,它能夠讓系統的執行狀態變得更健康。

監控迴圈

當 Go 語言程式啟動時,執行時會在第一個 Goroutine 中呼叫 runtime.main 啟動主程式,該函數會在系統棧中建立新的執行緒:

func main() {
	...
	if GOARCH != "wasm" {
		systemstack(func() {
			newm(sysmon, nil)
		})
	}
	...
}

runtime.newm 會建立一個儲存待執行函數和處理器的新結構體 runtime.m。執行時執行系統監控不需要處理器,系統監控的 Goroutine 會直接在建立的執行緒上執行:

func newm(fn func(), _p_ *p) {
	mp := allocm(_p_, fn)
	mp.nextp.set(_p_)
	mp.sigmask = initSigmask
	...
	newm1(mp)
}

runtime.newm1 會呼叫特定平臺的 runtime.newosproc通過系統呼叫 clone 建立一個新的執行緒並在新的執行緒中執行 runtime.mstart:

func newosproc(mp *m) {
	stk := unsafe.Pointer(mp.g0.stack.hi)
	var oset sigset
	sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
	ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))
	sigprocmask(_SIG_SETMASK, &oset, nil)
	...
}

在新建立的執行緒中,我們會執行儲存在 runtime.m 中的 runtime.sysmon 啟動系統監控:

func sysmon() {
	sched.nmsys++
	checkdead()

	lasttrace := int64(0)
	idle := 0
	delay := uint32(0)
	for {
		if idle == 0 {
			delay = 20
		} else if idle > 50 {
			delay *= 2
		}
		if delay > 10*1000 {
			delay = 10 * 1000
		}
		usleep(delay)
		...
	}
}

當執行時剛剛呼叫上述函數時,會先通過 runtime.checkdead 檢查是否存在死鎖,然後進入核心的監控迴圈;系統監控在每次迴圈開始時都會通過 usleep 掛起當前執行緒,該函數的引數是微秒,執行時會遵循以下的規則決定休眠時間

初始的休眠時間是 20μs;
最長的休眠時間是 10ms;
當系統監控在 50 個迴圈中都沒有喚醒 Goroutine 時,休眠時間在每個迴圈都會倍增;

當程式趨於穩定之後,系統監控的觸發時間就會穩定在 10ms。它除了會檢查死鎖之外,還會在迴圈中完成以下的工作:

執行計時器 — 獲取下一個需要被觸發的計時器;
輪詢網路 — 獲取需要處理的到期檔案描述符;
搶佔處理器 — 搶佔執行時間較長的或者處於系統呼叫的 Goroutine;
垃圾回收 — 在滿足條件時觸發垃圾收集回收記憶體;

  • 檢查死鎖
    系統監控通過 runtime.checkdead 檢查執行時是否發生了死鎖,我們可以將檢查死鎖的過程分成以下三個步驟:

檢查是否存在正在執行的執行緒;
檢查是否存在正在執行的 Goroutine;
檢查處理器上是否存在計時器;

該函數首先會檢查 Go 語言執行時中正在執行的執行緒數量,我們通過排程器中的多個欄位計算該值的結果:

func checkdead() {
	var run0 int32
	run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys
	if run > run0 {
		return
	}
	if run < 0 {
		print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")
		throw("checkdead: inconsistent counts")
	}
	...
}

runtime.mcount 根據下一個待建立的執行緒 id 和釋放的執行緒數得到系統中存在的執行緒數;
nmidle 是處於空閒狀態的執行緒數量;
nmidlelocked 是處於鎖定狀態的執行緒數量;
nmsys 是處於系統呼叫的執行緒數量;

利用上述幾個執行緒相關資料,我們可以得到正在執行的執行緒數,如果執行緒數量大於 0,說明當前程式不存在死鎖;如果執行緒數小於 0,說明當前程式的狀態不一致;如果執行緒數等於 0,我們需要進一步檢查程式的執行狀態:

func checkdead() {
	...
	grunning := 0
	for i := 0; i < len(allgs); i++ {
		gp := allgs[i]
		if isSystemGoroutine(gp, false) {
			continue
		}
		s := readgstatus(gp)
		switch s &^ _Gscan {
		case _Gwaiting, _Gpreempted:
			grunning++
		case _Grunnable, _Grunning, _Gsyscall:
			print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
			throw("checkdead: runnable g")
		}
	}
	unlock(&allglock)
	if grunning == 0 {
		throw("no goroutines (main called runtime.Goexit) - deadlock!")
	}
	...
}

當存在 Goroutine 處於 _Grunnable、_Grunning 和 _Gsyscall 狀態時,意味著程式發生了死鎖;
當所有的 Goroutine 都處於 _Gidle、_Gdead 和 _Gcopystack 狀態時,意味著主程式呼叫了 runtime.goexit;
當執行時存在等待的 Goroutine 並且不存在正在執行的 Goroutine 時,我們會檢查處理器中存在的計時器:

func checkdead() {
	...
	for _, _p_ := range allp {
		if len(_p_.timers) > 0 {
			return
		}
	}

	throw("all goroutines are asleep - deadlock!")
}
  • 執行時計數器
    在系統監控的迴圈中,我們通過 runtime.nanotime 和 runtime.timeSleepUntil 獲取當前時間和計時器下一次需要喚醒的時間;當前排程器需要執行垃圾回收或者所有處理器都處於閒置狀態時,如果沒有需要觸發的計時器,那麼系統監控可以暫時陷入休眠:
    休眠的時間會依據強制 GC 的週期 forcegcperiod 和計時器下次觸發的時間確定,runtime.notesleep 會使用號誌同步系統監控即將進入休眠的狀態。當系統監控被喚醒之後,我們會重新計算當前時間和下一個計時器需要觸發的時間、呼叫 runtime.noteclear 通知系統監控被喚醒並重置休眠的間隔。

如果在這之後,我們發現下一個計時器需要觸發的時間小於當前時間,這也說明所有的執行緒可能正在忙於執行 Goroutine,系統監控會啟動新的執行緒來觸發計時器,避免計時器的到期時間有較大的偏差。

  • 輪詢網路
    如果上一次輪詢網路已經過去了 10ms,那麼系統監控還會在迴圈中輪詢網路,檢查是否有待執行的檔案描述符:
func sysmon() {
	...
	for {
		...
		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)
			if !list.empty() {
				incidlelocked(-1)
				injectglist(&list)
				incidlelocked(1)
			}
		}
		...
	}
}

上述函數會非阻塞地呼叫 runtime.netpoll 檢查待執行的檔案描述符並通過 runtime.injectglist 將所有處於就緒狀態的 Goroutine 加入全域性執行佇列中:

func injectglist(glist *gList) {
	if glist.empty() {
		return
	}
	lock(&sched.lock)
	var n int
	for n = 0; !glist.empty(); n++ {
		gp := glist.pop()
		casgstatus(gp, _Gwaiting, _Grunnable)
		globrunqput(gp)
	}
	unlock(&sched.lock)
	for ; n != 0 && sched.npidle != 0; n-- {
		startm(nil, false)
	}
	*glist = gList{}
}

該函數會將所有 Goroutine 的狀態從 _Gwaiting 切換至 _Grunnable 並加入全域性執行佇列等待執行,如果當前程式中存在空閒的處理器,會通過 runtime.startm 啟動執行緒來執行這些任務。

  • 搶佔處理器
    系統監控會在迴圈中呼叫 runtime.retake 搶佔處於執行或者系統呼叫中的處理器,該函數會遍歷執行時的全域性處理器,每個處理器都儲存了一個 runtime.sysmontick:
type sysmontick struct {
	schedtick   uint32
	schedwhen   int64
	syscalltick uint32
	syscallwhen int64
}

該結構體中的四個欄位分別儲存了處理器的排程次數、處理器上次排程時間、系統呼叫的次數以及系統呼叫的時間。runtime.retake 的迴圈包含了兩種不同的搶佔邏輯

func retake(now int64) uint32 {
	n := 0
	for i := 0; i < len(allp); i++ {
		_p_ := allp[i]
		pd := &_p_.sysmontick
		s := _p_.status
		if s == _Prunning || s == _Psyscall {
			t := int64(_p_.schedtick)
			if pd.schedwhen+forcePreemptNS <= now {
				preemptone(_p_)
			}
		}

		if s == _Psyscall {
			if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
				continue
			}
			if atomic.Cas(&_p_.status, s, _Pidle) {
				n++
				_p_.syscalltick++
				handoffp(_p_)
			}
		}
	}
	return uint32(n)
}

當處理器處於 _Prunning 或者 _Psyscall 狀態時,如果上一次觸發排程的時間已經過去了 10ms,我們會通過 runtime.preemptone 搶佔當前處理器;
當處理器處於 _Psyscall 狀態時,在滿足以下兩種情況下會呼叫 runtime.handoffp 讓出處理器的使用權:
當處理器的執行佇列不為空或者不存在空閒處理器時2;
當系統呼叫時間超過了 10ms 時3;
系統監控通過在迴圈中搶佔處理器來避免同一個 Goroutine 佔用執行緒太長時間造成飢餓問題。

  • 垃圾回收
    在最後,系統監控還會決定是否需要觸發強制垃圾回收,runtime.sysmon 會構建 runtime.gcTrigger 並呼叫 runtime.gcTrigger.test 方法判斷是否需要觸發垃圾回收:
    如果需要觸發垃圾回收,我們會將用於垃圾回收的 Goroutine 加入全域性佇列,讓排程器選擇合適的處理器去執行。

記憶體管理

記憶體分配器

程式中的資料和變數都會被分配到程式所在的虛擬記憶體中。記憶體空間包含倆個重要區域:棧區 Stack 和堆區 Heap。
函數呼叫的引數、返回值、區域性變數大都匯被分配到棧上。這部分記憶體會由編譯器進行管理
不同程式語言使用不同的方法管理堆區的記憶體。
C++程式語言會由工程師主動申請和釋放記憶體,
Go、java由工程師和編譯器共同管理。堆中的物件由記憶體分配器分配並由垃圾回收器會後。

不同的程式語言會選擇不同的方式管理記憶體。

記憶體分配器設計原理

記憶體管理包含三個不同的元件,分別是使用者程式 Muator、分配器 Allocator和收集器 Collector.
當用戶程式申請記憶體時,它會通過記憶體分配器申請新記憶體,而分配器會負責從堆中初始化相應的記憶體區域。

  • 分配方法
    程式語言的記憶體分配器一般包含倆種分配方法 ,線性分配器 Sequential Allocator ,Bump Allocator
    空閒連結串列分配器 Free-List Allocator.

  • 線性分配器
    Bump Allocator 是一種高效的記憶體分配方法,但是有較大的侷限性。
    當我們使用線性分配器時,只需要在記憶體中維護一個指向記憶體特定位置的指標。
    如果使用者程式向分配器申請記憶體,分配器只需要檢查剩餘的空閒記憶體、返回分配的記憶體區域、修改指標在記憶體中的位置。

    線性分配器帶來了較快的執行速度和較低的實現複雜度。但是無法再記憶體被釋放時重用記憶體。

    所以線性分配器需要與合適的垃圾回收演演算法配合,例如標記壓縮 Mark-Compact、複製回收Copying GC、分代回收 Generation GC
    可以通過拷貝的方式整理存活物件的碎片,將空閒記憶體定期合併,
    線性分配器需要與具有拷貝特性的垃圾回收演演算法配合,所以C和C++等需要直接對外暴露指標的語言就無法使用該策略。

  • 空閒連結串列分配器
    Free-List Allocator 可以重用已經被釋放的記憶體,它在內部會維護一個類似連結串列的資料結構。
    當用戶程式申請記憶體時,空閒連結串列分配器會依次遍歷空閒的記憶體塊,找到足夠大的記憶體塊,然後申請資源並修改連結串列

因為不同的記憶體塊通過指標構成了連結串列,所以使用這種方式的分配器可以利用回收的資源。
但是分配記憶體的時候需要遍歷連結串列,所以它的時間複雜度是O(n).
空閒連結串列分配器可以選擇不同的策略在連結串列中的記憶體塊中進行選擇,最常見以下四種:
1)首次適應 First-Fit - 從連結串列頭開始遍歷,選擇第一個大小大於申請記憶體的記憶體塊
2)迴圈首次適應 Next-Fit - 從上次遍歷的結束位置開始遍歷,選擇第一個大小大於申請記憶體的記憶體塊
3)最優適應 Best-Fit - 從連結串列頭遍歷整個連結串列,選擇最合適的記憶體塊
4)隔離適應 Segregated-Fit - 將記憶體分割成多個連結串列,每個連結串列中的記憶體塊大小相同。申請記憶體時先找到滿足條件的連結串列,再從連結串列中選擇合適的記憶體塊。
Go語言類似第四個。

該策略會將記憶體分割成由 4、8、16、32 位元組的記憶體塊組成的連結串列,當我們向記憶體分配器申請 8 位元組的記憶體時,它會在上圖中找到滿足條件的空閒記憶體塊並返回。
隔離適應的分配策略減少了需要遍歷的記憶體塊數量,提高了記憶體分配的效率。

  • 分級分配
    執行緒快取分配 Thread-Caching Malloc。TCMalloc. 是用於分配記憶體的機制,它比glibc中的malloc還要快很多。
    Go語言借鑑了TCMalloc的設計實現高速的記憶體分配。
    核心理念:使用多級快取將物件根據大小分類,並按照類別實施不同的分配策略

  • 物件大小
    Go語言的記憶體分配的記憶體大小選擇不同的處理邏輯,執行時根據物件的大小,將物件分成微物件、小物件、大物件 3種。
    | 類別 | 大小 |
    | ---- | ----------- |
    | 微物件 | (0, 16B) |
    | 小物件 | [16B, 32KB] |
    | 大物件 | (32KB, +∞) |
    因為程式中的絕大多數物件的大小都在32KB以下,而申請的記憶體大小影響Go語言執行時分配記憶體的過程和開銷。
    所以分別處理大物件和小物件有利於提高記憶體分配器的效能。

  • 多級快取 記憶體分配
    記憶體分配器不僅會區別對待大小不同的物件,還會將記憶體分成不同的級別 分別管理。
    TCMalloc和Go執行時分配器都會引入執行緒快取Thread Cache、中心快取 Central Cache、頁堆 Page Heap.三個元件分級管理記憶體


執行緒快取屬於每一個獨立的執行緒,它能夠滿足執行緒上絕大多數的記憶體分配需求,
因為不涉及多執行緒,所以不需要使用互斥鎖來保護記憶體。
當執行緒快取不能滿足需求時,執行時會使用中心快取作為解決小物件的記憶體分配,
在遇到32KB以上的物件時,記憶體分配器會選擇頁堆直接分配大記憶體。

這種多層級的記憶體分配設計與計算機作業系統的多級快取有些類似,因為多數的物件都是小物件,我們可以通過執行緒快取和中心快取提供足夠的記憶體空間。
發現資源不足時,從上一級元件中獲取更多的記憶體資源。

  • 虛擬機器器記憶體佈局

GO語言堆區記憶體地址空間的設計及演進過程。
1.10 以前版本。堆區的記憶體空間都是連續的。
1.11 版本。go團隊使用稀疏的堆記憶體空間替代了連續的記憶體。解決了連續記憶體帶來的限制以及在特殊場景下可能出現的問題。

  • 線性記憶體
    Go 語言裝載1.10版本在啟動時會初始化整片虛擬記憶體區域。
    三個區域spans、bitmap、arena分別預留了512MB、16GB、512GB的記憶體空間。
    這些記憶體並不是真正存在的實體記憶體,而是虛擬記憶體。

spans 區域儲存了指向記憶體管理單元 runtime.mspan 的指標,每個記憶體單元會管理幾頁的記憶體空間,每頁大小為 8KB;
bitmap 用於標識 arena 區域中的那些地址儲存了物件,點陣圖中的每個位元組都會表示堆區中的 32 位元組是否空閒;
arena 區域是真正的堆區,執行時會將 8KB 看做一頁,這些記憶體頁中儲存了所有在堆上初始化的物件

對於任意一個地址,我們都可以根據 arena 的基地址計算該地址所在的頁數
並通過 spans 陣列獲得管理該片記憶體的管理單元 runtime.mspan,
spans 陣列中多個連續的位置可能對應同一個 runtime.mspan 結構。

  • 稀疏記憶體
    1.11 提出的方案。
    使用稀疏的記憶體佈局不僅能移除堆大小的上限5,還能解決 C 和 Go 混合使用時的地址空間衝突問題。不過因為基於稀疏記憶體的記憶體管理失去了記憶體的連續性這一假設,這也使記憶體管理變得更加複雜:

    執行時使用二維的 runtime.heapArena 陣列管理所有的記憶體,每個單元都會管理 64MB 的記憶體空間:
type heapArena struct {
	bitmap       [heapArenaBitmapBytes]byte  
	spans        [pagesPerArena]*mspan
	pageInUse    [pagesPerArena / 8]uint8
	pageMarks    [pagesPerArena / 8]uint8
	pageSpecials [pagesPerArena / 8]uint8
	checkmarks   *checkmarksMap
	zeroedBase   uintptr
}

該結構體中的 bitmap 和 spans 與線性記憶體中的 bitmap 和 spans 區域一一對應

zeroedBase 欄位指向了該結構體管理的記憶體的基地址。上述設計將原有的連續大記憶體切分成稀疏的小記憶體,而用於管理這些記憶體的元資訊也被切成了小塊。

不同平臺和架構的二維陣列大小可能完全不同,
如果我們的 Go 語言服務在 Linux 的 x86-64 架構上執行,二維陣列的一維大小會是 1,而二維大小是 4,194,304,
因為每一個指標占用 8 位元組的記憶體空間,所以元資訊的總大小為 32MB。
由於每個 runtime.heapArena 都會管理 64MB 的記憶體,整個堆區最多可以管理 256TB 的記憶體,這比之前的 512GB 多好幾個數量級。

由於記憶體的管理變得更加複雜,上述改動對垃圾回收稍有影響,大約會增加 1% 的垃圾回收開銷,不過這也是我們為了解決已有問題必須付出的成本

  • 地址空間
    因為所有的記憶體空間最終都是要從作業系統中申請的。所以Go語言的執行時構建了作業系統的記憶體管理抽象層,
    該抽象層將執行時管理的地址空間分為以下四種狀態。
狀態 解釋
None 記憶體沒有被保留或者對映,是地址空間的預設狀態
Reserved 執行時持有該地址空間,但是存取該記憶體會導致錯誤
Prepared 記憶體被保留,一般沒有對應的實體記憶體存取該片記憶體的行為是未定義的可以快速轉換到 Ready 狀態
Ready 可以被安全存取

每個不同的作業系統都會包含一組用於管理記憶體的特定方法,這些方法可以讓記憶體地址空間在不同的狀態之間轉換,我們可以通過下圖瞭解不同狀態之間的轉換過程:

runtime.sysAlloc 會從作業系統中獲取一大塊可用的記憶體空間,可能為幾百 KB 或者幾 MB;
runtime.sysFree 會在程式發生記憶體不足(Out-of Memory,OOM)時呼叫並無條件地返回記憶體;
runtime.sysReserve 會保留作業系統中的一片記憶體區域,存取這片記憶體會觸發異常;
runtime.sysMap 保證記憶體區域可以快速轉換至就緒狀態;
runtime.sysUsed 通知作業系統應用程式需要使用該記憶體區域,保證記憶體區域可以安全存取;
runtime.sysUnused 通知作業系統虛擬記憶體對應的實體記憶體已經不再需要,可以重用實體記憶體;
runtime.sysFault 將記憶體區域轉換成保留狀態,主要用於執行時的偵錯;

執行時使用 Linux 提供的 mmap、munmap 和 madvise 等系統呼叫實現了作業系統的記憶體管理抽象層,抹平了不同作業系統的差異,
為執行時提供了更加方便的介面,除了 Linux 之外,執行時還實現了 BSD、Darwin、Plan9 以及 Windows 等平臺上抽象層。

記憶體管理元件

Go語言的記憶體分配器包含記憶體管理單元、執行緒快取、中心快取、和頁堆。
runtime.mspan、runtime.mcache、runtime.mcentral 和 runtime.mheap

所有的 Go 語言程式都會在啟動時初始化如上圖所示的記憶體佈局,
每一個處理器都會分配一個執行緒快取 runtime.mcache 用於處理微物件和小物件的分配,它們會持有記憶體管理單元 runtime.mspan。

每個型別的記憶體管理單元都會管理特定大小的物件,
當記憶體管理單元中不存在空閒物件時,它們會從 runtime.mheap 持有的 134 箇中心快取 runtime.mcentral 中獲取新的記憶體單元,
中心快取屬於全域性的堆結構體 runtime.mheap,它會從作業系統中申請記憶體。

在 amd64 的 Linux 作業系統上,runtime.mheap 會持有 4,194,304 runtime.heapArena,每個 runtime.heapArena 都會管理 64MB 的記憶體,單個 Go 語言程式的記憶體上限也就是 256TB。

  • 記憶體管理單元
    runtime.mspan是Go語言記憶體管理的基本單元你,有next prev倆個欄位。分別指向了前一個和後一個mspan.
type mspan struct {
	next *mspan
	prev *mspan
	...
}


串聯後的mspan 構成雙向連結串列。執行時會使用mSpanList儲存雙向連結串列的頭節點和尾節點併線上程快取和中心快取中使用。

  • 頁和記憶體
    每個mspan都管理npages個大小為8kb的頁。這裡的頁不是作業系統中的記憶體頁。
    它們是作業系統記憶體頁的整數倍。該結構體會使用下面這些欄位來管理記憶體頁的分配和回收:
type mspan struct {
	startAddr uintptr // 起始地址
	npages    uintptr // 頁數
	freeindex uintptr

	allocBits  *gcBits
	gcmarkBits *gcBits
	allocCache uint64
	...
}

startAddr 和 npages — 確定該結構體管理的多個頁所在的記憶體,每個頁的大小都是 8KB;
freeindex — 掃描頁中空閒物件的初始索引;
allocBits 和 gcmarkBits — 分別用於標記記憶體的佔用和回收情況;
allocCache — allocBits 的二補數,可以用於快速查詢記憶體中未被使用的記憶體;

runtime.mspan 會以兩種不同的視角看待管理的記憶體,當結構體管理的記憶體不足時,執行時會以頁為單位向堆申請記憶體:

當用戶程式或者執行緒向 runtime.mspan 申請記憶體時,它會使用 allocCache 欄位以物件為單位在管理的記憶體中快速查詢待分配的空間:


如果我們能在記憶體中找到空閒的記憶體單元會直接返回,當記憶體中不包含空閒的記憶體時,
上一級的元件 runtime.mcache 會為呼叫 runtime.mcache.refill 更新記憶體管理單元以滿足為更多物件分配記憶體的需求。

  • 狀態
    執行時會使用 runtime.mSpanStateBox 儲存記憶體管理單元的狀態 runtime.mSpanState:
type mspan struct {
	...
	state       mSpanStateBox
	...
}

該狀態可能處於 mSpanDead、mSpanInUse、mSpanManual 和 mSpanFree 四種情況。
當 runtime.mspan 在空閒堆中,它會處於 mSpanFree 狀態;
當 runtime.mspan 已經被分配時,它會處於 mSpanInUse、mSpanManual 狀態,執行時會遵循下面的規則轉換該狀態:

在垃圾回收的任意階段,可能從 mSpanFree 轉換到 mSpanInUse 和 mSpanManual;
在垃圾回收的清除階段,可能從 mSpanInUse 和 mSpanManual 轉換到 mSpanFree;
在垃圾回收的標記階段,不能從 mSpanInUse 和 mSpanManual 轉換到 mSpanFree;
設定 runtime.mspan 狀態的操作必須是原子性的以避免垃圾回收造成的執行緒競爭問題。

  • 跨度類
    runtime.spanClass 是 runtime.mspan 的跨度類,它決定了記憶體管理單元中儲存的物件大小和個數:
type mspan struct {
	...
	spanclass   spanClass
	...
}

Go 語言的記憶體管理模組中一共包含 67 種跨度類,
每一個跨度類都會儲存特定大小的物件並且包含特定數量的頁數以及物件,
所有的資料都會被預選計算好並儲存在 runtime.class_to_size 和 runtime.class_to_allocnpages 等變數中:

  • 執行緒快取
    runtime.mcache 是 Go 語言中的執行緒快取,它會與執行緒上的處理器一一系結,
    主要用來快取使用者程式申請的微小物件。每一個執行緒快取都持有 68 * 2 個 runtime.mspan,
    這些記憶體管理單元都儲存在結構體的 alloc 欄位中:

執行緒快取在剛剛被初始化時是不包含 runtime.mspan 的,只有當用戶程式申請記憶體時才會從上一級元件獲取新的 runtime.mspan 滿足記憶體分配的需求。

初始化
執行時在初始化處理器時會呼叫 runtime.allocmcache 初始化執行緒快取,該函數會在系統棧中使用 runtime.mheap 中的執行緒快取分配器初始化新的 runtime.mcache 結構體:

func allocmcache() *mcache {
	var c *mcache
	systemstack(func() {
		lock(&mheap_.lock)
		c = (*mcache)(mheap_.cachealloc.alloc())
		c.flushGen = mheap_.sweepgen
		unlock(&mheap_.lock)
	})
	for i := range c.alloc {
		c.alloc[i] = &emptymspan
	}
	c.nextSample = nextSample()
	return c
}

替換
runtime.mcache.refill 會為執行緒快取獲取一個指定跨度類的記憶體管理單元,被替換的單元不能包含空閒的記憶體空間,而獲取的單元中需要至少包含一個空閒物件用於分配記憶體:

微分配器 #
執行緒快取中還包含幾個用於分配微物件的欄位,下面的這三個欄位組成了微物件分配器,專門管理 16 位元組以下的物件:

type mcache struct {
	tiny             uintptr
	tinyoffset       uintptr
	local_tinyallocs uintptr
}
  • 中心快取
    runtime.mcentral 是記憶體分配器的中心快取,與執行緒快取不同,存取中心快取中的記憶體管理單元需要使用互斥鎖:
type mcentral struct {
	spanclass spanClass
	partial  [2]spanSet
	full     [2]spanSet
}

每個中心快取都會管理某個跨度類的記憶體管理單元,它會同時持有兩個 runtime.spanSet,分別儲存包含空閒物件和不包含空閒物件的記憶體管理單元。

  • 記憶體管理單元
    執行緒快取會通過中心快取的 runtime.mcentral.cacheSpan 方法獲取新的記憶體管理單元,該方法的實現比較複雜,我們可以將其分成以下幾個部分

呼叫 runtime.mcentral.partialSwept 從清理過的、包含空閒空間的 runtime.spanSet 結構中查詢可以使用的記憶體管理單元;
呼叫 runtime.mcentral.partialUnswept 從未被清理過的、有空閒物件的 runtime.spanSet 結構中查詢可以使用的記憶體管理單元;
呼叫 runtime.mcentral.fullUnswept 獲取未被清理的、不包含空閒空間的 runtime.spanSet 中獲取記憶體管理單元並通過 runtime.mspan.sweep 清理它的記憶體空間;
呼叫 runtime.mcentral.grow 從堆中申請新的記憶體管理單元;
更新記憶體管理單元的 allocCache 等欄位幫助快速分配記憶體;

首先我們會在中心快取的空閒集合中查詢可用的 runtime.mspan,執行時總是會先從獲取清理過的記憶體管理單元,後檢查未清理的記憶體管理單元:
當找到需要回收的記憶體單元時,執行時會觸發 runtime.mspan.sweep 進行清理,如果在包含空閒空間的集合中沒有找到管理單元,那麼執行時嘗試會從未清理的集合中獲取
如果 runtime.mcentral 通過上述兩個階段都沒有找到可用的單元,它會呼叫 runtime.mcentral.grow 觸發擴容從堆中申請新的記憶體
無論通過哪種方法獲取到了記憶體單元,該方法的最後都會更新記憶體單元的 allocBits 和 allocCache 等欄位,讓執行時在分配記憶體時能夠快速找到空閒的物件

  • 擴容
    中心快取的擴容方法 runtime.mcentral.grow 會根據預先計算的 class_to_allocnpages 和 class_to_size 獲取待分配的頁數以及跨度類並呼叫 runtime.mheap.alloc 獲取新的 runtime.mspan 結構:
    獲取了 runtime.mspan 後,我們會在上述方法中初始化 limit 欄位並清除該結構在堆上對應的點陣圖。

  • 頁堆

runtime.mheap 是記憶體分配的核心結構體,Go 語言程式會將其作為全域性變數儲存,而堆上初始化的所有物件都由該結構體統一管理,該結構體中包含兩組非常重要的欄位,
其中一個是全域性的中心快取列表 central,另一個是管理堆區記憶體區域的 arenas 以及相關欄位。

頁堆中包含一個長度為 136 的 runtime.mcentral 陣列,其中 68 個為跨度類需要 scan 的中心快取,另外的 68 個是 noscan 的中心快取:

Go 語言所有的記憶體空間都由如下所示的二維矩陣 runtime.heapArena 管理,這個二維矩陣管理的記憶體可以是不連續的:

記憶體分配

堆上所有的物件都會通過呼叫newobject函數分配記憶體。
該函數會呼叫mallocgc分配指定大小的記憶體空間。

微物件 (0, 16B) — 先使用微型分配器,再依次嘗試執行緒快取、中心快取和堆分配記憶體;
小物件 [16B, 32KB] — 依次嘗試使用執行緒快取、中心快取和堆分配記憶體;
大物件 (32KB, +∞) — 直接在堆上分配記憶體;

垃圾收集器


當程式的記憶體佔用達到一定閾值時,整個應用程式就會全部暫停,垃圾收集器會掃描已經分配的所有物件並回收不再使用的記憶體空間,
當這個過程結束後,使用者程式才可以繼續執行,Go 語言在早期也使用這種策略實現垃圾收集
記憶體分配器和垃圾收集器共同管理著程式中的堆記憶體空間

  • 標記清除
    標記清除(Mark-Sweep)演演算法是最常見的垃圾收集演演算法,標記清除收集器是跟蹤式垃圾收集器,其執行過程可以分成標記(Mark)和清除(Sweep)兩個階段:

標記階段 — 從根物件出發查詢並標記堆中所有存活的物件;
清除階段 — 遍歷堆中的全部物件,回收未被標記的垃圾物件並將回收的記憶體加入空閒連結串列;
如下圖所示,記憶體空間中包含多個物件,我們從根物件出發依次遍歷物件的子物件並將從根節點可達的物件都標記成存活狀態,即 A、C 和 D 三個物件,剩餘的 B、E 和 F 三個物件因為從根節點不可達,所以會被當做垃圾

標記階段結束後會進入清除階段,在該階段中收集器會依次遍歷堆中的所有物件,釋放其中沒有被標記的 B、E 和 F 三個物件並將新的空閒記憶體空間以連結串列的結構串聯起來,方便記憶體分配器的使用。

這裡介紹的是最傳統的標記清除演演算法,垃圾收集器從垃圾收集的根物件出發,遞迴遍歷這些物件指向的子物件並將所有可達的物件標記成存活;標記階段結束後,垃圾收集器會依次遍歷堆中的物件並清除其中的垃圾,整個過程需要標記物件的存活狀態,使用者程式在垃圾收集的過程中也不能執行,我們需要用到更復雜的機制來解決 STW 的問題。

  • 三色抽象

為了解決原始標記清除演演算法帶來的長時間 STW,多數現代的追蹤式垃圾收集器都會實現三色標記演演算法的變種以縮短 STW 的時間。
三色標記演演算法將程式中的物件分成白色、黑色和灰色三類

白色物件 — 潛在的垃圾,其記憶體可能會被垃圾收集器回收;
黑色物件 — 活躍的物件,包括不存在任何參照外部指標的物件以及從根物件可達的物件;
灰色物件 — 活躍的物件,因為存在指向白色物件的外部指標,垃圾收集器會掃描這些物件的子物件;

在垃圾收集器開始工作時,程式中不存在任何的黑色物件,垃圾收集的根物件會被標記成灰色,
垃圾收集器只會從灰色物件集合中取出物件開始掃描,當灰色集合中不存在任何物件時,標記階段就會結束。

三色標記垃圾收集器的工作原理很簡單,我們可以將其歸納成以下幾個步驟:

從灰色物件的集合中選擇一個灰色物件並將其標記成黑色;
將黑色物件指向的所有物件都標記成灰色,保證該物件和被該物件參照的物件都不會被回收;
重複上述兩個步驟直到物件圖中不存在灰色物件;

當三色的標記清除的標記階段結束之後,應用程式的堆中就不存在任何的灰色物件,
我們只能看到黑色的存活物件以及白色的垃圾物件,垃圾收集器可以回收這些白色的垃圾,
下面是使用三色標記垃圾收集器執行標記後的堆記憶體,堆中只有物件 D 為待回收的垃圾:

本來不應該被回收的物件卻被回收了,這在記憶體管理中是非常嚴重的錯誤,我們將這種錯誤稱為懸掛指標
,即指標沒有指向特定型別的合法物件,影響了記憶體的安全性,想要並行或者增量地標記物件還是需要使用屏障技術。

  • 屏障技術
    記憶體屏障技術是一種屏障指令,它可以讓 CPU 或者編譯器在執行記憶體相關操作時遵循特定的約束,
    目前多數的現代處理器都會亂序執行指令以最大化效能,
    但是該技術能夠保證記憶體操作的順序性,
    在記憶體屏障前執行的操作一定會先於記憶體屏障後執行的操作

想要在並行或者增量的標記演演算法中保證正確性,我們需要達成以下兩種三色不變性(Tri-color invariant)中的一種:
強三色不變性 — 黑色物件不會指向白色物件,只會指向灰色物件或者黑色物件;
弱三色不變性 — 黑色物件指向的白色物件必須包含一條從灰色物件經由多個白色物件的可達路徑

垃圾收集中的屏障技術更像是一個勾點方法,
它是在使用者程式讀取物件、建立新物件以及更新物件指標時執行的一段程式碼,
根據操作型別的不同,我們可以將它們分成讀屏障(Read barrier)和寫屏障(Write barrier)兩種,
因為讀屏障需要在讀操作中加入程式碼片段,對使用者程式的效能影響很大,所以程式語言往往都會採用寫屏障保證三色不變性。

Go 語言中使用的兩種寫屏障技術,分別是 Dijkstra 提出的插入寫屏障和 Yuasa 提出的刪除寫屏障

  • 插入寫屏障

Dijkstra 在 1978 年提出了插入寫屏障,通過如下所示的寫屏障,
使用者程式和垃圾收集器可以在交替工作的情況下保證程式執行的正確性:

writePointer(slot, ptr):
    shade(ptr)
    *slot = ptr

上述插入寫屏障的虛擬碼非常好理解,
每當執行類似 *slot = ptr 的表示式時,我們會執行上述寫屏障通過 shade 函數嘗試改變指標的顏色。
如果 ptr 指標是白色的,那麼該函數會將該物件設定成灰色,其他情況則保持不變。


假設我們在應用程式中使用 Dijkstra 提出的插入寫屏障,
在一個垃圾收集器和使用者程式交替執行的場景中會出現如上圖所示的標記過程

垃圾收集器將根物件指向 A 物件標記成黑色並將 A 物件指向的物件 B 標記成灰色;
使用者程式將 A 物件原本指向 B 的指標指向 C,觸發刪除寫屏障,但是因為 B 物件已經是灰色的,所以不做改變;
使用者程式將 B 物件原本指向 C 的指標刪除,觸發刪除寫屏障,白色的 C 物件被塗成灰色;
垃圾收集器依次遍歷程式中的其他灰色物件,將它們分別標記成黑色
上述過程中的第三步觸發了 Yuasa 刪除寫屏障的著色,
因為使用者程式刪除了 B 指向 C 物件的指標,所以 C 和 D 兩個物件會分別違反強三色不變性和弱三色不變性:
強三色不變性 — 黑色的 A 物件直接指向白色的 C 物件;
弱三色不變性 — 垃圾收集器無法從某個灰色物件出發,經過幾個連續的白色物件存取白色的 C 和 D 兩個物件;

Yuasa 刪除寫屏障通過對 C 物件的著色,保證了 C 物件和下游的 D 物件能夠在這一次垃圾收集的迴圈中存活,避免發生懸掛指標以保證使用者程式的正確性。

  • 增量和並行

傳統的垃圾收集演演算法會在垃圾收集的執行期間暫停應用程式,一旦觸發垃圾收集,
垃圾收集器會搶佔 CPU 的使用權佔據大量的計算資源以完成標記和清除工作,然而很多追求實時的應用程式無法接受長時間的 STW。

遠古時代的計算資源還沒有今天這麼豐富,今天的計算機往往都是多核的處理器,垃圾收集器一旦開始執行就會浪費大量的計算資源,
為了減少應用程式暫停的最長時間和垃圾收集的總暫停時間,我們會使用下面的策略優化現代的垃圾收集器:

增量垃圾收集 — 增量地標記和清除垃圾,降低應用程式暫停的最長時間;
並行垃圾收集 — 利用多核的計算資源,在使用者程式執行時並行標記和清除垃圾;

因為增量和並行兩種方式都可以與使用者程式交替執行,所以我們需要使用屏障技術保證垃圾收集的正確性;
與此同時,應用程式也不能等到記憶體溢位時觸發垃圾收集,因為當記憶體不足時,應用程式已經無法分配記憶體,這與直接暫停程式沒有什麼區別,
增量和並行的垃圾收集需要提前觸發並在記憶體不足前完成整個迴圈,避免程式的長時間暫停。

  • 增量收集器
    增量式(Incremental)的垃圾收集是減少程式最長暫停時間的一種方案,
    它可以將原本時間較長的暫停時間切分成多個更小的 GC 時間片,
    雖然從垃圾收集開始到結束的時間更長了,
    但是這也減少了應用程式暫停的最大時間:

    增量式的垃圾收集需要與三色標記法一起使用,
    為了保證垃圾收集的正確性,我們需要在垃圾收集開始前開啟寫屏障,
    這樣使用者程式修改記憶體都會先經過寫屏障的處理,保證了堆記憶體中物件關係的強三色不變性或者弱三色不變性。
    雖然增量式的垃圾收集能夠減少最大的程式暫停時間,
    但是增量式收集也會增加一次 GC 迴圈的總時間,
    在垃圾收集期間,因為寫屏障的影響使用者程式也需要承擔額外的計算開銷,
    所以增量式的垃圾收集也不是隻帶來好處的,但是總體來說還是利大於弊

  • 並行收集器
    並行(Concurrent)的垃圾收集不僅能夠減少程式的最長暫停時間,還能減少整個垃圾收集階段的時間,
    通過開啟讀寫屏障、利用多核優勢與使用者程式並行執行,並行垃圾收集器確實能夠減少垃圾收集對應用程式的影響:

    雖然並行收集器能夠與使用者程式一起執行,但是並不是所有階段都可以與使用者程式一起執行,
    部分階段還是需要暫停使用者程式的,不過與傳統的演演算法相比,並行的垃圾收集可以將能夠並行執行的工作儘量並行執行;
    當然,因為讀寫屏障的引入,並行的垃圾收集器也一定會帶來額外開銷,不僅會增加垃圾收集的總時間,還會影響使用者程式,這是我們在設計垃圾收集策略時必須要注意的。

演進過程

Go 語言的垃圾收集器從誕生的第一天起就一直在演進,除了少數幾個版本沒有大更新之外,幾乎每次釋出的小版本都會提升垃圾收集的效能,
而與效能一同提升的還有垃圾收集器程式碼的複雜度,本節將從 Go 語言 v1.0 版本開始分析垃圾收集器的演進過程。
v1.0 — 完全序列的標記和清除過程,需要暫停整個程式;
v1.1 — 在多核主機並行執行垃圾收集的標記和清除階段
v1.3 — 執行時基於只有指標型別的值包含指標的假設增加了對棧記憶體的精確掃描支援,實現了真正精確的垃圾收集
將 unsafe.Pointer 型別轉換成整數型別的值認定為不合法的,可能會造成懸掛指標等嚴重問題;
v1.5 — 實現了基於三色標記清掃的並行垃圾收集器
大幅度降低垃圾收集的延遲從幾百 ms 降低至 10ms 以下;
計算垃圾收集啟動的合適時間並通過並行加速垃圾收集的過程;
v1.6 — 實現了去中心化的垃圾收集協調器;
基於顯式的狀態機使得任意 Goroutine 都能觸發垃圾收集的狀態遷移;
使用密集的點陣圖替代空閒連結串列表示的堆記憶體,降低清除階段的 CPU 佔用
v1.7 — 通過並行棧收縮將垃圾收集的時間縮短至 2ms 以內
v1.8 — 使用混合寫屏障將垃圾收集的時間縮短至 0.5ms 以內
v1.9 — 徹底移除暫停程式的重新掃描棧的過程
v1.10 — 更新了垃圾收集調頻器(Pacer)的實現,分離軟硬堆大小的目標
v1.12 — 使用新的標記終止演演算法簡化垃圾收集器的幾個階段
v1.13 — 通過新的 Scavenger 解決瞬時記憶體佔用過高的應用程式向作業系統歸還記憶體的問題
v1.14 — 使用全新的頁分配器優化記憶體分配的速度
我們從 Go 語言垃圾收集器的演進能夠看到該元件的實現和演演算法變得越來越複雜,
最開始的垃圾收集器還是不精確的單執行緒 STW 收集器,
但是最新版本的垃圾收集器卻支援並行垃圾收集、去中心化協調等特性,
我們在這裡將介紹與最新版垃圾收集器相關的元件和特性。

  • 並行垃圾收集

並行垃圾收集器必須在合適的時間點觸發垃圾收集迴圈,
假設我們的 Go 語言程式執行在一臺 4 核的物理機上,那麼在垃圾收集開始後,收集器會佔用 25% 計算資源在後臺來掃描並標記記憶體中的物件

Go 語言的並行垃圾收集器會在掃描物件之前暫停程式做一些標記物件的準備工作,
其中包括啟動後臺標記的垃圾收集器以及開啟寫屏障,如果在後臺執行的垃圾收集器不夠快,應用程式申請記憶體的速度超過預期,
執行時會讓申請記憶體的應用程式輔助完成垃圾收集的掃描階段,在標記和標記終止階段結束之後就會進入非同步的清理階段,將不用的記憶體增量回收。

v1.5 版本實現的並行垃圾收集策略由專門的 Goroutine 負責在處理器之間同步和協調垃圾收集的狀態。
當其他的 Goroutine 發現需要觸發垃圾收集時,它們需要將該資訊通知給負責修改狀態的主 Goroutine,
然而這個通知的過程會帶來一定的延遲,這個延遲的時間視窗很可能是不可控的,使用者程式會在這段時間繼續分配記憶體。

  • 回收堆目標
    STW 的垃圾收集器雖然需要暫停程式,但是它能夠有效地控制堆記憶體的大小,
    Go 語言執行時的預設設定會在堆記憶體達到上一次垃圾收集的 2 倍時,觸發新一輪的垃圾收集,
    這個行為可以通過環境變數 GOGC 調整,在預設情況下它的值為 100,即增長 100% 的堆記憶體才會觸發 GC。

實現原理

在介紹垃圾收集器的演進過程之前,我們需要初步瞭解最新垃圾收集器的執行週期,
這對我們瞭解其全域性的設計會有比較大的幫助。
Go 語言的垃圾收集可以分成清除終止、標記、標記終止和清除四個不同階段,它們分別完成了不同的工作

清理終止階段;
暫停程式,所有的處理器在這時會進入安全點(Safe point);
如果當前垃圾收集迴圈是強制觸發的,我們還需要處理還未被清理的記憶體管理單元;
標記階段;
將狀態切換至 _GCmark、開啟寫屏障、使用者程式協助(Mutator Assists)並將根物件入隊;
恢復執行程式,標記程序和用於協助的使用者程式會開始並行標記記憶體中的物件,寫屏障會將被覆蓋的指標和新指標都標記成灰色,而所有新建立的物件都會被直接標記成黑色;
開始掃描根物件,包括所有 Goroutine 的棧、全域性物件以及不在堆中的執行時資料結構,掃描 Goroutine 棧期間會暫停當前處理器;
依次處理灰色佇列中的物件,將物件標記成黑色並將它們指向的物件標記成灰色;
使用分散式的終止演演算法檢查剩餘的工作,發現標記階段完成後進入標記終止階段;
標記終止階段;
暫停程式、將狀態切換至 _GCmarktermination 並關閉輔助標記的使用者程式;
清理處理器上的執行緒快取;
清理階段;
將狀態切換至 _GCoff 開始清理階段,初始化清理狀態並關閉寫屏障;
恢復使用者程式,所有新建立的物件會標記成白色;
後臺並行清理所有的記憶體管理單元,當 Goroutine 申請新的記憶體管理單元時就會觸發清理;
執行時雖然只會使用 _GCoff、_GCmark 和 _GCmarktermination 三個狀態表示垃圾收集的全部階段,
但是在實現上卻複雜很多,本節將按照垃圾收集的不同階段詳細分析其實現原理。

  • 全域性變數
    在垃圾收集中有一些比較重要的全域性變數,在分析其過程之前,我們會先逐一介紹這些重要的變數,
    這些變數在垃圾收集的各個階段中會反覆出現,所以理解他們的功能是非常重要的,我們先介紹一些比較簡單的變數:

runtime.gcphase 是垃圾收集器當前處於的階段,可能處於 _GCoff、_GCmark 和 _GCmarktermination,Goroutine 在讀取或者修改該階段時需要保證原子性;
runtime.gcBlackenEnabled 是一個布林值,當垃圾收集處於標記階段時,該變數會被置為 1,在這裡輔助垃圾收集的使用者程式和後臺標記的任務可以將物件塗黑;
runtime.gcController 實現了垃圾收集的調步演演算法,它能夠決定觸發並行垃圾收集的時間和待處理的工作;
runtime.gcpercent 是觸發垃圾收集的記憶體增長百分比,預設情況下為 100,即堆記憶體相比上次垃圾收集增長 100% 時應該觸發 GC,並行的垃圾收集器會在到達該目標前完成垃圾收集;
runtime.writeBarrier 是一個包含寫屏障狀態的結構體,其中的 enabled 欄位表示寫屏障的開啟與關閉;
runtime.worldsema 是全域性的號誌,獲取該號誌的執行緒有權利暫停當前應用程式;

除了上述全域性的變數之外,我們在這裡還需要簡單瞭解一下 runtime.work 變數

var work struct {
	full  lfstack
	empty lfstack
	pad0  cpu.CacheLinePad

	wbufSpans struct {
		lock mutex
		free mSpanList
		busy mSpanList
	}
	...
	nproc  uint32
	tstart int64
	nwait  uint32
	ndone  uint32
	...
	mode gcMode
	cycles uint32
	...
	stwprocs, maxprocs int32
	...
}

該結構體中包含大量垃圾收集的相關欄位,例如:表示完成的垃圾收集迴圈的次數、當前迴圈時間和 CPU 的利用率、垃圾收集的模式等等

  • 觸發時機
    執行時會通過如下所示的 runtime.gcTrigger.test 方法決定是否需要觸發垃圾收集,
    當滿足觸發垃圾收集的基本條件時 — 允許垃圾收集、程式沒有崩潰並且沒有處於垃圾收集迴圈,該方法會根據三種不同方式觸發進行不同的檢查:
func (t gcTrigger) test() bool {
	if !memstats.enablegc || panicking != 0 || gcphase != _GCoff {
		return false
	}
	switch t.kind {
	case gcTriggerHeap:
		return memstats.heap_live >= memstats.gc_trigger
	case gcTriggerTime:
		if gcpercent < 0 {
			return false
		}
		lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
		return lastgc != 0 && t.now-lastgc > forcegcperiod
	case gcTriggerCycle:
		return int32(t.n-work.cycles) > 0
	}
	return true
}

gcTriggerHeap — 堆記憶體的分配達到控制器計算的觸發堆大小;
gcTriggerTime — 如果一定時間內沒有觸發,就會觸發新的迴圈,該觸發條件由 runtime.forcegcperiod 變數控制,預設為 2 分鐘;
gcTriggerCycle — 如果當前沒有開啟垃圾收集,則觸發新的迴圈

用於開啟垃圾收集的方法 runtime.gcStart 會接收一個 runtime.gcTrigger 型別的謂詞,所有出現 runtime.gcTrigger 結構體的位置都是觸發垃圾收集的程式碼:

runtime.sysmon 和 runtime.forcegchelper — 後臺執行定時檢查和垃圾收集;
runtime.GC — 使用者程式手動觸發垃圾收集;
runtime.mallocgc — 申請記憶體時根據堆大小觸發垃圾收集;

  • 後臺觸發
    執行時會在應用程式啟動時在後臺開啟一個用於強制觸發垃圾收集的 Goroutine,
    該 Goroutine 的職責非常簡單 — 呼叫 runtime.gcStart 嘗試啟動新一輪的垃圾收集
func init() {
	go forcegchelper()
}

func forcegchelper() {
	forcegc.g = getg()
	for {
		lock(&forcegc.lock)
		atomic.Store(&forcegc.idle, 1)
		goparkunlock(&forcegc.lock, waitReasonForceGGIdle, traceEvGoBlock, 1)
		gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
	}
}
  • 手動觸發

使用者程式會通過 runtime.GC 函數在程式執行期間主動通知執行時執行,該方法在呼叫時會阻塞呼叫方直到當前垃圾收集迴圈完成,在垃圾收集期間也可能會通過 STW 暫停整個程式:

func GC() {
	n := atomic.Load(&work.cycles)
	gcWaitOnMark(n)
	gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
	gcWaitOnMark(n + 1)

	for atomic.Load(&work.cycles) == n+1 && sweepone() != ^uintptr(0) {
		sweep.nbgsweep++
		Gosched()
	}

	for atomic.Load(&work.cycles) == n+1 && atomic.Load(&mheap_.sweepers) != 0 {
		Gosched()
	}

	mp := acquirem()
	cycle := atomic.Load(&work.cycles)
	if cycle == n+1 || (gcphase == _GCmark && cycle == n+2) {
		mProf_PostSweep()
	}
	releasem(mp)
}
  • 申請記憶體
    最後一個可能會觸發垃圾收集的就是 runtime.mallocgc 了,
    我們在上一節記憶體分配器中曾經介紹過執行時會將堆上的物件按大小分成微物件、小物件和大物件三類,
    這三類物件的建立都可能會觸發新的垃圾收集迴圈:

  • 垃圾收集啟動
    垃圾收集在啟動過程一定會呼叫 runtime.gcStart,
    主要職責是修改全域性的垃圾收集狀態到 _GCmark 並做一些準備工作,我們會分以下幾個階段介紹該函數的實現:
    兩次呼叫 runtime.gcTrigger.test 檢查是否滿足垃圾收集條件;
    暫停程式、在後臺啟動用於處理標記任務的工作 Goroutine、確定所有記憶體管理單元都被清理以及其他標記階段開始前的準備工作;
    進入標記階段、準備後臺的標記工作、根物件的標記工作以及微物件、恢復使用者程式,進入並行掃描和標記階段;
    驗證垃圾收集條件的同時,該方法還會在迴圈中不斷呼叫 runtime.sweepone 清理已經被標記的記憶體單元,完成上一個垃圾收集迴圈的收尾工作:

  • 暫停與恢復程式
    runtime.stopTheWorldWithSema 和 runtime.startTheWorldWithSema 是一對用於暫停和恢復程式的核心函數,
    它們有著完全相反的功能,但是程式的暫停會比恢復要複雜一些,我們來看一下前者的實現原理

func stopTheWorldWithSema() {
	_g_ := getg()
	sched.stopwait = gomaxprocs
	atomic.Store(&sched.gcwaiting, 1)
	preemptall()
	_g_.m.p.ptr().status = _Pgcstop
	sched.stopwait--
	for _, p := range allp {
		s := p.status
		if s == _Psyscall && atomic.Cas(&p.status, s, _Pgcstop) {
			p.syscalltick++
			sched.stopwait--
		}
	}
	for {
		p := pidleget()
		if p == nil {
			break
		}
		p.status = _Pgcstop
		sched.stopwait--
	}
	wait := sched.stopwait > 0
	if wait {
		for {
			if notetsleep(&sched.stopnote, 100*1000) {
				noteclear(&sched.stopnote)
				break
			}
			preemptall()
		}
	}
}

暫停程式主要使用了 runtime.preemptall,該函數會呼叫我們在前面介紹過的 runtime.preemptone,因為程式中活躍的最大處理數為 gomaxprocs,所以 runtime.stopTheWorldWithSema 在每次發現停止的處理器時都會對該變數減一,直到所有的處理器都停止執行。該函數會依次停止當前處理器、等待處於系統呼叫的處理器以及獲取並搶佔空閒的處理器,處理器的狀態在該函數返回時都會被更新至 _Pgcstop,等待垃圾收集器的重新喚醒。

程式恢復過程會使用 runtime.startTheWorldWithSema,該函數的實現也相對比較簡單:

呼叫 runtime.netpoll 從網路輪詢器中獲取待處理的任務並加入全域性佇列;
呼叫 runtime.procresize 擴容或者縮容全域性的處理器;
呼叫 runtime.notewakeup 或者 runtime.newm 依次喚醒處理器或者為處理器建立新的執行緒;
如果當前待處理的 Goroutine 數量過多,建立額外的處理器輔助完成任務;

func startTheWorldWithSema(emitTraceEvent bool) int64 {
	mp := acquirem()
	if netpollinited() {
		list := netpoll(0)
		injectglist(&list)
	}

	procs := gomaxprocs
	p1 := procresize(procs)
	sched.gcwaiting = 0
	...
	for p1 != nil {
		p := p1
		p1 = p1.link.ptr()
		if p.m != 0 {
			mp := p.m.ptr()
			p.m = 0
			mp.nextp.set(p)
			notewakeup(&mp.park)
		} else {
			newm(nil, p)
		}
	}

	if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
		wakep()
	}
	...
}

程式的暫停和啟動過程都比較簡單,暫停程式會使用 runtime.preemptall 搶佔所有的處理器,
恢復程式時會使用 runtime.notewakeup 或者 runtime.newm 喚醒程式中的處理器。

  • 後臺標記模式
    在垃圾收集啟動期間,執行時會呼叫 runtime.gcBgMarkStartWorkers 為全域性每個處理器建立用於執行後臺標記任務的 Goroutine,每一個 Goroutine 都會執行 runtime.gcBgMarkWorker,
    所有執行 runtime.gcBgMarkWorker 的 Goroutine 在啟動後都會陷入休眠等待排程器的喚醒

  • 並行掃描與標記輔助

棧空間管理

設計原理

棧區的記憶體一般由編譯器自動分配和釋放,其中儲存著函數的入參以及區域性變數。
這些引數會隨著函數的建立而建立,函數的返回而消亡。
一般不會在程式中長期存在,這種線性的記憶體分配策略有著極高的效率
但是工程師頁往往不能控制棧記憶體的分配。這部分工作基本都是由編譯器完成的。

  • 暫存器
    暫存器是CPU的稀缺資源。它的儲存能力非常有限。但是能提供最快的讀寫速度。
    充分利用暫存器的速度可以構建高效能的應用程式。
    棧區的操作會使用到倆個以上的暫存器。

棧暫存器是CPU暫存器的一種。主要作用是跟蹤函數的呼叫棧,Go語言的組合程式碼包含BP和SP倆個棧暫存器。
它們分別儲存了棧的基址地址和棧頂的地址。
棧記憶體與函數呼叫的關係非常緊密。

BP和SP之間的記憶體就是當前函數的呼叫棧。
因為歷史原因,棧記憶體都是從高地址向低地址擴充套件的。
當應用程式申請或者釋放棧記憶體時只需要修改SP暫存器的值。這種線性的記憶體分配方式與堆記憶體相比更加快速。

  • 執行緒棧
    如果我們在linux作業系統中執行pthread_create系統呼叫。程序會啟動一個新的執行緒。
    如果使用者沒有通過軟資源限制 RLIMIT_STACK指定執行緒棧的大小。那麼作業系統會根據架構選擇不同的預設棧的大小。
    | 架構 | 預設棧大小 |
    | ------- | ----- |
    | i386 | 2 MB |
    | IA-64 | 32 MB |
    | PowerPC | 4 MB |
    | … | … |
    | x86_64 | 2 MB |
    多數架構預設棧大小都在2-4MB左右,極少數會使用32MB的棧。
    使用者程式可以在分配的站上儲存函數引數和區域性變數。
    然而這個固定的棧大小在某些場景下不是合適的值,如果程式需要同時執行幾百個甚至上千個執行緒。
    這些執行緒中的大部分都只會用到很少的棧空間。當函數的呼叫棧非常深時,固定棧大小也無法滿足使用者程式的需求。

執行緒和程序都是程式碼執行的上下 文。
如果一個應用程式包含成百上千個執行上下文都是執行緒。會佔有大量的記憶體空間並帶來其他額開銷。
GO語言在設計時認為執行上下文是輕量級的,所以它在使用者態實現Goroutine作為執行上下文。

  • 逃逸分析
    在C語言和C++這類需要手動管理記憶體的程式語言中。
    將物件或者結構體分配到棧上或者堆上是由工程師自主決定的。
    在編譯器優化中,逃逸分析是用來決定指標動態作用域的方法。
    Go語言的編譯器使用逃逸分析決定哪些變數應該在棧上分配。哪些變數應該在堆上分配。
    遵循倆個不變形:
    指向棧物件的指標不能存在於堆中;
    指向棧物件的指標不能在棧物件回收後存活;

決定變數是在棧上還是堆上雖然重要,但是這是一個定義相對清晰的問題,
我們可以通過編譯器統一作決策。為了保證記憶體的絕對安全,編譯器可能會將一些變數錯誤地分配到堆上,但是因為堆也會被垃圾收集器掃描,所以不會造成記憶體洩露以及懸掛指標等安全問題,解放了工程師的生產力。

  • 棧記憶體空間
    Go語言使用使用者態執行緒 Goroutine作為執行上下文。
    它的額外開銷和預設棧大小都比執行緒小很多

v1.0 ~ v1.1 — 最小棧記憶體空間為 4KB;
v1.2 — 將最小棧記憶體提升到了 8KB7;
v1.3 — 使用連續棧替換之前版本的分段棧
v1.4 — 將最小棧記憶體降低到了 2KB
Goroutine 的初始棧記憶體在最初的幾個版本中多次修改,從 4KB 提升到 8KB 是臨時的解決方案,其目的是為了減輕分段棧中的棧分裂對程式的效能影響;
在 v1.3 版本引入連續棧之後,Goroutine 的初始棧大小降低到了 2KB,進一步減少了 Goroutine 佔用的記憶體空間。

  • 分段棧
    分段棧是1.3版本之前的實現,所有的Goroutine在初始化時都會呼叫stackalloc:go分配一塊固定大小的記憶體空間。
    這塊記憶體的大小有StackMin:go表示。在1.2版本中為8KB.
void* runtime·stackalloc(uint32 n) {
	uint32 pos;
	void *v;
	if(n == FixedStack || m->mallocing || m->gcing) {
		if(m->stackcachecnt == 0)
			stackcacherefill();
		pos = m->stackcachepos;
		pos = (pos - 1) % StackCacheSize;
		v = m->stackcache[pos];
		m->stackcachepos = pos;
		m->stackcachecnt--;
		m->stackinuse++;
		return v;
	}
	return runtime·mallocgc(n, 0, FlagNoProfiling|FlagNoGC|FlagNoZero|FlagNoInvokeGC);
}

如果通過該方法申請的記憶體大小為固定的 8KB 或者滿足其他的條件,
執行時會在全域性的棧快取連結串列中找到空閒的記憶體塊並作為新 Goroutine 的棧空間返回;
在其餘情況下,棧記憶體空間會從堆上申請一塊合適的記憶體。

當 Goroutine 呼叫的函數層級或者區域性變數需要的越來越多時,執行時會呼叫 runtime.morestack:go1.2 和 runtime.newstack:go1.2 建立一個新的棧空間,
這些棧空間雖然不連續,但是當前 Goroutine 的多個棧空間會以連結串列的形式串聯起來,執行時會通過指標找到連續的棧片段:

  • 連續棧
    連續棧可以解決分段棧中存在的兩個問題,其核心原理是每當程式的棧空間不足時,初始化一片更大的棧空間並將原棧中的所有值都遷移到新棧中,
    新的區域性變數或者函數呼叫就有充足的記憶體空間。使用連續棧機制時,棧空間不足導致的擴容會經歷以下幾個步驟
    在記憶體空間中分配更大的棧記憶體空間;
    將舊棧中的所有內容複製到新棧中;
    將指向舊棧對應變數的指標重新指向新棧;
    銷燬並回收舊棧的記憶體空間;
    在擴容的過程中,最重要的是調整指標的第三步,這一步能夠保證指向棧的指標的正確性,因為棧中的所有變數記憶體都會發生變化,所以原本指向棧中變數的指標也需要調整。
    我們在前面提到過經過逃逸分析的 Go 語言程式的遵循以下不變性 —— 指向棧物件的指標不能存在於堆中,所以指向棧中變數的指標只能在棧上,我們只需要調整棧中的所有變數就可以保證記憶體的安全了。

    因為需要拷貝變數和調整指標,連續棧增加了棧擴容時的額外開銷,但是通過合理棧縮容機制就能避免熱分裂帶來的效能問題10,
    在 GC 期間如果 Goroutine 使用了棧記憶體的四分之一,那就將其記憶體減少一半,這樣在棧記憶體幾乎充滿時也只會擴容一次,不會因為函數呼叫頻繁擴縮容

棧操作

Go 語言中的執行棧由 runtime.stack 表示,該結構體中只包含兩個欄位,分別表示棧的頂部和棧的底部,每個棧結構體都表示範圍為 [lo, hi) 的記憶體空間:

type stack struct {
	lo uintptr
	hi uintptr
}

棧的結構雖然非常簡單,但是想要理解 Goroutine 棧的實現原理,還是需要我們從編譯期間和執行時兩個階段入手:
編譯器會在編譯階段會通過 cmd/internal/obj/x86.stacksplit 在呼叫函數前插入 runtime.morestack 或者 runtime.morestack_noctxt 函數;
執行時在建立新的 Goroutine 時會在 runtime.malg 中呼叫 runtime.stackalloc 申請新的棧記憶體,並在編譯器插入的 runtime.morestack 中檢查棧空間是否充足;

  • 棧初始化
    棧空間在執行時中包含兩個重要的全域性變數,分別是 runtime.stackpool 和 runtime.stackLarge,
    這兩個變數分別表示全域性的棧快取和大棧快取,前者可以分配小於 32KB 的記憶體,後者用來分配大於 32KB 的棧空間:
var stackpool [_NumStackOrders]struct {
	item stackpoolItem
	_    [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}

type stackpoolItem struct {
	mu   mutex
	span mSpanList
}

var stackLarge struct {
	lock mutex
	free [heapAddrBits - pageShift]mSpanList
}

這兩個用於分配空間的全域性變數都與記憶體管理單元 runtime.mspan 有關,
我們可以認為 Go 語言的棧記憶體都是分配在堆上的,執行時初始化會呼叫 runtime.stackinit 初始化這些全域性變數:

func stackinit() {
	for i := range stackpool {
		stackpool[i].item.span.init()
	}
	for i := range stackLarge.free {
		stackLarge.free[i].init()
	}
}

從排程器和記憶體分配的經驗來看,如果執行時只使用全域性變數來分配記憶體的話,
勢必會造成執行緒之間的鎖競爭進而影響程式的執行效率,
棧記憶體由於與執行緒關係比較密切,所以我們在每一個執行緒快取 runtime.mcache 中都加入了棧快取減少鎖競爭影響

type mcache struct {
	stackcache [_NumStackOrders]stackfreelist
}

type stackfreelist struct {
	list gclinkptr
	size uintptr
}

執行時使用全域性的 runtime.stackpool 和執行緒快取中的空閒連結串列分配 32KB 以下的棧記憶體,
使用全域性的 runtime.stackLarge 和堆記憶體分配 32KB 以上的棧記憶體,提高本地分配棧記憶體的效能。

  • 棧分配

執行時會在 Goroutine 的初始化函數 runtime.malg 中呼叫 runtime.stackalloc 分配一個大小足夠棧記憶體空間,
根據執行緒快取和申請棧的大小,該函數會通過三種不同的方法分配棧空間:

如果棧空間較小,使用全域性棧快取或者執行緒快取上固定大小的空閒連結串列分配記憶體;
如果棧空間較大,從全域性的大棧快取 runtime.stackLarge 中獲取記憶體空間;
如果棧空間較大並且 runtime.stackLarge 空間不足,在堆上申請一片大小足夠記憶體空間;

  • 棧擴容
    編譯器會在 cmd/internal/obj/x86.stacksplit 中為函數呼叫插入 runtime.morestack 執行時檢查,
    它會在幾乎所有的函數呼叫之前檢查當前 Goroutine 的棧記憶體是否充足,
    如果當前棧需要擴容,我們會儲存一些棧的相關資訊並呼叫 runtime.newstack 建立新的棧:

runtime.newstack 會先做一些準備工作並檢查當前 Goroutine 是否發出了搶佔請求,如果發出了搶佔請求:

當前執行緒可以被搶佔時,直接呼叫 runtime.gogo 觸發排程器的排程;
如果當前 Goroutine 在垃圾回收被 runtime.scanstack 標記成了需要收縮棧,呼叫 runtime.shrinkstack;
如果當前 Goroutine 被 runtime.suspendG 函數掛起,呼叫 runtime.preemptPark 被動讓出當前處理器的控制權並將 Goroutine 的狀態修改至 _Gpreempted;
呼叫 runtime.gopreempt_m 主動讓出當前處理器的控制權;

如果當前 Goroutine 不需要被搶佔,意味著我們需要新的棧空間來支援函數呼叫和本地變數的初始化,執行時會先檢查目標大小的棧是否會溢位:

如果目標棧的大小沒有超出程式的限制,我們會將 Goroutine 切換至 _Gcopystack 狀態並呼叫 runtime.copystack 開始棧拷貝。
在拷貝棧記憶體之前,執行時會通過 runtime.stackalloc 分配新的棧空間:

  • 棧縮容
    runtime.shrinkstack 棧縮容時呼叫的函數,該函數的實現原理非常簡單,其中大部分都是檢查是否滿足縮容前置條件的程式碼

如果要觸發棧的縮容,新棧的大小會是原始棧的一半,不過如果新棧的大小低於程式的最低限制 2KB,那麼縮容的過程就會停止

執行時只會在棧記憶體使用不足 1/4 時進行縮容,縮容也會呼叫擴容時使用的 runtime.copystack 開闢新的棧空間。

棧總結

棧記憶體是應用程式中重要的記憶體空間,它能夠支援原生的區域性變數和函數呼叫,
棧空間中的變數會與棧一同建立和銷燬,這部分記憶體空間不需要工程師過多的干預和管理,
現代的程式設計
語言通過逃逸分析減少了我們的工作量,理解棧空間的分配對於理解 Go 語言的執行時有很大的幫助。

進階內容

外掛系統

通過外掛系統,我們可以在執行時載入動態庫 實現一些比較有趣的功能

設計原理

基於C語言的動態庫實現。

  • 靜態庫或者靜態連結庫是由編譯期決定的程式、外部函數和變數構成的。編譯器或者連結器會將程式和變數等內容拷貝到目標的應用 並生成一個獨立的可執行物件檔案。
  • 動態庫或者共用物件可以在多個可執行檔案共用。程式使用的模組會在執行時從共用物件中載入。而不是在編譯程式時打包成獨立的可執行檔案。

只依賴靜態庫並且通過靜態連結生成的二進位制檔案因為包含了全部的依賴,能夠獨立執行,但是編譯的結果比較大。
動態庫或者共用物件在多個執行檔案共用,可以減少記憶體佔用。其連結過程實在裝載或者執行期間觸發的。所以可以包含一些可以熱插拔的模組,並降低記憶體的佔用。

使用靜態連結編譯二進位制檔案在部署上有非常明顯的優勢,最終的編譯產物也可以直接執行在大多數的機器上,
靜態連結帶來的部署優勢遠比更低的記憶體佔用顯得重要,所以很多程式語言包括 Go 都將靜態連結作為預設的連結方式。

  • 外掛系統
    動態連結帶來的低記憶體優勢已經沒有太大作用,
    主要是可以提供靈活性,實現熱插拔。

通過在主程式和共用庫直接定義一系列的約定或者介面,我們可以通過以下的程式碼動態載入其他人編譯的 Go 語言共用物件,
這樣做的好處是主程式和共用庫的開發者不需要共用程式碼,只要雙方的約定不變,修改共用庫後也不需要重新編譯主程式。

type Driver interface {
    Name() string
}

func main() {
    p, err := plugin.Open("driver.so")
    if err != nil {
	   panic(err)
    }

    newDriverSymbol, err := p.Lookup("NewDriver")
    if err != nil {
        panic(err)
    }

    newDriverFunc := newDriverSymbol.(func() Driver)
    newDriver := newDriverFunc()
    fmt.Println(newDriver.Name())
}
  • 作業系統
    不同的作業系統會實現不同的動態連結機制和共用庫格式。
    Linux中的共用物件會使用ELF格式並提供了一組操作動態連結器介面。
void *dlopen(const char *filename, int flag);
char *dlerror(void);
void *dlsym(void *handle, const char *symbol);
int dlclose(void *handle);

dlopen 會根據傳入的檔名載入對應的動態庫並返回一個控制程式碼(Handle);
我們可以直接使用 dlsym 函數在該控制程式碼中搜尋特定的符號,也就是函數或者變數,它會返回該符號被載入到記憶體中的地址。
因為待查詢的符號可能不存在於目標動態庫中,所以在每次查詢後我們都應該呼叫 dlerror 檢視當前查詢的結果。

動態庫

Go 語言外掛系統的全部實現都包含在 plugin 中,這個包實現了符號系統的載入和決議。外掛是一個帶有公開函數和變數的包,我們需要使用下面的命令編譯外掛:

go build -buildmode=plugin ...
該命令會生成一個共用物件 .so 檔案,當該檔案被載入到 Go 語言程式時會使用下面的結構體 plugin.Plugin 表示,該結構體中包含檔案的路徑以及包含的符號等資訊:

type Plugin struct {
	pluginpath string
	syms       map[string]interface{}
	...
}

與外掛系統相關的兩個核心方法分別是用於載入共用檔案的 plugin.Open 和在外掛中查詢符號的 plugin.Plugin.Lookup,本節將詳細介紹它們的實現原理。

  • CGO
    plugin.pluginOpen 只是簡單包裝了一下標準庫中的 dlopen 和 dlerror 函數並在載入成功後返回指向動態庫的控制程式碼:
static uintptr_t pluginOpen(const char* path, char** err) {
	void* h = dlopen(path, RTLD_NOW|RTLD_GLOBAL);
	if (h == NULL) {
		*err = (char*)dlerror();
	}
	return (uintptr_t)h;
}

plugin.pluginLookup 使用了標準庫中的 dlsym 和 dlerror 獲取動態庫控制程式碼中的特定符號:

static void* pluginLookup(uintptr_t h, const char* name, char** err) {
	void* r = dlsym((void*)h, name);
	if (r == NULL) {
		*err = (char*)dlerror();
	}
	return r;
}
  • 載入過程
    用於載入共用物件的函數 plugin.Open 會將共用物件檔案的路徑作為引數並返回 plugin.Plugin 結構:
func Open(path string) (*Plugin, error) {
	return open(path)
}

上述函數會呼叫私有的函數 plugin.open 載入外掛,它是外掛載入過程的核心函數,我們可以將該函數拆分成以下幾個步驟:

準備 C 語言函數 plugin.pluginOpen 的引數;
通過 cgo 呼叫 plugin.pluginOpen 並初始化載入的模組;
查詢載入模組中的 init 函數並呼叫該函數;
通過外掛的檔名和符號列表構建 plugin.Plugin 結構;

  • 符號查詢

plugin.Plugin.Lookup 可以在 plugin.Open 返回的結構體中查詢符號 plugin.Symbol,
該符號是 interface{} 型別的一個別名,我們可以將它轉換成變數或者函數真實的型別:

func (p *Plugin) Lookup(symName string) (Symbol, error) {
	return lookup(p, symName)
}

func lookup(p *Plugin, symName string) (Symbol, error) {
	if s := p.syms[symName]; s != nil {
		return s, nil
	}
	return nil, errors.New("plugin: symbol " + symName + " not found in plugin " + p.pluginpath)
}

上述方法呼叫的私有函數 plugin.lookup 實現比較簡單,它直接利用了結構體中的符號表,如果沒有找到對應的符號會直接返回錯誤。

windows不支援,不建議使用。

程式碼生成

計算機程式可以生成另一個程式。
go 語言中的測試就是用了程式碼生成機制。
go test命令會掃描包中的測試用例並生成程式、編譯、並執行。

程式碼生成設計原理

超程式設計是計算機程式設計中一個很重要,很有趣的概念。
超程式設計:一種計算機程式可以將程式碼 看待成資料的能力。

如果能夠將程式碼看作資料,那麼程式碼就可以像資料一樣在執行時被修改、更新和替換。
超程式設計賦予了程式語言更加強大的表達能力
能夠讓我將一些計算過程從執行時 挪到 編譯時。
通過編譯期間的展開生成程式碼或者執行程式在執行時 改變自身的行為。

超程式設計就是 一種使用程式碼生成程式碼的方式 。
無論是編譯期間生成程式碼還是 執行時改變程式碼的行為都是生成程式碼的一種。

現代的程式語言大都會為我們提供不同的超程式設計能力,
從總體來看,根據生成程式碼的時機不同,我們將超程式設計能力分為兩種型別,
其中一種是編譯期間的超程式設計,例如:宏和模板;
另一種是執行期間的超程式設計,也就是執行時,它賦予了程式語言在執行期間修改行為的能力,
當然也有一些特性既可以在編譯期實現,也可以在執行期間實現。

Go 語言作為編譯型的程式語言,它提供了比較有限的執行時超程式設計能力,例如:反射特性,然而由於效能的問題,反射在很多場景下都不被推薦使用。
當然除了反射之外,Go 語言還提供了另一種編譯期間的程式碼生成機制 — go generate,它可以在程式碼編譯之前根據原始碼生成程式碼。

程式碼生成

Go 語言的程式碼生成機制會讀取包含預編譯指令的註釋
然後執行註釋中的命令讀取包中的檔案
它們將檔案解析成抽象語法樹並根據語法樹生成新的 Go 語言程式碼和檔案
生成的程式碼會在專案的編譯期間與其他程式碼一起編譯和執行。
//go:generate command argument...
go generate 不會被 go build 等命令自動執行,該命令需要顯式的觸發,手動執行該命令時會在檔案中掃描上述形式的註釋並執行後面的執行命令,
需要注意的是 go:generate 和前面的 // 之間沒有空格,這種不包含空格的註釋一般是 Go 語言的編譯器指令,而我們在程式碼中的正常註釋都應該保留這個空格

程式碼生成最常見的例子就是官方提供的 stringer,這個工具可以掃描如下所示的常數定義,然後為當前常數型別 Piller 生成對應的 String() 方法

// pill.go
package painkiller

//go:generate stringer -type=Pill
type Pill int
const (
	Placebo Pill = iota
	Aspirin
	Ibuprofen
	Paracetamol
	Acetaminophen = Paracetamol
)

當我們在上述檔案中加入 //go:generate stringer -type=Pill 註釋並呼叫 go generate 命令時,
在同一目錄下會出現如下所示的 pill_string.go 檔案,該檔案中包含兩個函數,分別是 _ 和 String:

// Code generated by "stringer -type=Pill"; DO NOT EDIT.

package painkiller

import "strconv"

func _() {
	// An "invalid array index" compiler error signifies that the constant values have changed.
	// Re-run the stringer command to generate them again.
	var x [1]struct{}
	_ = x[Placebo-0]
	_ = x[Aspirin-1]
	_ = x[Ibuprofen-2]
	_ = x[Paracetamol-3]
}

const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"

var _Pill_index = [...]uint8{0, 7, 14, 23, 34}

func (i Pill) String() string {
	if i < 0 || i >= Pill(len(_Pill_index)-1) {
		return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"
	}
	return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}

程式碼生成的過程可以分成以下兩個部分
掃描 Go 語言原始檔,查詢待執行的 //go:generate 預編譯指令;
執行預編譯指令,再次掃描原始檔並根據原始檔中的程式碼生成程式碼;

  • 預編譯指令
    當執行go generate命令時,會呼叫原始碼的generate.runGenerate掃描包中的預編譯指令。
    該函數會遍歷命令列傳入包中的全部檔案,並依次呼叫 generate.generate:
func runGenerate(ctx context.Context, cmd *base.Command, args []string) {
	...
	for _, pkg := range load.Packages(args) {
		...
		pkgName := pkg.Name
		for _, file := range pkg.InternalGoFiles() {
			if !generate(pkgName, file) {
				break
			}
		}
		pkgName += "_test"
		for _, file := range pkg.InternalXGoFiles() {
			if !generate(pkgName, file) {
				break
			}
		}
	}
}

cmd/go/internal/generate.generate 會開啟傳入的檔案並初始化一個用於掃描 cmd/go/internal/generate.Generator 的結構:

func generate(pkg, absFile string) bool {
	fd, err := os.Open(absFile)
	if err != nil {
		log.Fatalf("generate: %s", err)
	}
	defer fd.Close()
	g := &Generator{
		r:        fd,
		path:     absFile,
		pkg:      pkg,
		commands: make(map[string][]string),
	}
	return g.run()
}

結構體 cmd/go/internal/generate.Generator 的私有方法 cmd/go/internal/generate.Generator.run 會在對應的檔案中掃描指令並執行,該方法的實現原理很簡單,我們在這裡簡單展示一下該方法的簡化實現:

func (g *Generator) run() (ok bool) {
	input := bufio.NewReader(g.r)
	for {
		var buf []byte
		buf, err = input.ReadSlice('\n')
		if err != nil {
			if err == io.EOF && isGoGenerate(buf) {
				err = io.ErrUnexpectedEOF
			}
			break
		}

		if !isGoGenerate(buf) {
			continue
		}

		g.setEnv()
		words := g.split(string(buf))
		g.exec(words)
	}
	return true
}

上述程式碼片段會按行讀取被掃描的檔案並呼叫 cmd/go/internal/generate.isGoGenerate 判斷當前行是否以 //go:generate 註釋開頭,
如果該行確定以 //go:generate 開頭,那麼會解析註釋中的命令和引數並呼叫 cmd/go/internal/generate.Generator.exec 執行當前命令。

  • 抽象語法樹
    stringer 充分利用了 Go 語言標準庫對編譯器各種能力的支援,
    其中包括用於解析抽象語法樹的 go/ast、用於格式化程式碼的 go/fmt 等,
    Go 通過標準庫中的這些包對外直接提供了編譯器的相關能力,讓使用者可以直接在它們上面構建複雜的程式碼生成機制並實施超程式設計技術。

作為二進位制檔案,stringer 命令的入口就是如下所示的 golang/tools/main.main 函數,
在下面的程式碼中,我們初始化了一個用於解析原始檔和生成程式碼的 golang/tools/main.Generator,然後開始拼接生成的檔案:

func main() {
	types := strings.Split(*typeNames, ",")
	...
	g := Generator{
		trimPrefix:  *trimprefix,
		lineComment: *linecomment,
	}
	...

	g.Printf("// Code generated by \"stringer %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " "))
	g.Printf("\n")
	g.Printf("package %s", g.pkg.name)
	g.Printf("\n")
	g.Printf("import \"strconv\"\n")

	for _, typeName := range types {
		g.generate(typeName)
	}

	src := g.format()

	baseName := fmt.Sprintf("%s_string.go", types[0])
	outputName = filepath.Join(dir, strings.ToLower(baseName))
	if err := ioutil.WriteFile(outputName, src, 0644); err != nil {
		log.Fatalf("writing output: %s", err)
	}
}

整個生成程式碼的過程就是使用編譯器提供的庫解析原始檔並按照已有的模板生成新的程式碼,
這與 Web 服務中利用模板生成 HTML 檔案沒有太多的區別,只是生成檔案的用途稍微有一些不同,

標準庫

JSON

json作為一種輕量級的資料交換格式。

設計原理

幾乎所有的現代程式語言都會將處理JSON的函數直接納入標準庫。
共同encoding/json對外提供標準的JSON序列化和反序列化方法。
json.Marsha1和Unmarsha1.

序列化和反序列化的開銷完全不同,JSON 反序列化的開銷是序列化開銷的好幾倍,相信這背後的原因也非常好理解。
Go 語言中的 JSON 序列化過程不需要被序列化的物件預先實現任何介面,它會通過反射獲取結構體或者陣列中的值並以樹形的結構遞迴地進行編碼,
標準庫也會根據 encoding/json.Unmarshal 中傳入的值對 JSON 進行解碼。

Go 語言 JSON 標準庫編碼和解碼的過程大量地運用了反射這一特性,
JSON 標準庫中的介面和標籤,這是它為開發者提供的為數不多的影響編解碼過程的介面

  • 介面
	MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
	UnmarshalJSON([]byte) error
}

在 JSON 序列化和反序列化的過程中,它會使用反射判斷結構體型別是否實現了上述介面,如果實現了上述介面就會優先使用對應的方法進行編碼和解碼操作,除了這兩個方法之外,Go 語言其實還提供了另外兩個用於控制編解碼結果的方法,即 encoding.TextMarshaler 和 encoding.TextUnmarshaler:

type TextMarshaler interface {
	MarshalText() (text []byte, err error)
}

type TextUnmarshaler interface {
	UnmarshalText(text []byte) error
}

一旦發現 JSON 相關的序列化方法沒有被實現,上述兩個方法會作為候選方法被 JSON 標準庫呼叫並參與編解碼的過程。
總的來說,我們可以在任意型別上實現上述這四個方法自定義最終的結果,後面的兩個方法的適用範圍更廣,但是不會被 JSON 標準庫優先呼叫。

  • 標籤
    Go 語言的結構體標籤也是一個比較有趣的功能,在預設情況下,當我們在序列化和反序列化結構體時,

標準庫都會認為欄位名和 JSON 中的鍵具有一一對應的關係,
然而 Go 語言的欄位一般都是駝峰命名法,JSON 中下劃線的命名方式相對比較常見,所以使用標籤這一特性直接建立鍵與欄位之間的對映關係是一個非常方便的設計。

JSON 中的標籤由兩部分組成,如下所示的 name 和 age 都是標籤名,後面的所有的字串是標籤選項,即 encoding/json.tagOptions,
標籤名和欄位名會建立一一對應的關係,後面的標籤選項也會影響編解碼的過程:

type Author struct {
    Name string `json:"name,omitempty"`
    Age  int32  `json:"age,string,omitempty"`
}

常見的兩個標籤是 string 和 omitempty,前者表示當前的整數或者浮點數是由 JSON 中的字串表示的,
而另一個欄位 omitempty 會在欄位為零值時,直接在生成的 JSON 中忽略對應的鍵值對,
例如:"age": 0、"author": "" 等。
標準庫會使用如下所示的 encoding/json.parseTag 來解析標籤:

func parseTag(tag string) (string, tagOptions) {
	if idx := strings.Index(tag, ","); idx != -1 {
		return tag[:idx], tagOptions(tag[idx+1:])
	}
	return tag, tagOptions("")
}

從該方法的實現中,我們能分析出 JSON 標準庫中的合法標籤是什麼形式的:標籤名和標籤選項都以 , 連線,最前面的字串為標籤名,後面的都是標籤選項。

序列化

encoding/json.Marshal 是 JSON 標準庫中提供的最簡單的序列化函數,它會接收一個 interface{} 型別的值作為引數,
這也意味著幾乎全部的 Go 語言變數都可以被 JSON 標準庫序列化,為了提供如此複雜和通用的功能,在靜態語言中使用反射是常見的選項,下面我們來深入瞭解一下它的實現:

func Marshal(v interface{}) ([]byte, error) {
	e := newEncodeState()
	err := e.marshal(v, encOpts{escapeHTML: true})
	if err != nil {
		return nil, err
	}
	buf := append([]byte(nil), e.Bytes()...)
	encodeStatePool.Put(e)
	return buf, nil
}

上述方法會呼叫 encoding/json.newEncodeState 從全域性的編碼狀態池中獲取 encoding/json.encodeState,隨後的序列化過程都會使用這個編碼狀態,該結構體也會在編碼結束後被重新放回池中以便重複利用。

按照如上所示的複雜呼叫棧,一系列的序列化方法在最後獲取了物件的反射型別並呼叫了 encoding/json.newTypeEncoder 這個核心的編碼方法,
該方法會遞迴地為所有的型別找到對應的編碼方法,不過它的執行過程可以分成以下兩個步驟:

  1. 獲取使用者自定義的 encoding/json.Marshaler 或者 encoding.TextMarshaler 編碼器;
  2. 獲取標準庫中為基本型別內建的 JSON 編碼器;
    在該方法的第一部分,我們會檢查當前值的型別是否可以使用使用者自定義的編碼器,這裡有兩種不同的判斷方法:
func newTypeEncoder(t reflect.Type, allowAddr bool) encoderFunc {
	if t.Kind() != reflect.Ptr && allowAddr && reflect.PtrTo(t).Implements(marshalerType) {
		return newCondAddrEncoder(addrMarshalerEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(marshalerType) {
		return marshalerEncoder
	}
	if t.Kind() != reflect.Ptr && allowAddr && reflect.PtrTo(t).Implements(textMarshalerType) {
		return newCondAddrEncoder(addrTextMarshalerEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(textMarshalerType) {
		return textMarshalerEncoder
	}
	...
}

如果當前值是值型別、可以取地址並且值型別對應的指標型別實現了 encoding/json.Marshaler 介面,呼叫 encoding/json.newCondAddrEncoder 獲取一個條件編碼器,條件編碼器會在 encoding/json.addrMarshalerEncoder 失敗時重新選擇新的編碼器;
如果當前型別實現了 encoding/json.Marshaler 介面,可以直接使用 encoding/json.marshalerEncoder 序列化;
在這段程式碼中,標準庫對 encoding.TextMarshaler 的處理也幾乎完全相同,只是它會先判斷 encoding/json.Marshaler 介面,這也印證了我們在設計原理一節中的推測。

encoding/json.newTypeEncoder 會根據傳入值的反射型別獲取對應的編碼器,其中包括 bool、int、float 等基本型別編碼器等和陣列、結構體、切片等複雜型別的編碼器:

func boolEncoder(e *encodeState, v reflect.Value, opts encOpts) {
	if opts.quoted {
		e.WriteByte('"')
	}
	if v.Bool() {
		e.WriteString("true")
	} else {
		e.WriteString("false")
	}
	if opts.quoted {
		e.WriteByte('"')
	}
}

反序列化

標準庫會使用 encoding/json.Unmarshal 處理 JSON 的反序列化,與執行過程確定的序列化相比,反序列化的過程是逐漸探索的過程,所以會複雜很多,開銷也會高出幾倍。
因為 Go 語言的表達能力比較有限,反序列化的使用相對比較繁瑣,所以需要傳入一個變數幫助標準庫進行反序列化:

func Unmarshal(data []byte, v interface{}) error {
	var d decodeState
	err := checkValid(data, &d.scan)
	if err != nil {
		return err
	}

	d.init(data)
	return d.unmarshal(v)
}

JSON 本身就是一種樹形的資料結構,無論是序列化還是反序列化,都會遵循自頂向下的編碼和解碼過程,使用遞迴的方式處理 JSON 物件。
作為標準庫的 JSON 提供的介面非常簡潔,雖然它的效能一直被開發者所詬病,但是作為框架它提供了很好的通用性,
通過分析 JSON 庫的實現,我們也可以從中學習到使用反射的各種方法。

HTTP

超文字傳輸協定(Hypertext Transfer Protocol、HTTP 協定)是今天使用最廣泛的應用層協定,1989 年由 Tim Berners-Lee 在 CERN 起草的協定已經成為了網際網路的資料傳輸的核心1。在過去幾年的時間裡,HTTP/2 和 HTTP/3 也對現有的協定進行了更新,提供更加安全和快速的傳輸功能。多數的程式語言都會在標準庫中實現 HTTP/1.1 和 HTTP/2.0 已滿足工程師的日常開發需求,今天要介紹的 Go 語言的網路庫也實現了這兩個大版本的 HTTP 協定。

設計原理

HTTP 協定是應用層協定,在通常情況下我們都會使用 TCP 作為底層的傳輸層協定傳輸封包,但是 HTTP/3 在 UDP 協定上實現了新的傳輸層協定 QUIC 並使用 QUIC 傳輸資料,這也意味著 HTTP 既可以跑在 TCP 上,也可以跑在 UDP 上。

Go 語言標準庫通過 net/http 包提供 HTTP 的使用者端和伺服器端實現,在分析內部的實現原理之前,我們先來了解一下 HTTP 協定相關的一些設計以及標準庫內部的層級結構和模組之間的關係。

HTTP 協定中最常見的概念是 HTTP 請求與響應,我們可以將它們理解成使用者端和伺服器端之間傳遞的訊息,使用者端向伺服器端傳送 HTTP 請求,伺服器端收到 HTTP 請求後會做出計算後以 HTTP 響應的形式傳送給使用者端。

與其他的二進位制協定不同,作為文字傳輸協定,HTTP 協定的協定頭都是文字資料,HTTP 請求頭的首行會包含請求的方法、路徑和協定版本,接下來是多個 HTTP 協定頭以及攜帶的負載

GET / HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)
Host: draveness.me
Accept-Language: en-us
Accept-Encoding: gzip, deflate
Content-Length: <length>
Connection: Keep-Alive

<html>
    ...
</html>

HTTP 響應也有著比較類似的結構,其中也包含響應的協定版本、狀態碼、響應頭以及負載,在這裡就不展開介紹了。

HTTP 協定目前主要還是跑在 TCP 協定上的,TCP 協定是面向連線的、可靠的、基於位元組流的傳輸層通訊協定2,應用層交給 TCP 協定的資料並不會以訊息為單位向目的主機傳輸,這些資料在某些情況下會被組合成一個資料段傳送給目標的主機3。因為 TCP 協定是基於位元組流的,所以基於 TCP 協定的應用層協定都需要自己劃分訊息的邊界。

在應用層協定中,最常見的兩種解決方案是基於長度或者基於終結符(Delimiter)。HTTP 協定其實同時實現了上述兩種方案,在多數情況下 HTTP 協定都會在協定頭中加入 Content-Length 表示負載的長度,訊息的接收者解析到該協定頭之後就可以確定當前 HTTP 請求/響應結束的位置,分離不同的 HTTP 訊息,下面就是一個使用 Content-Length 劃分訊息邊界的例子:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 138
...
Connection: close

<html>
  <head>
    <title>An Example Page</title>
  </head>
  <body>
    <p>Hello World, this is a very simple HTML document.</p>
  </body>
</html>

不過 HTTP 協定除了使用基於長度的方式實現邊界,也會使用基於終結符的策略,當 HTTP 使用塊傳輸(Chunked Transfer)機制時,HTTP 頭中就不再包含 Content-Length 了,它會使用負載大小為 0 的 HTTP 訊息作為終結符表示訊息的邊界。
Go 語言的 net/http 中同時包好了 HTTP 使用者端和伺服器端的實現,為了支援更好的擴充套件性,它引入了 net/http.RoundTripper 和 net/http.Handler 兩個介面。
net/http.RoundTripper 是用來表示執行 HTTP 請求的介面,呼叫方將請求作為引數可以獲取請求對應的響應,
而 net/http.Handler 主要用於 HTTP 伺服器響應使用者端的請求:

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

HTTP 請求的接收方可以實現 net/http.Handler 介面,其中實現了處理 HTTP 請求的邏輯,處理的過程中會呼叫 net/http.ResponseWriter 介面的方法構造 HTTP 響應,
它提供的三個介面 Header、Write 和 WriteHeader 分別會獲取 HTTP 響應、將資料寫入負載以及寫入響應頭:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

type ResponseWriter interface {
	Header() Header
	Write([]byte) (int, error)
	WriteHeader(statusCode int)
}

使用者端和伺服器端面對的都是雙向的 HTTP 請求與響應,使用者端構建請求並等待響應,伺服器端處理請求並返回響應。
HTTP 請求和響應在標準庫中不止有一種實現,它們都包含了層級結構,標準庫中的 net/http.RoundTripper 包含如下所示的層級結構:


每個 net/http.RoundTripper 介面的實現都包含了一種向遠端發出請求的過程;標準庫中也提供了 net/http.Handler 的多種實現為使用者端的 HTTP 請求提供不同的服務。

使用者端

使用者端可以直接通過 net/http.Get 使用預設的使用者端 net/http.DefaultClient 發起 HTTP 請求,也可以自己構建新的 net/http.Client 實現自定義的 HTTP 事務,在多數情況下使用預設的使用者端都能滿足我們的需求,不過需要注意的是使用預設使用者端發出的請求沒有超時時間,所以在某些場景下會一直等待下去。除了自定義 HTTP 事務之外,我們還可以實現自定義的 net/http.CookieJar 介面管理和使用 HTTP 請求中的 Cookie:

事務和 Cookie 是我們在 HTTP 使用者端包為我們提供的兩個最重要模組,本節將從 HTTP GET 請求開始,按照構建請求、資料傳輸、獲取連線以及等待響應幾個模組分析使用者端的實現原理。當我們呼叫 net/http.Client.Get 發出 HTTP 時,會按照如下的步驟執行:

  1. 呼叫 net/http.NewRequest 根據方法名、URL 和請求體構建請求;
  2. 呼叫 net/http.Transport.RoundTrip 開啟 HTTP 事務、獲取連線並行送請求;
  3. 在 HTTP 持久連線的 net/http.persistConn.readLoop 方法中等待響應

HTTP 的使用者端中包含幾個比較重要的結構體,它們分別是 net/http.Client、net/http.Transport 和 net/http.persistConn:

net/http.Client 是 HTTP 使用者端,它的預設值是使用 net/http.DefaultTransport 的 HTTP 使用者端;
net/http.Transport 是 net/http.RoundTripper 介面的實現,它的主要作用就是支援 HTTP/HTTPS 請求和 HTTP 代理;
net/http.persistConn 封裝了一個 TCP 的持久連線,是我們與遠端交換訊息的控制程式碼(Handle);

使用者端 net/http.Client 是級別較高的抽象,它提供了 HTTP 的一些細節,包括 Cookies 和重定向;
而 net/http.Transport 會處理 HTTP/HTTPS 協定的底層實現細節,其中會包含連線重用、構建請求以及傳送請求等功能。

  • 構建請求
    net/http.Request 表示 HTTP 服務接收到的請求或者 HTTP 使用者端發出的請求,其中包含 HTTP 請求的方法、URL、協定版本、協定頭以及請求體等欄位,
    除了這些欄位之外,它還會持有一個指向 HTTP 響應的參照:
type Request struct {
	Method string
	URL *url.URL

	Proto      string // "HTTP/1.0"
	ProtoMajor int    // 1
	ProtoMinor int    // 0

	Header Header
	Body io.ReadCloser

	...
	Response *Response
}

net/http.NewRequest 是標準庫提供的用於建立請求的方法,這個方法會校驗 HTTP 請求的欄位並根據輸入的引數拼裝成新的請求結構體

func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
	if method == "" {
		method = "GET"
	}
	if !validMethod(method) {
		return nil, fmt.Errorf("net/http: invalid method %q", method)
	}
	u, err := urlpkg.Parse(url)
	if err != nil {
		return nil, err
	}
	rc, ok := body.(io.ReadCloser)
	if !ok && body != nil {
		rc = ioutil.NopCloser(body)
	}
	u.Host = removeEmptyPort(u.Host)
	req := &Request{
		ctx:        ctx,
		Method:     method,
		URL:        u,
		Proto:      "HTTP/1.1",
		ProtoMajor: 1,
		ProtoMinor: 1,
		Header:     make(Header),
		Body:       rc,
		Host:       u.Host,
	}
	if body != nil {
		...
	}
	return req, nil
}

請求拼裝的過程比較簡單,它會檢查並校驗輸入的方法、URL 以及負載,然而初始化了新的 net/http.Request 結構,處理負載的過程稍微有一些複雜,我們會根據負載的型別不同,使用不同的方法將它們包裝成 io.ReadCloser 型別。

  • 開啟事務
    當我們使用標準庫構建了 HTTP 請求之後,會開啟 HTTP 事務傳送 HTTP 請求並等待遠端的響應,經過下面一連串的呼叫,我們最終來到了標準庫實現底層 HTTP 協定的結構體 — net/http.Transport:
    net/http.Client.Do
    net/http.Client.do
    net/http.Client.send
    net/http.send
    net/http.Transport.RoundTrip

net/http.Transport 實現了 net/http.RoundTripper 介面,也是整個請求過程中最重要並且最複雜的結構體,該結構體會在 net/http.Transport.roundTrip 中傳送 HTTP 請求並等待響應,我們可以將該函數的執行過程分成兩個部分:

  1. 根據 URL 的協定查詢並執行自定義的 net/http.RoundTripper 實現;
  2. 從連線池中獲取或者初始化新的持久連線並呼叫連線的 net/http.persistConn.roundTrip 發出請求;
    我們可以在標準庫的 net/http.Transport 中呼叫 net/http.Transport.RegisterProtocol 為不同的協定註冊 net/http.RoundTripper 的實現,
    在下面的這段程式碼中就會根據 URL 中的協定選擇對應的實現來替代預設的邏輯:
func (t *Transport) roundTrip(req *Request) (*Response, error) {
	ctx := req.Context()
	scheme := req.URL.Scheme

	if altRT := t.alternateRoundTripper(req); altRT != nil {
		if resp, err := altRT.RoundTrip(req); err != ErrSkipAltProtocol {
			return resp, err
		}
	}
	...
}

在預設情況下,我們都會使用 net/http.persistConn 持久連線處理 HTTP 請求,該方法會先獲取用於傳送請求的連線,隨後呼叫 net/http.persistConn.roundTrip:

func (t *Transport) roundTrip(req *Request) (*Response, error) {
	...
	for {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		default:
		}

		treq := &transportRequest{Request: req, trace: trace}
		cm, err := t.connectMethodForRequest(treq)
		if err != nil {
			return nil, err
		}

		pconn, err := t.getConn(treq, cm)
		if err != nil {
			return nil, err
		}

		resp, err := pconn.roundTrip(treq)
		if err == nil {
			return resp, nil
		}
	}
}

net/http.Transport.getConn 是獲取連線的方法,該方法會通過兩種方法獲取用於傳送請求的連線:

func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
	req := treq.Request
	ctx := req.Context()

	w := &wantConn{
		cm:         cm,
		key:        cm.key(),
		ctx:        ctx,
		ready:      make(chan struct{}, 1),
	}

	if delivered := t.queueForIdleConn(w); delivered {
		return w.pc, nil
	}

	t.queueForDial(w)
	select {
	case <-w.ready:
		...
		return w.pc, w.err
	...
	}
}
  1. 呼叫 net/http.Transport.queueForIdleConn 在佇列中等待閒置的連線;
  2. 呼叫 net/http.Transport.queueForDial 在佇列中等待建立新的連線;

連線是一種相對比較昂貴的資源,如果在每次發出 HTTP 請求之前都建立新的連線,可能會消耗比較多的時間,帶來較大的額外開銷,通過連線池對資源進行分配和複用可以有效地提高 HTTP 請求的整體效能,多數的網路庫使用者端都會採取類似的策略來複用資源。
當我們呼叫 net/http.Transport.queueForDial 嘗試與遠端建立連線時,標準庫會在內部啟動新的 Goroutine 執行 net/http.Transport.dialConnFor 用於建連,從最終呼叫的 net/http.Transport.dialConn 中我們能找到 TCP 連線和 net 庫的身影:

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
	pconn = &persistConn{
		t:             t,
		cacheKey:      cm.key(),
		reqch:         make(chan requestAndChan, 1),
		writech:       make(chan writeRequest, 1),
		closech:       make(chan struct{}),
		writeErrCh:    make(chan error, 1),
		writeLoopDone: make(chan struct{}),
	}

	conn, err := t.dial(ctx, "tcp", cm.addr())
	if err != nil {
		return nil, err
	}
	pconn.conn = conn

	pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())
	pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())

	go pconn.readLoop()
	go pconn.writeLoop()
	return pconn, nil
}

在建立新的 TCP 連線後,我們還會在後臺為當前的連線建立兩個 Goroutine,分別從 TCP 連線中讀取資料或者向 TCP 連線寫入資料,從建立連線的過程我們可以發現,如果我們為每一個 HTTP 請求都建立新的連線並啟動 Goroutine 處理讀寫資料,會佔用很多的資源。

  • 等待請求
    持久的 TCP 連線會實現 net/http.persistConn.roundTrip 處理寫入 HTTP 請求並在 select 語句中等待響應的返回:
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
	writeErrCh := make(chan error, 1)
	pc.writech <- writeRequest{req, writeErrCh, continueCh}

	resc := make(chan responseAndError)
	pc.reqch <- requestAndChan{
		req:        req.Request,
		ch:         resc,
	}

	for {
		select {
		case re := <-resc:
			if re.err != nil {
				return nil, pc.mapRoundTripError(req, startBytesWritten, re.err)
			}
			return re.res, nil
		...
		}
	}
}

每個 HTTP 請求都由另一個 Goroutine 中的 net/http.persistConn.writeLoop 迴圈寫入的,這兩個 Goroutine 獨立執行並通過 Channel 進行通訊。
net/http.Request.write 會根據 net/http.Request 結構中的欄位按照 HTTP 協定組成 TCP 資料段:

func (pc *persistConn) writeLoop() {
	defer close(pc.writeLoopDone)
	for {
		select {
		case wr := <-pc.writech:
			startBytesWritten := pc.nwrite
			wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
			...
		case <-pc.closech:
			return
		}
	}
}

當我們呼叫 net/http.Request.write 向請求中寫入資料時,實際上直接寫入了 net/http.persistConnWriter 中的 TCP 連線中,TCP 協定棧會負責將 HTTP 請求中的內容傳送到目標伺服器上:

type persistConnWriter struct {
	pc *persistConn
}

func (w persistConnWriter) Write(p []byte) (n int, err error) {
	n, err = w.pc.conn.Write(p)
	w.pc.nwrite += int64(n)
	return
}

持久連線中的另一個讀迴圈 net/http.persistConn.readLoop 會負責從 TCP 連線中讀取資料並將資料傳送會 HTTP 請求的呼叫方,真正負責解析 HTTP 協定的還是 net/http.ReadResponse:

func ReadResponse(r *bufio.Reader, req *Request) (*Response, error) {
	tp := textproto.NewReader(r)
	resp := &Response{
		Request: req,
	}

	line, _ := tp.ReadLine()
	if i := strings.IndexByte(line, ' '); i == -1 {
		return nil, badStringError("malformed HTTP response", line)
	} else {
		resp.Proto = line[:i]
		resp.Status = strings.TrimLeft(line[i+1:], " ")
	}

	statusCode := resp.Status
	if i := strings.IndexByte(resp.Status, ' '); i != -1 {
		statusCode = resp.Status[:i]
	}
	resp.StatusCode, err = strconv.Atoi(statusCode)

	resp.ProtoMajor, resp.ProtoMinor, _ = ParseHTTPVersion(resp.Proto)

	mimeHeader, _ := tp.ReadMIMEHeader()
	resp.Header = Header(mimeHeader)

	readTransfer(resp, r)
	return resp, nil
}

我們在上述方法中可以看到 HTTP 響應結構的大致框架,其中包含狀態碼、協定版本、請求頭等內容,響應體還是在讀取回圈 net/http.persistConn.readLoop 中根據 HTTP 協定頭進行解析的。

伺服器

Go 語言標準庫 net/http 包提供了非常易用的介面,如下所示,我們可以利用標準庫提供的功能快速搭建新的 HTTP 服務:

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

上述的 main 函數只呼叫了兩個標準庫提供的函數,它們分別是用於註冊處理器的 net/http.HandleFunc 函數和用於監聽和處理器請求的 net/http.ListenAndServe,多數的伺服器框架都會包含這兩類介面,分別負責註冊處理器和處理外部請求,這一種非常常見的模式,我們在這裡也會按照這兩個維度介紹標準庫如何支援 HTTP 伺服器的實現。

  • 註冊處理器

HTTP 服務是由一組實現了 net/http.Handler 介面的處理器組成的,處理 HTTP 請求時會根據請求的路由選擇合適的處理器:

當我們直接呼叫 net/http.HandleFunc 註冊處理器時,標準庫會使用預設的 HTTP 伺服器 net/http.DefaultServeMux 處理請求,該方法會直接呼叫 net/http.ServeMux.HandleFunc:

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
	mux.Handle(pattern, HandlerFunc(handler))
}

上述方法會將處理器轉換成 net/http.Handler 介面型別呼叫 net/http.ServeMux.Handle 註冊處理器:

func (mux *ServeMux) Handle(pattern string, handler Handler) {
	if _, exist := mux.m[pattern]; exist {
		panic("http: multiple registrations for " + pattern)
	}

	e := muxEntry{h: handler, pattern: pattern}
	mux.m[pattern] = e
	if pattern[len(pattern)-1] == '/' {
		mux.es = appendSorted(mux.es, e)
	}

	if pattern[0] != '/' {
		mux.hosts = true
	}
}

路由和對應的處理器會被組成 net/http.DefaultServeMux,該結構會持有一個 net/http.muxEntry 雜湊,其中儲存了從 URL 到處理器的對映關係,HTTP 伺服器在處理請求時就會使用該雜湊查詢處理器。

  • 處理請求
    標準庫提供的 net/http.ListenAndServe 可以用來監聽 TCP 連線並處理請求,該函數會使用傳入的監聽地址和處理器初始化一個 HTTP 伺服器 net/http.Server,呼叫該伺服器的 net/http.Server.ListenAndServe 方法:
func ListenAndServe(addr string, handler Handler) error {
	server := &Server{Addr: addr, Handler: handler}
	return server.ListenAndServe()
}

net/http.Server.ListenAndServe 會使用網路庫提供的 net.Listen 監聽對應地址上的 TCP 連線並通過 net/http.Server.Serve 處理使用者端的請求:

func (srv *Server) ListenAndServe() error {
	if addr == "" {
		addr = ":http"
	}
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return srv.Serve(ln)
}

net/http.Server.Serve 會在迴圈中監聽外部的 TCP 連線併為每個連線呼叫 net/http.Server.newConn 建立新的 net/http.conn,它是 HTTP 連線的伺服器端表示:

func (srv *Server) Serve(l net.Listener) error {
	l = &onceCloseListener{Listener: l}
	defer l.Close()

	baseCtx := context.Background()
	ctx := context.WithValue(baseCtx, ServerContextKey, srv)
	for {
		rw, err := l.Accept()
		if err != nil {
			select {
			case <-srv.getDoneChan():
				return ErrServerClosed
			default:
			}
			...
			return err
		}
		connCtx := ctx
		c := srv.newConn(rw)
		c.setState(c.rwc, StateNew) // before Serve can return
		go c.serve(connCtx)
	}
}

建立了伺服器端的連線之後,標準庫中的實現會為每個 HTTP 請求建立單獨的 Goroutine 並在其中呼叫 net/http.Conn.serve 方法,
如果當前 HTTP 服務接收到了海量的請求,會在內部建立大量的 Goroutine,這可能會使整個服務質量明顯降低無法處理請求。

func (c *conn) serve(ctx context.Context) {
	c.remoteAddr = c.rwc.RemoteAddr().String()

	ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
	ctx, cancelCtx := context.WithCancel(ctx)
	c.cancelCtx = cancelCtx
	defer cancelCtx()

	c.r = &connReader{conn: c}
	c.bufr = newBufioReader(c.r)
	c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)

	for {
		w, _ := c.readRequest(ctx)
		serverHandler{c.server}.ServeHTTP(w, w.req)
		w.finishRequest()
		...
	}
}

上述程式碼片段是我們簡化後的連線處理過程,其中包含讀取 HTTP 請求、呼叫 Handler 處理 HTTP 請求以及呼叫完成該請求。讀取 HTTP 請求會呼叫 net/http.Conn.readRequest,該方法會從連線中獲取 HTTP 請求並構建一個實現了 net/http.ResponseWriter 介面的變數 net/http.response,向該結構體寫入的資料都會被轉發到它持有的緩衝區中:

func (w *response) write(lenData int, dataB []byte, dataS string) (n int, err error) {
	...
	w.written += int64(lenData)
	if w.contentLength != -1 && w.written > w.contentLength {
		return 0, ErrContentLength
	}
	if dataB != nil {
		return w.w.Write(dataB)
	} else {
		return w.w.WriteString(dataS)
	}
}

解析了 HTTP 請求並初始化 net/http.ResponseWriter 之後,我們就可以呼叫 net/http.serverHandler.ServeHTTP 查詢處理器來處理 HTTP 請求了:

type serverHandler struct {
	srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
	handler := sh.srv.Handler
	if handler == nil {
		handler = DefaultServeMux
	}
	if req.RequestURI == "*" && req.Method == "OPTIONS" {
		handler = globalOptionsHandler{}
	}
	handler.ServeHTTP(rw, req)
}

如果當前的 HTTP 伺服器中不包含任何處理器,我們會使用預設的 net/http.DefaultServeMux 處理外部的 HTTP 請求。

net/http.ServeMux 是一個 HTTP 請求的多路複用器,它可以接收外部的 HTTP 請求、根據請求的 URL 匹配並呼叫最合適的處理器:

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
	h, _ := mux.Handler(r)
	h.ServeHTTP(w, r)
}

經過一系列的函數呼叫,上述過程最終會呼叫 HTTP 伺服器的 net/http.ServerMux.match,該方法會遍歷前面註冊過的路由表並根據特定規則進行匹配:

func (mux *ServeMux) match(path string) (h Handler, pattern string) {
	v, ok := mux.m[path]
	if ok {
		return v.h, v.pattern
	}

	for _, e := range mux.es {
		if strings.HasPrefix(path, e.pattern) {
			return e.h, e.pattern
		}
	}
	return nil, ""
}

如果請求的路徑和路由中的表項匹配成功,我們會呼叫表項中對應的處理器,處理器中包含的業務邏輯會通過 net/http.ResponseWriter 構建 HTTP 請求對應的響應並通過 TCP 連線傳送回使用者端。
Go 語言的 HTTP 標準庫提供了非常豐富的功能,很多語言的標準庫只提供了最基本的功能,實現 HTTP 使用者端和伺服器往往都需要藉助其他開源的框架,但是 Go 語言的很多專案都會直接使用標準庫實現 HTTP 伺服器,這也從側面說明了 Go 語言標準庫的價值。

資料庫

設計原理

結構化查詢語言 structured query language SQL.是在關係型資料庫系統中使用的領域特定語言 Domanin-Specific Language DSL.
主要用於處理結構化的資料。有更加強大的表達能力。

  1. 可以使用單個命令在資料庫中存取多條資料
  2. 不需要在查詢中指定獲取資料的方法。
    所有的關係型資料庫都會提供 SQL 作為查詢語言,應用程式可以使用相同的 SQL 查詢在不同資料庫中查詢資料,當然不同的資料庫在實現細節和介面上還略有一些不同,這些不相容的特性在不同資料庫中仍然無法通用,例如:PostgreSQL 中的幾何型別,不過它們基本都會相容標準的 SQL 查詢以方便應用程式接入:

    SQL 是應用程式和資料庫之間的中間層,應用程式在多數情況下都不需要關心底層資料庫的實現,它們只關心 SQL 查詢返回的資料。
    Go 語言的 database/sql 就建立在上述前提下,我們可以使用相同的 SQL 語言查詢關係型資料庫,所有關係型資料庫的使用者端都需要實現如下所示的驅動介面:
type Driver interface {
	Open(name string) (Conn, error)
}

type Conn interface {
	Prepare(query string) (Stmt, error)
	Close() error
	Begin() (Tx, error)
}

database/sql/driver.Driver 介面中只包含一個 Open 方法,該方法接收一個資料庫連線串作為輸入引數並返回一個特定資料庫的連線,作為引數的資料庫連線串是資料庫特定的格式,這個返回的連線仍然是一個介面,整個標準庫中的全部介面可以構成如下所示的樹形結構:

MySQL 的驅動 go-sql-driver/mysql 就實現了上圖中的樹形結構,我們可以使用語言原生的介面在 MySQL 中查詢或者管理資料。

驅動介面

我們在這裡從 database/sql 標準庫提供的幾個方法為入口分析這個中間層的實現原理,其中包括資料庫驅動的註冊、獲取資料庫連線和查詢資料,這些方法都是我們在與資料庫打交道時的最常用介面。

database/sql 中提供的 database/sql.Register 方法可以註冊自定義的資料庫驅動,這個 package 的內部包含兩個變數,分別是 drivers 雜湊以及 driversMu 互斥鎖,所有的資料庫驅動都會儲存在這個雜湊中:

func Register(name string, driver driver.Driver) {
	driversMu.Lock()
	defer driversMu.Unlock()
	if driver == nil {
		panic("sql: Register driver is nil")
	}
	if _, dup := drivers[name]; dup {
		panic("sql: Register called twice for driver " + name)
	}
	drivers[name] = driver
}

MySQL 驅動會在 go-sql-driver/mysql/mysql.init 中呼叫上述方法將實現 database/sql/driver.Driver 介面的結構體註冊到全域性的驅動列表中:

func init() {
	sql.Register("mysql", &MySQLDriver{})
}

當我們在全域性變數中註冊了驅動之後,就可以使用 database/sql.Open 方法獲取特定資料庫的連線。在如下所示的方法中,我們通過傳入的驅動名獲取 database/sql/driver.Driver 組成 database/sql.dsnConnector 結構體後呼叫 database/sql.OpenDB:

func Open(driverName, dataSourceName string) (*DB, error) {
	driversMu.RLock()
	driveri, ok := drivers[driverName]
	driversMu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
	}
	...
	return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}

database/sql.OpenDB 會返回一個 database/sql.DB 結構,這是標準庫包為我們提供的關鍵結構體,無論是我們直接使用標準庫查詢資料庫,還是使用 GORM 等 ORM 框架都會用到它:

func OpenDB(c driver.Connector) *DB {
	ctx, cancel := context.WithCancel(context.Background())
	db := &DB{
		connector:    c,
		openerCh:     make(chan struct{}, connectionRequestQueueSize),
		lastPut:      make(map[*driverConn]string),
		connRequests: make(map[uint64]chan connRequest),
		stop:         cancel,
	}
	go db.connectionOpener(ctx)
	return db
}

結構體 database/sql.DB 在剛剛初始化時不會包含任何的資料庫連線,它持有的資料庫連線池會在真正應用程式申請連線時在單獨的 Goroutine 中獲取。database/sql.DB.connectionOpener 方法中包含一個不會退出的迴圈,每當該 Goroutine 收到了請求時都會呼叫 database/sql.DB.openNewConnection:

func (db *DB) openNewConnection(ctx context.Context) {
	ci, _ := db.connector.Connect(ctx)
	...
	dc := &driverConn{
		db:         db,
		createdAt:  nowFunc(),
		returnedAt: nowFunc(),
		ci:         ci,
	}
	if db.putConnDBLocked(dc, err) {
		db.addDepLocked(dc, dc)
	} else {
		db.numOpen--
		ci.Close()
	}
}

資料庫結構體 database/sql.DB 中的連結器是實現了 database/sql/driver.Connector 型別的介面,我們可以使用該介面建立任意數量完全等價的連線,建立的所有連線都會被加入連線池中,MySQL 的驅動在 go-sql-driver/mysql/mysql.connector.Connect 方法實現了連線資料庫的邏輯。

無論是使用 ORM 框架還是直接使用標準庫,當我們在查詢資料庫時都會呼叫 database/sql.DB.Query 方法,該方法的入參就是 SQL 語句和 SQL 語句中的引數,它會初始化新的上下文並呼叫 database/sql.DB.QueryContext:

func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error) {
	var rows *Rows
	var err error
	for i := 0; i < maxBadConnRetries; i++ {
		rows, err = db.query(ctx, query, args, cachedOrNewConn)
		if err != driver.ErrBadConn {
			break
		}
	}
	if err == driver.ErrBadConn {
		return db.query(ctx, query, args, alwaysNewConn)
	}
	return rows, err
}

database/sql.DB.query 的執行過程可以分成兩個部分,首先呼叫私有方法 database/sql.DB.conn 獲取底層資料庫的連線,資料庫連線既可能是剛剛通過聯結器建立的,也可能是之前快取的連線;獲取連線之後呼叫 database/sql.DB.queryDC 在特定的資料庫連線上執行查詢:

func (db *DB) queryDC(ctx, txctx context.Context, dc *driverConn, releaseConn func(error), query string, args []interface{}) (*Rows, error) {
	queryerCtx, ok := dc.ci.(driver.QueryerContext)
	var queryer driver.Queryer
	if !ok {
		queryer, ok = dc.ci.(driver.Queryer)
	}
	if ok {
		var nvdargs []driver.NamedValue
		var rowsi driver.Rows
		var err error
		withLock(dc, func() {
			nvdargs, err = driverArgsConnLocked(dc.ci, nil, args)
			if err != nil {
				return
			}
			rowsi, err = ctxDriverQuery(ctx, queryerCtx, queryer, query, nvdargs)
		})
		if err != driver.ErrSkip {
			if err != nil {
				releaseConn(err)
				return nil, err
			}
			rows := &Rows{
				dc:          dc,
				releaseConn: releaseConn,
				rowsi:       rowsi,
			}
			rows.initContextClose(ctx, txctx)
			return rows, nil
		}
	}
	...
}

上述方法在準備了 SQL 查詢所需的引數之後,會呼叫 database/sql.ctxDriverQuery 完成 SQL 查詢,我們會判斷當前的查詢上下文究竟實現了哪個介面,然後呼叫對應介面的 Query 或者 QueryContext:

func ctxDriverQuery(ctx context.Context, queryerCtx driver.QueryerContext, queryer driver.Queryer, query string, nvdargs []driver.NamedValue) (driver.Rows, error) {
	if queryerCtx != nil {
		return queryerCtx.QueryContext(ctx, query, nvdargs)
	}
	dargs, err := namedValueToValue(nvdargs)
	if err != nil {
		return nil, err
	}
	...
	return queryer.Query(query, dargs)
}

對應的資料庫驅動會真正負責執行呼叫方輸入的 SQL 查詢,作為中間層的標準庫可以不在乎具體的實現,抹平不同關係型資料庫的差異,為使用者程式提供統一的介面。
Go 語言的標準庫 database/sql 是一個抽象層的經典例子,雖然關係型資料庫的功能相對比較複雜,但是我們仍然可以通過定義一系列構成樹形結構的介面提供合理的抽象,這也是我們在編寫框架和中間層時應該注意的,即面向介面程式設計 —— 只依賴抽象的介面,不要依賴具體的實現。