0%

书接上回,从一个 core 文件,生成内存引用关系火焰图时,虽然可以从 core 文件中,读到所有的内存对象,但是并不知道它们的类型信息。

这是因为 go 作为静态类型语言,在运行时,内存对象的类型是已知的;也就是说,并不需要想动态类型语言那样,为每个内存对象,在内存中存储其类型信息(Go 有点例外的是 interface)。

比如这个 go 语言例子:

1
2
3
4
5
6
7
8
type Foo struct {
a uint64
b int64
}

func foo(f *Foo) int64 {
return f.b
}

foo 函数在使用 f 这个指针时,并不需要判断其类型,直接读一个带偏移量地址就能得到 f.b,也就是一条指令:mov rax, qword ptr [rax + 8],就是这么简单直接。

再看 Lua 语言这个例子

1
2
3
4
function foo(f)
return f.b
end
foo({ b = 1 })

foo 函数在执行的时候,首先得判断 f 的类型,如果是 table,则按照 key 取 b 的值;如果不是,则抛运行时 error。

能够运行时判断 f 的类型,是因为 Lua 中变量是用 TValue 来表示的,这个 TValue 结构中,就有一个头信息用来存储变量类型。

逆向类型推导

逆向类型推导的逻辑是,根据已知内存的类型信息,推导被引用的内存对象的类型信息。

比如这个例子:

1
2
3
4
5
6
7
8
type Foo struct {
a uint64
b int64
}
type Bar struct {
f *Foo
}
var b Bar

如果我们知道了 b 的类型是 Bar,那么 b 中第一个 field 指向的内存对象,就是 Foo 类型了(前提是合法的内存对象地址)

既然存在推导,那我们怎么知道一些初始值呢,一共有两类来源:

  1. 全局变量
  2. 协程中每一帧函数的局部变量

全局变量

go 在编译的时候,默认会生成一些调试信息,按照 dwarf 标准格式,放在 ELF 文件中 .debug_* 这样的段里。

这些调试信息中,我们关注两类关键信息:

  1. 类型信息,包括了源码中定义的类型,比如某个 struct 的名字,大小,以及各个 field 类型信息
  2. 全局变量,包括变量名,地址,类型
    调试信息中的,全局变量的地址,以及其类型信息,也就是构成推导的初始值。

函数局部变量,要复杂一些,不过基本原理是类似的,这里就不细说了~

推导过程

推导过程,跟 GC mark 的过程类似,甚至初始值也跟 GC root 一样。
所以,全部推导完毕之后,GC mark 认为是 alive的内存对象,其类型信息都会被推导出来。

interface

Go 语言中 interface 比较类似动态类型,如下是空接口的内存结构,每个对象都存储了其类型信息:

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

按照类型推导,我们能知道一个对象是 interface{},但是其中 data 指向对象,是什么类型,我们则需要读取 _type 中的信息了。

_type 中有两个信息,对我们比较有用:

  1. 名字
    不过比较坑的是,只存了 pkg.Name 并没有存完整的 include path
    这个也合理的,毕竟 go 运行时并不需要那么精确,也就是异常时,输出错误信息中用一下。不过在类型推导的时候,就容易踩坑了。
  2. 指针信息
    具体存储形式有点绕,不过也就是表示这个对象中,有哪些偏移量是指针

有了这两个信息之后,就可以从全量的类型中,筛选出符合上面两个信息的类型。

通常情况下,会选出一个正确的答案,不过有时候选出多个,仅仅根据这两个信息还不能区分出来,一旦一步错了,后面可能就全推导不出来了。

我们给 go 官方 debug 贡献了一个补丁,可以进一步的筛选,有兴趣的可以看 CL 419176

unsafe.pointer

其实,在上面的 interface 示例中,最根源的原因,也就是 data unsafe.pointer,这个指针并没有类型信息,只是 interface 的实现中,有另外的字段来存储类型信息。

不过,在 go runtime 中还有其他的 unsafe.pointer,就没有那么幸运了。
比如 mapsync.map 的实现都有 unsafe.Pointer,这种就没有办法像 interface 那样统一来处理了,只能 case by case,根据 map/sync.map 的结构特征来逆向写死了…

我们给 go 官方 debug 贡献了 sync.map 的逆向实现,有兴趣的可以看 CL 419177

隐藏类型

除了源码中显示定义的类型,还有一些隐藏的类型,比如,Method ValueClouse 的实现中,也都是用 struct 来表示的,这些属于不太容易被关注到的 “隐藏”类型。

Method Value 在逆向推导中,还是比较容易踩坑的,我们给 go 官方 debug 贡献了这块的实现,有兴趣的可以看 CL 419179

相比 Method Value 这种固定结构的,Closure 这种会更难搞一些,不过幸运的是,我们目前的使用过程中,还没有踩坑的经历。

还有吗

这种逆向推导要做到 100% 完备,还是挺难的,根本原因,还是 unsafe.pointer

reflect.Value 中也有 unsafe.pointer,据我所知,这个是还没有逆向推导实现的,类似的应该也还有其他未知的。

甚至,如果是标准库中的类型,我们还是可以一个个按需加上,如果是上层应用代码用到的 unsafe.pointer,那就很难搞了。

还有一种可能,推导不出来的原因,就是内存泄漏的来源,我们就碰到这样一个例子,以后有机会再分享~

也还好

幸运的是,如果是只是少量的对象没有推导出来,对于全局内存泄漏分析这种场景,通常影响其实也不大。

另外,对于一个对象,只需要有一个路径可以推导出来,也就够了。

也就是说,如果一条推导线索因为 unsafe.pointer 断了,如果另外有一个线索可以推导到这个对象,那也是不影响的。因为从 GC root 到一个 GC obj 的引用关系链,可能会不止一条。

最后

Go 虽然是静态类型语言,不过由于提供了 unsafe.pointer,给逆向类型推导带来了很大的麻烦。好在 Go 对于 unsafe.pointer 的使用还是比较克制,把标准库中常用到的 unsafe.pointer 搞定了,基本也够用了。

理论上来说,逆向推导这一套也适用于 C 语言,只不过 C 语言这种指针漫天飞的,动不动就来个强制类型转换,就很难搞了。

pprof 确实很好用,设计实现都很精巧,半年前写过一篇,go 语言 pprof heap profile 实现机制
用 pprof 来分析内存泄漏,通常情况下,是够用了,不过,有时候也不够用~
为啥呢,因为 pprof 只是记录了内存对象被创建时的调用栈,并没有引用关系。也就是说,没有办法知道,内存对象是因为被谁引用了,导致没有被释放。
对此,同事元总有一个很形象的比喻,pprof 只能看到出生证,却查不了暂住证。

需要引用关系

有些场景下,我们知道了泄漏的内存,是从哪里申请的,但是翻了半天代码,也搞不清楚内存为啥没有释放。
比如,内存对象经过复杂的调用传递,或者复杂的内存池复用机制,又或者传给了某个不熟悉第三方库,在第三方库中有非预期的使用 …
这些情况下,一个很直觉的想法是,想看看这些内存对象的引用关系

内存引用关系火焰图

