本文目录速览:程序员
经过上一篇走进Golang之汇编原理,咱们知道了目标代码的生成经历了那些过程。今天咱们一块儿来学习一下生成的目标代码如何在计算机上执行。以及经过查阅 Golang
的 Plan9 汇编来了解Golang的一些内部秘密。golang
当咱们把编译后的Go代码运行起来,它会以进程的方式出如今系统中。而后开始处理请求、数据,咱们会看到这个进程占用了内存消耗、cpu占比等等信息。本文就是要来解释在程序的运行过程当中,内存、CPU、操做系统(固然还有其它的硬件,文中关系不大,就不说了)是如何进行配合,完成了咱们代码所指定的事情。面试
首先,咱们先来讲说内存。先来看一个咱们运行的go进程。shell
代码以下:编程
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", sayHello)
err := http.ListenAndServe(":9999", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
func sayHello(w http.ResponseWriter, r *http.Request) {
fmt.Printf("fibonacci: %d\n", fibonacci(1000))
_, _ = fmt.Fprint(w, "Hello World!")
}
func fibonacci(num int) int {
if num < 2 {
return 1
}
return fibonacci(num-1) + fibonacci(num-2)
}
复制代码
来看一下执行状况segmentfault
dayu.com >ps aux
USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
xxxxx 3584 99.2 0.1 4380456 4376 s003 R+ 8:33下午 0:05.81 ./myhttp
复制代码
这里咱们先来不关注其它指标,先来看 VSZ
与 RSS
。缓存
每个进程都是运行在本身的内存沙盒里,程序被分配的地址都是 “虚拟内存”,物理内存对程序开发者来讲实际是不可见的,并且虚拟地址比进程实际的物理地址要大的多。咱们常常编程中取指针对应的地址实际就是虚拟地址。这里必定要注意区分虚拟内存与物理内存。来一张图感觉一下。网络
这张图主要是为了说明两个问题:ide
上面搞明白了程序中的内存具体是指什么,接下来讲明程序是如何使用内存的(虚拟内存),内存说白了就是比硬盘存取速度更快的一个硬件,为了方便内存的管理,操做系统把分配给进程的内存划分红了不一样的功能块。像咱们常常说的:代码区,静态数据区,堆区,栈区等。函数
这里借用一张网络上的图来看一下。
这里就是咱们程序(进程)在虚拟内存中的分布。
代码区:存放的就是咱们编译后的机器码,通常来讲这个区域只能是只读。
静态数据区:存放的是全局变量与常量。这些变量的地址编译的时候就肯定了(这也是使用虚拟地址的好处,若是是物理地址,这些地址编译的时候是不可能肯定的)。Data与BSS都属于这一部分。这部分只有程序停止(kill掉、crasg掉等)才会被销毁。
栈区:主要是 Golang
里边的函数、方法以及其本地变量存储的地方。这部分伴随函数、方法开始执行而分配,运行完后就被释放,特别注意这里的释放并不会清空内存。后面文章讲内存分配的时候再详细说;还有一个点须要记住栈通常是从高地址向低地址方向分配,换句话说:高地址属于栈低,低地址属于栈底,它分配方向与堆是相反的。
堆区:像 C/C++
语言,堆彻底是程序员本身控制的。可是 Golang
里边因为有GC机制,咱们写代码的时候并不须要关心内存是在栈仍是堆上分配。Golang
会本身判断若是变量的生命周期在函数退出后还不能销毁或者栈上资源不够分配等等状况,就会被放到堆上。堆的性能会比栈要差一些。缘由也留到内存分配相关的文章再给你们介绍。
内存的结构搞明白了,咱们的程序被加载到内存还须要操做系统来指挥才能正确运行。
补充一个比较重要的概念:
寻址空间:通常指的是CPU对于内存寻址的能力,通俗地说,就是能最多用到多少内存的一个问题。好比:32条地址线(32位机器),那么总的地址空间就有 2^32 个,若是是64位机器,就是 2^64 个寻址空间。可使用
uname -a
来查看本身系统支持的位数字。
为了讲清楚程序运行与调用,咱们得先理清楚操做系统、内存、CPU、寄存器这几者之间的关系。
这里操做系统是一个软件,CPU、寄存器、内存(物理内存)都是实打实的硬件。操做系统虽然也是一堆代码写出来的。可是她是硬件对其它应用程序的接口。总的来说操做系统经过系统调用控制全部的硬件资源,他把其它的程序调度到CPU上让其它程序执行,可是为了让每一个程序都有机会使用CPU,CPU又经过时间中断把控制权交给操做系统。
让操做系统能够控制咱们的程序,咱们编写的程序须要遵循操做系统的规定。这样操做系统才能控制程序执行、切换进程等操做。
最后咱们的代码被编译成机器码以后,本质就是一条条的指令。咱们指望的就是CPU去执行完这些指令进而完成任务。而操做系统又可以帮助咱们让CPU来执行代码以及提供所需资源的调用接口(系统调用)。是否是很是简单?
在上面咱们知道整个虚拟内存被咱们划分为:代码区、静态数据区、栈区、堆区。接下来要讲的Go程序的调用规约(其实就是函数、方法运行的规则),主要是涉及上面所说的栈部分(堆部分会在内存分配的文章里边去讲)。以及计算机软硬各个部分如何配合。接下来咱们就来看一下程序的基本单位函数跟方法是怎么执行与相互调用的。
这一部分,咱们先来了解一些理论,而后接着用一个实际的例子来分析一下。先经过一张图来看一下在 Golang
中函数是如何在栈上分布的。
几个涉及到的专业用语:
这幅图所展现的就是一个 栈帧
的结构。也能够说栈桢是栈给一个函数分配的栈空间,它包括了函数调用者地址、本地变量、返回值地址、调用者参数等信息。
这里有几个注意点,图中的 BP
、SP
都表示对应的寄存器。
BP
与 SP
放在一块儿,一个表示开始(栈顶)、一个表示结束(栈低)。
有了上面的基础知识,接着下面用实际的例子来验证一下。
才开始,咱们就从一个简单的函数开始来分析一下整个函数的调用过程(下面涉及到 Plan9
汇编,请别慌,大部分都可以看懂,而且我也会写注释)。
package main
func main() {
a := 3
b := 2
returnTwo(a, b)
}
func returnTwo(a, b int) (c, d int) {
tmp := 1 // 这一行的主要目的是保证栈桢不为0,方便分析
c = a + b
d = b - tmp
return
}
复制代码
上面有两个函数,main
定义了两个本地变量,而后调用 returnTwo
函数。returnTwo
函数有两个参数与两个返回值。设计两个返回值主要是一块儿来看一下 golang
的多返回值是如何实现的。接下来咱们把上面的代码对应的汇编代码展现出来。
有几行代码须要特别解释下,
0x0000 00000 (test1.go:3) TEXT "".main(SB), ABIInternal, $56-0
复制代码
这一行中的重点信息:$56-0
。56 表示的该函数栈桢大小(两个本地变量,两个参数是int类型,两个返回值是int类型,1个保存base pointer,合计7 * 8 = 56);0表示 mian
函数的参数与返回值大小。待会能够在 returnTwo
中去看一下它的返回值又是多少。
接下来在看一下计算机是怎么在栈上分配大小的。
0x000f 00015 (test1.go:3) SUBQ $56, SP // 分配,56的大小在上面第一行定义了
... ...
0x004b 00075 (test1.go:7) ADDQ $56, SP // 释放掉,可是并未清空
复制代码
这两行,一个是分配,一个是释放。为何用了 SUBQ
指令就能进行分配呢?而 ADDQ
是释放?记得咱们前面说过吗? SP
是一个指针寄存器,而且指向栈顶,栈又是从高地址向低地址分配。那么对它作一次减法,是否是表示从高地址向低地址方向移动指针了呢?释放也是一样的道理,一次加法操做又把 SP
恢复到初始状态。
再来看一下对 BP
寄存器的操做。
0x0013 00019 (test1.go:3) MOVQ BP, 48(SP) // 保存BP
0x0018 00024 (test1.go:3) LEAQ 48(SP), BP // BP存放了新的地址
... ...
0x0046 00070 (test1.go:7) MOVQ 48(SP), BP // 恢复BP的地址
复制代码
这三行代码是否是感受很变扭?写来写去让人云里雾里的。我先用文字描述一下,后面再用图来解释。
咱们先作以下假设:此时 BP 指向的 值 是:0x00ff,48(SP) 的 地址 是:0x0008。
MOVQ BP, 48(SP)
是把 0x00ff
写入到 48(SP)
的位置;LEAQ 48(SP), BP
是更新寄存器指针,让 BP
保存 48(SP)
这个位置的地址,也就是 0x00ff
这个值。MOVQ 48(SP), BP
,由于一开始 48(SP)
保存了最开始 BP
的所存的值 0x00ff
,因此这里是又把 BP
恢复回去了。这几行代码的做用相当重要,正由于如此在执行的时候,咱们才能找到函数开始的地方以及回到调用函数的位置,它才能够继续往下执行(若是以为饶,先放过,后面有图,看完后再回来理解)。接着来看一下 returnTwo
函数。
这里 NOSPLIT|ABIInternal, $0-32
说明,该函数的栈桢大小是0,因为有两个int参数,以及2个int返回值,合计为 4*8 = 32
字节大小,是否是跟上面的 main
函数对上了?。
这里有没有对 returnTwo
函数的栈桢大小是0表示迷惑呢?难道这个函数不须要栈空间吗?其实主要缘由是:golang的参数传递与返回值都是要求使用栈来进行的(这也是为何go可以支持多参数返回的缘由)。因此参数与返回值所需空间都由 caller
来提供。
接下来,咱们用完整的图来演示一下这个调用过程。
这个图就画了将近1个小时,但愿对你们理解有帮助。
整个的流程是:初始化 ----> call main function ----> call returnTwo function ----> returnTwo return ----> main return。
经过这张图,在结合我上面的文字解释,相信你们可以理解了。不过这里还有几个注意点:
SP
作运算找到下一个指令的位置;ADDQ $56, SP
,只是改变了 SP
指向的位置,内存中的数据并不会清空,只有下次被分配使用的时候才会清空;0x0d00
地址所保存的指令,因此 returnTwo 函数返回后,SP
又指向了 0x0d08
地址。因为上面涉及到一些 Plan9
的知识,就顺带一块儿介绍一些它的语法,若是直接讲语法会很枯燥,下面会结合一些实际中会用到的状况来介绍。既有收获又能学会语法。
咱们整个程序的编译最终会被翻译成机器码,而汇编能够算是机器码的文本形式,他们之间能够一一对应。因此若是咱们可以看懂汇编一点点就可以分析出不少实际问题。
开发go语言的都是当前世界最TOP的那群程序员,他们选择了持续装逼,不用标准的 AT&T 也不用 Intel 汇编器,偏要本身搞一套,没办法,谁让人家牛呢!Golang的汇编是基于 Plan9
汇编的,我的以为要彻底学懂太复杂了,由于这涉及到不少底层知识。不过若是只是要求看懂仍是可以作到的。下面咱们就举一些例子来试试看。
PS: 这东西彻底学懂也没有必要,投入产出比过低了,对于一个应用工程师可以看懂就行。
在正式开始前,咱们仍是补充一些必要信息,上文已经涉及过一些,为了完整这里在总体介绍一下。
几个重要的伪寄存器:
stack-split
的用来检查栈须要扩张的前导指令;其它还有一些操做指令,根据名字多半都可以看出来,就再也不介绍,直接开始干。
package main
func main() {
}
func test() []string {
a := make([]string, 10)
return a
}
--------
"".test STEXT size=151 args=0x18 locals=0x40
0x0000 00000 (test1.go:6) TEXT "".test(SB), ABIInternal, $64-24 // 栈帧大小,与参数、返回值大小
0x0000 00000 (test1.go:6) MOVQ (TLS), CX
0x0009 00009 (test1.go:6) CMPQ SP, 16(CX)
0x000d 00013 (test1.go:6) JLS 141
0x000f 00015 (test1.go:6) SUBQ $64, SP
0x0013 00019 (test1.go:6) MOVQ BP, 56(SP)
0x0018 00024 (test1.go:6) LEAQ 56(SP), BP
... ...
0x001d 00029 (test1.go:6) MOVQ $0, "".~r0+72(SP)
0x0026 00038 (test1.go:6) XORPS X0, X0
0x0029 00041 (test1.go:6) MOVUPS X0, "".~r0+80(SP)
0x002e 00046 (test1.go:7) PCDATA $2, $1
0x002e 00046 (test1.go:7) LEAQ type.string(SB), AX
0x0035 00053 (test1.go:7) PCDATA $2, $0
0x0035 00053 (test1.go:7) MOVQ AX, (SP)
0x0039 00057 (test1.go:7) MOVQ $10, 8(SP)
0x0042 00066 (test1.go:7) MOVQ $10, 16(SP)
0x004b 00075 (test1.go:7) CALL runtime.makeslice(SB) // 对应的底层runtime function
... ...
0x008c 00140 (test1.go:8) RET
0x008d 00141 (test1.go:8) NOP
0x008d 00141 (test1.go:6) PCDATA $0, $-1
0x008d 00141 (test1.go:6) PCDATA $2, $-1
0x008d 00141 (test1.go:6) CALL runtime.morestack_noctxt(SB)
0x0092 00146 (test1.go:6) JMP 0
复制代码
根据对应的代码行数与名字,很明显的能够看到应用层写的 make
对应底层是 makeslice
。
这里先说一下逃逸分析的概念。这里牵扯到栈、堆分配的问题。若是变量被分配到栈上,会伴随函数调用结束自动回收,而且分配效率很高;其次分配到堆上,则须要GC进行标记回收。所谓逃逸就是指变量从栈上逃到了堆上(不少人对这个概念都不清楚就在谈逃逸分析,面试遇到了好几回😓)。
package main
func main() {
}
func test() *int {
t := 3
return &t
}
------
"".test STEXT size=98 args=0x8 locals=0x20
0x0000 00000 (test1.go:6) TEXT "".test(SB), ABIInternal, $32-8
0x0000 00000 (test1.go:6) MOVQ (TLS), CX
0x0009 00009 (test1.go:6) CMPQ SP, 16(CX)
0x000d 00013 (test1.go:6) JLS 91
0x000f 00015 (test1.go:6) SUBQ $32, SP
0x0013 00019 (test1.go:6) MOVQ BP, 24(SP)
0x0018 00024 (test1.go:6) LEAQ 24(SP), BP
... ...
0x001d 00029 (test1.go:6) MOVQ $0, "".~r0+40(SP)
0x0026 00038 (test1.go:7) PCDATA $2, $1
0x0026 00038 (test1.go:7) LEAQ type.int(SB), AX
0x002d 00045 (test1.go:7) PCDATA $2, $0
0x002d 00045 (test1.go:7) MOVQ AX, (SP)
0x0031 00049 (test1.go:7) CALL runtime.newobject(SB) // 堆上分配空间,表示逃逸了
... ...
复制代码
这里若是是对 slice
使用汇编进行逃逸分析,并不会很直观。由于只会看到调用了 runtime.makeslice
函数,该函数内部其实又调用了 runtime.mallocgc
函数,这个函数会分配的内存其实就是堆上的内存(若是栈上足够保存,是不会看到对 runtime.makslice
函数的调用)。
实际go也提供了更方便的命令来进行逃逸分析:go build -gcflags="-m"
,若是真的是作逃逸分析,建议使用该命令,别折腾用汇编。
对于golang中的基本类型:字符串、整型、布尔类型就很少说了,确定是值传递,那么对于结构体、指针究竟是值传递仍是指针传递呢?
package main
type Student struct {
name string
age int
}
func main() {
jack := &Student{"jack", 30}
test(jack)
}
func test(s *Student) *Student {
return s
}
-------
"".test STEXT nosplit size=20 args=0x10 locals=0x0
0x0000 00000 (test1.go:14) TEXT "".test(SB), NOSPLIT|ABIInternal, $0-16
... ...
0x0000 00000 (test1.go:14) MOVQ $0, "".~r1+16(SP) // 初始返回值为0
0x0009 00009 (test1.go:15) PCDATA $2, $1
0x0009 00009 (test1.go:15) PCDATA $0, $1
0x0009 00009 (test1.go:15) MOVQ "".s+8(SP), AX // 将引用地址复制到 AX 寄存器
0x000e 00014 (test1.go:15) PCDATA $2, $0
0x000e 00014 (test1.go:15) PCDATA $0, $2
0x000e 00014 (test1.go:15) MOVQ AX, "".~r1+16(SP) // 将 AX 的引用地址又复制到返回地址
0x0013 00019 (test1.go:15) RET
复制代码
经过这里能够看到在go里边,只有值传递,由于它底层仍是经过拷贝对应的值。
今天的文章到此结束,本次主要讲了下面几个点:
Golang
编写的代码,函数与方法是怎么执行的,主要讲了栈上分配与相关调用;Plan9
分析了一些常见的问题。但愿本文对你们在理解、学习Go的路上有一些帮助。
参考资料
我的公众号:dayuTalk