深入剖析 Golang 程序启动原理 - 从 ELF 入口点到GMP初始化到执行 main!

2023-09-01 13:01:10 浏览数 (3)

在过去的开发工作中,大家都是通过创建进程或者线程来工作的。Linux进程是如何创建出来的?聊聊Linux中线程和进程的联系与区别!你的新进程是如何被内核调度执行到的? 这几篇文章就是帮大家深入理解进程线程原理的。

但是,时至今日光了解进程和线程已经不够了。因为现在协程编程模型大行其道。很多同学知道进程和线程,但就是不理解协程是如何工作的。虽然能写出来代码,但不理解底层运行原理。

今天就让我以 golang 版本的 hello world 程序为例,给大家拆解一下协程编程模型的工作过程。

在本文中我会从 ELF 可执行文件的入口讲起,讲到 GMP 调度器的初始化,到主协程的创建,到主协程进入 runtime.main 最后执行到用户定义的 main 函数。

一、 hello world 程序的运行入口

golang 的 hello world 写起来非常的简单。

代码语言:javascript复制
package main
import "fmt"
func main() {
 fmt.Println("Hello World!")
}

运行起来也是一样非常的简单。

代码语言:javascript复制
# go build main.go
# ./main
Hello World!

程序是跑起来了,但是问题来了。传说中的协程究竟长什么样子,是何时被创建的,又是如何被加载运行并打印出 “Hello World!” 的。

不管是啥语言编译出来的可执行文件,都有一个执行入口点。shell 在将程序加载完后会跳转到程序入口点开始执行。

但值得提前说的一点是一般编程语言的入口点都不会是我们在代码中写的那个 main。c 语言中如此,golang 中更是这样。这是因为各个语言都需要在进程启动过程中做一些启动逻辑的。在 golang 中,其底层运行的 GMP、垃圾回收等机制都需要在进入用户的 main 函数之前启动起来。

接下来我们需要借助 readelf 和 nm 命令来找到上述编译出来的可执行文件 main 的执行入口。首先使用 readelf 找到 main 的入口点是在 0x45c220 位置处,如下图所示。

代码语言:javascript复制
$ readelf --file-header main
ELF Header:
 ......
 Entry point address:               0x45c220

那么 0x45c220 这个位置对应的是哪个函数呢?借助 nm 命令我们可以看到它是 _rt0_amd64_linux。

代码语言:javascript复制
nm -n main | grep 45c220
000000000045c220 T _rt0_amd64_linux

这其实是一个汇编函数。

代码语言:javascript复制
// file:asm_amd64.s
// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

这个函数的开头也有明确的注释 “_rt0_amd64 is common startup code for most amd64 systems when using internal linking”。这说明我们找对了。

接下来的 golang 运行就是顺着这个汇编函数开始执行,最后一步步地运行到我们所熟悉的 main 函数的。

二、入口执行分析

这一小节我们来看看 golang 程序在启动的时候都做了哪些事情。相信理解这些底层的工作机制对从事 golang 开发的同学会非常的大有裨益。

在上一小节我们看到了的 golang 入口函数 _rt0_amd64。要注意的是,当代码运行到这里的时候,操作系统已经为当前可执行文件创建好了一个主线程了。_rt0_amd64 只是将参数简单地保存一下后就 JMP (汇编中的函数调用)到 runtime·rt0_go 中了。

这个函数很长,我们只挑有重要的讲!

代码语言:javascript复制
// file:runtime/asm_amd64.s
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
	......

	// 2.1 Golang 核心初始化过程
	CALL	runtime·osinit(SB)
	CALL	runtime·schedinit(SB)


	//2.2 调用 runtime·newproc 创建一个协程
	//    并将 runtime.main 函数作为入口
	MOVQ	$runtime·mainPC(SB), AX		// entry
	PUSHQ	AX
	CALL	runtime·newproc(SB)
	POPQ	AX

	//2.3 启动线程,启动调度系统
	CALL	runtime·mstart(SB)

洋洋洒洒好几百行汇编代码,其实缩略完后,关键的核心逻辑就是上面几个关键点。

第一、通过 runtime 中的 osinit、schedinit 等函数对 golang 运行时进行关键的初始化。在这里我们将看到 GMP 的初始化,与调度逻辑。

第二、创建一个主协程,并指明 runtime.main 函数是其入口函数。因为操作系统加载的时候只创建好了主线程,协程这种东西还是得用户态的 golang 自己来管理。golang 在这里创建出了自己的第一个协程。

第三、调用 runtime·mstart 真正开启运行。

接下来我们分三个小节来详细了解下这三块的逻辑。

2.1 golang 核心初始化

golang 的核心初始化包括 runtime·osinit 和 runtime·schedinit 这两个函数。

在 runtime·osinit 中主要是获取CPU数量,页大小和 操作系统初始化工作。

