go编译器以及defer实现
go编译器
Go 语言的编译器完全用 Go 语言本身来实现,它完全实现了从(编译)前端到后端的所有工作
Golang 的生态中相关工具我们能用到的有 pprof 和 trace。pprof 可以看 CPU、内存、协程等信息在压测流量进来时系统调用的各部分耗时情况。而 trace 可以查看 runtime 的情况,比如可以查看协程调度信息等。代码层面的优化,是 us 级别的,而针对业务对存储进行优化,可以做到 ms 级别的,所以优化越靠近应用层效果越好。对于代码层面,优化的步骤是:
- 压测工具模拟场景所需的真实流量
- pprof 等工具查看服务的 CPU、mem 耗时
- 锁定平顶山逻辑,看优化可能性:异步化,改逻辑,加 cache 等
- 局部优化完写 benchmark 工具查看优化效果
- 整体优化完回到步骤一,重新进行 压测+pprof 看效果,看 95 分位耗时能否满足要求(如果无法满足需求,那就换存储吧
Go 程序启动引导
一般编程语言的入口点都不会是我们在代码中写的那个 main。c 语言中如此,golang 中更是这样。这是因为各个语言都需要在进程启动过程中做一些启动逻辑的。在 golang 中,其底层运行的 GMP、垃圾回收等机制都需要在进入用户的 main 函数之前启动起来。
Go 程序既不是从 main.main 直接启动,也不是从 runtime.main 直接启动。 相反,其实际的入口位于 runtime.rt0_amd64*。随后会转到 runtime.rt0_go 调用。在这个调用中,除了进行运行时类型检查外,还确定了两个很重要的运行时常量,即处理器核心数以及内存物理页大小。
在 schedinit 这个函数的调用过程中, 还会完成整个程序运行时的初始化,包括调度器、执行栈、内存分配器、调度器、垃圾回收器等组件的初始化。 最后通过 newproc 和 mstart 调用进而开始由调度器转为执行主 goroutine。
TEXT runtime·rt0_go(SB),NOSPLIT,$0
(...)
// 调度器初始化
CALL runtime·schedinit(SB)
// 创建一个新的 goroutine 来启动程序
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
PUSHQ $0 // 参数大小
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// 启动这个 M,mstart 应该永不返回
CALL runtime·mstart(SB)
(...)
RET
主 Goroutine 运行runtime.main
// 主 Goroutine
func main() {
...
// 执行栈最大限制:1GB(64位系统)或者 250MB(32位系统)
if sys.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
...
// 启动系统后台监控(定期垃圾回收、抢占调度等等)
systemstack(func() {
newm(sysmon, nil)
})
...
// 执行 runtime.init。运行时包中有多个 init 函数,编译器会将他们链接起来。
runtime_init()
...
// 启动垃圾回收器后台操作
gcenable()
...
// 执行用户 main 包中的 init 函数,因为链接器设定运行时不知道 main 包的地址,处理为非间接调用
fn := main_init
fn()
...
// 执行用户 main 包中的 main 函数,同理
fn = main_main
fn()
...
// 退出
exit(0)
}
defer
- 在函数返回、产生恐慌或者 runtime.Goexit 时被调用。PS: 配对编程在文件、锁等场景很有必要,但全对的配对很难实现,要考虑异常等场景,在c++ 里“释放”动作经常要封装到 析构函数里
- 直觉上看, defer 应该由编译器直接将需要的函数调用插入到该调用的地方,似乎是一个编译期特性, 不应该存在运行时性能问题。但实际情况是,由于 defer 并没有与其依赖资源挂钩,也允许在条件、循环语句中出现,无法在编译期决定存在多少个 defer 调用。
defer 使用的几个注意事项
- 对于有返回值的自定义函数或方法,返回值会在 deferred 函数被调度执行的时候被自动丢弃。
- Go 语言中除了自定义函数 / 方法,还有 Go 语言内置的 / 预定义的函数。append、cap、len、make、new、imag 等内置函数都是不能直接作为 deferred 函数的,而 close、copy、delete、print、recover 等内置函数则可以直接被 defer 设置为 deferred 函数。对于那些不能直接作为 deferred 函数的内置函数,我们可以使用一个包裹它的匿名函数来间接满足要求,以 append 为例是这样的:
defer func() { _ = append(sl, 11) }()
- 有时候为了 明确defer的作用域,可能故意在函数中使用匿名函数。常见于 缩小锁的作用域。
func someFunc(){ ... func(){ mu.Lock() defer mu.Unlock() ... } ... // 这里很长 }
- defer 关键字后面的表达式,是在将 deferred 函数注册到 deferred 函数栈的时候进行求值的。
三种实现方案
func main() {
defer foo()
return
}
堆上分配
// 编译器伪代码
func main() {
deferproc foo() // 记录被延迟的函数调用
...
deferreturn // 取出defer记录执行被延迟的调用
return
}
一个函数中的延迟语句会被保存为一个 _defer 记录的链表,附着在一个 Goroutine 上。
// src/runtime/panic.go
type _defer struct {
siz int32 // 参数和结果的内存大小
started bool
openDefer bool
sp uintptr // 代表栈指针
pc uintptr // 代表调用方的程序计数器
fn *funcval // defer 关键字中传入的函数
_panic *_panic
link *_defer // 通过link 构成链表
}
// src/runtime/runtime2.go
type g struct {
...
_defer *_defer
...
}
栈上分配
在栈上创建 defer, 直接在函数调用帧上使用编译器来初始化 _defer 记录
func main() {
t := deferstruct(stksize) // 从编译器角度构造 _defer 结构
arg0 := s.constOffPtrSP(types.Types[TUINTPTR], Ctxt.FixedFrameSize()) // 对该记录的初始化
...
deferreturn // 取出defer记录执行被延迟的调用
return
}
开放编码式
允许进行 defer 的开放编码的主要条件:没有禁用编译器优化,即没有设置 -gcflags “-N”;存在 defer 调用;函数内 defer 的数量不超过 8 个、且返回语句与延迟语句个数的乘积不超过 15;没有与 defer 发生在循环语句中。
defer f1(a1)
if cond {
defer f2(a2)
}
...
==================>
deferBits = 0 // 初始值 00000000
deferBits |= 1 << 0 // 遇到第一个 defer,设置为 00000001
_f1 = f1
_a1 = a1
if cond {
// 如果第二个 defer 被设置,则设置为 00000011,否则依然为 00000001
deferBits |= 1 << 1
_f2 = f2
_a2 = a2
}
==================== 在退出位置,再重新根据被标记的延迟比特,反向推导哪些位置的 defer 需要被触发
exit:
// 按顺序倒序检查延迟比特。如果第二个 defer 被设置,则
// 00000011 & 00000010 == 00000010,即延迟比特不为零,应该调用 f2。
// 如果第二个 defer 没有被设置,则
// 00000001 & 00000010 == 00000000,即延迟比特为零,不应该调用 f2。
if deferBits & 1 << 1 != 0 { // 00000011 & 00000010 != 0
deferBits &^= 1<<1 // 00000001
_f2(_a2)
}
// 同理,由于 00000001 & 00000001 == 00000001,因此延迟比特不为零,应该调用 f1
if deferBits