内存引用关系火焰图,是一种内存对象引用关系的可视化方式,由春哥首创,最早应用于 OpenResty XRay 产品。这个工具确实是内存分析神器,给不少的客户定位过内存问题,感兴趣的可以移步 OpenResty 官方博客
下图是分析一个 MOSN 服务产生的,从下到上表示的是,从 GC root 到 GC object 的引用关系链,宽度表示的是对象大小(也包括其引用的对象的大小之和)
有了这样的可视化结果,我们可以直观的看到内存对象的引用关系。
图中可以看到,大部分的内存是,MOSN 中 cluster_manager 全局变量中引用的 cluster 内存对象:

内存引用关系火焰图

实现原理

在生成火焰图之前,首先我们需要提取两个关键信息:

  1. 每个内存对象之间的引用关系
  2. 每个内存对象的类型

引用关系

获取引用关系比较简单,首先,我们可以在 heap 中找到所有的 GC 对象。然后遍历所有的对象,再结合 bitmap 信息,获取这个对象引用的其他对象。
基本原理跟 GC mark 是类似的,不过实现上很不一样,因为这个是离线工具,可以简单粗暴的实现。

类型推导

Go 语言作为编译型静态语言,是不需要为每个内存对象存储类型信息的(有点例外的是 interface)。如果是动态类型语言,比如 Lua,则会方便很多,每个 GC 对象都存储了对象的类型。
所以,要获取每个对象的类型,还是比较麻烦的,也是投入时间最多的一块~
当然,也是有解决办法,简单来说就是做逆向类型推导,根据已知内存的类型信息,推导被引用的内存对象的类型信息。
这块还是比较复杂的,后面有空可以单独写一篇来分享~

生成过程

有了这两个关键信息之后,生成过程还是比较清晰的:

  1. 获取所有的内存对象,包括类型,大小,以及他们之间的引用关系,形成一个图
  2. 从 root 对象出发,按照层次遍历,形成一棵树(也就是剪枝过程,每个对象只能被引用一次)
  3. 将这棵树的完整引用关系,当做 backtrace dump 下来
    count 是当前节点的总大小(包括所有子节点),也就是火焰图上的宽度
  4. 从 bt 文件生成 svg,这一步是 brendangregg 的 FlameGraph 标准工具链

使用方式

这个工具是基于 go 官方的 debug 改进来的,不过鉴于 go 官方不那么热心维护 viewcore 了,MOSN 社区先 fork 了一份,搞了个 mosn 分支,作为 MOSN 社区维护的主分支。
待 go 官方先接受了我们之前提的 bugfix 之后,我们再去提交这个 feature。
所以,使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 编译 mosn 维护的 viewcore
git clone git@github.com:mosn/debug.git
cd debug/cmd/viewcore
go build .

# 假设已经有了一个 core 文件(CORE-FILE)
# 以及对应的可执行程序文件(BIN-FILE)
viewcore CORE-FILE --exe BIN-FILE objref ref.bt

# 下载 FlameGraph 工具
git clone git@github.com:brendangregg/FlameGraph.git
../FlameGraph/stackcollapse-stap.pl ref.bt | ../FlameGraph/flamegraph.pl > ref.svg

# 浏览器打开 ref.svg 即可看到火焰图

如果使用碰到问题,欢迎联系~
如果成功定位了某个问题,也欢迎反馈给我们,一起开心下的~

广告

如果觉得有意思,欢迎关注我的公众号~

微信公众号

真的很重要

接入层网关,承接了所有对外服务的入口流量,其重要程度不言而喻。稍不注意就出搞个故障,如果不幸赶上黑天鹅,全站故障,那就是整个公司都下线了。
从事网关开发这么些年,也经历过一些故障,全网下线的经历过两次,都是上市公司,故障时间小时级。这种级别的故障,半小时内就会惊动公司最高层,现场排查恢复的压力可能而知。而且,故障恢复之后,还有各种漫长的复盘和改进讨论。

一场故障搞下来耗时耗力,所以稳定性是第一位的。简单谈谈我的想法

bug 与 故障

首先,bug 并不等同于故障,bug 常有,而故障不常有。
对于一个复杂的软件,bug 是很难完全避免的。有时候,面对一个长期稳定运行的软件,人们容易误以为没有 bug 了,其实不然,可能只是规模还没到,还碰到触发 bug 的场景,亦或是,软件多个模块之间的配合,刚好能够让 bug 不容易出现而已,比如这张经典的图
刚好工作

当潮水褪去,才能发现原来 bug 一直在裸泳 …

虽然 bug 一直都有,但是大部分系统通常还是正常运行的,因为系统稳定运行并不要求完全没 bug,而只需要当前用到的场景下,没有 bug 也就够了。

实际上,我们认为很稳定的系统,可能就像以上面的图那样在运行着的,看起来很魔幻,可是现实世界有时候就是这么魔幻,且真实…

接下来,从两个视角来聊聊(仅仅是两个视角,并非特指两个工种)

开发视角

开发会认为,最重要的是,产出优秀的代码。
从软件架构,模块化设计,到测试用例的设计,甚至到编程语言的选择,等等,这些是产出优秀代码应该多关心的。

核心就一点,让代码尽量少 bug,就是开发对稳定性最大的贡献。

另外,即使故障发生时,开发视角最关心的是如何定位 bug,甚至来个 hotfix 直接修复上线就更好了。
所以,在故障发生时,最希望是能够保留现场,甚至可以上线 debug …

运维视角

运维视角最关心一个问题,为什么原来是正常的,现在就有问题了。

也就是说,运维并不关心 bug 是什么,只关心是什么触发了 bug,只要触发条件没了,那就能恢复正常。

以运维视角来看,变更是一切故障的起因,这里的变更,包括了软件版本更新,软件配置更新,软件使用场景的更新。
所以,运维会构建一套系统来追踪这些变更,当故障发生时,通过时间线来推导是哪个变更产生的,以便回滚恢复。

简单点说,故障发生的时候,运维第一反应是,看看哪个变更最可疑,回滚掉 …

变更三板斧

为了能否快速 & 精确的回滚,运维会有一整套完善的系统,从变更发布,到监控报警,甚至自动回滚止血。

其中,对于发布变更,最核心的就是变更三板斧:可灰度,可监控,可回滚。

  1. 可灰度
    一方面可以控制影响范围,另一方面也可以作为对比因素,用于故障到变更的的推导,比如故障的发生范围,跟变更的灰度范围是否一致。
  2. 可监控
    有两层含义,一是:如果产生了故障,至少可以知道;二来,变更产生了某些变化,也需要能被看到
  3. 可回滚
    这可是救命操作,如果确认是某个变更产生的故障,不能回滚,或者因为回滚产生另外的故障,那就是个大写的悲剧了。

接入层网关的变更

就接入层网关而言,很关键的一个点,配置变更的管理,同样需要三板斧。
因为软件版本更新的三板斧能力,通常在运维系统中就已经有很好的支撑了。

而以网关目前的演进状态来说,也就是抽离了控制面这个单独的概念,来控制网关的运行配置的状态,配置变更是很频繁的操作,而且可能会有多个的控制面来控制配置。这种情况下,配置变更产生故障的风险就高很多了。
也是目前演进状态中,可能容易不被重视的一个点,而且以常见的网关软件架构,也是比较难做到的一个点。

