首页 / 科技百科

golang获取系统内存

2025-05-03 08:06科技百科
介绍

本文主要内容如下:

Golang runtime 是个什么? Golang Runtime 的发展历程, 每个版本的改进Go 调度: 协程结构体, 上下文切换, 调度队列, 大致调度流程, 同步执行流又不阻塞线程的网络实现等Go 内存: 内存结构, mspan 结构, 全景图及分配策略等Go GC: Golang GC 停顿大致的一个发展历程, 三色标记实现的一些细节, 写屏障, 三色状态, 扫描及元信息, 1.12 版本相对 1.5 版本的改进点, GC Pacer 等实践: 观察调度, GC 信息, 一些优化的方式, 几点问题排查的思路, 几个有意思的问题排查总结: 贯穿 Runtime 的思想总结

本文完整版 PPT 可在文末获取。

为什么去了解 runtime 呢?

可以解决一些棘手的问题: 在写这个 PPT 的时候, 就有一位朋友在群里发了个 pprof 图, 说同事写的代码有问题, CPU 利用率很高., 找不出来问题在哪, 我看了下 pprof 图, 说让他找找是不是有这样用 select 的, 一查的确是的. 平时也帮同事解决了一些和并发, 调度, GC 有关的问题好奇心: 大家写久了 go, 惊叹于它的简洁, 高性能外, 必然对它是怎么实现的有很多好奇. 协程怎么实现, GC 怎么能并发, 对象在内存里是怎么存在的? 等等技术深度的一种

Runtime 简介及发展Runtime 简介

go 的 runtime 代码在 go sdk 的 runtime 目录下,主要有所述的 4 块功能.

提到 runtime, 大家可能会想起 java, python 的 runtime. 不过 go 和这两者不太一样, java, python 的 runtime 是虚拟机, 而 go 的 runtime 和用户代码一起编译到一个可执行文件中.

用户代码和 runtime 代码除了代码组织上有界限外, 运行的时候并没有明显的界限. 如上所示, 一些常用的关键字被编译成 runtime 包下的一些函数调用.

Runtime 版本历史

左边标粗的是一些更新比较大的版本. 右边的 GC STW 仅供参考.

调度

调度简述

goroutine 实现

我们去看调度的一个进化, 从进程到线程再到协程, 其实是一个不断共享, 不断减少切换成本的过程. go 实现的协程为有栈协程, go 协程的用法和线程的用法基本类似. 很多人会疑问, 协程到底是个什么东西? 用户态的调度感觉很陌生, 很抽象, 到底是个什么东西?

我觉得要理解调度, 要理解两个概念: 运行和阻塞. 特别是在协程中, 这两个概念不容易被正确理解. 我们理解概念时往往会代入自身感受, 觉得线程或协程运行就是像我们吭哧吭哧的处理事情, 线程或协程阻塞就是做事情时我们需要等待其他人. 然后就在这等着了. 要是其他人搞好了, 那我们就继续做当前的事.

其实主体对象搞错了.正确的理解应该是我们处理事情时就像 CPU, 而不是像线程或者协程. 假如我当前在写某个服务, 发现依赖别人的函数还没有 ready, 那就把写服务这件事放一边. 点开企业微信, 我去和产品沟通一些问题了. 我和产品沟通了一会后, 检查一下, 发现别人已经把依赖的函数提交了, 然后我就最小化企业微信, 切到 IDE, 继续写服务 A 了.

对操作系统有过一些了解, 知道 linux 下的线程其实是 task_struct 结构, 线程其实并不是真正运行的实体, 线程只是代表一个执行流和其状态.真正运行驱动流程往前的其实是 CPU. CPU 在时钟的驱动下, 根据 PC 寄存器从程序中取指令和操作数, 从 RAM 中取数据, 进行计算, 处理, 跳转, 驱动执行流往前. CPU 并不关注处理的是线程还是协程, 只需要设置 PC 寄存器, 设置栈指针等(这些称为上下文), 那么 CPU 就可以欢快的运行这个线程或者这个协程了.

