0%

最近在 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,又是封装了一层编译的测试过程,这里面一环套一环的,还是比较多细节的。

go 提供了很多的内存指标,多了就容易分不清楚,本文解读几个容易让人迷糊的指标。

内存管理机制

磨刀不误砍柴工,我们先了解下 go 如何管理内存的。

首先,go 是 runtime 自己管理内存的,并没有依赖 libc 这种内存分配器。
具体而言,就是 runtime 自己直接用 mmap 从操作系统申请内存,供自己使用,没有中间商赚差价。

不过,也只是这种中间商角色,go runtime 自己来干了。
因为从操作系统申请内存,都是按页来申请,go 中的内存对象可以是任意大小的。
所以,go runtime 搞了个 mheap 数据结构,来维护所有申请到的内存,还搞了 mcentral, mcache, span 好多级来管理。

为了分配不同大小的内存对象,go 分了 67 个级别的 span,每个级别的 span 对应一个 size,可以理解为这个 size 的内存池。
分配内存的时候,从 size 最小且合适的 span 中分配一个出来。
是的,这种搞法是会浪费一些内存,不过可以比较好的解决,内存碎片的问题。

其他细节这里就先不展开了,简单而言,通常 C 程序用的 libc 中的内存分配器的活,go runtime 也自己干了。

与操作系统交互

当现有的 span 没有空闲的内存块时,go runtime 会通过 mmap 从操作系统申请内存。

但是,有意思的是,go runtime 释放内存的时候,并不是用 munmap,而是用的 madvise
其中区别是,munmap 是将整个地址空间都还给系统,madvise 并不会将地址空间归还,而只是给系统一个建议(advise),说这个地址空间对应的物理内存,go runtime 暂时不用了,系统可以释放了,但是虚拟地址空间还是留着的。
下一次 go runtime 需要的时候,再通过 madvise 让系统为这个地址空间准备物理内存。

这里有个小插曲,go 1.12 版本,将 madvise 的参数,也就是给系统的建议,从默认 MADV_DONTNEED 改成了默认 MADV_FREE
比起 MADV_DONTNEEDMADV_FREE 会更激进一些,操作系统并不会立马释放物理内存,而只是在物理内存比较紧张的时候,再真的释放。
这么改的目的是优化,可以减少下一次需要的时候,会触发的 pagefault 行为。

只是,这个副作用也比较明显的,没有释放的物理内存还是归属于 go 程序,所以 RSS 也算在 go 程序的头上,这个对于指标理解会造成很大的困扰,也更容易造成 OOM

所以,后面又改回了默认 MADV_FREE,具体可见这个 CL:https://go-review.googlesource.com/c/go/+/267100/

内存指标

回到主题,go runtime 提供了很多的内存指标,文档也写得挺清楚的。
https://pkg.go.dev/runtime#MemStats

选几个常用的指标,做些个人直白解读:

HeapReleased

已经释放给系统,只保留了地址空间的内存大小

HeapIdle

处于空闲状态的 span 的内存大小

注意,这里包含了 HeapReleased
HeapIdle - HeapReleased,就是 go runtime 目前预留的空闲内存池,
分配内存的时候,会优先从这里找合适的。

HeapInuse

处于被使用的 span 的内存大小

需要注意的是,如果 span 里有一个在使用的 GC object,整个 span 也处于 inuse 状态。
对象 Inused,包括:真实 alive(被 GC root 引用);新申请还没有被 markmark 标记死亡,但是还没有 sweep 掉。

HeapSys

总共从系统申请的内存地址大小
注意并不是 RSS,有两种可能使得这个值与 RSS 不一样:

  1. 申请了地址空间,但是没有读写,系统并不会分配真实物理内存
  2. 物理内存释放给系统了,但是地址空间还是保留的,也就是 HeapReleased.

当然,还有不在 heap 管理的 RSS

HeapAlloc

GC 管理的对象的大小

注意,是 object 粒度,不是 spanHeapInuse - HeapAlloc,也就是 Inused span 中还剩余的空间内存块。
这个语义跟 Inused 类似,除了包含活对象,还包括 mark 标记死亡,但是还没 sweep 调用的。
不过,如果 sweep 掉了,也就不算在内了,即使内存还没有还给系统。

StackInuse

被当做 stack 正在使用的 span 的大小

没有 StackIdle,因为 stackheap,其实都是通过 mheap 那一套机制管理的,Idle 的 span,都算在 HeapIdle 里面了。

