专栏首页Go fasterGo程序生命周期
原创

Go程序生命周期

本文是要讲什么(WHAT)

本文希望能讲清楚一个Go程序从开始写下第一行代码到程序完全退出这期间都发生了什么事情,当然每个程序的执行逻辑千差万别,但这里想讲清楚的事情是所有程序都共通的事情,所有程序都需要经历的处理逻辑。

为什么要讲这些(WHY)

于其他同学而言,

  • 从代码层面理解编译,链接,装载过程;
  • 从代码层面理解运行时;
  • 理解什么是编译期决定的事情,什么是运行时决定的事情; 于我个人而言, 读了许多书和资料,想要消化理解,而怎么才算消化了呢,把读到的概念和知识反刍出来用自己的语言和文字输出,这是很重要的一个里程碑。如何讲(HOW)会按作者本人的理解从多个角度尝试理解同一件事情,希望不同角度互相印证,最终是同一个目的,理解,消化那些我们常见又不太明白的东西。

接下来我们进入正文。

从最简单的Hello Golang说起。

package main

import "fmt"

func main() {
        fmt.Printf("Hello Golang!")
}

我们通常会上一个go build来编译生成一个ELF可执行文件。

我们先来看下go build都帮我们做了啥。

 > # go build -a -n
# import config
packagefile fmt=$WORK/b002/_pkg_.a
packagefile runtime=$WORK/b007/_pkg_.a
EOF
/usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" -p main -lang=go1.17 -complete -buildid z0ktmI1YTOzCHQM1j7-R/z0ktmI1YTOzCHQM1j7-R -goversion go1.17.2 -importcfg $WORK/b001/importcfg -pack -c=4 ./hello.go $WORK/b001/_gomod_.go
/usr/local/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/_pkg_.a # internal
cat >$WORK/b001/importcfg.link << 'EOF' # internal
packagefile git.code.oa.com/laboratory/hello=$WORK/b001/_pkg_.a
packagefile fmt=$WORK/b002/_pkg_.a
packagefile runtime=$WORK/b007/_pkg_.a
packagefile errors=$WORK/b003/_pkg_.a
packagefile internal/fmtsort=$WORK/b015/_pkg_.a
packagefile io=$WORK/b027/_pkg_.a
packagefile math=$WORK/b018/_pkg_.a
packagefile os=$WORK/b028/_pkg_.a
packagefile reflect=$WORK/b016/_pkg_.a
packagefile strconv=$WORK/b020/_pkg_.a
packagefile sync=$WORK/b022/_pkg_.a
packagefile unicode/utf8=$WORK/b021/_pkg_.a
packagefile internal/abi=$WORK/b008/_pkg_.a
packagefile internal/bytealg=$WORK/b009/_pkg_.a
packagefile internal/cpu=$WORK/b010/_pkg_.a
packagefile internal/goexperiment=$WORK/b011/_pkg_.a
packagefile runtime/internal/atomic=$WORK/b012/_pkg_.a
packagefile runtime/internal/math=$WORK/b013/_pkg_.a
packagefile runtime/internal/sys=$WORK/b014/_pkg_.a
packagefile internal/reflectlite=$WORK/b004/_pkg_.a
packagefile sort=$WORK/b026/_pkg_.a
packagefile math/bits=$WORK/b019/_pkg_.a
packagefile internal/itoa=$WORK/b017/_pkg_.a
packagefile internal/oserror=$WORK/b029/_pkg_.a
packagefile internal/poll=$WORK/b030/_pkg_.a
packagefile internal/syscall/execenv=$WORK/b034/_pkg_.a
packagefile internal/syscall/unix=$WORK/b031/_pkg_.a
packagefile internal/testlog=$WORK/b035/_pkg_.a
packagefile internal/unsafeheader=$WORK/b005/_pkg_.a
packagefile io/fs=$WORK/b036/_pkg_.a
packagefile sync/atomic=$WORK/b024/_pkg_.a
packagefile syscall=$WORK/b032/_pkg_.a
packagefile time=$WORK/b033/_pkg_.a
packagefile unicode=$WORK/b025/_pkg_.a
packagefile internal/race=$WORK/b023/_pkg_.a
packagefile path=$WORK/b037/_pkg_.a
EOF
mkdir -p $WORK/b001/exe/
cd .
/usr/local/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=yUoLSlHsIhJ4gtJbQMNi/z0ktmI1YTOzCHQM1j7-R/z0ktmI1YTOzCHQM1j7-R/yUoLSlHsIhJ4gtJbQMNi -extld=gcc $WORK/b001/_pkg_.a
/usr/local/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal
mv $WORK/b001/exe/a.out hello