至于接入流量的变更,这种很难被纳入运维系统管理了(除了新接入业务流量),因为变更来源是来自外部,甚至可能就是突然来一波黑客攻击。
这种可能需要系统具备快速的流量拆分调度能力,能把“坏流量”摘出来,尽量减少影响面,也缩小问题范围。

黑天鹅

运维系统完善,三板斧做得好,可以大幅降低故障风险。不过,黑天鹅要来,谁也挡不住。

如果发生了大面积故障,一通操作猛如虎,一看还是没有恢复,能咋办呢?
这种时候,另外开一条支线,拉上开发直接 online debug,没准也能提供一些有用的线索,比如某些流量特征会导致故障,可以指导快速切分“坏流量”;甚至来个 hotfix,直接一把修复了。

当然,online debug 也不是好干的活,也需要一套的系统来支持,生产环境通常是最小化的,缺少很多开发依赖的 debug 工具。
那么,作为开发,紧急 online debug,我想可能需要的两类工具:

  1. 快速看到当前软件运行全景图的,比如 CPU 火焰图工具;这样可以有一个大致的定性认识
  2. 快速动态加 debug log,在某些关键路径上,动态打印一些信息;这样可以确认/排除某些原因,从而确认/缩小问题

这里必须得说,OpenResty XRay 在这方面是走得很前沿的商业产品
如果只论技术方案的话,基于 ebpf 的工具链,比如 bpftrace,个人感觉会比较有前途

保持敬畏之心

稳定性是系统的一部分,需要用系统性思维来解决,也没有银弹。

需要的是,保持敬畏之心,敬畏每一个环节,每一个视角;学习新技术,有思考,有总结,学而时习之~

前言

istio 文档有很多,但是多是面向使用者的角度,介绍了好多个新概念,对于开发者而言,不是很友好。
最近折腾了一番 istio,以开发者的视角,简单谈谈我的理解。

一句话介绍

Istio 是零散的网关配置的组装 & 推送通道

背景

在现代网关软件中,已经积累了大量的通用能力,比如:路由,限流,鉴权,日志,等等各式各样的能力。
而且这些能力并不是一成不变的,而是在不同业务场景下,需要有不同的策略调整。
所以,网关软件会通过暴露配置,来满足各种场景的定制需求。

以往常规的搞法是,搞个配置文件,网关软件启动之后,读取本地的配置文件,按照配置文件的规则来执行网关的各种逻辑。
比如,经典的 Nginx,就是提供了一套配置语法,用户将配置写在文件里。
但是,随着网关规模越来越大,配置越来越多,更新也越来越频繁,配置文件已经不太能满足需求了。(主要是,不太好做到动态局部更新)
所以,又单独搞了一个软件,负责管理这些配置,其主要用途就是将配置同步给网关软件。

这里,也就对应了两个概念:

  1. 控制面,提供配置的软件;本文主角 istio 就是代表
  2. 数据面,具体执行网关逻辑的软件;比如 Envoy,它通常是作为 Istio 的搭档出场。

干什么的

搞懂一个软件,核心是抓住输入 & 输出

  1. 输入:各种零散的网关配置
  2. 输出:Envoy 能理解的配置

这里引出两个层次概念:

Istio CRD

如上所述,在网关领域,有各种各样的配置,那么,在 Istio 这一层,则对这些配置进行了一层语义抽象。
按照 k8s 的套路,也就有了 CRD 这个概念,Custom Resource Description(k8s 把啥都抽象为了资源)。
(当然,Istio 也可以读取 k8s 里的中的标准资源,比如 Service,Endpoints 等)

具体是咋个抽象的呢?列举几个 CRD:

  1. Gateway:描述网关层,流量进来的端口,协议,域名之类的
  2. VirtualService:描述的虚拟服务,主要是路由规则,比如不同请求的转发处理策略
  3. Destination Rule:描述服务的流量策略,复杂均衡算法,连接池策略等
  4. Service Entry:描述服务,地址,端口,等

举个栗子,有这么个需求:http://example.com/v1.txt 随机转发到 1.1.1.1:802.2.2.2:80 这两个地址,需要拆分为四个资源:

  1. Gateway,定义一个网关,端口是 80,协议是 http,域名是 example.com
  2. Virtual Service,定义个虚拟服务,uri 是 /v1.txt 的转发到一个目标 xxx
  3. Destination Rule,指定 xxx 目标的复杂均衡策略:随机
  4. Service Entry,指定 xxx 服务的地址:1.1.1.1:802.2.2.2:80

这里不同种类的资源,也就是 Istio 的输入,零散的网关配置。
CRD 是面向使用的抽象,用户只需要将希望的效果,通过 CR 描述出来,具体的实现是不用关心的。

为啥要拆开成这么多资源呢?以我开发者的视角,主要还是为了方便局部动态更新,比如应用服务扩容了,只需要更新 Service Entry 就行了,其他的不用动。

xDS

在数据面,也就是 Envoy 这一层,并不是直接接受 CR 的,而是又提供了一套自己的抽象。
也就是 LDS,RDS,CDS,EDS 这些,统称为 xDS,这里就不详细展开了。

简单点来说,xDS 的抽象更贴近 Envoy 的实现细节,Istio CRD 更贴近用户描述。
xDS,既是 Envoy 的输入,也是 Istio 的输出。

工作流程

首先,基本模式是,Envoy 是向 Istio 订阅指定资源。

当 Istio 收到一个 xDS 订阅时(比如 LDS),大致有这么几个环节:

  1. 筛选当前订阅的 xDS,所需要用到的 CR
    这里的筛选条件还是挺复杂的,也不细说了。举一个简单的,用 Envoy 所属的 namespace 来筛选
  2. 组装 & 转换
    一个 xDS 可能会对应多个 CR,这个时候需要组装起来,转换为 xDS 格式
  3. 推送
    推送给 Envoy,如果后续某个 CR 有更新,也是这个推送流程

那么,Istio 用到的 CR 资源是从哪里来呢?
有多种方式,从 k8s API server 来,从 MCP server 来,从本地 watch 的文件来,等。

Istio 收到了 CR 之后,就全量存储在本地内存中(是的,就是这么简单粗暴)

  1. 收到了 Envoy 的订阅,就走上面的筛选 & 组装 & 推送流程
  2. 收到了 CR 更新,也是走类似的筛选 & 组装 & 推送流程

总结

打个比方,Istio 就是一个工厂,将原材料 CR,加工成为 xDS,也就是它的下游(Envoy)所需要的原材料。
不过呢,它对需要的原材料是有标准的,也就是 CRD 描述的标准。

最后,软件系统就像积木一样,是一层层搭建出来的,每一层都有抽象出一套自己的标准。
如果抽象得好,表达能力强,适用范围广,大家都遵循这套标准,那就成为了业界标准,也就有了更长远的生命力。
如果只是一个小范围流行,生命力就没那么强了,不是说一定活不下去,但是很难说得上绚烂。

