跟着曹大学Golang第一回 – Go 程序是怎么跑起来的

Coding Alan 3年前 (2022-01-17) 2187次浏览 0个评论 扫描二维码
  • 理解可执行文件
  • 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 TrendingRedditMediumHacker Newsmorning paper(作者不⼲了),acm.orgO’Reilly,国外的领域相关⼤会(如 OSDI,SOSP,VLDB)论⽂,国际⼀流公司的技术博客,YouTube 上的国外⼯程师演讲。

为什么 Go 语⾔适合现代的后端编程环境?

  • 服务类应⽤以 API 居多,IO 密集型,且⽹络 IO 最多
  • 运⾏成本低,⽆ VM。⽹络连接数不多的情况下内存占⽤低。
  • 强类型语⾔,易上⼿,易维护。

为什么适合基础设施?

  • k8s、etcd、istio、docker 已经证明了 Go 的能⼒

对 Go 的启动和执⾏流程建⽴简单的宏观认识

理解可执⾏⽂件

实验使用的 Dockerfile(鉴于 CentOS 8已正式停止维护,可将 centos 镜像替换为 rockylinux)

构建镜像和启动容器进入的命令

vim tab 空格数的配置(~/.vimrc)

Go 程序 hello.go 的编译过程:

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

可执行go build -x hello.go进行查看,代码中高亮的两行分别为 complie(编译)和 link(链接)部分:

编译是将文本代码编译为目标文件(.o, .a),链接是将目标文件合并为可执行文件。可执行文件在不同的操作系统上规范也不同:

LinuxWindowsmacOS
ELFPEMach-O

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

  • ELF header
  • Section header
  • Sections

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的图片来源:https://github.com/corkami/pics/blob/28cb0226093ed57b348723bc473cea0162dad366/binary/elf101/elf101.pdf

操作系统执⾏可执⾏⽂件的步骤(以 linux 为例):

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