代码语言:javascript复制
// file:os_linux.go
func osinit() {
 ncpu = getproccount()
 physHugePageSize = getHugePageSize()
 osArchInit()
}

接下来是 runtime.schedinit 的初始化,这里主要是对调度系统的初始化。

在这个函数的注释中,也贴心地告诉了我们,golang 的 bootstrap(启动)流程步骤分别是 call osinit、call schedinit、make & queue new G 和 call runtime·mstart 四个步骤。这和我们前面说的一致。

Golang 中调度的核心就是 GMP 原理。这里我们不展开对 GMP 进行过多的说明,留着将来再细说。这里只提一下,在 runtime.schedinit 这个函数中,会将所有的 P 都给初始化好,并用一个 allp slice 维护管理起来。

代码语言:javascript复制
// file:runtime/proc.go
// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {
 ......

 // 默认情况下 procs 等于 cpu 个数
 // 如果设置了 GOMAXPROCS 则以这个为准
 procs := ncpu
 if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
  procs = n
 }

 // 分配 procs 个 P
 if procresize(procs) != nil {
  throw("unknown runnable goroutine during bootstrap")
 }

 ......
} 

从上述源码中我们可以看到,P 的数量取决于当前 cpu 的数量,或者是 runtime.GOMAXPROCS 的配置。

不少 golang 的同学都有一种错误的认知,认为 runtime.GOMAXPROCS 限制的是 golang 中的线程数。这个认知是错误的。runtime.GOMAXPROCS 真正制约的是 GMP 中的 P,而不是 M。

再来简单看下 procresize,这个函数其实就是在维护 allp 变量,在这里保存着所有的 P。

代码语言:javascript复制
// file:runtime/proc.go
// Change number of processors
// Returns list of Ps with local work, they need to be scheduled by the caller
func procresize(nprocs int32) *p {

 // 申请存储 P 的数组
 if nprocs > int32(len(allp)) {
  allp = ...
 }

 // 对新 P 进行内存分配和初始化,并保存到 allp 数组中
 for i := old; i < nprocs; i   {
  pp := allp[i]
  if pp == nil {
   pp = new(p)
  }
  pp.init(i)
  atomicstorep(unsafe.Pointer(&allp[i]), unsafe.Pointer(pp))
 }
 ...
}

2.2 golang 主协程的创建

汇编代码调用 runtime·newproc 创建一个协程,并将 runtime.main 函数作为入口。我们来看下第一个主协程是如何创建出来的。

代码语言:javascript复制
//file:runtime/proc.go
func newproc(fn *funcval) {
  ...
  systemstack(func() {
  newg := newproc1(fn, gp, pc)

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

  if mainStarted {
   wakep()
  }
 })
}

systemstack 这个函数是 golang 内部经常使用的,runtime 代码经常通过调用 systemstack 临时性的切换到系统栈去执行一些特殊的任务。这里所谓的系统栈,就是操作系统视角创建出来的线程和线程栈。如果不理解,先不管这个也问题不大。

接着调用 newproc1 来创建一个协程出来,runqput 达标的是将协程添加到运行队列。最后的 wakep 是去唤醒一个线程去执行运行队列中的协程。

协程创建

我们一个一个分别来看。先看 newproc1 是如何创建协程的。

代码语言:javascript复制
// file:runtime/proc.go
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
 ...
 //从缓存中获取或者创建 G 对象
 newg := gfget(_p_)
 if newg == nil {
  newg = malg(_StackMin)
  ...
 }

 newg.sched.sp = sp
 newg.stktopsp = sp
 ...
 newg.startpc = fn.fn
 ...
 return newg
}

在 gfget 中是尝试从缓存中获取一个 G 对象出来。我们忽略这个逻辑,直接看 malg,因为它是创建一个 G。对我们理解更有帮助。在 malg 创建完后,对新的 gorutine 对象进行一些设置后就返回了。

在调用 malg 时传入了一个 _StackMin,这表示默认的栈大小,在 Golang 中的默认值是 2048。

这也就是很多人所说的 Golang 中协程很轻量,只需要消耗 2 KB 内存的缘由。但其实这个说法并不是很准确。首先这里分配的并不是 2KB,下面我们会看到还有有一些预留。另外当发生缺页中断的时候,Linux 是以 4 KB为单位分配的。

代码语言:javascript复制
// file:runtime/proc.go
func malg(stacksize int32) *g {
 newg := new(g)
 if stacksize >= 0 {
  //这里会在 stacksize 的基础上为每个栈预留系统调用所需的内存大小 _StackSystem
  //在 Linux/Darwin 上( _StackSystem == 0 )本行不改变 stacksize 的大小
  stacksize = round2(_StackSystem   stacksize)
 }
 // 切换到 G0 为 newg 初始化栈内存
 systemstack(func() {
   newg.stack = stackalloc(uint32(stacksize))
  })

 // 设置 stackguard0 ,用来判断是否要进行栈扩容 
 newg.stackguard0 = newg.stack.lo   _StackGuard
 newg.stackguard1 = ^uintptr(0)
} 