总结

go 舍弃了 libc 这类内存分配管理的中间商,自己重新撸一套,确实可以看有一些新花样出来(不只提到的这些,还有很多新花样绑定在 go 自己的内存分配管理实现上了),并不是简单的重复造轮子。

至于指标的含义,确实是指标太多了,每个都记住了估计是很难的。
个人的做法,理解清楚原理,搞清楚几个常用的,没记住的,用的时候再翻文档吧。

最近学习了 go 语言内置的 heap profile 实现机制,就像一个精致机械手表。

问题

首先,go 语言有自动的垃圾回收,已经很大程度的降低了程序员的心智负担。
需要的时候,直接申请使用即可,不需要手动的显示释放内存。
不过呢,也还是会经常碰到内存泄漏,内存占用高的问题。

作为内存分析工具,主要是需要解释两个问题:

  1. 内存从哪里申请来的
  2. 又是为什么没有被释放

go 内置的 pprof heap profile 则可以很好的回答第一个问题。

ps: 问题二,通常可以根据问题一的答案,再结合看代码是可以搞清楚的,不过有些复杂的场景下,也还是需要借助工具来分析定位,这个我们以后有空再说。

解决办法

通常情况下,我们并不会关心那些已经被 GC 回收掉的临时内存,而只关心还没有被回收的内存。
所以,我们需要追踪内存分配和释放这两个动作。

采样

因为内存申请是程序运行过程中一个比较高频的行为,为了减少开销,一个有效的方法是采样,仅仅追踪部分行为。
go 默认是每分配 512KB 内存采样一次,可以通过环境变量 memprofilerate 来修改。

实现方式就是,在每次 malloc 的时候,维护一个计数器,累计申请的内存达到阈值即进行采样,并开始下一个计数周期。

采样的时候,需要记录以下几个信息:

  1. 当前调用栈。注意并不是最终可见的文本调用栈,仅需要记录每层栈帧的 PC 值。
  2. 申请的大小。这里会是一个统计周期内的累计值。

每个调用栈,会创建一个 memory Record,也就是源码中的 mp 对象,进行归类。

并且会为本次采样的内存块,记录一个 special(且 special 会关联到 mp),会在 GC sweep 处理 finalizer 的阶段,处理这个内存块的释放逻辑。
此时的处理逻辑也就非常简单了,直接在关联的 mp 中统计已经释放的内存大小即可,O(1) 操作,非常干净。

周期

因为内存分配是会持续发生的事件,而内存回收又是另外一个独立的 GC 周期,这个时候精准卡点就显得非常重要了。

go 中的每个 mp,并不是全局的实时统计,而是会分为好几个区域,按照 GC 周期的进行,依序往前推进(是的,就像机械手表那样精巧)

  1. active,这是最终汇总统计的,通过 pprof heap profile 读取的数据,通常就是 active 里的汇总数据
  2. feature[3],这里是统计正在发生的,长度为 3 的一个数组。

推进器则是 cycle 计数器,在 GC mark 完成,start the world 之前,cycle 会 +1。

结合长度为 3 的 feature 数组:

  1. feature[(c+2)%3]: malloc 时记录到这个位置,表示正在申请的。
  2. feature[(c+1)%3]: sweep 时从位置去统计 free,表示正在 sweep 或者将要被 sweep。
  3. feature[c%3]: sweep 开始之前,将这个 c 中的值,累加到 active 中,表示已经经过一个 GC 周期 sweep 的。

这样的方式,非常的精准,和 GC 周期完美的结合起来了。
active 中统计是:从程序启动开始,到某一次 GC 周期完成之后,还没有释放的内存。
至于具体是哪个 GC 周期,就是说不好了,可能最多会落后两个 GC 周期。

用途

了解这些是有啥用呢,当然是为了更好的分析内存问题。

有些时候,内存并不会持续增长,而只是突增一下又恢复了,我们需要一个非常精准的方式,拿到这一次突增的的原因。
这时我们需要这样一种分析思路,基于 GC 周期的内存 profile,当然,这也是 pprof heap profile 思路的延续。

突增的危害

通常情况下,内存突增一下又恢复,并不是什么大问题,只是短时间的让 GC 变得更活跃。
但是,这种异常的波动,也不可简单忽略

  1. 内存突增,意味着内存申请/回收变得频繁,可能是有非预期的大批内存申请,造成瞬间的响应时间变长。
  2. 推高 GC goal,也就是下一次 GC 的阈值会变高,如果内存相对紧张,会导致 OOM。

