开场闲聊
目标
语⾔深度
调度原理、调试技巧、汇编反汇编、内部数据结构实现、常⻅ syscall、函数
调⽤规约(待定)、内存管理与垃圾回收、并发编程
应⽤深度
框架原理、社区框架分析、模块分层、linter 规范、中台场景实现、性能调优
架构⼴度
模块拆分、CI/CD 实战(coding 平台)、监控与可观测性、服务发现/信息检索/
定时任务/MQ 等基础设施、稳定性保障、未来架构、语⾔前沿
跨语⾔学习者
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 这种书,应该读好多遍) 。
关注⼀些靠谱的国内外新闻源,通过问题出发,主动使⽤ Google,主动去 reddit、hackernews 上参与讨论,避免被困在信息茧房中。
锻炼⼝才和演讲能⼒,内部分享 -> 外部分享。在公司内,该演要演,不要只是闷头⼲活。
通过输出促进输⼊(博客、公众号、分享),打造个⼈品牌,通过读者的反馈循环提升⾃⼰的认知。
信息源:Github Trending、reddit、medium、hacker news,morning paper(作者不⼲了),acm.org,oreily,国外的领域相关⼤会(如 OSDI,SOSP,VLDB)论⽂,国际⼀流公司的技术博客,YouTube 上的国外⼯程师演讲。
理解可执⾏⽂件
代码
1 | package main |
运行$ go build hello.go
,可以看到生成的可执行文件:
1 | ls |
可执⾏⽂件在不同的操作系统上规范不⼀样,
Linux:ELF
Windows:PE
MacOS:Mach-O
Linux 的可执⾏⽂件 ELF(Executable and Linkable Format) 为例,ELF 由⼏部分构成:
- ELF header
- Section header
- Sections
操作系统执⾏可执⾏⽂件的步骤(以 linux 为例):
解析ELF header
-> 加载⽂件内容⾄内存 -> 从entry point
开始执⾏代码。
通过 entry point 找到 Go 进程的执⾏⼊⼝,使⽤ readelf:
Go 进程的启动与初始化
计算机是怎么执⾏我们的程序的呢?
CPU ⽆法理解⽂本,只能执⾏⼀条⼀条的⼆进制机器码指令每次执⾏完⼀条指令,pc 寄存器就指向下⼀条继续执⾏。
在 64 位平台上 pc 寄存器 = rip。
Go 语⾔是⼀⻔有 runtime 的语⾔,那么 runtime 是什么?
可以认为 runtime 是为了实现额外的功能,⽽在程序运⾏时⾃动加载/运⾏的⼀些模块。
Go 语⾔的 runtime 包括:
这些模块中,最核⼼的就是 Scheduler,它负责串联所有的 runtime 流程。
通过 entry point 找到 Go 进程的执⾏⼊⼝:
m0: Go 程序启动后创建的第⼀个线程;
调度组件与调度循环
当我每次写下一个完整的helloworld的时候,到底发⽣了什么?
答:你其实是向 runtime 提交了⼀个计算任务。func() { xxxxx }
⾥包裹的代码就是这个计算任务的内容。
Go 的调度流程本质上是⼀个⽣产-消费流程
调度组件,go func去了哪⾥?
goroutine 的⽣产端:
goroutine 的消费端:
M 执⾏调度循环时,必须与⼀个 P 绑定
Work stealing 就是说的 runqsteal -> runqgrab 这个流程
现在我们再来看看这些⽂字定义,是不是就很好懂了:
- G:goroutine,⼀个计算任务。由需要执⾏的代码和其上下⽂组成,上下⽂包括:当前代码位置,栈顶、栈底地址,状态等。
- M:machine,系统线程,执⾏实体,想要在 CPU 上执⾏代码,必须有线程,与 C 语⾔中的线程相同,通过系统调⽤ clone 来创建。
- Processor,虚拟处理器,M 必须获得 P 才能执⾏代码,否则必须陷⼊休眠(后台监控线程除外),你也可以将其理解为⼀种 token,有这个 token,才有在物理 CPU 核⼼上执⾏的权⼒。
但是:仅仅能够处理正常情况是不⾏的,如果程序中有阻塞,那线程不就全被堵上了?
处理阻塞
在线程发⽣阻塞的时候,会⽆限制地创建线程么?——并不会!!!例如:
1 | //channel send |
这些情况不会阻塞调度循环,⽽是会把 goroutine 挂起,所谓的挂起,其实让 g 先进某个数据结构,待 ready 后再继续执⾏,不会占⽤线程。这时候,线程会进⼊ schedule,继续消费队列,执⾏其它的 g
各种情况:
应⽤阻塞在锁上的情况:
为啥有的等待是 sudog,有的是 g?
就是说⼀个 g 可能对应多个 sudog,⽐如⼀个 g 会同时 select 多个channel。
前⾯这些都是能被 runtime 拦截到的阻塞,还有⼀些是 runtime ⽆法拦截的:
CGO
//sysnb: syscall nonblocking
//sys: syscall blocking
在执⾏ c 代码,或者阻塞在 syscall 上时,必须占⽤⼀个线程。
sysmon: system monitor,⾼优先级,在专有线程中执⾏,不需要绑定 P 就可以执⾏。
调度器的发展历史
知识点总结
可执⾏⽂件 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
- 负责:
(1)检查是否已经没有活动线程,如果是,则崩溃
(2)轮询 netpoll
(3)剥离在 syscall 上阻塞的 M 的P
(4)发信号,抢占已经执⾏时间过⻓的 G
与调度有关的常⻅问题
Goroutine ⽐ Thread 优势在哪?
goroutine 的切换成本
gobuf 描述⼀个 goroutine 所有现场,从⼀个 g 切换到另⼀个 g,只要把这⼏个现场字段保存下来,再把 g 往队列⾥⼀扔,m 就可以执⾏其它g了⽆需进⼊内核态。
⼀个⽆聊的输出顺序的问题
死循环导致进程 hang 死问题
与 GMP 有关的⼀些缺陷:创建的 M 正常情况下是⽆法被回收
与 GMP 有关的⼀些缺陷:runtime 中有⼀个 allgs 数组所有创建过的 g 都会进该数组⼤⼩与 g 瞬时最⾼值相关
怎么在代码⾥找到这些阻塞场景的?
要知道 runtime 中可以接管的阻塞是通过 gopark/goparkunlock 挂起和 goready 恢复的,那么我们只要找到 runtime.gopark 的调⽤⽅,就可以知道在哪些地⽅会被 runtime 接管了:
QA
为什么 Go 语⾔适合现代的后端编程环境?
- 服务类应⽤以 API 居多,IO 密集型,且⽹络 IO 最多
- 运⾏成本低,⽆ VM。⽹络连接数不多的情况下内存占⽤低。
- 强类型语⾔,易上⼿,易维护。
为什么适合基础设施?
- k8s、etcd、istio、docker 已经证明了 Go 的能⼒
References
ELF ⽂件解析
Go Scheduler 变更历史
Goroutine vs Thread
Measuring context switching and memory overheads for Linux threads