编译过程
词法分析
- 由词法分析器(
lexer
)将源代码文件转换成Token
序列 - 每一个
Token
表示一个词法单元,比如标识符,关键字,常量等等Token
分成四类- 特殊类型
- 基础类型
- 运算符
- 关键字
go
func main() {
fset := token.NewFileSet()
var s scanner.Scanner
file, err := os.Open("main2.go")
if err != nil {
panic(err)
}
content, err := io.ReadAll(file)
if err != nil {
panic(err)
}
fileInfo, _ := file.Stat()
s.Init(fset.AddFile("main2.go", fset.Base(), int(fileInfo.Size())), content, nil, scanner.ScanComments)
log.Println("token sequence")
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break
}
fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
}
}
语法分析
- 将
token
序列转换为抽象语法树AST
(AST
是用来表示程序的语法结构) - 抽象语法树的每个节点表示一个语法元素,比如常量,表达式,语句,函数等等
- 每一个抽象都对应一个单独的
go
语言文件
- 每一个抽象都对应一个单独的
go
语言在解析的过程中发生的任何语法错误都会被语法解析器发现,并且将这些信息输出到标准输出上,整个编译过程也会随着错误的出现而终止
go
func main() {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main2.go", nil, parser.ParseComments)
if err != nil {
panic(err)
}
log.Println("Abstract syntax tree")
ast.Print(fset, f)
}
类型检查(语义分析)
- 对
AST
的每个节点进行类型检查- 语法分析只能检测到括号或者操作符是否匹配,这些常规的语法问题,并不能确定语句的具体含义,所以还需要对抽象语法树的每个节点进行类型检查
- 需要遍历抽象语法树来识别节点的类型,包括需要推断出来的类型
中间码生成
- 将源码翻译成介于源代码和目标机器码中间的中间代码(
Intermediate Representation
)- 编译前端完成由源代码到
Token
再到AST
的翻译工作 - 中间代码生成阶段,完成
AST
到基于AST
的IR Tree
的翻译工作
- 编译前端完成由源代码到
为什么要生成中间代码
- 便于对代码的优化
- 解耦合复用
编译器面临的场景是很复杂的,很多编译器都需要将源码翻译成各种机器码
将编程语言到成机器码的生成,拆成中间代码的生成和机器码的生成,可以简化机器码生成的过程
中间代码是一种更接近机器语言的表达形式,对中间代码的优化和分析,相比直接分析高级语言更加容易
我们知道编译后端最终是生成不同 cpu
架构上的机器码,而在生成机器码之前,我们还需要对代码进行优化,这些优化不是针对某个 cpu
架构的,而是跟编译环境无关的一些优化,我们将跟生成机器码之前的这些步骤都耦合在一起,那随着对不同架构 cpu
的支持,这些工作也得在新的 cpu
架构上实现一遍
所以将一部分不依赖环境的部分独立出来,先生成中间代码,这样即使调整了一些语法特性,只要在编译前端生成相同的中间代码,编译后端就不需要改动
代码优化
- 死代码消除,函数内联,逃逸分析,闭包重写,循环不变量外提等
- 死代码消除
- 作用
- 去除一些无用代码,以减少程序的体积大小
- 避免程序在执行过程中进行一些不必要的运算行为
- 包括:
- 程序执行不到的代码
- 不会影响函数执行结果的变量
- 分析工具:
GOSSAFUNC=main go build main.go
- 作用
- 函数内联
- 作用:
- 用函数体替换函数调用来减少因函数调用而造成的额外上下文切换开销
- 分析工具:
go build -gcflags="-m -m" main.go
,-m
越多,分析越详细- 通过注释
//go:noinline
告诉编译器不要内联(必须按照这个格式,不能多空格)
- 作用:
- 逃逸分析
- 作用:
- 自动决定将变量分配到
goroutine
的栈内存或者堆内存上
- 自动决定将变量分配到
- 分析工具:
go build -gcflags="-m -m" main.go
,-m
越多,分析越详细
- 作用:
- 死代码消除
- 变量捕获
-
针对协程启动的闭包函数中使用闭包外的变量的情况
gofunc main() { x, y := 1, 2 go func() { println(x, y) // 使用了外部变量 x, y }() x = 2 // x 被修改, y 没有修改 }
-
使用工具
go build -gcflags="-m -m -m -m" main.go
查看变量逃逸情况bash./main.go:4:2: main capturing by ref: x (addr=false assign=true width=8) ./main.go:4:2: x escapes to heap: ./main.go:4:2: flow: {storage for func literal} = &x: ./main.go:4:2: from x (captured by a closure) at ./main.go:6:11 ./main.go:4:2: from x (reference) at ./main.go:6:11 ./main.go:4:5: main capturing by value: y (addr=false assign=false width=8) ./main.go:4:2: moved to heap: x ./main.go:5:5: func literal escapes to heap
x
不是地址类型,因为addr=false
,但后面有一个assign=true
,就会以地址的形式进行传参flow: {storage for func literal} = &x:
y
没有在闭包外进行重新赋值,所以它的assign=false
- 所以最终输出时,取的
x
的是&x
的值
-
-
SSA
生成,静态单赋值(Static Single Assignment
)IR
将被转换为静态单赋值形式- 可以更轻松地实现优化并最终生成机器码
机器码生成
- 针对具体目标架构,通过
SSA
进行多轮转换来执行代码优化 - 多轮转换后得到
genssa
再翻译成汇编代码 - 调用汇编器将他们转换成机器码并生成最终的目标文件
内存
所有高级编程语言的内存管理机制都是建立在操作系统的内存之上的,编程语言的内存管理的目的是尽可能的发挥系统层面的优势,同时考虑开发人员编写代码过程中的便利性,屏蔽了内存的申请和释放这些操作的细节
- 可以把内存看成一个数组,每个元素的大小为一个字节(也就是
8
位),内存地址可以看成是数组的下标 CPU
在执行指令时,通过内存地址将物理内存上的数据载入到寄存器执行机器指令- 对于频繁访问的指令会缓存到
CPU
的缓存中
- 对于频繁访问的指令会缓存到
寄存器
- 寄存器是计算机中用来存储数据的一种硬件设备
- 可以直接被中央处理器
CPU
访问 - 通常是在
CPU
内部或与CPU
紧密相连的芯片上,速度极快
CPU 缓存
- 一种高速缓存,用于存储
CPU
频繁访问的数据和指令 - 分为三级:
- 一级缓存
- 位于
CPU
内部 - 容量很小,通常只有几十
KB
- 用于存储
CPU
频繁访问的数据和指令
- 位于
- 二级缓存
- 位于
CPU
内部,离一级缓存远一点 - 容量比一级缓存要大,速度比一级缓存慢,通常几百
KB
到几MB
- 由静态随机存储器组成
- 用于缓存一级缓存中没有命中的数据和指令,以减少数据读取过程中的延迟
- 位于
- 三级缓存
- 位于
CPU
内部,离二级缓存远一点 - 容量比二级缓存要大,速度比二级缓存慢,通常几
MB
到几十MB
- 用于缓存一级、二级缓存中没有命中的数据和指令
- 位于
- 一级缓存
虚拟内存
直接使用物理内存会导致内存利用率低
假设只有物理内存,程序直接向物理内存申请空间,由于操作系统上会运行多个程序,而这些程序都有可能像操作系统申请内存
CPU
在运算的过程中,如果需要获取内存中的数据,就需要通过物理地址来获取,而内存地址是连续的,可以通过基准地址和偏移量来获取
而程序运行中需要的内存地址并不是固定的,这是为了确保物理内存能够被不同的程序使用,所以操作系统就需要分配这些程序他们所需要的最大内存
多个程序同时运行,如果都这样分配内存,那么内存的利用率就会很低
如果操作系统允许不同的程序使用同一块内存空间,那么就会出现操作内存时冲突的问题
虚拟内存就是为了解决这个问题,虚拟内存是程序和物理内存交互的桥梁
用户程序只能通过虚拟地址来获取内存数据,操作系统会将虚拟地址映射到实际的物理地址
从程序的角度看,是独享了一整块内存,不需要考虑内存冲突的问题了
虚拟内存本质上将磁盘当做最终的存储介质,物理内存则作为缓存
程序可以从虚拟内存中申请很大的内存空间,操作系统并不会立马从物理内存开辟这么大一块空间,而是先开辟一小块空间给程序使用,当程序使用更多空间时,操作系统会根据能否映射到物理地址上,当物理地址不够用时,操作系统会将虚拟地址映射到磁盘上,这样对用户程序而言它只需要跟虚拟内存打交道,而操作系统主动将数据在主存和磁盘之间进行交换
实现
- 虚拟内存一般是通过页表来实现
- 操作系统会将虚拟内存分成一页一页来管理
- 通常每页大小为
4KB
- 磁盘和主存之间的置换也是以页为单位来操作的
- 虚拟地址到物理地址的映射关系由页表(
Page Table
)记录- 每个条目是
Page Tabe Entry
,由有效位和n
位地址构成,有效位表示这个物理地址能否分配内存 - 页表被操作系统放在物理地址的指定位置
CPU
会把虚拟地址给CPU
管理单元,CPU
管理单元会去物理内存中查询页表,得到实际的物理地址- 由于内存不是最快的,所以页表也会在
CPU
中进行缓存
- 每个条目是
我们在写代码时,很少会去利用这些地址,都是通过变量名来访问数据的,编译器会自动将这些变量名转成真正的虚拟地址
而操作系统已经将一整块内存给划分好了区域,每个区域可以用来做不同的事情
下面就是一个程序的内存布局
- 代码区:为了避免堆栈溢出,放在了最下面,正常情况下,不允许进程对代码区的数据进行修改,也就是说代码区是只读的,但可以被多个进程共享
- 数据区和未初始化数据区
- 都是静态分配
- 这个区域内的数据在编译阶段就确定了大小,并且不会被释放
- 内核区
- 用来存放操作系统的内核代码和数据
- 内核空间:虚拟地址
0xC0000000
到0xFFFFFFFF
- 用户空间:虚拟地址
0x00000000
到0xBFFFFFFF
- 这个区域是操作系统独占的,用户程序不能访问
linux
内核由系统内的所有进程共享,每个进程有各自的私有用户空间 (0 ~ 3G
)- 内核空间与用户空间隔离以确保内核程序的安全稳定
go 内存
go
内置运行时(runtime
) 实现了自己的管理方式
go
运行时的内存分配算法主要源自于 TCMalloc
算法。它的核心算法是把内存分为多级管理,从而降低锁的粒度,将可用的内存采用多级管理,每个线程都会自行维护一个的内存池,进行内存分配时优先从自己的内存池中分配,如果自己的内存池不够用,再从全局内存池中分配,以避免不同线程之间的锁竞争
频繁申请很小的内存空间,容易出现大量的内存碎片。系统调用会导致程序进入内核态,内核需要查询页表完成虚拟内存地址到物理地址的映射
go
内存分配器是三级管理结构:
mcache
go
运行时会为每个逻辑处理P
提供一个本地span
缓存(mspan
)- 协程需要内存可以从
mcache
中获取 mcache
包含所有大小规格的mspan
,但每种规格只包含一个
mcentral
mcentral
对象收集所有sizeclass
的span
sizeclass
相同的span
会以链表的形式组织在一起mcentral
是被所有逻辑处理P
共享的
mheap
- 所有级别的这些
mcentral
,其实是一个数组,由mheap
进行管理 - 另外大对象也会直接通过
mheap
进行分配span
的最大只能32KB
,对于超过32KB
的大对象,会直接从mheap
中分配
mheap
是管理内存的最核心单元
- 所有级别的这些
GMP 调度
在单进程时代,只能运行一个进程,通常一个程序就是一个进程,这样所有的程序通过串行的方式来执行
这种方式有两个问题:
- 进程阻塞会带来严重的
CPU
时间浪费,导致程序运行效率低下 - 执行流程单一,无法实现复杂的异步交互能力
多进程或者多线程的操作系统,就是解决这个问题,允许 CPU
在阻塞时切换到其他进程,而且 CPU
的调度算法确保所有进程都可以分配到 CPU
运行时间片
这个时间片在某一个时间内,还是只有一个进程在运行,但由于时间片很短,所以看起来就像多个进程同时运行
时间片:
- 时间片时由操作系统内核的调度程序分配给每个进程
- 内核会给每个进程分配相等的初始时间片
- 每个进程轮番执行相应的时间,时间片耗尽时由内核重新计算分配
linux
系统的线程分为:用户态线程和内核态线程
- 操纵系统层面的线程就是内核态线程
- 在同一个内核线程上执行多个任务的线程都可以成为用户态线程
- 用户态线程本质上是把内核态线程在用户态实现了一遍
用户态中的所有东西,内核态都可以看到,对内核而言,用户态只是一堆数据,一个用户态线程必须绑定一个内核态线程,但是 CPU
并不知道用户态的存在,它只知道它运行的是一个内核态线程
1:1 模型
假设一个内核态线程绑定一个协程
实现最为简单,但是无法避免协程的穿件、删除和切换的 CPU
开销
缺点:切换会带来 CPU
时间的损耗,这种和多进程或者多线程遇到的 CPU
开销的问题是一样的,都是没办反避免的
n:1 模型
假设一个内核线程绑定多个协程
可以在用户态实现协程之间的切换(协程在用户态由调度器分配给内核线程的)
不需要内核态去完成内核线程的切换带来 CPU
时间的损耗
问题:
- 线程一旦阻塞会导致与之绑定的全部协程都无法执行
- 单线程的绑定无法发挥多核
CPU
的优势
n:m 模型
假设有 n
个协程绑定到 m
个线程上,多个协程依然是通过一个调度器,调度给多个内核线程,这个调度实现很复杂,但可以解决上面的问题,调度器下面绑定的是多个 CPU
资源,可以利用多核 CPU
的优势
线程是由 CPU
调度的,是抢占式的,协程是由用户态调度的,它是协作式的,所以协程器的调度和优化,直接关系到程序的执行效率
goroutine
的本质是让一组可复用的函数运行在一组线程上
早期的 GM
调度模型:
- 所有的协程
G
都会被放到一个全局的go
协程队列中,这个队列是被多个M
共享的 M
想要执行、方会队列的操作就需要加锁保证互斥或者同步
GM
调度模型的问题:
- 创建、销毁、调度
G
都需要每个M
获取锁,容易形成激烈的锁竞争 - 调度实现的局部性差,而导致额外的资源消耗
M
线程创建了G2
,但此时M
线程需要执行G1
,就有可能导致G2
被调度给了其他线程,造成了局部性很差
M
的CPU
切换频繁,还有优化空间
局部性是计算机程序中内存系统性能优化的原则之一,良好的局部性可以显著的提升程序的执行效率和性能,减少缓存未命中的次数,降低数据访问的延时和数据传输的开销
局部性有两种:
-
空间局部性(
Spatial Locality
)- 程序在一段时间内访问的数据地址之间有较近的关联性,如果程序在执行的过程中访问的物理地址是相邻的,那它就有较好的空间局部性
- 比如数组数据的连续访问,局部变量的频繁使用
-
时间局部性(
Timporal Locality
)- 程序在短时间内多次访问相同的地址
- 比如在循环体内重复访问相同的变量,或者局部变量的反复使用
- 局部性差会导致缓存未命中的概率增加,使得处理器不得不频繁的从主存中获取数据,增加了数据的开销和延迟,降低了程序的执行效率
P
是什么?
P
的数量默认是逻辑 CPU
的核心数,P
是 go
协程的本地队列,存放的也是等待运行的 gouroutine
,每个 P
最多存放 256
个等待运行的 gouroutine
,分担 G
的操作,减少锁竞争
- 多个本地队列
P
来分担G
的操作,减少锁竞争,多核CPU
的并行优势 - 为了平很多个
P
队列的任务,实现了Work Stealing
算法(也就是任务窃取),如果P
的本地队列为空,就会从全局队列(或者从其他的P
队列)中窃取,通常是偷一半的goroutine
过来 - 实现了
handoff
机制- 某个线程
M
因为goroutine
调度阻塞时,线程就会顺势绑定P
,把这个P
转给其他空旋的M
执行 - 当发生上下文切换时,需要对执行现场进行保护,以便下次被调度执行,对现场的恢复,
go
调度器M
的栈保存在goroutine
上只需要将所需要的寄存器保存在goroutine
上,就可以实现现场的保护
- 某个线程
为什么要有 P
M
作为工作线程,M
最多能创建1w
个,当然这些需要操作系统内核的支持,当M
阻塞,又没有其他M
可用时,M
的数量就会增加,增加到1w
个程序就会崩溃,当然如果有不用的M
,程序也会进行回收M
和P
的执行机制不同,从降低模块耦合度的角度看应该将P
与M
解耦M
被阻塞之后,我们希望把现有没有执行完的任务,分配给其他的线程继续运行,而不是一阻塞就全部停止P
的加入就是将G
调度到其他的空闲的M
上执行
P
的数量由 GOMAXPROCS
设置,或者 runtime.GOMAXPROCS
设置,在 go 1.5
版本之后默认和逻辑 CPU
的核心数是一致
在 I/O
密集的时候设置 P
的数量大于 CPU
的核数是有好处的,因为涉及到 I/O
操作时,单纯的计算能力并不是瓶颈,而 I/O
操作导致的延迟,P
中 G
的数量不会超过 256
个
M
工作线程在执行完 61
个本地 goroutine
之后,会去全局队列中获取 goroutine
,避免了全局队列中的 goroutine
长时间得不到执行
GMP
调度模型的一些细节:
- 极可能复用系统线程
M
,避免频繁的线程创建和销毁 - 利用多核并行能力,让同时处理的任务队列数量等于
CPU
的核数 - 任务窃取机制,
M
可以从其他M
绑定的P
的运行队列偷取G
执行 Hand off
交接机制,M
阻塞时会将M
上的P
的运行队列交给其他M
执行- 基于协作的抢占机制
GO 1.14
之后引入了基于信号的抢占式调度解决了GC
和栈扫描时无法被抢占问题