不会飞的章鱼

熟能生巧,勤能补拙;念念不忘,必有回响。

Go高级工程师_第1课_Go程序是怎么跑起来的

开场闲聊

目标

语⾔深度

调度原理、调试技巧、汇编反汇编、内部数据结构实现、常⻅ 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
2
3
4
5
package main

func main() {
println("hello")
}

运行$ go build hello.go,可以看到生成的可执行文件:

1
2
 ls
hello hello.go

可执⾏⽂件在不同的操作系统上规范不⼀样,

Linux:ELF
Windows:PE
MacOS:Mach-O

Linux 的可执⾏⽂件 ELF(Executable and Linkable Format) 为例,ELF 由⼏部分构成:

  • ELF header
  • Section header
  • Sections

github-elf101

操作系统执⾏可执⾏⽂件的步骤(以 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
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
//channel send
var ch = make(chan int)
ch <- 1

//channel recv
var ch = make(chan int)
<-ch

//time sleep
time.Sleep(time.Hour)

//net read
var c net.Conn
var buf = make([]byte,1024)

//data not ready,block here
n,err := c.Read(buf)

//net write
var c net.Conn
var buf = []byte("hello")

//send buffer full,wirte blocked
n,err := c.Write(buf)

var(
ch1 := make(chan int)
ch2 := make(chan int)
)
//no case ready,block
select {
case <- ch1:
println("ch1 ready")
case <-ch2:
println("ch2 ready")
}

var l sync.RWMutex
//somebody already grab the lock
//block here
l.Lock

这些情况不会阻塞调度循环,⽽是会把 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

------ 本文结束------
如果本篇文章对你有帮助,可以给作者加个鸡腿~(*^__^*),感谢鼓励与支持!