线程的运行, 其实是被运行.其阻塞, 其实是切换出调度队列, 不再去调度执行这个执行流. 其他执行流满足其条件, 便会把被移出调度队列的执行流重新放回调度队列.协程同理, 协程其实也是一个数据结构, 记录了要运行什么函数, 运行到哪里了.go 在用户态实现调度, 所以 go 要有代表协程这种执行流的结构体, 也要有保存和恢复上下文的函数, 运行队列. 理解了阻塞的真正含义, 也就知道能够比较容易理解, 为什么 go 的锁, channel 这些不阻塞线程.

对于实现的同步执行流效果, 又不阻塞线程的网络, 接下来也会介绍.

协程结构体和切换函数

我们 go 一个 func 时一般这样写

go func1(arg1 type1,arg2 type2){....}(a1,a2)

一个协程代表了一个执行流, 执行流有需要执行的函数(对应上面的 func1), 有函数的入参(a1, a2), 有当前执行流的状态和进度(对应 CPU 的 PC 寄存器和 SP 寄存器), 当然也需要有保存状态的地方, 用于执行流恢复.

真正代表协程的是 runtime.g 结构体. 每个 go func 都会编译成 runtime.newproc 函数, 最终有一个 runtime.g 对象放入调度队列. 上面的 func1 函数的指针设置在 runtime.g 的 startfunc 字段, 参数会在 newproc 函数里拷贝到 stack 中, sched 用于保存协程切换时的 pc 位置和栈位置.

协程切换出去和恢复回来需要保存上下文, 恢复上下文, 这些由以下两个汇编函数实现. 以上就能实现协程这种执行流, 并能进行切换和恢复.(下图中的 struct 和函数都做了精简)

GM 模型及 GPM 模型

有了协程的这种执行流形式, 那待运行的协程放在哪呢?在 Go1.0 的时候:

调度队列 schedt 是全局的, 对该队列的操作均需要竞争同一把锁, 导致伸缩性不好.新生成的协程也会放入全局的队列, 大概率是被其他 m(可以理解为底层线程的一个表示)运行了, 内存亲和性不好. 当前协程 A 新生成了协程 B, 然后协程 A 比较大概率会结束或者阻塞, 这样 m 直接去执行协程 B, 内存的亲和性也会好很多.因为 mcache 与 m 绑定, 在一些应用中(比如文件操作或其他可能会阻塞线程的系统调用比较多), m 的个数可能会远超过活跃的 m 个数, 导致比较大的内存浪费.

那是不是可以给 m 分配一个队列, 把阻塞的 m 的 mcache 给执行 go 代码的 m 使用? Go 1.1 及以后就是这样做的.

再 1.1 中调度模型更改为 GPM 模型, 引入逻辑 Process 的概念, 表示执行 Go 代码所需要的资源, 同时也是执行 Go 代码的最大的并行度.

这个概念可能很多人不知道怎么理解. P 涉及到几点, 队列和 mcache, 还有 P 的个数的选取.

首先为什么把全局队列打散, 以及 mcache 为什么跟随 P, 这个在 GM 模型那一页就讲的比较清楚了.然后为什么 P 的个数默认是 CPU 核数: Go 尽量提升性能, 那么在一个 n 核机器上, 如何能够最大利用 CPU 性能呢? 当然是同时有 n 个线程在并行运行中, 把 CPU 喂饱, 即所有核上一直都有代码在运行.

在 go 里面, 一个协程运行到阻塞系统调用, 那么这个协程和运行它的线程 m, 自然是不再需要 CPU 的, 也不需要分配 go 层面的内存. 只有一直在并行运行的 go 代码才需要这些资源, 即同时有 n 个 go 协程在并行执行, 那么就能最大的利用 CPU, 这个时候需要的 P 的个数就是 CPU 核数. (注意并行和并发的区别)