在调用 malg 的时候会将传入的内存大小加上一个 _StackSystem 值预留给系统调用使用,round2 函数会将传入的值舍入为 2 的指数。然后会切换到 G0 执行 stackalloc 函数进行栈内存分配。分配完毕之后会设置 stackguard0 为 stack.lo _StackGuard,作为将来判断是否需要进行栈扩容使用。

代码语言:javascript复制
//file:runtime/stack.go
func stackalloc(n uint32) stack {
 thisg := getg()
 ...
 //对齐到整数页
 n = uint32(alignUp(uintptr(n), physPageSize))
 v := sysAlloc(uintptr(n), &memstats.stacks_sys)
 return stack{uintptr(v), uintptr(v)   uintptr(n)}
}

其中栈是这样一个结构体

代码语言:javascript复制
//file:runtime/runtime2.go
type stack struct {
 lo uintptr
 hi uintptr
}

sysAlloc 使用 mmap 系统调用来真正为协程栈申请指定大小的地址空间。

代码语言:javascript复制
// file:runtime/mem_darwin.go
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {
 v, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
 if err != 0 {
  return nil
 }
 sysStat.add(int64(n))
 return v
}
将协程添加到运行队列

在协程创建出来后,会调用 runqput 将它添加到运行队列中。在讲这块的逻辑之前,我们得首先讲讲 Golang 中的运行队列。

Golang 为什么会抽象出一个 P 来呢。这是因为 Golang 在 1.0 版本的多线程调度器的实现中,调度器和锁都是全局资源,锁的竞争和开销非常的大,导致性能比较差。

其实这个问题在 Linux 中早已很好地解决了。Golang 就把它学来了。在 Linux 中每个 CPU 核都有一个 runqueue,来保存着将来要在该核上调度运行的进程或线程。这样调度的时候,只需要看当前的 CPU 上的资源就行,把锁的开销就砍掉了。

所以,Golang 中的 P 可以认为是对 Linux 中 CPU 的一个虚拟,目的是和 Linux 一样,找一个无竞争地保管运行队列资源的方法。在 Golang 中,每个 P 都有它的运行队列。

理解了这个背景,我们再来看 Golang 中的 runqput 是如何将协程添加到 P 的运行队列中的。

代码语言:javascript复制
// file:runtime/proc.go
// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the _p_.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool) {
 ...

 //将新 goroutine 添加到 P 的 runnext 中
 if next {
 retryNext:
  oldnext := _p_.runnext
  if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
   goto retryNext
  }
  if oldnext == 0 {
   return
  }
  // 将原来的 runnext 添加到运行队列中
  gp = oldnext.ptr()
 }

 //将新协程或者被从 runnext 上踢下来的协程添加到运行队列中
retry:
 h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
 t := _p_.runqtail

 //如果 P 的运行队列没满,那就添加到尾部
 if t-h < uint32(len(_p_.runq)) {
  _p_.runq[t%uint32(len(_p_.runq))].set(gp)
  atomic.StoreRel(&_p_.runqtail, t 1) // store-release, makes the item available for consumption
  return
 }

 //如果满了,就添加到全局运行队列中
 if runqputslow(_p_, gp, h, t) {
  return
 }
}

在 runqput 中首先尝试将新协程放到 runnext 中,这个有优先执行权。然后会将新协程,或者被新协程从 runnext 上踢下来的协程加入到当前 P(运行队列)的尾部去。但还有可能当前这个运行队列已经任务过多了,那就需要调用 runqputslow 分一部分运行队列中的协程到全局队列中去。以便于减轻当前运行队列的执行压力。

唤醒一个线程去

前面只是将新创建的 goroutine 添加到了 P 的运行队列中。现在 GMP 中的 G 有了,P 也有了,就还差 M 了。真正的运行还是需要操作系统的线程去执行的。

代码语言:javascript复制
// file:runtime/proc.go
func wakep() {
 ...
 startm(nil, true)
}

wakep 核心是调用 startm。这个函数将会调度线程去运行 P 中的运行队列。如果有必要的话,可能也需要创建新线程出来。

代码语言:javascript复制
// file:runtime/proc.go
// Schedules some M to run the p (creates an M if necessary).
func startm(_p_ *p, spinning bool) {
 mp := acquirem()

 //如果没有传入 p,就获取一个 idel p
 if _p_ == nil {
  _p_ = pidleget()
 }

 //再获取一个空闲的 m
 nmp := mget()
 if nmp == nil {
  //如果获取不到,就创建一个出来
  newm(fn, _p_, id)
  ...
  return
 }

 ...
}

