从 c 到可执行文件,包含了 编译 和 链接 这两步。通常在编译构建 c 项目的时候,也可以在 make 的过程中,看到 编译 和 链接 这种中间步骤。
然而,go 在这方面,有更进一步的封装,直接跑 go build 就行了,也不知道背后干了个啥。
最近因为搞 cgo 的优化,需要了解这里面的过程,记录一下的。
编译过程
底层还是分为 编译 和 链接 这两步,go build 可以类比为 go 标准的 make 工具。
对于 go 编译器而言,go 是提供给用户的统一的命令,实际上它还包含了很多其他的执行程序,比如 compile, asm, cgo, link 等。
go build 的执行过程,跟常见的 make 是类似的,大致有这么些事情:
调用 compile 将 go 文件(以及依赖文件)编译为 .a 文件
注意,这一步也是有缓存的,原文件没变更,则直接 copy .a 文件
如果期间有 .s 文件,则用 asm 来编译
如果有 import C,则调用 cgo 先生成一段 go 文件
最后,通过 link 链接成最终的可执行文件
如果想看具体的编译过程,可以指定 -x,比如:
1 2
# -work 表示保留编译时的临时目录 go build -x -work .
值得一提的是,go test 也是先 build 生成一个,封装了测试框架的可执行文件,所以,build 的参数也同样可用。 比如:
1
go test -x -work
PS: go test 默认编译出来的是不带调试符号的,如果需要调试,可以加上 -o file 指定可执行文件,这样可以启用调试符号。 (貌似这个在 MacOS 上并不有效,还是 Linux 上可靠 :<)
自举过程
go 语言是完成自举了的,自举大致过程:
首先需要一个老版本的 go,1.4+,先用老的 go,编译 cmd/dist,生成 dist 可执行文件;再用这个 dist 来完成新版本 go 的编译。
在输出日志中,可以看到一下主要步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13
# 老的 go 编译 cmd/dist Building Go cmd/dist using /path/to/old/go. (go1.14.15 linux/amd64) # 接下来的几步,都是 cmd/dist 来执行的 # 老的 go,编译新代码的工具链,compile, asm, link, cgo Building Go toolchain1 using /path/to/old/go. # 新工具链,编译 go 命令,这里叫 go_bootstrap Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1. # 新的 go_bootstrap,重新编译工具链,以为 toolchain1 没有 buildid Building Go toolchain2 using go_bootstrap and Go toolchain1. # 再来一回,还是因为 buildid,为了更加一致 Building Go toolchain3 using go_bootstrap and Go toolchain2. # 使用 go_bootstrap,编译完整的 go Building packages and commands for linux/amd64.
简单解释一下:
dist 只是一个封装的临时,编译的时候,还是用的 compile, link 这种编译工具(也就是 toolchain)
先用老的 go 重新编译新的 toolchain,然后再用新的 toolchain 编译新的 go(中间有一些重复编译 toolchain,不是那么重要)
如果想看具体的编译过程,可以指定 -v=5,比如:
1
bash -x all.bash -v=5
总结
go 对编译构建工具都提供了完整的封装,这个对于使用者而言,确实是更方便了,不需要自己折腾 Makefile,或者 bazel 这种构建工具了。
其具体过程,则跟常见的构建工具是类似的,分开编译,中间结果缓存,最后链接。
平常开发倒是用不着了解这些,不过要是修改 go 本身的话,对这些过程还是得比较清楚才行了;尤其是跑测试的时候,dist 命令中的 test,又是封装了一层编译的测试过程,这里面一环套一环的,还是比较多细节的。
PS:示例中的 c 函数参数没有用 int 类型,因为用 int 的话,会有碰到一个优化,两个 int 合并到一个 long 了,汇编指令看起来就没那么直观了
Goroutine 调度
其中调度相关的逻辑主要在 runtime.cgocall 中的 entersyscall,
将当前 P 和 M 解除绑定,将 P 记录到 M.oldp
将当前 P 的状态改为 _Psyscall
需要注意的是:
此时并没有将 P 释放给其他 M 使用 而是在另外的 sysmon 线程中不定期检查所有 P 的状态,具体逻辑在 retake 函数中: 简单的点说,如果 P 处于 _Psyscall 超过一个 sysmon 的轮询周期(至少 20us)则会将 P 切换到 _Pidle 状态,供其他 M 使用。
M 线程还是由操作系统调度运行的 即使在 P 被抢走的情况下,M 也还是继续运行的,毕竟操作系统只认识 M。 当 M 中的任务(syscall or C function call)完成后继续运行的,会执行到 exitsyscall。 此时会按照这个顺序去执行:绑定 oldp 恢复执行,绑定其他空闲的 P 恢复执行,放回到运行队列等待调度。
总结一下,简单来说:
entersyscall 标记进入可抢占状态
sysmon 轮询检查,将长期运行的 P 释放
exitsyscall 恢复 G 的运行
1
PS:runtime.cgocall 中另外一个关键逻辑是:asmcgocall(fn, arg),这个是切换到 g0 栈执行 fn 这个 C 函数
C 调用 go
C 调用 go 又分为两种情况:
原生 C 调用 go
go -> C -> go
这里我们看下第一种情况:
go 生成 so
第一步,我们需要从 go 代码生成 so,并且生成 .h 的头文件,供 C 代码使用。
如下的 go 源码:
1 2 3 4 5 6 7 8
import "C"
//export AddFromGo func AddFromGo(a int64, b int64) int64 { return a + b }
func main() {}
通过如下命令,会生成 libgo-hello.so 和 libgo-hello.h 头文件:
1
go build -o libgo-hello.so -buildmode=c-shared hello.go
#0 runtime.gopark (unlockf={void (runtime.g *, void *, bool *)} 0x0, lock=0x7fffd00a0700, reason=2 '\002', traceEv=27 '\033', traceskip=5) at /home/dou/work/go/src/runtime/proc.go:349 #1 0x000000000042e5fe in runtime.netpollblock (pd=<optimized out>, mode=<optimized out>, waitio=<optimized out>) at /home/dou/work/go/src/runtime/netpoll.go:445 #2 0x000000000045c489 in internal/poll.runtime_pollWait (pd=<optimized out>, mode=140736683706112) at /home/dou/work/go/src/runtime/netpoll.go:229 #3 0x000000000048b0b2 in internal/poll.(*pollDesc).wait (pd=<optimized out>, mode=140736683706112, isFile=2) at /home/dou/work/go/src/internal/poll/fd_poll_runtime.go:84 #4 0x000000000048ba1a in internal/poll.(*pollDesc).waitRead (isFile=2, pd=<optimized out>) at /home/dou/work/go/src/internal/poll/fd_poll_runtime.go:89 #5 internal/poll.(*FD).Read (fd=0xc0001a8000, p=..., ~r1=<optimized out>, ~r2=...) at /home/dou/work/go/src/internal/poll/fd_unix.go:167 #6 0x00000000004ac629 in net.(*netFD).Read (fd=0xc0001a8000, p=...) at /home/dou/work/go/src/net/fd_posix.go:56 #7 0x00000000004b6965 in net.(*conn).Read (c=0xc000094008, b=...) at /home/dou/work/go/src/net/net.go:183 #8 0x00000000004c1d2e in net.(*TCPConn).Read (b=...) at <autogenerated>:1
补充一点:gopark 的核心逻辑是,切换到 g0 栈,执行 park_m。在 park_m (g0 栈)中再把当前 G 挂起。
恢复执行
上一步的 park_m 函数,除了会挂起当前 G,另外一个重要的任务就是执行 schedule 函数,挑一个新的 G 开始运行。
在上一篇介绍过,挑选一个新的 G 的过程中,就有 检查 netpoll 这一步。 我们可以在 gdb 中看到如下的调用栈:
1 2 3 4 5
#0 runtime.netpollready (toRun=0x7fffcaffc6c8, pd=0x7fffd00606d8, mode=119) at /home/dou/work/go/src/runtime/netpoll.go:372 #1 0x000000000042f057 in runtime.netpoll (delay=<optimized out>) at /home/dou/work/go/src/runtime/netpoll_epoll.go:176 #2 0x000000000043abd3 in runtime.findrunnable () at /home/dou/work/go/src/runtime/proc.go:2947 #3 0x000000000043bdd9 in runtime.schedule () at /home/dou/work/go/src/runtime/proc.go:3367 #4 0x000000000043c32d in runtime.park_m (gp=0xc000202000) at /home/dou/work/go/src/runtime/proc.go:3516
netpoll 中的核心逻辑即是调用 epoll_wait,获取一批已经准备就绪的 events,恢复这批 G 到 runable 状态,并运行第一个 G。
总结
对于非阻塞网络的实现,核心点是 EAGAIN 和 epoll_wait。 go 语言把这个细节隐藏到了语言/标准库内部,确实很大程度的降低了程序员的心智负担。