解决办法

代码已经提交到 MOSN 社区的 holmes:
https://github.com/mosn/holmes/pull/54

具体的做法是:

  1. 在每次 GC Mark 结束之后,检查本次 GC 之后的依然 live 内存量,是否有突增。这个值基本就决定了下一次的 GC goal。
  2. 如果有突增,就获取当前 active 的 heap profile。注意:此时 profile 中的数据,并不包含突增的内存。
  3. 并且,在下一次 GC 完成之后,再一次获取 active 的 heap profile。此时 profile 中的数据,就多了刚才突增的内存。

我们需要获取两次 heap profile,使用 pprof 的 base 功能,就可以精准地获取突增的那一个 GC 周期,到底新增了什么内存。

ps: 当然 GC goal 被推高的问题,也还有另外的办法来缓解/解决,也就是动态的 SetGCPercent,这里也暂且不表。

案例

上面的 PR 中也有一个测试案例:
https://github.com/mosn/holmes/blob/15e2b9bedf130993d3a4e835290b2065278e062f/example/gcheap/README.md

描述的是这样一个场景:

  1. 长期存活内存基本保持在 10MB
  2. /rand,表示正常的接口,申请的内存很快能被回收,这个接口一直被频繁的调用
  3. /spike,表示异常的接口,突然会申请 10MB 内存,并且保持一段时间之后才释放,这个接口偶尔有调用

具体的过程就不再重复描述了,最终我们可以看到通过 holmes 的 gcHeap 检查,可以精准地抓到 /spike 这个偶尔调用的异常接口的现场。

最后

go 语言的 pprof heap profile 是很强大的基础能力,对于那种持续泄漏的场景,我们只需要取两个点的 profile 就可以分析出来。
但是,对于非持续的内存增长毛刺,则需要我们充分理解它的工作机制,才能精准地抓到问题现场。

另外,还有一个中内存 “突增”,突然申请了大量临时内存,并且能立马被 GC 回收掉,并不会导致 GC goal 被推高。
这种情况,其实危险要相对小一点,坏处就是 GC 频率变高了。

或者,压根就不是 “突增”,就是 GC 频率一直很快,一直有大量的临时内存申请。

这种情况,其实也可以借助 heap profile 来精准区分,按照上面的分析,我们只需要获取 feature[c+2] 中的数据即可。
那个就是新增内存的来源,我们可以根据这个来分析哪些临时内存是可以复用的,也是一个很有效的优化方法。

只不过,目前的 go runtime 中并没有一个很好的 callback 来实现精准的读取 feature[c+2]
而且看起来也不是很有必要的样子,后面有需要的话,可以给 go 提个 proposal,听听 rsc 大佬的意见。

用了差不多十年的 vim,近期转到 vscode 了,记录一些使用心得

缘起

原来一直用 vim,感觉还挺顺手的。

大约去年底的时候,要看 hotspot JVM 的 c++ 代码,瞬间觉得 vim 不够用了。因为 c++ 太复杂了,重载/虚函数 之类的,函数跳转就很难搞了。vim 通常是搭配 ctags 来做函数跳转,看 JVM 代码的时候,一个函数跳转,能出来好几页的候选项,瞬间就头大了。
当时看隔壁晖哥用 vscode 还挺方便的,就也装了用起来了,确实感觉耳目一新。

最近主要看 go 代码,也就顺势切到 vscode 了。

初印象

vscode 总体感觉是比较成熟,各种插件比较丰富,如果碰到错误,也能搜到解决办法;这也是随大流的好处,坑基本都有人踩过了。

vscode 的好用,主要是依赖插件,选择合适的插件就很关键了。因为 vscode 提供的应用市场,插件是有第三方提供的,质量也可能参差不齐,装插件的时候,需要一些时间精力来挑选。

引申一下,这种应用市场的机制,一方面可以让插件丰富一起来,充分发挥开放平台的优势;另一方面,对于插件的质量,其实是比较容易失控的,也是需要平台方有机制来保障的。
比如下载数量,好评数,都是比较好的机制。

接下来,记录一些我这边用着比较好的插件。

Vim

虽然用上了 vscode,但是我还是喜欢 vim 的操作习惯。
这个 Vim 插件可以保留 vim 下的大部分操作习惯,所以感觉切换过来还比较的顺畅。

Remote - SSH