最近在 hack viewcore,需要了解闭包的实现机制,来完成逆向的类型推导,所以搞了几个小例子,分析了闭包的实现机制,简单记录一下的。

运行时表示

在运行时,闭包是一个 GC 对象,可以用下面的结构来表示。

1
2
3
4
5
6
type closure struct {
pc func
arg1
arg2
...
}

pc 比较好理解,就是对应函数的入口地址。
arg1, arg2, … 则是闭包函数引用的上层局部变量。

如下示例:

1
2
3
4
5
func genClosure(a int, b int) func(int) bool {
return func(n int) bool {
return n*a-b > 0
}
}

生成的汇编如下,核心就是构造一个新的 GC 对象:

1
2
3
4
5
6
7
8
LEAQ runtime.rodata+58080(SB), AX   ## 0x1099b00 <_type.*+0xe2e0>
CALL runtime.newobject(SB)
LEAQ main.genClosure.func1(SB), CX
MOVQ CX, 0(AX)
MOVQ 0x20(SP), CX
MOVQ CX, 0x8(AX)
MOVQ 0x28(SP), CX
MOVQ CX, 0x10(AX)

此时生成的闭包则等效于这样的 struct 对象:

1
2
3
4
5
type closure struct {
pc func
a int
b int
}

调用过程

调用闭包,跟普通的函数调用基本类似,只是把闭包对象放到 DX 寄存器。

例如下面的示例:

1
2
f := genClosure(10, 100)
v := f(10)

生成如下汇编:

1
2
3
4
5
6
7
MOVL $0xa, AX
MOVL $0x64, BX
CALL main.genClosure(SB)
MOVQ 0(AX), CX
MOVQ AX, DX
MOVL $0xa, AX
CALL CX

我们可以看到:

  1. 先从闭包对象中取出函数入口地址,写入 CX 寄存器
  2. 将闭包对象写入 DX 寄存器
  3. CALL CX,调用闭包函数

总结

简单的来说,闭包的实现也比较简单,通过一个 GC 对象,将函数入口地址,以及引用的局部变量,都装进来,就是一个闭包对象了。
调用的时候,将闭包对象,作为函数的第四个参数,也就是使用 DX 寄存器传参。

去年刚学 go 语言的时候,写了这篇 cgo 实现机制,介绍了 cgo 的基本情况。
主要介绍的是 go=>c 这个调用方式,属于比较浅的层次。随着了解的深入,发现 c=>go 的复杂度又高了一级,所以有了这篇文章。

两个方向

首先,cgo 包含了两个方向,c=>go, go=>c

相对来说,go=>c 是更简单的,是在 go runtime 创建的线程中,调用执行 c 函数。对 go 调度器而言,调用 c 函数,就相当于系统调用。
执行环境还是在本线程,只是调用栈有切换,还多了一个函数调用的 ABI 对齐,对于 go runtime 依赖的 GMP 环境,都是现有的,并没有太大的区别。

c=>go 则复杂很多,是在一个 c 宿主创建的线程上,调用执行 go 函数。这意味着,需要在 c 线程中,准备好 go runtime 所需要的 GMP 环境,才能运行 go 函数。
以及,go 和 c 对于线程掌控的不同,主要是信号这块。所以,复杂度又高了一级。

GMP 从哪里来

首先简单解释一下,为什么需要 GMP,因为在 go 函数运行的时候,总是假设是运行在一个 goroutine 环境中,以及绑定有对应的 MP
比如,要申请内存的时候,则会先从 P 这一层 cache 的 span 中的获取,如果这些没有的话,go runtime 就没法运行了。

虽然 M 是线程,但是具体实现上,其实就是一个 M 的数据结构来表示,对于 c 创建的协程,获取的是 extra M,也就是单独的表示线程的 M 数据结构。

简单来说,c 线程需要获取的 GMP,就是三个数据对象。在具体的实现过程中,是分为两步来的:

  1. needm 获取一个 extra M

开启了 cgo 的情况下,go runtime 会预先创建好额外的 M,同时还会创建一个 goroutine,跟这个 M 绑定。
所以,获取到 M,也就同时得到了 G。

而且,go runtime 对于 M 并没有限制,可以认为是无限的,也就不存在获取不到 M 的情况。

  1. exitsyscall 获取 P

是的,这个就是 go=>c 的反向过程。
只是 P 资源是有限的,可能会出现抢不到 P 的情况,此时就得看调度机制了。

调度机制

简单情况下,MP 资源都顺利拿到了,这个 c 线程,就可以在 M 绑定的 goroutine 中运行指定的 go 函数了。
更进一步,如果 go 函数很简单,只是简单的做点纯 CPU 计算就结束了,那么这期间则不依赖 go 的调度了。

有两种情况,会发生调度:

exitsyscall 获取不到 P

此时没法继续执行了,只能:

  1. 将当前 extra M 上绑定的 g,放入全局 g 等待队列
  2. 将当前 c 线程挂起,等待 g 被唤起执行

在 g 被唤起执行的时候,因为 g 和 M 是绑定关系:

  1. 执行 g 的那个线程,会挂起,让出 P,唤起等待的 c 线程
  2. c 线程被唤起之后,拿到 P 继续执行

go 函数执行过程中发生了协程挂起

比如,go 函数中发起了网络调用,需要等待网络响应,按照之前介绍的文章,Goroutine 调度 - 网络调用
当前 g 会挂起,唤醒下一个 g,继续执行。

但是,因为 M 和 g 是绑定关系,此时会:

  1. g 放入等待队列
  2. 当前 c 线程被挂起,等待 g 被唤醒
  3. P 被释放

在 g 被唤醒的时候,此时肯定不是在原来的 c 线程上了

  1. 当前线程挂起,让出 P,唤醒等待的 c 线程
  2. c 线程被唤醒后,拿到 P,继续执行

直观来说,也就是在 c 线程上执行的 goroutine,并不像普通的 go 线程一样,参与 go runtime 的调度。
对于 go runtime 而言,协程中的网络任务,还是以非阻塞的方式在执行,只是对于 c 线程而言,则完全是以阻塞的方式来执行了。

为什么需要这样呢,还是因为线程的调用栈,只有一个,没有办法并发,需要把线程挂起,保护好调用栈。

PS:这里的执行流程,其实跟上面抢不到 P 的流程,很类似,底层也是同一套函数在跑(核心还是 schedule)。

信号处理

另外一大差异是,信号处理。

  1. c 语言世界里,把信号处理的权利/责任,完全交给用户了。
  2. go 语言,则在 runtime 做了一层处理。

比如,一个具体的问题,当程序运行过程中,发生了 segfault 信号,此时是应该由 go 来处理,还是 c 来响应信号呢?
答案是,看发生 segfault 时的上下文,

  1. 如果正在运行 go 代码,则交给 go runtime 来处理
  2. 如果正在运行 c 代码,则还是 c 来响应

那具体是怎么实现的呢?
信号处理还是比较复杂的,有比较多的细节,这里我们只介绍几个核心点。

sighandler 注册

首先,对于操作系统而言,同一个信号,只能有一个 handler。
再看 go 和 c 发生 sighandler 注册的时机:

  1. go 编译产生的 so 文件,被加载的时候,会注册 sighandler(仅针对 go 需要用的信号),并且会把原始的 sighandler 保存下来。
  2. c 可以在任意的时间,注册 sighandler,可以是任意的信号。