从这里可以看到从宏观角度一个ELF文件的诞生经历来compile,link,mv这几个过程;

  • compile会将go文件编译成$WORK/b001/pkg.a文件;
  • link会将 $WORK/b001/pkg.a 进行链接生成 $WORK/b001/exe/a.out ;
  • mv 将生成的 $WORK/b001/exe/a.out 修改为go.mod中指定的文件名; 当然,这里的过程简化了亿点点细节,接下来我们深入一点点,回到课堂,一个完整的编译流程都有什么:
image.png

看到这里我相信大多数人可能想要ctrl+w了,但先不要。尝试跟着我的思路往下读一下。

词法分析

我们用代码来模拟一下词法分析的过程以及结果:

var src = `
 package main
 import "fmt"
 func main() {
     fmt.Println("Hello Golang!")
 }
`

func main() {
	fset := token.NewFileSet() // positions are relative to fset
	tokenfile := fset.AddFile("hello.go", fset.Base(), len(src))
	// 词法分析
	var s scanner.Scanner
	s.Init(tokenfile, []byte(src), nil, scanner.ScanComments)
	for {
		pos, tok, lit := s.Scan()
		if tok == token.EOF {
			break
		}
		fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
	}
}

生成结果:

image.png

词法分析的过程其实就是把代码文件转换为一个string slice的过程。即[]byte -> tokens。

token大致可分为以下几类:

  • 特殊token。例如,ILLEGAL(非法TOKEN)、EOF(文件末尾)、COMMENT(注释)
  • 字面token。例如,标识符IDENT、数字INT、字符串STRING等等。
  • 操作符token。+ - * / , . ; ( )等等。
  • 关键字token。var,select,chan等。

词法分析阶段,会给源码的每行的最后添加上分号;。这就是go代码每行最后不用加分号的原因

语法分析

词法分析的结果是一个token序列,以及代码位置等描述信息。同样的我们用代码模拟一下语法分析的过程:

var src = `
 package main
 import "fmt"
 func main() {
     fmt.Println("Hello Golang!")
 }
`

func main() {
	fset := token.NewFileSet() // positions are relative to fset
	tokenfile := fset.AddFile("hello.go", fset.Base(), len(src))
	// 词法分析
	var s scanner.Scanner
	s.Init(tokenfile, []byte(src), nil, scanner.ScanComments)
	for {
		pos, tok, lit := s.Scan()
		if tok == token.EOF {
			break
		}
		fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
	}

	// 语法分析
	astfile, err := parser.ParseFile(fset, "", src, 0)
	if err != nil {
		panic(err)
	}
	// Print the AST.
	ast.Print(fset, astfile)
}