虽然用得是 Mac 电脑,不过一直习惯 Linux 上的开发环境,所以一直都是用虚拟机来开发。
以前是直接 ssh 到 Ubuntu 上来开发,现在这个 Remote - SSH 插件就显得很必要的了。

另外,Ctrl + ~ 可以唤出一个 terminal 执行 shell,也是挺方便的,有些时候还是需要执行一些 shell 的。

至此,原本的主要的 ssh + vim 流程,就有了完整的替代了。

GitLens

这个插件对我而言,主要是:

  1. 可以方便的显示每行代码的最后修改记录,包括作者,commit 等。
  2. 可以本地看某个文件的 diff,左右对比的那种,确实对眼睛比较友好。

原来 vim 里也有插件能做类似的事情,不过体验比 GitLens 要差一些,这算切换过来的甜头了。

c/c++

这个是当时看 jvm 源码时用的插件。

跟 Vim 比起来,最大的感触是,函数跳转就顺畅多了。很庆幸当时旁边坐了晖哥,哈哈,谢谢晖哥带我走上 vscode 的坑。

go

go 官方出的这个插件确实很棒,比如:

  1. 代码格式自动调整
  2. 自动补全提醒
  3. 非法代码提醒,比如未定义变量
  4. 自动引入/删除 import
  5. 跳转:函数定义/引用

原来没有折腾过 vim 下的 go 插件,应该也有能类似做到这些的插件,不过可能没有官方出的这个插件好

不过也踩到一个坑,vendor 下定义了一个函数,同时 vendor 下的其他 package 有引用这个函数,但是找函数引用的时候,会说没有引用。
这个坑暂时还没有找到解法…

现在有点犹豫要不要入 goland 的坑…

debug

以前搞 c 开发的调试,通常是徒手 gdb 搞起,这次切换后也尝试了下编辑器的调试功能。

在编辑器打断点确实挺爽的,之前虽然见过,不过一直也没有真实体会过,体会了一下,确实是挺好的。
不过也有一个不好的点,断点之后,有时候想看下汇编代码,看看寄存器的值,或者想汇编的单步跟踪一下,现在是不知道怎么弄了。
或许有解决办法,只是我还不知道…

另外是,编辑器 debug 的学习成本也有一丢丢,主要是需要配置下 launch.json,还可能需要 tasks.json。

最近分析 viewcode 一个小 bug 的时候,碰到了调试的时候,不再接受标准输入的问题,按照这个路子才搞定,也是费了一些功夫。
https://blog.csdn.net/weixin_42529589/article/details/104583672

总结

vscode 目前给我的体验还是不错滴,不过也有点纠结要不要切到 goland …
主要是周围的人都用 goland,我这踩到的 vscode 的坑,都没个人来请教,有点忧伤 …

之前 看书 的时候,对于 go 的 interface 机制,对我个人而言,感觉挺新颖的,又不得其要领,心中留下了不少疑惑。
实践了一些小例子,对有了基本的了解,记录下这篇文章。

struct method

在了解 interface 之前,我们先看看 struct method 的用法,这个就比较有面向对象的感觉,fields + methods。
第 6 行中的 (r Rectangle) 的用法有点像 Lua 语法糖里的 self,Java 里面的 this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Rectangle struct {
a, b uint64
}

//go:noinline
func (r Rectangle) perimeter() uint64 {
return (r.a * r.b) * 2
}

func main() {
s := Rectangle{4, 5}
p := s.perimeter()

fmt.Printf("perimeter: %v\n", p)
}

之前看书的时候,struct method 和 interface 是一起出现的,所以心中比较疑惑这两者的关系,这回算是清楚了。

另外,这里有一个有趣的优化,我们看下生成的汇编代码,这里直接把 struct 里的 field 当做参数传给 perimeter 函数了。

1
2
3
4
MOVL $0x4, AX
MOVL $0x5, BX
NOPW
CALL main.Rectangle.perimeter(SB)

PS:去掉第 5 行 go:noinline 的话,连函数调用都会被优化掉了。

Interface 抽象的是什么

struct + method 已经有面向对象的感觉了,那么 interface 抽象的又是什么呢?

先看一个示例,这里申明了一个叫 Shape 的 interface,其有一个 perimeter 的方法。

1
2
3
type Shape interface {
perimeter() uint64
}

如果只有 RectangleShape 的话,看起来 Shape 看起来没啥用。
如果再加一个 Triangle,就比较好懂了,此时 RectangleTriangle 都实现了 Shape 接口。