所以,推荐的做法是,在加载 go so 之前,c 先完成信号注册,在 go so 加载之后,不要再注册 sighandler 了,避免覆盖 go 注册 sighandler。

信号处理

对于最简单的情况,如果一个信号,只有 c 注册了 sighandler,那么还是按照常规 c 信号处理的方式来。

对于 sigfault 这种,go 也注册了 sighandler 的信号,按照这个流程来:

  1. 操作系统触发信号时,会调用 go 注册的 sighandler(最佳实践中,go 的信号注册在后面),
  2. go sighandler 先判断是否在 c 上下文中(简单的理解,也就是没有 g,实际上还是挺复杂的),
  3. 如果,在 c 上下文中,会调用之前保存的原始 sighandler(没有原始的 sighandler,则会临时恢复 signal 配置,重新触发信号),
  4. 如果,在 go 上下文中,则会执行普通的信号处理流程。

其中,2 和 3 是最复杂的,因为 cgo 包含了两个方向,以及信号还有 sigmask 等等额外的因素,所以这里细节是非常多的,不过思路方向还是比较清晰的。

优化

上篇 cgo 实现机制,提过优化一些思路,不过主要针对 go => c 这个方向。
因为 c => go 的场景中,还有其他更重要的优化点。

复用 extra M

通常情况下,最大的性能消耗点在 获取/释放 M

  1. 上面提到,从 c 进入 go,需要通过 needm 来获取 M
    这期间有 5 个信号相关的系统调用。比如:避免死锁用的,临时屏蔽所有信号,以及开启 go 所需要的信号。
  2. 从 go 返回 c 的时候,通过 dropm 来释放 M
    这期间有 3 个信号相关的系统调用。目的是恢复到 needm 之前的信号状态(因为 needm 强制开启了 go 必须的信号)。

这两个操作,在 MOSN 新的 MOE 架构的测试中,可以看到约占整体 2~5% 的 CPU 占用,还是比较可观的。

了解了瓶颈之后,也就成功了一半。

优化思路也很直观,第一次从 go 返回 c 的时候,不释放 extra M,继续留着使用,下一次从 c 进入 go 也就不需要再获取 extra M 了。
因为 extra M 资源是无限的,c 线程一直占用一个 extra M 也无所谓。不过,在 c 线程退出的时候,还是需要释放 extra M,避免泄漏。
所以,这个优化,在 windows 就不能启用了,因为 windows 的 pthread API 没有线程退出的 callback 机制。

目前实现了一版在 CL 392854
虽然通过了一个大佬的初步 review,以及跑通了全部测试,不过,估计要合并还要很久。。。
因为这个 PR 已经比较大了,被标记 L size 了,这种 CL 估计大佬们 review 起来也头大。。。

在简单场景的测试中,单次 c => go 的调用,从 ~1600ns 优化到了 ~140ns,提升 10 倍,达到了接近 go => c 的水平(~80ns),效果还是挺明显的。

实现上主要有两个较复杂的点:

  1. 接受到信号时,判断在哪个上下文里,以及是否应该转发给 c。
    因为 cgo 有两个方向,而且这两个方向又是可以在一个调用栈中同时发生的,以及信号还有 mask,系统默认 handler 之分。
    这里面已经不是简单的状态机可以描述的,go runtime 在这块有约 100+ 行的核心判断代码,以应对各式各样的用法。
    估计没几个人可以全部记住,只有碰到具体场景临时去分析。或者在跑测试用例失败的时候,才具体去分析。

  2. 在 c 线程退出,callback 到 go 的时候,涉及到 c 和 go function call ABI 对齐。
    这里主要的复杂度在于,需要处理好不同的 CPU 体系结构,以及操作系统上的差异。所以工作量还是比较大的。比如 arm,arm64,
    期间有一个有意思的坑,Aarch64 的 stack pointer 必须是 16 byte 对齐的,否则会触发 bus error 信号。
    (也因此 arm64 的压栈/出栈指令,都是两个两个操作的)

获取不到 P

从 c 进入 go,获取 GMP 的过程中,只有 P 资源是受限的,在负载较高时,获取不到 P 也是比较容易碰到的。

当获取不到 P 时,c 线程会挂起,等待进入全局队列的 g 被唤醒。
这个过程对于 go runtime 而言是比较合理的,但是对于 c 线程则比较危险,尤其当 c 线程中跑的是多路复用的逻辑,则影响更大了。

此时有两个优化思路:

  1. 类似 extra M,再给 c 线程绑一个 extra P,或者预先绑定一个 P。这样 c 线程就不需要被挂起了。
    这个思路,最大的挑战在于 extra P,是不受常规 P 数量的限制,对于 go 中 P 的定义,是一个不小的挑战。

  2. g 不放入全局队列,改为放到优先级更高的 P.runnext,这样 g 可以被快速的调度到,c 线程可以等待的时间更短了。
    这个思路,最大的挑战则在于,对这个 g 加了优先级的判断,或许有一点有悖于 g 应该是平等的原则。
    不过应该也还好,P.runnext 本来也是为了应对某些需要优先的场景的,这里只是多了一个场景。

这个优化方向,还没有 CL,不过我们有同学在搞了。

尽快释放 P

当从 go 返回 c 的时候,会调用 entersyscall,具体是,MP 并没有完全解除绑定,而是让 P 进入 syscall 的状态。

接下来,会有两种情况:

  1. 很快又有了下一个 c => go 调用,则直接用这个 P,
  2. sysmon 会强制解除绑定。对于进入 syscall 的 P,sysmon 会等 20 us => 10 ms,然后将 P 抢走释放掉。
    等待时间跨度还是挺大的,具体多久就看命了,主要看 sysmon 是否之前已经长时间空闲了。

对于 go => c 这方向,一个 syscall 的等待时间,通常是比较小的,所以这套机制是合适的。
但是对于 c => go 这个方向,这种伪 syscall 的等待时间,取决于两个 c => go 调用的间隔时间,其实不太有规律的。
所以,可能会造成 P 资源被浪费 20us => 10ms。

所以,又有一个优化方向,两个思路:

  1. 从 go 返回 c 的时候,立即释放 P,这样不会浪费 P 资源。
  2. 调整下 sysmon,针对这种场景,有一种机制,能尽量在 20 us 就把 P 抢走。

其中,思路 1,这个 CL 411034 里顺便实现了。
这个本来是为了修复 go trace 在 cgo 场景下不能用的 bug,改到这个点,是因为跟 Michael 大佬讨论,引发的一个改动(一开始还没有意识到是一个优化)。

总结

不知道看到这里,你是否一样觉得,c => go 比 go => c 的复杂度又高了一级。反正我是有的。

首先,c 线程得拿到 GMP 才能运行 go 函数,然后,c 线程上的 g 发生了协程调度事件的时候,调度策略又跟普通的 go 线程不一样。
另外一个大坑则是信号处理,在 go runtime 接管了 sighandler 之后,我们还需要让 c 线程之前注册的 sighandler 一样有效,使 c 线程感觉不到被 go runtime 接管了一道。