语法分析的过程其实是将上面的token序列构建一个AST(抽象语法树)对于抽象语法树我想可能很多同学都只闻其声不见其身,好在我们这里不是Print出来了么:

     0  *ast.File {
     1  .  Package: 2:2
     2  .  Name: *ast.Ident {
     3  .  .  NamePos: 2:10
     4  .  .  Name: "main"
     5  .  }
     6  .  Decls: []ast.Decl (len = 2) { // 声明列表
     7  .  .  0: *ast.GenDecl { // generic declaration node,用于表示import,const,type,variable声明语句
     8  .  .  .  TokPos: 3:2
     9  .  .  .  Tok: import
    10  .  .  .  Lparen: -
    11  .  .  .  Specs: []ast.Spec (len = 1) {
    12  .  .  .  .  0: *ast.ImportSpec { // 
    13  .  .  .  .  .  Path: *ast.BasicLit { // A BasicLit node represents a literal of basic type.(这个是用的源码里面的comment信息,我实在没想到如何翻译这个)
    14  .  .  .  .  .  .  ValuePos: 3:9
    15  .  .  .  .  .  .  Kind: STRING
    16  .  .  .  .  .  .  Value: "\"fmt\""
    17  .  .  .  .  .  }
    18  .  .  .  .  .  EndPos: -
    19  .  .  .  .  }
    20  .  .  .  }
    21  .  .  .  Rparen: -
    22  .  .  }
    23  .  .  1: *ast.FuncDecl { // 函数声明,函数定义由 Name Type Body组成
    24  .  .  .  Name: *ast.Ident { // 函数名描述
    25  .  .  .  .  NamePos: 4:7
    26  .  .  .  .  Name: "main"
    27  .  .  .  .  Obj: *ast.Object {
    28  .  .  .  .  .  Kind: func
    29  .  .  .  .  .  Name: "main"
    30  .  .  .  .  .  Decl: *(obj @ 23)
    31  .  .  .  .  }
    32  .  .  .  }
    33  .  .  .  Type: *ast.FuncType { // 函数类型信息
    34  .  .  .  .  Func: 4:2
    35  .  .  .  .  Params: *ast.FieldList { // 参数列表
    36  .  .  .  .  .  Opening: 4:11 // 参数列表开始位置
    37  .  .  .  .  .  Closing: 4:12 // 参数列表结束位置
    38  .  .  .  .  }
    39  .  .  .  }
    40  .  .  .  Body: *ast.BlockStmt { // 函数体描述
    41  .  .  .  .  Lbrace: 4:14 // 函数体左花括号位置
    42  .  .  .  .  List: []ast.Stmt (len = 1) {
    43  .  .  .  .  .  0: *ast.ExprStmt { // 第一个元素是一个表达式
    44  .  .  .  .  .  .  X: *ast.CallExpr { // 函数调用表达式
    45  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr { // 选择器表达式
    46  .  .  .  .  .  .  .  .  X: *ast.Ident {
    47  .  .  .  .  .  .  .  .  .  NamePos: 5:6
    48  .  .  .  .  .  .  .  .  .  Name: "fmt"
    49  .  .  .  .  .  .  .  .  }
    50  .  .  .  .  .  .  .  .  Sel: *ast.Ident { // 选择器字段描述
    51  .  .  .  .  .  .  .  .  .  NamePos: 5:10
    52  .  .  .  .  .  .  .  .  .  Name: "Println"
    53  .  .  .  .  .  .  .  .  }
    54  .  .  .  .  .  .  .  }
    55  .  .  .  .  .  .  .  Lparen: 5:17 // 左括号(
    56  .  .  .  .  .  .  .  Args: []ast.Expr (len = 1) { // 参数列表
    57  .  .  .  .  .  .  .  .  0: *ast.BasicLit {
    58  .  .  .  .  .  .  .  .  .  ValuePos: 5:18
    59  .  .  .  .  .  .  .  .  .  Kind: STRING
    60  .  .  .  .  .  .  .  .  .  Value: "\"Hello, Golang!\""
    61  .  .  .  .  .  .  .  .  }
    62  .  .  .  .  .  .  .  }
    63  .  .  .  .  .  .  .  Ellipsis: - // "..."的位置
    64  .  .  .  .  .  .  .  Rparen: 5:34 // 右括号)
    65  .  .  .  .  .  .  }
    66  .  .  .  .  .  }
    67  .  .  .  .  }
    68  .  .  .  .  Rbrace: 6:2 // 函数体右花括号位置
    69  .  .  .  }
    70  .  .  }
    71  .  }
    72  .  Scope: *ast.Scope { // 作用域信息这里只有一个main
    73  .  .  Objects: map[string]*ast.Object (len = 1) {
    74  .  .  .  "main": *(obj @ 27)
    75  .  .  }
    76  .  }
    77  .  Imports: []*ast.ImportSpec (len = 1) {
    78  .  .  0: *(obj @ 12)
    79  .  }
    80  .  Unresolved: []*ast.Ident (len = 1) {
    81  .  .  0: *(obj @ 46)
    82  .  }
    83  }