1
2
3
4
5
6
7
type Triangle struct {
a, b, c uint64
}

func (t Triangle) perimeter() uint64 {
return t.a + t.b + t.c
}

接下来就可以这样使用了,RectangleTriangle 都实现了 Shape 接口。

1
2
3
4
5
6
7
8
9
10
var s Shape
s = Rectangle{4, 5}
p := s.perimeter()

fmt.Printf("Rectangle perimeter: %v\n", p)

s = Triangle{3, 4, 5}
p = s.perimeter()

fmt.Printf("Triangle perimeter: %v\n", p)

从我的理解而言,interface 是一种更高层次的抽象,表示具有某些能力(method)的对象,并不是特指某个对象(struct);只要某个 struct 具有 interface 定义的所有 method,则这个 struct 即自动实现了这个 interface。

有了 interface 抽象之后,我们可以只关心能力(method)而不用关心其具体的实现(struct)。

对比 C 语言常规的接口

乍一眼看 interface 的定义的时候,很像 C 语言暴露在 .h 头文件里的接口函数;但是实际上二者差距很大。

C 语言中的接口函数,更像 go package 中 export 的 function,只是公共函数而已。
interface 则是面向对象的概念,不仅仅是定义的 method 有一个隐藏的 struct 参数,而且一个 interface 变量真的会绑定一个真实的 struct。

interface 也是 go 语言里的一等公民,跟 struct 同等地位,这个跟 C 里面的函数接口就完全不是一回事了。

对比 go 语言自己的 struct

虽然 interface 和 struct 在调用 method 的使用,用法很像;但是这两也不是一回事。

interface 是更高一层的抽象,由不同的 struct 都可以实现某个接口;
而且 interface 变量只能调用 interface 申明的 method,不能调用绑定的 struct 的其他 method。

interface 的实现

里面的解释其实还是有些粗糙,看下 interface 的实现机制,就比较容易理解了。

首先,interface 是一等公民,上面例子里的 var s Shape,实际上是构建了如下这样一个 struct。
tab 表示 interface 的一些基本信息,data 则指向了一个具体的 struct。

1
2
3
4
type iface struct {
tab *itab
data unsafe.Pointer
}

我们看下上面例子中,interface 调用过程的实际汇编代码:

1
2
3
4
5
6
7
8
MOVQ $0x4, 0x38(SP)
MOVQ $0x5, 0x40(SP)
LEAQ go.itab.main.Rectangle,main.Shape(SB), AX
LEAQ 0x38(SP), BX
CALL runtime.convT2Inoptr(SB)
MOVQ 0x18(AX), CX
MOVQ BX, AX
CALL CX
  1. 1-2 行,在栈上构建了一个 Rectangle struct
  2. 3-5 行,把 itab 和 struct 地址,传给 convT2Inoptr,由其构建一个堆上的 interface 变量,即 iface struct
  3. 6 行:获取 iface 中 method perimeter 的地址,main.(*Rectangle).perimeter 这个函数
  4. 7-8 行,相当于这个效果,perimeter(&struct Rectangle)

其中 convT2Inoptr 的核心代码如下,即是在堆上构建 iface 的过程。

1
2
3
4
5
6
7
8
func convT2Inoptr(tab *itab, elem unsafe.Pointer) (i iface) {
t := tab._type
x := mallocgc(t.size, t, false)
memmove(x, elem, t.size)
i.tab = tab
i.data = x
return
}

这里有一个比较有意思的地方,第 7 行 MOVQ BX, AX 中的 BX 并不是来自第 4 行的赋值,因为 go function call ABI 中,所有寄存器都是 caller-saved 的。
我们看下 convT2Inoptr 的汇编代码,可以看到它是这样处理返回值的,直接把 iface 中的两个成员返回了;按照源码的字面意思,应该只有一个返回值的。

1
2
3
4
5
MOVQ 0x40(SP), AX
MOVQ 0x18(SP), BX
MOVQ 0x30(SP), BP
ADDQ $0x38, SP
RET

总结

go interface 是一个挺有意思的设计,作为一等公民,跟普通类型无异,可以构建普通的 interface 变量。

另外在实现的时候,对于 iface 这种很小的 struct,go 编译器做了比较有意思的优化,直接把 struct 中的成员展开,用多个值来表示这个 struct。这样可以更充分的利用寄存器,更好的发挥 go function call ABI 的特性。