通过 entry point 找到 Go 进程的执⾏⼊⼝,使⽤ readelf(readelf -h ./hello

通过以上的入口地址0x45cd80可借助dlv命令来设置断点找到代码位置(事实上进入后默认已经在入口位置):

常用的命令:c, si, r, n, disass,更多参见https://github.com/go-delve/delve

 

Go 进程的启动与初始化

有助于深入汇编的游戏:人力资源机器(Human Resource Machine

CPU 无法理解文本,只能执行一条一条的二进制机器码指令 每次执行完一条指令,pc 寄存器就指向下一条继续执行

在 64 位平台上 pc 寄存器 = rip

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

Go 语⾔是⼀⻔有 runtime 的语⾔,runtime 是为了实现额外的功能,⽽在程序运⾏时⾃动加载/运⾏的⼀些模块。(下图仅供参考,不够严谨,线程创建在 Linux 中也是通过系统调用)

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的Go 语⾔的 runtime 包括如下模块:

Scheduler调度器管理所有的 G,M,P,在后台执⾏调度循环
Netpoll⽹络轮询负责管理⽹络 FD 相关的读写、就绪事件
Memory
Management
当代码需要内存时,负责内存分配⼯作
Garbage
Collector
当内存不再需要时,负责回收内存

这些模块中,最核⼼的就是 Scheduler,它负责串联所有的 runtime 流程。

通过 entry point 找到 Go 进程的执⾏⼊⼝,它会通过3个回调函数一路跳到runtime.rt0_go这个函数里,该函数是初始化的一个非常重要的函数。下图中m0为Go 程序启动后创建的第⼀个线程,执行 main 函数后开始进入调试循环:

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

调度组件与调度循环

在写下go func(){...}的时候,其实是向 runtime 提交了⼀个计算任务。 func(){...}⾥包裹的代码,就是这个计算任务的内容。所以Go 的调度流程本质上是⼀个⽣产-消费流程:

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

go func去了哪⾥?

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

goroutine 的生产端

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

生产端点击这里通过动画进行理解

goroutine 的消费端

M 执行调度循环时, 必须与一个 P 绑定。Work stealing 就是说的 runqsteal -> runqgrab 这个流程。

每执行60次本地队列的获取(可参见下图中使用的是一个魔法数字61),就会去全局队列中检查一次

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

下图中P.schedtick即为记录前面本地队列获取的次数

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

消费端点击这里通过动画进行理解

使用是 M:N 模型?关于 GMP 的说明:

  • G:goroutine,⼀个计算任务。由需要执⾏的代码和其上下⽂组成,上下⽂包括:当前代码位置,栈顶、栈底地址,状态等。
  • M:machine,系统线程,执⾏实体,想要在 CPU 上执⾏代码,必须有线程,与 C 语⾔中的线程相同,通过系统调⽤ clone 来创建。
  • P:processor,虚拟处理器,M 必须获得 P 才能执⾏代码,否则必须陷⼊休眠(后台监控线程除外),你也可以将其理解为⼀种 token,有这个 token,才有在物理 CPU 核⼼上执⾏的权⼒。

处理阻塞

在线程发生阻塞的时候, 会无限制地创建线程么?

并不会!! 先来看看阻塞有哪几种情况:

1. make 了一个 buffer 是0的 channel,向里面塞数据:

2.make 了一个 buffer 是0的 channel,去消费数据:

3.执行time.sleep

4.网络读,但无数据可读

5.网络写,缓冲区已满

6.执行 select,但 case 均为 ready

7.锁被其他人占用

这些情况不会阻塞调度循环,而是会把 goroutine 挂起。所谓的挂起,其实让 g 先进某个数据结构,待 ready 后再继续执行,不会占用线程。这时候,线程会进入 schedule,继续消费队列,执行其它的 g

上述6种情况挂起的示意:

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

为何有的等待是 sudog,有的是 g呢?

就是说一个 g 可能对应多个 sudog,比如一个 g 会同时 select 多个 channel。前面这些都是能被 runtime 拦截到的阻塞,还有一些是 runtime 无法拦截的:

1. cgo:在执行 c 代码,或者阻塞在 syscall 上时,必须占用一个线程

2. syscall

sysnb: syscall nonblocking sys: syscall blocking

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

处理是通过sysmon(system monitor),具有高优先级,在专有线程中执行,不需要绑定 P 就可以执行。主要有3个工作

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

  • checkdead,常⻅误解是这个可以检查死锁
  • netpoll: inject g list to global run queue
  • retake
    • 如果是 syscall 卡了很久,那就把 p 剥离(handoffp)
    • 如果是用户 g 运行很久了,那么发信号 SIGURG 抢占(Go 1.14新增)

调度器的发展历史

参见:https://github.com/golang-design/history#scheduler

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

小结

可执行文件 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 优势在哪?

GoroutineThread
内存占用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 了。无需进入内核态

一个无聊的输出顺序的问题

第一段代码:

第二段代码:

死循环导致进程 hang 死问题

GC 时需要停止所有 goroutine 而老版本的 Go 的 g 停止需要主动让出

1.14 增加基于信号的抢占之后,该问题被解决

链接:https://xargin.com/how-to-locate-for-block-in-golang/

与 GMP 有关的一些缺陷

在 PPT 里有各种阻塞场景,你是怎么在代码里找到这些阻塞场景的?

要知道 runtime 中可以接管的阻塞是通过 gopark/goparkunlock 挂起和 goready 恢复 的,那么我们只要找到 runtime.gopark 的调用方,就可以知道在哪些地方会被 runtime 接管了,你也应该用 IDE 试一试,很简单:

跟着曹大学Golang第一回 - Go 程序是怎么跑起来的

其它参考链接:

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,然后输入ccontinue,如执行完成可输入rrestart重新开启执行
      ➤ Goland 中使用 Cmd+Opt+o 搜索runqput函数,按下 Cmd,单击函数名
    • 选做:schedule,findrunnable,sysmon
  • 难度++课外作业:跟踪进程启动流程中的关键函数,rt0_go,需要汇编知识,可以暂时不做,只给有兴趣的同学

内容来源为曹大的《Go高级工程师实战营》,想要报名的小伙伴请访问https://learn.gocn.vip/course(无偿广告,内容是否适合读者请自行评估)。

喜欢 (2)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址