在上面的AST结果中我按照自己的理解加了一些注释方便各位对照参考。

Go中的AST结构定义为:

// A File node represents a Go source file.
//
// The Comments list contains all comments in the source file in order of
// appearance, including the comments that are pointed to from other nodes
// via Doc and Comment fields.
//
// For correct printing of source code containing comments (using packages
// go/format and go/printer), special care must be taken to update comments
// when a File's syntax tree is modified: For printing, comments are interspersed
// between tokens based on their position. If syntax tree nodes are
// removed or moved, relevant comments in their vicinity must also be removed
// (from the File.Comments list) or moved accordingly (by updating their
// positions). A CommentMap may be used to facilitate some of these operations.
//
// Whether and how a comment is associated with a node depends on the
// interpretation of the syntax tree by the manipulating program: Except for Doc
// and Comment comments directly associated with nodes, the remaining comments
// are "free-floating" (see also issues #18593, #20744).
//
type File struct {
	Doc        *CommentGroup   // associated documentation; or nil
	Package    token.Pos       // position of "package" keyword
	Name       *Ident          // package name
	Decls      []Decl          // top-level declarations; or nil
	Scope      *Scope          // package scope (this file only)
	Imports    []*ImportSpec   // imports in this file
	Unresolved []*Ident        // unresolved identifiers in this file
	Comments   []*CommentGroup // list of all comments in the source file
}

一个go文件在语法分析阶段就会被解析为一个ast.File结构。

语义分析

同样的我们用代码来模拟一下语义分析过程:

var src = `
 package main
 import "fmt"
 func main() {
    b = "a" + 1 // 这里故意写一个语法错误语句
     fmt.Println("Hello, Golang!")
 }
`

func main() {
	fset := token.NewFileSet() // positions are relative to fset
	tokenfile := fset.AddFile("hello.go", fset.Base(), len(src))
	// 词法分析
	var s scanner.Scanner
	s.Init(tokenfile, []byte(src), nil, scanner.ScanComments)
	for {
		pos, tok, lit := s.Scan()
		if tok == token.EOF {
			break
		}
		fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
	}

	// 语法分析
	astfile, err := parser.ParseFile(fset, "", src, 0)
	if err != nil {
		panic(err)
	}
	// Print the AST.
	ast.Print(fset, astfile)

	// 语义分析
	typeconfig := &types.Config{Importer: importer.Default()}
	pkg, err := typeconfig.Check("hello.go", fset, []*ast.File{astfile}, nil)
	if err != nil {
		fmt.Printf("%v\n", err)
	}
	_ = pkg
}

上面的代码中的typeconfig.Check会返回一个error,表示上面的程序中有语法错误:

5:8: cannot convert "a" (untyped string constant) to untyped int

语义分析的过程其实包含了:

  • 类型检查
  • 关键字展开
  • 逃逸分析
  • 变量捕获
  • 函数内联
  • 闭包处理中间代码生成在这个阶段需要引入一个概念,SSA(static single assignment)静态单一赋值,这个阶段的主要目的就是将经过语义分析处理的AST转换为SSA。即在编译期针对每一个变量都只有一次赋值过程描述。 例如, 我想谈的是为什么需要这样的一层中间代码生成这一步,为什么不能把AST直接转换为汇编代码或机器码。 我们在计算机领域会遇到各种问题,遇到问题的时候通常是现状与目标状态的理想差,往往这个理想差看起来是一个不可逾越的鸿沟,但这些问题有一个经典的解决思路,就是在我们看似不可达的目的与现状之间加一层代理,或称之为中间态。再将中间态的something转换为我们的目标态。 这里遇到的高级语言到机器语言之间的鸿沟如此,Go中的协程调度器GMP模型同样如此。如果一层中间态不够那就多加几层。 使用GOSSAFUNC=main go build hello.go命令可生成hello.go文件的编译过程中的可视化生成过程ssa.html:
image.png

可以看到从源代码与AST到最终生成的genssa中间经历了几十轮的代码转换过程,每一步都是将代码转换为更接近汇编代码的过程,这一过程被成为SSA降级。最终生成的genssa代码其实已经非常接近汇编代码了。

机器码生成