2.3 启动调度系统

现在 GMP 中的三元素全具备了,而且主协程中的运行函数 fn 也指定为了 runtime.main。接下来就是调用 mstart 来启动线程,启动调度系统。

汇编中的 mstart 函数调用的是 golang 源码中的 mstart0

代码语言:javascript复制
// file:runtime/proc.go
func mstart0() {
 ...
 mstart1()
}
代码语言:javascript复制
// file:runtime/proc.go
func mstart1() {
 ...
 // 进入调度循环
 schedule()
}

其中,schedule 是整个 golang 程序的运行核心。所有的协程都是通过它来开始运行的。

schedule 的主要工作逻辑有这么几点

  1. 每隔 61 次调度轮回从全局队列找,避免全局队列中的g被饿死。
  2. 从 p.runnext 获取 g,从 p 的本地队列中获取。
  3. 调用 findrunnable 找 g,找不到的话就将 m 休眠,等待唤醒。
  4. 当找到一个 g 后,就会调用 execute 去执行 g

然后再来看源码就很容易理解了。

代码语言:javascript复制
// file:runtime/proc.go
func schedule() {
 _g_ := getg()
 ...
top:
 pp := _g_.m.p.ptr()

 //每 61 次从全局运行队列中获取可运行的协程
 if gp == nil {
  if _g_.m.p.ptr().schedticka == 0 && sched.runqsize > 0 {
   lock(&sched.lock)
   gp = globrunqget(_g_.m.p.ptr(), 1)
   unlock(&sched.lock)
  }
 }

 if gp == nil {
  //从当前 P 的运行队列中获取可运行
  gp, inheritTime = runqget(_g_.m.p.ptr())
 }

 if gp == nil {
  //当前P或者全局队列中获取可运行协程
  //尝试从其它P中steal任务来处理
  //如果获取不到,就阻塞
  gp, inheritTime = findrunnable() // blocks until work is available
 }

//执行协程
 execute(gp, inheritTime) 
}

其中 findrunnable 如果从当前 P 的运行队列和全局运行队列获取 G 都没有任务后,还会尝试从其它的 P 中获取一些任务过来运行。代码就不过多展示了。

三、main 函数的真正运行

至此,整个 golang 的调度系统就算是跑起来了。因为前面我们创建了主协程,而且还给它设置了 runtime.main 函数作为入口。所以对于主协程的调度,就会进入这个入口进行执行。终于,能看到 runtime 快运行到我们自己写的 main 函数中了。

runtime.main 在执行 main 包中的 main 之前,还是做了一些不少其他工作,包括:

  1. 新建一个线程来执行 sysmon。sysmon的工作是系统后台监控(定期垃圾回收和调度抢占)。
  2. 执行 runtime init 函数。runtime 包中也有不少的 init 函数,会在这个时机运行
  3. 启动 gc 清扫的 goroutine。
  4. 执行 main init 函数。包括用户定义的所有的 init 函数。
  5. 执行用户 main 函数。
代码语言:javascript复制
// file:runtime/proc.go
// The main goroutine.
func main() {
 g := getg()

 // 在系统栈上运行 sysmon
 systemstack(func() {
  newm(sysmon, nil, -1)
 })

 // runtime 内部 init 函数的执行,编译器动态生成的。
 doInit(&runtime_inittask) // Must be before defer.

 // gc 启动一个goroutine进行gc清扫
 gcenable()

 // 执行main init
 doInit(&main_inittask)

 // 执行用户main
 fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
 fn()

 // 退出程序
 exit(0)
}

好了,终于,我们定义的 main 函数能被执行到。可以输出 “Hello World!”了。

四、总结

Golang 程序的运行入口是 runtime 定义的一个汇编函数。这个函数核心有三个逻辑:

第一、通过 runtime 中的 osinit、schedinit 等函数对 golang 运行时进行关键的初始化。在这里我们将看到 GMP 的初始化,与调度逻辑。

第二、创建一个主协程,并指明 runtime.main 函数是其入口函数。因为操作系统加载的时候只创建好了主线程,协程这种东西还是得用户态的 golang 自己来管理。golang 在这里创建出了自己的第一个协程。

第三、调用 runtime·mstart 真正开启调度器进行运行。

当调度器开始执行后,其中主协程会进入 runtime.main 函数中运行。在这个函数中进行几件初始化后,最后后真正进入用户的 main 中运行。

第一、新建一个线程来执行 sysmon。sysmon的工作是系统后台监控(定期垃圾回收和调度抢占)。 第二、启动 gc 清扫的 goroutine。 第三、执行 runtime init,用户 init。 第四、执行用户 main 函数。

看似简简单单的一个 Golang 的 Hello World 程序,只要你愿意深挖,里面真的有极其丰富的营养的!

0 人点赞