优化这块,相对来说,比较好理解一些,主要是涉及到 go 目前的实现方式,并没有太多底层原理上的改进。
复用 extra M 属于降低 CPU 开销;P 相关的获取和释放,则更多涉及到延时类的优化(如果搞了 extra P,则也会有 CPU 的优化效果)。

最后

最后吐个槽,其实目前的实现方案中,从 c 调用 go 的场景,go runtime 的调度策略,更多是考虑 go 这一侧,比如 goroutine 和 P 不能被阻塞。
但是,对 c 线程其实是很不友好的,只要涉及到等待,就会把 c 线程挂起…

因为 go 的并发模型中,线程挂起通常是可以接受的,但是对于宿主 c 线程而言,有时候被阻塞挂起则是很敏感的。
比如,在 MOSN 的 MOE 架构中,对于这类可能导致 c 线程被挂起的行为,必须非常的小心。

那有没有办法改变呢,也是有的,只是改动相对要大一点,大体思路是,将 c 调用 go 的 API 异步化:

1
2
g = GoFunc(a, b)
printf("g.status: %d, g.result: %d\n", g.status, g.result)

意思是,调用 Go 函数,不再同步返回函数返回值,而是返回一个带状态 g,这样的好处是,因为 API 异步了,所以执行的时候,也不必同步等待 g 返回了。
如果碰到 g 被挂起了,直接返回 status = yield 的 g 即可,goroutine 协程继续走 go runtime 的调度,c 线程也不必挂起等待了。

这样的设计,对于 c 线程是最友好的,当然也还得有一些配套的改动,比如缺少 P 的时候,得有个 extra P 更好一些,等其他的细节。
不过呢,这样子的改动呢,还是比较大的,让 go 官方接受这种设计,应该还是比较难度的,以后没准可以试试,万一接受了呢~

前言

早听说 go 语言有完善的调试分析工具:pprof 和 trace。

  1. pprof 之前接触过,有了基本的了解。以我的理解,pprof 主要适用于 CPU,内存,这种资源使用的分析,属于资源使用视角。
  2. trace 最近才有了一些了解,以我的理解,trace 属于行为视角,记录的程序运行过程中的关键行为。

最近碰到一个 cgo 场景下,trace 不可用的问题,具体跟这个 issue #29707 描述的一样,解析 trace 文件的时候失败了。
因此了解了一下 trace 的实现机制,整体感觉下来,确实很牛,很精巧。

经过一番苦战,给 go 贡献了这个 CL 411034 ,以及有了这篇文章。

Trace 是干嘛的

之前看过一些文章,介绍如何使用 trace,看完之后还是一头雾水 …
了解了实现机制之后,则容易理解多了。

Go trace 跟其他的应用服务中的 trace 是类似的,比如现在很流行的分布式调用 trace,理念上是一样的,只是追踪的对象不一样。
分布式 trace,追踪的是请求的处理流程;go trace 追踪的是 go runtime 感知到的关键运行流程,比如 goroutine 执行流程,GC 执行流程,等。

Go trace 实现,主要是两个环节:

  1. 程序运行时,将一些关键的 event 记录下来,生成一个 trace 文件。
  2. 解析 trace 文件,用于分析程序的运行过程。

记录 event

trace 主要记录的是 goroutine 的一些关键状态变化事件,还有 GC 的状态变化事件,等等,一共有 48 个事件。

贴一部分出来,感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
traceEvProcStart    = 5  // start of P
traceEvProcStop = 6 // stop of P
traceEvGCStart = 7 // GC start
traceEvGCDone = 8 // GC done
traceEvGCSTWStart = 9 // GC STW start
traceEvGCSTWDone = 10 // GC STW done
traceEvGCSweepStart = 11 // GC sweep start
traceEvGCSweepDone = 12 // GC sweep done
traceEvGoCreate = 13 // goroutine creation
traceEvGoStart = 14 // goroutine starts running
traceEvGoEnd = 15 // goroutine ends
traceEvGoStop = 16 // goroutine stops (like in select{})
traceEvGoSched = 17 // goroutine calls Gosched
traceEvGoPreempt = 18 // goroutine is preempted
traceEvGoSleep = 19 // goroutine calls Sleep
traceEvGoBlock = 20 // goroutine blocks
traceEvGoUnblock = 21 // goroutine is unblocked

总体来而,包括了 goroutine 从开始到结束,以及 GC 从开始到结束,这类重要流程中的关键节点。

具体实现上而言,以 goroutine 创建为例,核心代码代码如下:

1
2
3
4
5
6
7
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
// ...
if trace.enabled {
traceGoCreate(newg, newg.startpc)
}
// ...
}

意思是,创建协程的时候,如果 trace 是开启的,则记录一个 traceEvGoCreate = 13 的 event。

每个 event 可能还有一些不同的参数,大部分都有 timestamp,有些还有当时的调用栈。
比如,协程创建的 event,则包含了这么几个参数 [timestamp, new goroutine id, new stack id, stack id]
时间戳,新创建的 goroutine id,新协程的调用栈,当前协程的调用栈(调用栈都用一个数字 ID 表示,具体的栈内容另外存,以减少存储空间)。

记录 trace event,发生在被追踪程序运行时,所以需要尽量少的开销(虽然开销肯定是不小的,通常百分之几十的性能损耗)。
所以这期间,尽量只做必要的记录,剩下的事情,被放到了之后的阶段。

解析 trace 文件

拿到了原始的 trace 文件,还是需要做一些预处理的,以便后续的分析。这次碰到的 bug,就是发生在这个阶段。

其中预处理有一个很重要的环节,就是做 goroutine 的 event 进行逻辑上的排序。
逻辑上的排序,也就是按照 goroutine event 执行的逻辑先后关系排序(比如先创建了,然后才能执行),而不是简单的通过时间戳来排序。
因为 go 是多线程并发的,同一个 g 也可能切换在不同的 M/P 上去执行,并且不同的线程上产生的时间不一定可靠,所以不能简单依赖时间戳来排序。

解析完毕之后,我们可以得到每个 goroutine 的完整执行过程,从出生到死亡,包括了中间各种的状态变化,比如进/出系统调用,以及各种被阻塞的事件。

分析运行过程

有了完整的执行过程,要怎么分析,其实是严格限定的,只是 go 语言大佬们,提供一些常用的分析方式。

比如:

  1. View trace:查看所有 goroutine 的执行流,全局视图
  2. Goroutine analysis:主要是查看某个 goroutine 执行情况
  3. Network blocking profile:专门分析网络阻塞
  4. Synchronization blocking profile:专门分析同步阻塞
  5. Syscall blocking profile:专门分析系统调用阻塞
  6. Scheduler latency profile:专门分析调度延迟

这些是在解析 trace 文件之后,有对应的分析逻辑,来提供一套可视化的界面数据用于分析。
如果有必要,也可以自己实现一个适用的分析逻辑/界面。

具体每一个的用法,我也不太熟,不过好在有不少的分享文章了,懂了底层原理之后,去看这种分享使用的文章,就更容易理解了。

动态开启