机器码生成的过程其实是针对不同的硬件平台对SSA中间代码进行的降级替换,替换为目标平台的指令集展开。Golang之所以能够跨平台或交叉编译就是在编译器后端部分针对不同平台不同指令集进行的不断迭代降级。

计算机硬件与软件之间的交互就是通过指令集来完成的,最常见的指令集架构分类方法是根据指令的复杂度将其分为复杂指令集(CISC)和精简指令集(RISC)。

按我个人理解,

复杂指令集(CISC)的思路其实很像我们业务开发很熟悉的对于各种底层各种接口的封装,来了一个新的需求,需要一个之前未曾有过的新能力那我就加一个指令吧,不断往指令集中添加新的指令来完成各种事情,随着各种指令的添加对指令规范的约束力也只能日趋下降,导致每个指令长度各不相同,且越来越复杂;

精简指令集(RISC)的思路则有点架构分层的味道了,指令集中我只提供能够完成各种事情的原子操作,需要什么新的能力或功能那你在上层对我提供的各种指令进行封装就好了,但不要寄希望于往指令集中加新东西,当然精简指令集对所有指令就有了很好的规范能力,能够保持每条指令长度相等,相对简单。

对于当前软件开发人员不需要直接接触汇编代码,而是通过编译器和汇编器生成指令,复杂的机器指令对编译器来说同样增加了指令生成的复杂度和扩展性,所以编译器生成的指令现在几乎都使用精简指令集了。

在中间代码生成阶段生成的最终的genssa文件会在这一步被汇编器转换成汇编代码:

package main

import "fmt"

func main() {
        fmt.Printf("hello")
}
$ go tool compile -N -l -S hello.go
"".main STEXT size=96 args=0x0 locals=0x48 funcid=0x0
        0x0000 00000 (hello.go:5)       TEXT    "".main(SB), ABIInternal, $80-0 // TEXT 伪操作符,代码段标识;"". 表示命名空间;SB 伪寄存器;static base 静态基地址指针;80表示函数栈帧长度;0表示参数长度
        0x0000 00000 (hello.go:5)       MOVD    16(g), R1
        0x0004 00004 (hello.go:5)       PCDATA  $0, $-2 // 将PC寄存器0偏移的地址赋值为-2 官方说法是这条指令包含gc信息与主流程无关
        0x0004 00004 (hello.go:5)       MOVD    RSP, R2
        0x0008 00008 (hello.go:5)       CMP     R1, R2 // 比较栈空间大小,判断是否需要扩容
        0x000c 00012 (hello.go:5)       BLS     72 // 如果需要扩容的话,跳转到72地址指令,也就是NOP那行
        0x0010 00016 (hello.go:5)       PCDATA  $0, $-1
        0x0010 00016 (hello.go:5)       MOVD.W  R30, -80(RSP)
        0x0014 00020 (hello.go:5)       MOVD    R29, -8(RSP)
        0x0018 00024 (hello.go:5)       SUB     $8, RSP, R29
        0x001c 00028 (hello.go:5)       FUNCDATA        ZR, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) // 与上面的PCDATA指令一样,是gc要用到的指令
        0x001c 00028 (hello.go:5)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x001c 00028 (hello.go:6)       MOVD    $go.string."hello"(SB), R0
        0x0024 00036 (hello.go:6)       MOVD    R0, 8(RSP)
        0x0028 00040 (hello.go:6)       MOVD    $5, R0
        0x002c 00044 (hello.go:6)       MOVD    R0, 16(RSP)
        0x0030 00048 (hello.go:6)       STP     (ZR, ZR), 24(RSP)
        0x0034 00052 (hello.go:6)       MOVD    ZR, 40(RSP)
        0x0038 00056 (hello.go:6)       PCDATA  $1, ZR
        0x0038 00056 (hello.go:6)       CALL    fmt.Printf(SB) // 函数调用指令
        0x003c 00060 (hello.go:7)       MOVD    -8(RSP), R29
        0x0040 00064 (hello.go:7)       MOVD.P  80(RSP), R30
        0x0044 00068 (hello.go:7)       RET     (R30)
        0x0048 00072 (hello.go:7)       NOP
        0x0048 00072 (hello.go:5)       PCDATA  $1, $-1
        0x0048 00072 (hello.go:5)       PCDATA  $0, $-2
        0x0048 00072 (hello.go:5)       MOVD    R30, R3
        0x004c 00076 (hello.go:5)       CALL    runtime.morestack_noctxt(SB) // 执行栈扩容
        0x0050 00080 (hello.go:5)       PCDATA  $0, $-1
        0x0050 00080 (hello.go:5)       JMP     0 // 跳转到0地址重新执行
        0x0000 81 0b 40 f9 e2 03 00 91 5f 00 01 eb e9 01 00 54  ..@....._......T
        0x0010 fe 0f 1b f8 fd 83 1f f8 fd 23 00 d1 00 00 00 90  .........#......
        0x0020 00 00 00 91 e0 07 00 f9 a0 00 80 d2 e0 0b 00 f9  ................
        0x0030 ff ff 01 a9 ff 17 00 f9 00 00 00 94 fd 83 5f f8  .............._.
        0x0040 fe 07 45 f8 c0 03 5f d6 e3 03 1e aa 00 00 00 94  ..E..._.........
        0x0050 ec ff ff 17 00 00 00 00 00 00 00 00 00 00 00 00  ................
        rel 28+8 t=3 go.string."hello"+0
        rel 56+4 t=10 fmt.Printf+0
        rel 76+4 t=10 runtime.morestack_noctxt+0