协程状态及流转

协程的状态其实和线程状态类似,状态转换和发生状态转换的时机如图所示. 还是需要注意: 协程只是一个执行流, 并不是运行实体.

调度

并没有一个一直在运行调度的调度器实体. 当一个协程切换出去或新生成的 m, go 的运行时从 stw 中恢复等情况时, 那么接下来就需要发生调度. go 的调度是通过线程(m)执行 runtime.schedule 函数来完成的.

sysmon 协程

在 linux 内核中有一些执行定时任务的线程, 比如定时写回脏页的 pdflush, 定期回收内存的 kswapd0, 以及每个 cpu 上都有一个负责负载均衡的 migration 线程等.在 go 运行时中也有类似的协程, sysmon.功能比较多: 定时从 netpoll 中获取 ready 的协程, 进行抢占, 定时 GC,打印调度信息,归还内存等定时任务.

协作式抢占

go 目前(1.12)还没有实现非协作的抢占. 基本流程是 sysmon 协程标记某个协程运行过久, 需要切换出去, 该协程在运行函数时会检查栈标记, 然后进行切换.

同步执行流不阻塞线程的网络的实现

go 写后台最舒服的就是能够以同步写代码的方式操作网络, 但是网络操作不阻塞线程.主要是结合了非阻塞的 fd, epoll 以及协程的切换和恢复.linux 提供了网络 fd 的非阻塞模式, 对于没有 ready 的非阻塞 fd 执行网络操作时, linux 内核不阻塞线程, 会直接返回 EAGAIN, 这个时候将协程状态设置为 wait, 然后 m 去调度其他协程.

go 在初始化一个网络 fd 的时候, 就会把这个 fd 使用 epollctl 加入到全局的 epoll 节点中. 同时放入 epoll 中的还有 polldesc 的指针.

func netpollopen(fd uintptr, pd *pollDesc) int32 { var ev epollevent ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET *(**pollDesc)(unsafe.Pointer(149(gc 结束的堆大小)-75MB(gc 结束时的存活堆大小), 151 MB goal(本次 gc 预计结束的堆大小), 8P(8 个 P).

优化

个人建议, 没事不要总想着优化, 好好 curd 就好.

当然还是有一些优化方法的.

一点实践

我们将 pprof 的开启集成到模板中, 并自动选择端口, 并集成了 gops 工具, 方便查询 runtime 信息, 同时在浏览器上可直接点击生成火焰图, pprof 图, 非常的方便, 也不需要使用者关心.

问题排查的一点思路

一次有意思的问题排查

负载, 依赖服务都很正常, CPU 利用率也不高, 请求也不多, 就是有很多超时.

该服务在线上打印了 debug 日志, 因为早期的服务模板开启了 gctrace, 框架把 stdout 重定向到一个文件了. 而输出 gctrace 时本来是到 console 的, 输出到文件了, 而磁盘跟不上, 导致 gctrace 日志被阻塞了.

这里更正一下 ppt 中的内容, 并不是因为 gc 没完成而导致其他协程不能运行, 而是后续 gc 无法开启, 导致实质上的 stw.打印 gc trace 日志时, 已经 start the world 了, 其他协程可以开始运行了. 但是在打印 gctrace 日志时, 还保持着开启 gc 需要的锁, 所以, 打印 gc trace 日志一直没完成, 而 gc 又比较频繁, 比如 0.1s 一次, 这样会导致下一次 gc 开始时无法获取锁, 每一个进入 gc 检查的 p 阻塞, 实际上就造成了 stw.

Runtime 的一点个人总结

并行, 纵向多层次, 横向多个 class, 缓存, 缓冲, 均衡.

参考文档

参考链接在 PPT 文件中。

本文完整 PPT 可点击下方链接获得:

https://www.lanzous.com/i7lj0he

猜你喜欢

微信分享

微信分享二维码

扫描二维码分享到微信或朋友圈

链接已复制