因为开启 trace 记录 event 有比较大的开销,所以 trace 也不会一直开着,偶尔有棘手的问题需要调试的时候,才会动态开启。

在这方面,go 做得还是挺方便的,如果启用了 net/http/pprof,可以直接动态开启得到一个 trace 文件,比如:

1
curl http://127.0.0.1:6060/debug/pprof/trace?seconds=10

具体实现上来说,有两个关键点:

  1. 先来一个 STW,遍历当前所有的 goroutine,产生对应的前置 event,把 goroutine 的创建,到目前状态所依赖的 event 给补上(这样保证每个 goroutine 都有出生的 event)
  2. 然后,标记 trace.enabled = true,此后各个 hook 点的判断条件 if trace.enabled,就开始生效了,陆续开始生产 event 了。

是的,即使没有开启 trace,hook 点的条件判断还是会跑的,只是由于 CPU 的分支预测,这点开销基本是可以忽略不计的了。
个人拍脑袋估计,即使去掉了这个判断条件,万分之一的提升都不一定有。go 在这种便利性和微弱的性能提升之间的取舍,我还是蛮喜欢的。
go 中可以看到类似很多的这种取舍,比如在 c 中会通过宏开启的 assert 检查,go 直接就给默认开启了。(不过 go 也没有宏 …)

PS:这次的 bug,就是因为 cgo 中,extra M 上的 g,缺少了一些前置的 event,导致 goroutine event 的排序没法完成。

最后

了解一番之后,go trace 实现机制其实也简单,就是将 go 程序中的关键事件给记录下来,用于事后的分析。
这个角度看,跟我们日常用的打印日志调试也没有太大区别。

不过 trace 厉害的点在于它的完备性,便捷性,针对 go 语言层面可以感知的(比如 goroutine,GC)记录的信息是完备的,因此还可以进行逻辑推理。
也就是把大佬们的知识/经验,沉淀到这套 trace 系统里了(这个点上,跟平常业务系统用的 trace 又有异曲同工之妙)。
不需要平常临时加日志那样,少了一些日志还需要再加一轮 …

另外呢,也因为关键事件全部都记录下来了,信息量还是很大的,由此可以衍生出的分析方法也是非常之多的。
等哪天有机会体会了一把,或许也可以再来分享一些体会。

本博的微信公众号,欢迎扫描订阅 >_<

微信公众号

约一年前,开了这个博客,原也想过微信公众号,不过也有点担心自己坚持不了,也不想给自己啥更新的压力,然后就没开了。

现在看来,保持得还不错,所以打算把微信公众号搞起来,估计每月一两篇的节奏,记录一些自己的学习心得之类的,欢迎关注。

写作的目的

为啥想写文章呢

  1. 让自己的体会更深,更系统化
    工作中的学习,通常都是目的性比较强的,为了解决某个具体问题,了解个大概,知道怎么用,就也够用了。
    写文章呢,相对来说会更系统化一些,也会促使了解底层的原理机制。
    所以,写文章可以让自己有更好的沉淀,有更深的认识。

  2. 好记性不如烂笔头
    有些知识当时记得挺清楚,过个一年半载就模糊了,把自己的文章翻出来看看,也是挺有用的,可以快速捡起来。

  3. 期望得到一些正反馈
    自己的学习心得,总结成文,如果对他人也有些用处,可以得到一些认可,那也是很好的正反馈,可以大大提高写作动力。
    如果能有幸产生一些交流,纠正错误 / 加深理解,那就更美了,毕竟搞技术大多时候是孤独的,有人同道中人聊上几句是很难得的美事。

为啥微信公众号

去年一开始的时候,写的文章是发在思否,这种专业社区网站确实流量大,随便一篇文章都是上千的阅读。
如果赶上编辑能推荐上个首页,大几千的阅读也是有的。

不过,不是太喜欢这种模式,一是功利心变强,会比较多的分心去关注阅读量;二是平台的操纵感比较强,获得编辑推荐,阅读量就会更可观。
最后,感觉这些阅读量也没啥意义,虽然数字有几千,但是也没啥人留言互动,没啥意思,就只在自己的博客站上发了。

后来,在博客上写文章,自认为写得不错的,就分享到微信朋友圈,也有一些阅读量,朋友圈还能收到一些赞,感觉也挺好的了。

希望开了公众号之后,能减少在朋友圈的自我吆喝,能收获一些阅读/点赞,就挺好的了~

如果你对我的文章有兴趣,欢迎订阅,更欢迎交流,唠嗑~

旧印象

对于 c++,一直以来的感觉是,就像现在的邻居,明明经常能见到,还很熟一样的打打招呼,但是对他的底细却一点也不清楚,能看到他每天也去上班,连他干啥的也不清楚。

大学的入门编程语言是 c,虽也曾看过一点 c++ 的书,但是也不得要领,留下了一个 带类的 c 语言,这么个初步印象。

工作之后,写过一些 c 代码,对 c 还算得上有一些了解,还看过一些 c 和 c++ 的语言之争,留下一个 c++ 非常复杂,让人望而却步的印象。

缘起

近来要搞 Envoy,需要用到 c++,开始认真的学习 c++,目前有了一些体会,准备写几篇记录一下,加深下理解。

目前计划的有:

  1. 智能指针,也就是本文
  2. 变量引用
  3. 并发冲突

一句话解释

智能指针就是,基于引用计数实现的,自动垃圾回收。

垃圾回收

现代语言的演进,有一个方向就是,降低使用者的心智负担,把复杂留给编译器。
其中,自动垃圾回收(GC),就是其中一个进展良好的细分技术。

比如 c,作为一个 “古老” 的语言,提供了直接面向硬件资源的抽象,内存是要自己管理的,申请释放都完全由自己控制。

然,很多后辈语言,都开始提供了自动垃圾回收,比如,我之前用得比较用的 Lua,前一阵学的 go 语言,都是有 GC 加持的。
有了 GC 加持,确实很大程度的较低了程序员的心智负担,谁用谁知道。

既然效果这么好,那为什么不是每个语言都提供呢?

因为 GC 要实现好了,是挺复杂的。
想想 Java 的 GC 都演进了多少代了,多少牛人的聪明才智投入其中,没一定的复杂度,是对不起观众的。

在 GC 实现方式里,有两个主要方案:

标记清除

这个方案里,最简单的实现里,会将程序运行时,简单分为两种状态:

  1. 正常执行代码逻辑状态,期间会申请各种需要的 GC 对象(内存块)
  2. GC 状态,期间会扫描所有的 GC 对象,如果一个对象没有引用了,那就是 了,会被清除掉(释放)。

这个方案,有一个弊端,GC 状态的时候,正常的代码逻辑就没法跑了,处于世界停止的状态 STW

虽然,有很多的优化手段,可以将这个 GC 状态,并发执行,或者将其打散,拆分为很多的小段,使得 STW 时间更多。
比如有 Go,Java 这种有多线程的,可以有并发的线程来执行 GC;Lua 这种单线程的,也会将标记拆分为分步执行。
甚至,Java 还有分代 GC 等优化技术,减少扫描标记的消耗。