go.cuinfo.packagename. SDWARFCUINFO dupok size=0
        0x0000 6d 61 69 6e                                      main
""..inittask SNOPTRDATA size=32
        0x0000 00 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00  ................
        0x0010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        rel 24+8 t=1 fmt..inittask+0
go.string."hello" SRODATA dupok size=5
        0x0000 68 65 6c 6c 6f                                   hello
type..importpath.fmt. SRODATA dupok size=6
        0x0000 00 00 03 66 6d 74                                ...fmt
gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
        0x0000 01 00 00 00 00 00 00 00                          ........

在其中个人认为不太容易看懂的地方加了一些个人理解的注释,可能理解得有问题,欢迎赐教。

链接

链接过程目的主要是将多文件编译出的目标文件或静态库文件链接生成最终的可执行文件。以我对链接器粗浅的理解,链接的主要工作其实是地址重定位的过程。

比如,当A.go文件中访问了B.go文件中定义的一个变量Ver,

// A.go
Ver = 42

当编译A文件时生成的汇编代码会为:

movl $0x2a, Ver

但这个时候目标代码中其实是不知道ver变量的地址的,所以A编译出的目标文件中Ver的地址会被置为0。当链接器链接的时候才知道Ver的地址,这个时候会重新修正A的目标文件中的变量地址。这个过程被称为地址重定位的过程。

装载

当我们在命令行中敲下./hello并回车这中间发生了什么?操作系统帮我们把我们的elf文件加载到内存并把pc寄存器指向程序入口地址,cpu开始执行本程序的一系列指令。这个过程中首先shell会帮我们fork一个子进程,并调用execv从磁盘加载可执行文件到内存中进行执行。整个的调用过程是:

sys_execve/sys_execveat
|
|--> do_execve
			|-->  do_execveat_common
					|-->  _do_execve_file
								|--> exec_binprm
										|--> search_binary_handler
												|--> load_elf_binary

进入程序入口点之后呢,因为go程序前面的引导启动过程多数为汇编代码,我们读go的源码的时候其实往往不知道从何处入手,所以需要先知道程序入口点是哪里(本文涉及go源码以1.17版本为准):

gdb ./hello // gdb加载elf文件

然后info file查看程序入口点,

