- 理解可执行文件
- Go 进程的启动与初始化
- 调度组件与调度循环
- 处理阻塞
- 调度器的发展历史
- 与调度有关的常⻅问题
暖场内容
跨语⾔学习
PHP 转 Go
- PHP-FPM 是多进程模型,FPM 内单线程执⾏。PHP 底层是 C 语⾔实现,整套系统难精通。⽐如我遇到过 PHP 底层的 bug,束⼿⽆策。
- Go 从⽤户代码⼀直到底层都是 Go(会有⼀些汇编),相对来说要从上层学到底层容易很多,不要有⼼理负担。
- Go 代码是强类型语⾔的写法,分层之间有清晰的结构体定义,⼤项⽬好维护。
Python 转 Go
- Python 转 Go 同样也是⼀个趋势,Python 底层是 C 实现,想把整套系统学精通有⼀定难度。
- 在线系统中 Go 的性能要⽐ Python 好很多。
- 因为强类型的写法,Go 代码要⽐ Python 好维护。
⼯程师的学习与进步
多写代码,积累代码量(⾄少积累⼏⼗万的代码量,才能对设计有⾃⼰的观点),要总结和思考,如何对过去的⼯作进⾏改进(如⾃动化/系统化);积累⾃⼰的代码库、笔记库、开源项⽬。
读好书,建⽴知识体系(⽐如像 Designing Data-Intensive Application,中文GitHub版:设计数据密集型应用这种书,应该读好多遍)。
关注⼀些靠谱的国内外新闻源,通过问题出发,主动使⽤ Google,主动去 reddit、hackernews 上参与讨论,避免被困在信息茧房中。
锻炼⼝才和演讲能⼒,内部分享 -> 外部分享。在公司内,该演要演,不要只是闷头⼲活。
通过输出促进输⼊(博客、公众号、分享),打造个⼈品牌,通过读者的反馈循环提升⾃⼰的认知。(费曼学习法)
信息源:Github Trending、Reddit、Medium、Hacker News,morning paper(作者不⼲了),acm.org,O’Reilly,国外的领域相关⼤会(如 OSDI,SOSP,VLDB)论⽂,国际⼀流公司的技术博客,YouTube 上的国外⼯程师演讲。
为什么 Go 语⾔适合现代的后端编程环境?
- 服务类应⽤以 API 居多,IO 密集型,且⽹络 IO 最多
- 运⾏成本低,⽆ VM。⽹络连接数不多的情况下内存占⽤低。
- 强类型语⾔,易上⼿,易维护。
为什么适合基础设施?
- k8s、etcd、istio、docker 已经证明了 Go 的能⼒
对 Go 的启动和执⾏流程建⽴简单的宏观认识
理解可执⾏⽂件
实验使用的 Dockerfile(鉴于 CentOS 8已正式停止维护,可将 centos 镜像替换为 rockylinux)
1 2 3 4 5 6 |
FROM centos RUN yum install golang -y \ && yum install dlv -y \ && yum install binutils -y \ && yum install vim -y \ && yum install gdb -y |
构建镜像和启动容器进入的命令
1 2 |
docker build -t test . docker run -it --rm test bash |
vim tab 空格数的配置(~/.vimrc)
1 2 3 4 5 |
set ts=4 set softtabstop=4 set shiftwidth=4 set expandtab set autoindent |
Go 程序 hello.go 的编译过程:
可执行go build -x hello.go
进行查看,代码中高亮的两行分别为 complie(编译)和 link(链接)部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# go build -x hello.go WORK=/tmp/go-build2676931951 mkdir -p $WORK/b001/ cat >$WORK/b001/_gomod_.go << 'EOF' # internal package main import _ "unsafe" //go:linkname __debug_modinfo__ runtime.modinfo var __debug_modinfo__ = "0w\xaf\f\x92t\b\x02A\xe1\xc1\a\xe6\xd6\x18\xe6path\tcommand-line-arguments\nmod\tcommand-line-arguments\t(devel)\t\n\xf92C1\x86\x18 r\x00\x82B\x10A\x16\xd8\xf2" EOF cat >$WORK/b001/importcfg << 'EOF' # internal # import config packagefile runtime=/usr/lib/golang/pkg/linux_amd64/runtime.a EOF cd /cao/hello /usr/lib/golang/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" -p main -complete -buildid u9rA4S8cPkMjRUT56udb/u9rA4S8cPkMjRUT56udb -goversion go1.16.12 -D _/cao/hello -importcfg $WORK/b001/importcfg -pack -c=4 ./hello.go $WORK/b001/_gomod_.go /usr/lib/golang/pkg/tool/linux_amd64/buildid -w $WORK/b001/_pkg_.a # internal cp $WORK/b001/_pkg_.a /root/.cache/go-build/2b/2be70e10778e5b23d83770351d1a7908724204d8a6bcace8ecb1b31eb14d4eed-d # internal cat >$WORK/b001/importcfg.link << 'EOF' # internal packagefile command-line-arguments=$WORK/b001/_pkg_.a packagefile runtime=/usr/lib/golang/pkg/linux_amd64/runtime.a packagefile internal/bytealg=/usr/lib/golang/pkg/linux_amd64/internal/bytealg.a packagefile internal/cpu=/usr/lib/golang/pkg/linux_amd64/internal/cpu.a packagefile runtime/internal/atomic=/usr/lib/golang/pkg/linux_amd64/runtime/internal/atomic.a packagefile runtime/internal/math=/usr/lib/golang/pkg/linux_amd64/runtime/internal/math.a packagefile runtime/internal/sys=/usr/lib/golang/pkg/linux_amd64/runtime/internal/sys.a EOF mkdir -p $WORK/b001/exe/ cd . /usr/lib/golang/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=zsLvUjr6eNKNNK8GfKhl/u9rA4S8cPkMjRUT56udb/6IkzwXMvzyUSdMJzmjRJ/zsLvUjr6eNKNNK8GfKhl -extld=gcc $WORK/b001/_pkg_.a /usr/lib/golang/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal mv $WORK/b001/exe/a.out hello rm -r $WORK/b001/ |
编译是将文本代码编译为目标文件(.o, .a),链接是将目标文件合并为可执行文件。可执行文件在不同的操作系统上规范也不同:
Linux | Windows | macOS |
---|---|---|
ELF | PE | Mach-O |
Linux 的可执⾏⽂件 ELF(Executable and Linkable Format) 为例,ELF 由⼏部分构成:
- ELF header
- Section header
- Sections
图片来源:https://github.com/corkami/pics/blob/28cb0226093ed57b348723bc473cea0162dad366/binary/elf101/elf101.pdf
操作系统执⾏可执⾏⽂件的步骤(以 linux 为例):
通过 entry point 找到 Go 进程的执⾏⼊⼝,使⽤ readelf(readelf -h ./hello
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# readelf -h ./hello ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x45cd80 Start of program headers: 64 (bytes into file) Start of section headers: 456 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 7 Size of section headers: 64 (bytes) Number of section headers: 23 Section header string table index: 3 |
通过以上的入口地址0x45cd80
可借助dlv
命令来设置断点找到代码位置(事实上进入后默认已经在入口位置):
1 2 3 4 5 |
# dlv exec ./hello Type 'help' for list of commands. (dlv) b *0x45cd80 Breakpoint 1 set at 0x45cd80 for _rt0_amd64_linux() /usr/lib/golang/src/runtime/rt0_linux_amd64.s:8 (dlv) |
常用的命令:c, si, r, n, disass,更多参见https://github.com/go-delve/delve
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
Running the program: call ------------------------ Resumes process, injecting a function call (EXPERIMENTAL!!!) continue (alias: c) --------- Run until breakpoint or program termination. next (alias: n) ------------- Step over to next source line. rebuild --------------------- Rebuild the target executable and restarts it. It does not work if the executable was not built by delve. restart (alias: r) ---------- Restart process. step (alias: s) ------------- Single step through program. step-instruction (alias: si) Single step a single cpu instruction. stepout (alias: so) --------- Step out of the current function. Manipulating breakpoints: break (alias: b) ------- Sets a breakpoint. breakpoints (alias: bp) Print out info for active breakpoints. clear ------------------ Deletes breakpoint. clearall --------------- Deletes multiple breakpoints. condition (alias: cond) Set breakpoint condition. on --------------------- Executes a command when a breakpoint is hit. trace (alias: t) ------- Set tracepoint. Viewing program variables and memory: args ----------------- Print function arguments. display -------------- Print value of an expression every time the program stops. examinemem (alias: x) Examine memory: locals --------------- Print local variables. print (alias: p) ----- Evaluate an expression. regs ----------------- Print contents of CPU registers. set ------------------ Changes the value of a variable. vars ----------------- Print package variables. whatis --------------- Prints type of an expression. Listing and switching between threads and goroutines: goroutine (alias: gr) -- Shows or changes current goroutine goroutines (alias: grs) List program goroutines. thread (alias: tr) ----- Switch to the specified thread. threads ---------------- Print out info for every traced thread. Viewing the call stack and selecting frames: deferred --------- Executes command in the context of a deferred call. down ------------- Move the current frame down. frame ------------ Set the current frame, or execute command on a different frame. stack (alias: bt) Print stack trace. up --------------- Move the current frame up. Other commands: config --------------------- Changes configuration parameters. disassemble (alias: disass) Disassembler. edit (alias: ed) ----------- Open where you are in $DELVE_EDITOR or $EDITOR exit (alias: quit | q) ----- Exit the debugger. funcs ---------------------- Print list of functions. help (alias: h) ------------ Prints the help message. libraries ------------------ List loaded dynamic libraries list (alias: ls | l) ------- Show source code. source --------------------- Executes a file containing a list of delve commands sources -------------------- Print list of source files. types ---------------------- Print list of types |
Go 进程的启动与初始化
有助于深入汇编的游戏:人力资源机器(Human Resource Machine)
CPU 无法理解文本,只能执行一条一条的二进制机器码指令 每次执行完一条指令,pc 寄存器就指向下一条继续执行
在 64 位平台上 pc 寄存器 = rip
Go 语⾔是⼀⻔有 runtime 的语⾔,runtime 是为了实现额外的功能,⽽在程序运⾏时⾃动加载/运⾏的⼀些模块。(下图仅供参考,不够严谨,线程创建在 Linux 中也是通过系统调用)
Scheduler | 调度器管理所有的 G,M,P,在后台执⾏调度循环 |
Netpoll | ⽹络轮询负责管理⽹络 FD 相关的读写、就绪事件 |
Memory Management | 当代码需要内存时,负责内存分配⼯作 |
Garbage Collector | 当内存不再需要时,负责回收内存 |
这些模块中,最核⼼的就是 Scheduler,它负责串联所有的 runtime 流程。
通过 entry point 找到 Go 进程的执⾏⼊⼝,它会通过3个回调函数一路跳到runtime.rt0_go这个函数里,该函数是初始化的一个非常重要的函数。下图中m0为Go 程序启动后创建的第⼀个线程,执行 main 函数后开始进入调试循环:
调度组件与调度循环
在写下go func(){...}
的时候,其实是向 runtime 提交了⼀个计算任务。 func(){...}
⾥包裹的代码,就是这个计算任务的内容。所以Go 的调度流程本质上是⼀个⽣产-消费流程:
go func去了哪⾥?
goroutine 的生产端
goroutine 的消费端
M 执行调度循环时, 必须与一个 P 绑定。Work stealing 就是说的 runqsteal -> runqgrab 这个流程。
每执行60次本地队列的获取(可参见下图中使用的是一个魔法数字61),就会去全局队列中检查一次
下图中P.schedtick即为记录前面本地队列获取的次数
使用是 M:N 模型?关于 GMP 的说明:
- G:goroutine,⼀个计算任务。由需要执⾏的代码和其上下⽂组成,上下⽂包括:当前代码位置,栈顶、栈底地址,状态等。
- M:machine,系统线程,执⾏实体,想要在 CPU 上执⾏代码,必须有线程,与 C 语⾔中的线程相同,通过系统调⽤ clone 来创建。
- P:processor,虚拟处理器,M 必须获得 P 才能执⾏代码,否则必须陷⼊休眠(后台监控线程除外),你也可以将其理解为⼀种 token,有这个 token,才有在物理 CPU 核⼼上执⾏的权⼒。
处理阻塞
在线程发生阻塞的时候, 会无限制地创建线程么?
并不会!! 先来看看阻塞有哪几种情况:
1. make 了一个 buffer 是0的 channel,向里面塞数据:
1 2 3 |
// channel send var ch = make(chan int) ch <- 1 |
2.make 了一个 buffer 是0的 channel,去消费数据:
1 2 3 |
// channel recv var ch = make(chan int) <- ch |
3.执行time.sleep
1 |
time.Sleep(time.Hour) |
4.网络读,但无数据可读
1 2 3 4 5 6 |
// net read var c net.Conn var buf = make([]byte, 1024) // data not ready, block here n, err := c.Read(buf) |
5.网络写,缓冲区已满
1 2 3 4 5 6 |
// net write var c net.Conn var buf = []byte("hello") // send buffer full, write blocked n, err := c.Write(buf) |
6.执行 select,但 case 均为 ready
1 2 3 4 5 6 7 8 9 10 11 12 |
var ( ch1 = make(chan int) ch2 = make(chan int) ) // no case ready, block select { case <-ch1: println("ch1 ready") case <-ch2: println("ch2 ready") } |
7.锁被其他人占用
1 2 3 4 5 |
var l sync.RWMutex // somebody already grab the lock // block here l.Lock() |
这些情况不会阻塞调度循环,而是会把 goroutine 挂起。所谓的挂起,其实让 g 先进某个数据结构,待 ready 后再继续执行,不会占用线程。这时候,线程会进入 schedule,继续消费队列,执行其它的 g
上述6种情况挂起的示意:
为何有的等待是 sudog,有的是 g呢?
1 2 3 4 5 6 7 |
// sudog represents a g in a wait list, such as for sending/receiving // on a channel. // // sudog is necessary because the g ↔ synchronization object relation // is many-to-many. A g can be on many wait lists, so there may be // many sudogs for one g; and many gs may be waiting on the same // synchronization object, so there may be many sudogs for one object. |
就是说一个 g 可能对应多个 sudog,比如一个 g 会同时 select 多个 channel。前面这些都是能被 runtime 拦截到的阻塞,还有一些是 runtime 无法拦截的:
1. cgo:在执行 c 代码,或者阻塞在 syscall 上时,必须占用一个线程
1 2 3 4 5 6 7 8 9 10 11 12 13 |
package main /* #include <stdio.h> #include <stdlib.h> #include <unistd.h> void output(char *str) { usleep(1000000); printf("%s\n", str); } */ import "C" import "unsafe" |
2. syscall
sysnb: syscall nonblocking sys: syscall blocking
处理是通过sysmon(system monitor),具有高优先级,在专有线程中执行,不需要绑定 P 就可以执行。主要有3个工作
- checkdead,常⻅误解是这个可以检查死锁
12// Check for deadlock situation.// The check is based on number of running M's, if 0 -> deadlock. - netpoll:
inject g list to global run queue
- retake
- 如果是 syscall 卡了很久,那就把 p 剥离(handoffp)
- 如果是用户 g 运行很久了,那么发信号 SIGURG 抢占(Go 1.14新增)
调度器的发展历史
参见:https://github.com/golang-design/history#scheduler
小结
可执行文件 ELF:
- 使用 go build -x 观察编译和链接过程
- 通过 readelf -H 中的 entry 找到程序入口
- 在 dlv 调试器中 b *entry_addr 找到代码位置
启动流程:
- 处理参数 -> 初始化内部数据结构 -> 主线程 -> 启动调度循环
Runtime 构成:
- Scheduler、Netpoll、内存管理、垃圾回收
GMP:
- M,任务消费者;G,计算任务;P,可以使用 CPU 的 token
队列:
- P 的本地 runnext 字段 -> P 的 local run queue -> global run queue,多级队列减少锁竞争
调度循环:
- 线程 M 在持有 P 的情况下不断消费运行队列中的 G 的过程。
处理阻塞:
- 可以接管的阻塞:channel 收发,加锁,网络连接读/写,select
- 不可接管的阻塞:syscall,cgo,⻓时间运行需要剥离 P 执行
sysmon:
- 一个后台高优先级循环,执行时不需要绑定任何的 P
- 负责:
- 检查是否已经没有活动线程,如果是,则崩溃 轮询 netpoll
- 剥离在 syscall 上阻塞的 M 的 P 发信号,抢占已经执行时间过⻓的 G
补充:与调度有关的常⻅问题
Goroutine 比 Thread 优势在哪?
Goroutine | Thread | |
---|---|---|
内存占用 | 2KB -> 1GB | 从 8k 开始,服务端程序上限很多是 8M(用 ulimit -a 可看),调用多会 stack overflow |
Context switch | 几十 NS 级 | 1-2 us |
由谁管理 | Go runtime | 操作系统 |
通信方式 | CSP/传统共享内存 | 传统共享内存 |
ID | 有,用户无法访问 | 有 |
抢占 | 1.13 以前需主动让出 1.14 开始可由信号中断 | 内核抢占 |
参考链接:https://www.geeksforgeeks.org/golang-goroutine-vs-thread/
goroutine 的切换成本
gobuf 描述一个 goroutine 所有现场,从一个 g 切换到另一个 g,只要把这几个现场字段保存下来,再把 g 往队列里一扔,m 就可以执行其它 g 了。无需进入内核态
一个无聊的输出顺序的问题
第一段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package main import ( "fmt" "runtime" ) func main() { runtime.GOMAXPROCS(1) for i := 0; i < 10; i++ { i := i go func() { fmt.Println("A: ", i) }() } var ch = make(chan int) <-ch } |
第二段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package main import ( "fmt" "runtime" "time" ) func main() { runtime.GOMAXPROCS(1) for i := 0; i < 10; i++ { i := i go func() { fmt.Println("A: ", i) }() } time.Sleep(time.Hour) } |
死循环导致进程 hang 死问题
GC 时需要停止所有 goroutine 而老版本的 Go 的 g 停止需要主动让出
1.14 增加基于信号的抢占之后,该问题被解决
1 2 3 4 5 6 7 8 9 10 11 |
package main func main() { var i = 1 go func() { // 这个 goroutine 会导致进行在 gc 时 hang 死 for { i++ } }() } |
链接:https://xargin.com/how-to-locate-for-block-in-golang/
与 GMP 有关的一些缺陷
- 创建的 M 正常情况下是无法被回收
解决方法:https://xargin.com/shrink-go-threads/ - runtime 中有一个 allgs 数组 所有创建过的 g 都会进该数组 大小与 g 瞬时最高值相关
详细说明:https://xargin.com/cpu-idle-cannot-recover-after-peak-load/
在 PPT 里有各种阻塞场景,你是怎么在代码里找到这些阻塞场景的?
要知道 runtime 中可以接管的阻塞是通过 gopark/goparkunlock 挂起和 goready 恢复 的,那么我们只要找到 runtime.gopark 的调用方,就可以知道在哪些地方会被 runtime 接管了,你也应该用 IDE 试一试,很简单:
其它参考链接:
Measuring context switching and memory overheads for Linux threads
课后作业
- 部署好本机的 docker 环境,使用 ppt 中的 dockerfile build 自己的环境
- 使用 readelf 工具,查看编译后的进程入口地址
readelf -h ./hello | grep Entry
- 在 dlv 调试工具中,使用断点功能找到代码位置
- 使用断点调试功能,查看 Go 的 runtime 的下列函数执行流程,使用 IDE 查看函数的调用方:
- 必做:runqput,runqget,globrunqput,globrunqget
➤ dlv 进入后,输入b runqput
,然后输入c
或continue
,如执行完成可输入r
或restart
重新开启执行
➤ Goland 中使用 Cmd+Opt+o 搜索runqput
函数,按下 Cmd,单击函数名 - 选做:schedule,findrunnable,sysmon
- 必做:runqput,runqget,globrunqput,globrunqget
- 难度++课外作业:跟踪进程启动流程中的关键函数,rt0_go,需要汇编知识,可以暂时不做,只给有兴趣的同学
内容来源为曹大的《Go高级工程师实战营》,想要报名的小伙伴请访问https://learn.gocn.vip/course(无偿广告,内容是否适合读者请自行评估)。