但是,终于是有一些很难绕过去的点,GC 过程中还是需要一些 STW 来完成一些状态的同步。
且,GC 终究是一个较大的批处理任务,即使并发 + 打散,对于运行的程序而言,始终是一些 额外 的负担。

GC 对于实时在线提供服务的系统而言,就是不确定的突发任务来源,搞不好就来个波动,造成业务系统的突发毛刺。

引用计数

简单来说,每个 GC 对象,都有一个计数器,被引用的数量。

如下示例中,第一行,new Foo() 创建了一个对象,f 则是引用了这个对象,此时对象的引用技术器为 1
当前作用域结束之后,f 作为局部变量,也到了生命的尽头,对象也少了这种引用,引用计数为 0
如果有 GC 加持的话,这里新建的对象,也就会被自动释放了。

1
2
3
4
{
Foo *f = new Foo();
...
}

引用计数器的变化,是混在正常的逻辑代码执行流中的,天然就是打散的。
引用计数,可以很好的解决上一个方案的问题。

只不过,引用计数有个致命的弱点,对于循环引用无解,所以,通常使用引用计数的语言,也会混合使用标记清除办法,解决循环引用的问题。

智能指针

c++ 提供了 share_ptr, unique_ptrweak_ptr 这三种智能指针。

shared_ptr 就是带了引用计数的指针,所以上面的示例代码中,在 c++ 的真实实现就是:

1
2
3
4
{
Foo *f = std::make_shared<Foo>();
... // using f, even passing to another function.
}

另外,为了解决循环引用的问题,又提供了 weak_ptr,被弱引用的 share_ptr 计数器不会 +1。

至于什么时候需要用 weak_ptr,这个锅又甩给使用者了,如果不小心写出了引用循环,那也是程序员的锅。

至于,unique_ptr 更像是针对一种常用的使用情况的定制,优化。

实现机制

比如这个示例:

1
auto f = std::make_shared<Foo>();

简单来说,分为这两层:

1
share_ptr => _Sp_counted_base

通过 make_shared 构造出来的智能指针,实际是一个 shared_ptr 对象,这个对象基本就是个空壳,指向内部的 _Sp_counted_base 对象。
_Sp_counted_base 对象则包含了:

  1. use_count 计数器
  2. weak_count 计数器
  3. 实际 Foo 对象的指针

shared_ptr 对象拷贝的时候,会让 _Sp_counted_base->use_count + 1,析构的时候,会让 _Sp_counted_base->use_count - 1
_Sp_counted_base 会在 use_count = 0 时,销毁 Foo 对象。

类似的,weak_ptr 则影响的是 weak_count,当 use_count = 0 && weak_count = 0 时,_Sp_counted_base 自身会被析构销毁。
这也就保证了 weak_ptr 不会访问野指针。

通过汇编代码,我们也可以看到 ~shared_ptr() 这个关键的析构调用,这是智能指针操作计数器的精髓。

1
2
3
40a0bf:       e8 68 02 00 00          call   40a32c <std::shared_ptr<Foo> std::make_shared<Foo>()>
...
40a0f2: e8 9d 01 00 00 call 40a294 <std::shared_ptr<Foo>::~shared_ptr()>

总结

个人感觉,c++ 的智能指针设计,还是很精巧的。
利用了局部对象的自动析构,自定义拷贝函数,析构函数,等等隐藏了很多的细节,使用体验上也很大程度的接近于自动 GC,确实能很大程度的降低程序员在这方面的心智负担。

不过,也是有些坑需要避免的,比如循环引用。我们多了解一些内部实现机制,则可以更好的用好智能指针,尽量少踩坑。

前言

从 c 到可执行文件,包含了 编译链接 这两步。通常在编译构建 c 项目的时候,也可以在 make 的过程中,看到 编译链接 这种中间步骤。

然而,go 在这方面,有更进一步的封装,直接跑 go build 就行了,也不知道背后干了个啥。

最近因为搞 cgo 的优化,需要了解这里面的过程,记录一下的。

编译过程

底层还是分为 编译链接 这两步,go build 可以类比为 go 标准的 make 工具。

对于 go 编译器而言,go 是提供给用户的统一的命令,实际上它还包含了很多其他的执行程序,比如 compile, asm, cgo, link 等。

go build 的执行过程,跟常见的 make 是类似的,大致有这么些事情:

  1. 调用 compile 将 go 文件(以及依赖文件)编译为 .a 文件
  2. 注意,这一步也是有缓存的,原文件没变更,则直接 copy .a 文件
  3. 如果期间有 .s 文件,则用 asm 来编译
  4. 如果有 import C,则调用 cgo 先生成一段 go 文件
  5. 最后,通过 link 链接成最终的可执行文件

如果想看具体的编译过程,可以指定 -x,比如:

1
2
# -work 表示保留编译时的临时目录
go build -x -work .

值得一提的是,go test 也是先 build 生成一个,封装了测试框架的可执行文件,所以,build 的参数也同样可用。
比如:

1
go test -x -work

PS: go test 默认编译出来的是不带调试符号的,如果需要调试,可以加上 -o file 指定可执行文件,这样可以启用调试符号。
(貌似这个在 MacOS 上并不有效,还是 Linux 上可靠 :<)

自举过程

go 语言是完成自举了的,自举大致过程:

首先需要一个老版本的 go,1.4+,先用老的 go,编译 cmd/dist,生成 dist 可执行文件;再用这个 dist 来完成新版本 go 的编译。

在输出日志中,可以看到一下主要步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 老的 go 编译 cmd/dist 
Building Go cmd/dist using /path/to/old/go. (go1.14.15 linux/amd64)
# 接下来的几步,都是 cmd/dist 来执行的
# 老的 go,编译新代码的工具链,compile, asm, link, cgo
Building Go toolchain1 using /path/to/old/go.
# 新工具链,编译 go 命令,这里叫 go_bootstrap
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
# 新的 go_bootstrap,重新编译工具链,以为 toolchain1 没有 buildid
Building Go toolchain2 using go_bootstrap and Go toolchain1.
# 再来一回,还是因为 buildid,为了更加一致
Building Go toolchain3 using go_bootstrap and Go toolchain2.
# 使用 go_bootstrap,编译完整的 go
Building packages and commands for linux/amd64.

简单解释一下:

  1. dist 只是一个封装的临时,编译的时候,还是用的 compile, link 这种编译工具(也就是 toolchain)
  2. 先用老的 go 重新编译新的 toolchain,然后再用新的 toolchain 编译新的 go(中间有一些重复编译 toolchain,不是那么重要)

如果想看具体的编译过程,可以指定 -v=5,比如:

1
bash -x all.bash -v=5

总结

go 对编译构建工具都提供了完整的封装,这个对于使用者而言,确实是更方便了,不需要自己折腾 Makefile,或者 bazel 这种构建工具了。

其具体过程,则跟常见的构建工具是类似的,分开编译,中间结果缓存,最后链接。

平常开发倒是用不着了解这些,不过要是修改 go 本身的话,对这些过程还是得比较清楚才行了;尤其是跑测试的时候,dist 命令中的 test,又是封装了一层编译的测试过程,这里面一环套一环的,还是比较多细节的。