(gdb) info file
Symbols from "/data/git/workspace/src/laboratory/hello/hello".
Local exec file:
        `/data/git/workspace/src/laboratory/hello/hello', file type elf64-x86-64.
        Entry point: 0x45c200
        0x0000000000401000 - 0x000000000047f7ac is .text
        0x0000000000480000 - 0x00000000004b51c4 is .rodata
        0x00000000004b5360 - 0x00000000004b5838 is .typelink
        0x00000000004b5840 - 0x00000000004b5898 is .itablink
        0x00000000004b5898 - 0x00000000004b5898 is .gosymtab
        0x00000000004b58a0 - 0x000000000050ea38 is .gopclntab
        0x000000000050f000 - 0x000000000050f020 is .go.buildinfo
        0x000000000050f020 - 0x000000000051f5e0 is .noptrdata
        0x000000000051f5e0 - 0x0000000000526df0 is .data
        0x0000000000526e00 - 0x0000000000555d08 is .bss
        0x0000000000555d20 - 0x000000000055b080 is .noptrbss
        0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid

在Entry point处加个断点,然后启动程序:

(gdb) b *0x45c200
Breakpoint 1 at 0x45c200: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.
(gdb) r
Starting program: /data/git/workspace/src/laboratory/hello/./hello 

Breakpoint 1, _rt0_amd64_linux () at /usr/local/go/src/runtime/rt0_linux_amd64.s:8
8               JMP     _rt0_amd64(SB)

可以看到程序暂停在了/usr/local/go/src/runtime/rt0_linux_amd64.s:8这里,这里就是go源代码中的入口了。我们如果要看go源码的话,从这里看起再合适不过了。

#include "textflag.h"

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

其实go程序的引导,协程调度,gc等逻辑都实现在runtime包里所以大家在读go源码的时候runtime包是研究go运行机制,运行逻辑的一个很重要的package。

// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

_rt0_amd64函数注释中说得已经很清楚了其实这里把主程序参数分别存放再了DI和SI两个寄存器中。

rt0_go这个函数做的事情就比较多了,其中最重要的几行:

	MOVQ	$runtime·mainPC(SB), AX	// runtime.main函数地址放进AX寄存器
    PUSHQ	AX
    PUSHQ	$0			// arg size
	CALL	runtime·newproc(SB) // 新建一个goroutine,该goroutine绑定runtime.main,放在P的本地队列,等待调度
	POPQ	AX
	POPQ	AX

	// start this M
	// 启动M,开始调度goroutine
	CALL	runtime·mstart(SB)

这是程序中第一个g和m的创建流程。所有的g都通过runtime.newproc函数创建,

// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
	gp := getg()
	pc := getcallerpc()
	systemstack(func() {
		newg := newproc1(fn, gp, pc)

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

		if mainStarted {
			wakep()
		}
	})
}

newproc函数的逻辑也并不复杂,使用newproc1创建一个g结构体,并把g放到等待队列,如果m0已经启动了则唤醒一个p起来执行这个g。

回到rt0_go这个函数,其中开启了m0与g0去执行runtime.mainPC函数,这个函数的定义在这里:

// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

这里就调到了runtime.main方法中,这个方法的定义有点长这里就不列代码了。主要是做了N多初始化的操作,包括runtime开启的后台任务,gc,package中引入的各种package的init函数等;最重要的是调用了main包中的main方法进入到用户自定义逻辑中。

运行

runtime是一个可以单拿出来做好几期专项的一块内容,我理解的runtime其实是语言在后台启动帮用户做事情的后台协程,由于我是从c++语言转过来的所以想类比一下以前公司在c++时代是如何做这类事情的。

在公司全面拥抱golang之前,在c++时代,我们业务常用的spp框架提供了一套被叫做微线程的用户态逻辑调度机制,其依赖于用户级微线程的主动让出,相比当前go的协程调度机制显得简单且自由,用户可以控制在合适的地方是否让出cpu给其他微线程使用。目前rust的tokio的协程实现库的调度机制与微线程的调度机制几乎一样,因为这两种语言都没有runtime运行时,rust中最重要的特性内存安全也是建立在编译期的trait上的,这种设计也导致了rust对写出来的代码有更高的要求,只有一条路你给我走过去就好了,不行我就给断给你看。这就是我理解的runtime。

退出

引导程序最后几行是这样的:

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

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

exit(0)

main.main的程序return之后如果还有未执行defer的panic信息会再最多调度1000次执行defer函数。

总结

至此,一个Go程序结束了其轰轰烈烈而又潇潇洒洒的一生。本文中出现的各个阶段,单拿出来都可以做一个专题来讲,有些地方讲得不够深入,没有完全展开(这里想做一个比喻的,但又想不到合适的比拟对象,想了想删了吧)。希望这篇文能帮大家梳理一些概念,进一步希望能够带给大家一些启发,再进一步希望其他人能够从中抽取一些概念展开讲讲。能够看到这里我已经非常爱你了,感谢!!!

参考资料

https://draveness.me/golang/

https://www.cnblogs.com/qcrao-2018/p/11124360.html

https://segmentfault.com/a/1190000040181868

https://segmentfault.com/a/1190000020996545

https://golang.design/under-the-hood/

程序员的自我修养--链接、装载与库

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 小程序 — 小程序生命周期及页面生命周期

    (1)首先小程序的生命周期函数是在app.js里面调用的,App(Object)函数用来注册一个小程序。接受一个 Object 参数,指定其小程序的生命周期回调...

    Ewall
  • 小程序 — 小程序生命周期及页面生命周期

    前言:很多同学容易将小程序生命周期和页面的生命周期混淆为一起,这两个其实应该是不同却又相互关联的生命周期,所以,用实际代码操作并结合官方理论讲讲这个,好好捋捋。

    前端老鸟
  • 小程序生命周期

    小程序并不是 HTML5 应用,而是更偏向于传统的 CS 架构,它是基于数据驱动的模式,一切皆组件(视图组件)。下面是小程序与普通 Web App 的对比。 ...

    用户2305175
  • 小程序生命周期

    在技术中心,我们可以理解生命周期为从一个应用从创建到销毁的过程。在项目层面,我们每一个完整的项目中都会在不同时间不同位置处理不同问题及不同需求,也就是在特点时间...

    流眸
  • 小程序 | 4-生命周期

    小程序初始化完成时触发,全局只触发一次。参数也可以使用 wx.getLaunchOptionsSync 获取。

    CnPeng
  • 微信小程序生命周期

    明知山
  • 小程序生命周期与页面周期详解

    小程序生命周期的对象是整个小程序,页面周期的对象是单个页面,这点要分清,下面我们来详细了解一下这两个周期。

    许坏
  • 微信小程序----App生命周期

    总结:进入微信小程序,当我们需要获取用户信息并保存等全局信息处理的时候,需要再onLaunch周期进行处理。

    Rattenking
  • 小程序 | 5-页面生命周期

    小程序中的每个页面都有一个对应的 js 文件,在小程序初始化过程中,会调用其中的 Page() 实现该页面实例的注册。

    CnPeng
  • 微信小程序 应用程序生命周期

    微信小程序 应用程序生命周期

    Java架构师必看
  • asp.net MVC 应用程序的生命周期

      首先我们知道http是一种无状态的请求,他的生命周期就是从客户端浏览器发出请求开始,到得到响应结束。那么MVC应用程序从发出请求到获得响应,都做了些什么呢?...

    用户1172223
  • asp.net MVC 应用程序的生命周期

      首先我们知道http是一种无状态的请求,他的生命周期就是从客户端浏览器发出请求开始,到得到响应结束。那么MVC应用程序从发出请求到获得响应,都做了些什么呢?

    用户1172223
  • 微信小程序----页面生命周期

    Rattenking
  • asp.net MVC 应用程序的生命周期

      首先我们知道http是一种无状态的请求,他的生命周期就是从客户端浏览器发出请求开始,到得到响应结束。那么MVC应用程序从发出请求到获得响应,都做了些什么呢?

    用户1172223
  • WePY开发小程序(三):生命周期

    听着music睡
  • Java线程生命周期

    Tencent JCoder
  • 线程的生命周期

    用户2965768
  • 线程的生命周期

    进程:程序或者任务的执行过程,拥有资源和线程。 一个进程包括由操作系统分配的内存空间,包含一个或多个线程。 线程:系统中的最小执行单位,一个进程可以有多个线程,...

    硕人其颀
  • 线程的生命周期

    线程的六种状态: NEW、RUNNABLE、BIOCKED、WAITING、TIME_WAITING、TERMINATED。

    用户7386338

扫码关注云+社区

领取腾讯云代金券