0%

最近学习了 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 的特性。

上一篇文章 Goroutine 调度 - 网络调用 介绍了网络调用过程中,Goroutine 的切换过程。
对于网络操作而言,Linux 操作系统本身就提供了 epoll 机制,所以对于应用层比较友好。

那么对于其他系统调用,比如普通的文件读写,那是如何做到非阻塞的呢?
本文在介绍 cgo 的实现机制的时候,也会介绍到这里的调度机制。
因为对于 go 而言,C 函数默认是阻塞不安全的,被很保守的对待处理了,毕竟 go 并不是设计为嵌入式语言,go 自己才应该是主角。

跨语言函数调用

首先,对于 CPU 而言,函数调用也就是个 call 指令,对应的是将当前 PC 值压栈,同时 JMP 到新函数的指令位置。

那么 go 和 c 的跨语言函数调用,存在哪些难点呢?

  1. 数据类型不一样,go 和 c 都有自己的一套类型系统,参数/返回值可能需要做类型转换
  2. 函数调用的 ABI 不一样
    go 1.7 之后的函数调用 ABI 中,所有寄存器都是 caller saved。
    C 语言的 ABI 中 caller-saved 和 callee saved 基本一半一半
  3. Goroutine 的栈空间可能不够用
    Goroutine 的栈空间初始值只有 2 kB,是在执行 go 函数的时候,按需增长的。
    但是执行到 c 函数之后,c 函数里是不会检查栈空间是否够用的了。
  4. C 调用 go 的时候,如何绑定 M 和 P

go 调用 c

cgo 有两种用法:

  1. go 调用 c
  2. c 调用 go

我们先看 go 调用 c 这种简单的情况,如下面的例子。
为了方便,我们使用的内联 C 代码的方式,直接在 go 源码里定义了一个 c 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
long add(long a, long b) {
return a + b;
}
*/
import "C"
import "fmt"

func main() {
var a C.long = 2
var b C.long = 3

r1 := C.add(a, b)

fmt.Printf("%d + %d = %d\n", a, b, r1)
}

执行过程

这个示例的最终执行路径是这样子的:

1
2
3
4
5
1. main.main
2. main._Cfunc_add (把函数参数写入到内存,一个 struct 中)
3. runtime.cgocall (entersyscall + 切换到 g0 系统栈)
4. _cgo_05b35a9f2a70_Cfunc_add (从内存读取函数参数)
5. add (c 生成的函数)

调用开销

我们可以到其中额外的开销:

  1. 24 主要是对接 go 和 C 函数调用 ABI,将参数通过内存中转了一下
  2. 因为 C 函数中可能会调用一些阻塞操作,所以需要 entersyscall 做好调度准备
  3. 同时因为 C 函数需要的栈空间未知,切到 g0 栈是更安全的做法

所以,go 调用 c 很频繁的话,这部分开销还是值得关注的。

如下是 add 的 go 函数实现,我对比了一下,go->go 调用是纳秒级别的开销,go->C 则是接近 100 纳秒级别的开销了。
当然,这个 add 函数只需要非常简单的一条 add 指令,真实情况下肯定不会这么简单,所以真实情况下的差距则肯定不会这么大了。

1
2
3
4
//go:noinline
func add(a int64, b int64) int64 {
return a + b
}

实现细节

使用 go tool cgo test.go 可以看到 cgo 生成源文件,在 _obj 目录下。

  1. _Cfunc_add 是 cgo 自动生成的 go 函数。

这里有一个细节,看起来只有 p0 的地址当做参数传递给了 _cgo_runtime_cgocall 也就是 runtime.cgop1 并没有被用到。
实际上,p0p1 并不是通过寄存器传参的,而是通过栈内存传递的,p1 总是跟随在 p0 后面。

1
2
3
4
5
6
7
8
9
//go:cgo_unsafe_args
func _Cfunc_add(p0 _Ctype_long, p1 _Ctype_long) (r1 _Ctype_long) {
_cgo_runtime_cgocall(_cgo_9c59eeeff222_Cfunc_add, uintptr(unsafe.Pointer(&p0)))
if _Cgo_always_false {
_Cgo_use(p0)
_Cgo_use(p1)
}
return
}
  1. _cgo_05b35a9f2a70_Cfunc_add 是 cgo 自动生成的 C 函数

这里的参数 v 这是上面的 p0 地址。这里定义了一个 struct 来描述 p0p1 的内存布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
_cgo_9c59eeeff222_Cfunc_add(void *v)
{
struct {
long int p0;
long int p1;
long int r;
} __attribute__((__packed__, __gcc_struct__)) *_cgo_a = v;
char *_cgo_stktop = _cgo_topofstack();
__typeof__(_cgo_a->r) _cgo_r;
_cgo_tsan_acquire();
_cgo_r = add(_cgo_a->p0, _cgo_a->p1);
_cgo_tsan_release();
_cgo_a = (void*)((char*)_cgo_a + (_cgo_topofstack() - _cgo_stktop));
_cgo_a->r = _cgo_r;
_cgo_msan_write(&_cgo_a->r, sizeof(_cgo_a->r));
}
  1. p0p2 具体是由 _Cfunc_add 的调用方来写入

也就是 main.main 这个入口函数了,通过 go tool objdump -s main.main test 我们可以看到如下的汇编,两个参数确实是被放入了栈内存中。

1
2
3
MOVQ $0x2, 0(SP)
MOVQ $0x3, 0x8(SP)
CALL main._Cfunc_add.abi0(SB)
1
PS:示例中的 c 函数参数没有用 int 类型,因为用 int 的话,会有碰到一个优化,两个 int 合并到一个 long 了,汇编指令看起来就没那么直观了

Goroutine 调度

其中调度相关的逻辑主要在 runtime.cgocall 中的 entersyscall

  1. 将当前 PM 解除绑定,将 P 记录到 M.oldp
  2. 将当前 P 的状态改为 _Psyscall

需要注意的是:

  1. 此时并没有将 P 释放给其他 M 使用
    而是在另外的 sysmon 线程中不定期检查所有 P 的状态,具体逻辑在 retake 函数中:
    简单的点说,如果 P 处于 _Psyscall 超过一个 sysmon 的轮询周期(至少 20us)则会将 P 切换到 _Pidle 状态,供其他 M 使用。

  2. M 线程还是由操作系统调度运行的
    即使在 P 被抢走的情况下,M 也还是继续运行的,毕竟操作系统只认识 M
    当 M 中的任务(syscall or C function call)完成后继续运行的,会执行到 exitsyscall
    此时会按照这个顺序去执行:绑定 oldp 恢复执行,绑定其他空闲的 P 恢复执行,放回到运行队列等待调度。

总结一下,简单来说:

  1. entersyscall 标记进入可抢占状态
  2. sysmon 轮询检查,将长期运行的 P 释放
  3. exitsyscall 恢复 G 的运行
1
PS:runtime.cgocall 中另外一个关键逻辑是:asmcgocall(fn, arg),这个是切换到 g0 栈执行 fn 这个 C 函数

C 调用 go

C 调用 go 又分为两种情况:

  1. 原生 C 调用 go
  2. go -> C -> go

这里我们看下第一种情况:

go 生成 so

第一步,我们需要从 go 代码生成 so,并且生成 .h 的头文件,供 C 代码使用。

如下的 go 源码:

1
2
3
4
5
6
7
8
import "C"

//export AddFromGo
func AddFromGo(a int64, b int64) int64 {
return a + b
}

func main() {}

通过如下命令,会生成 libgo-hello.solibgo-hello.h 头文件:

1
go build -o libgo-hello.so -buildmode=c-shared hello.go

使用 go 生成的 so

有了头文件和 so,我们就可以当做普通库来使用了,比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include "libgo-hello.h"

int main() {
long a = 2;
long b = 3;

long r1 = AddFromGo(a, b);

printf("%ld + %ld = %ld\n", a, b, r1);
}

如下方式编译为可执行文件:

1
gcc -g -o hello hello.c -l go-hello -L. -Wl,-rpath,.

执行流程

这个示例的最终执行路径是这样子的:

1
2
3
4
5
6
1. main
2. AddFromGo (libgo-hello.so 导出的函数,将函数参数写入到内存,一个 struct 中)
3. crosscall2 (准备进入 cgocallback 这个 go 函数,对接两边的 call ABI)
4. runtime.cgocallback (获取 M 和 P 等等,逻辑比较多)
5. _cgoexp_51fb23d6311d_AddFromGo (从内存读取参数)
6. main.AddFromGo

函数参数这部分,和 go->C 没什么区别,都是将参数放入到一个 struct,然后固定传这个 struct 的地址。

如何获取 M 和 P

  1. libgo-hello.so 加载的时候,会触发 go runtime 的初始化,创建 M 和 P;是的,除了 c 主程序的线程,还会另外创建一些 go 的 runtime 线程。
  2. AddFromGo 函数中会检查 go runtime 是否已经初始化好了
  3. 执行 main.AddFromGo 的时候,并没有真的切换到新的线程。而是当前线程获取一个伪装的 M,extra M,具体过程这块还没细看。

总结

相对而言,目前的实现里,go 和 C 之间的调用开销也还是比较高的。应该比普通的 Lua C API 调用还是高,虽然传参都会经过内存,但是 go 还多了协程调度的逻辑。

优化的空间应该还是有的,对于某些 go 和 C 交互比较频繁,性能要求比较高的场景,应该还是可以搞一搞:

  1. 引入 unsafe C function call
    这种 unsafe 的情况下,不再调用 entersyscall 和 exitsyscall,C function 阻塞的风险就甩给程序员了
    不过还是需要注意下,Gorountine 的抢占调度在这个情况是否会踩坑
  2. 通过寄存器来传递参数,减少内存读写
    这个应该也可以搞,如果 1 实现了的话,应该相对容易一些,如果 1 没有的话,可能还比较难,或者效果也不明显了,毕竟中间又隔了好多的函数调用了。
  3. 避免切到 g0 栈运行
    这个看起来不太好搞。
    如果在 unsafe 模式下,让程序员来指定 C 需要的 stack size 倒是可以搞;不过不可靠,很容易就指定错了,可能会出现诡异的问题,不可取。

上一篇文章 goroutine 调度概览 大概介绍了 Goroutine 调度的基本情况,这篇来看个具体的例子:网络调用。

协作式主动让出执行权

Goroutine 很多时候都是通过主动让出执行权的,主动让出的执行效率可以更高;就像自己主动搬砖和被人催着搬砖一样。
除了 runtime.Gosched 这种 go 代码里显式指定让出的(实际上应该会比较少用到),更多时候是由某些行为底层触发,网络调用则是一个典型的例子。

网络调用

对于网络的非阻塞实现,linux 下最常见的就是用 epoll,我们通过例子来看看 go 语言如何在语言级别把 epoll 的细节屏蔽。

以下是个简单例子,向 example.com:80 发起了一个 HTTP GET 请求,然后读取第一行响应。

1
2
3
4
5
6
7
8
9
10
11
12
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
// handle err
}
fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n")

line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
// handle err
}

fmt.Println("line: ", line)

挂起

我们重点介绍发送之后,第一次读取数据的行为

  1. 因为刚发送完,此时立马读取,肯定读不到数据,syscall.Read 会返回 EAGAIN
  2. 接下来会调用 gopark 将当前 Goroutine 挂起

通过 gdb 可以看到这样的一个调用栈:

1
2
3
4
5
6
7
8
9
#0  runtime.gopark (unlockf={void (runtime.g *, void *, bool *)} 0x0, lock=0x7fffd00a0700, reason=2 '\002', traceEv=27 '\033', traceskip=5) at /home/dou/work/go/src/runtime/proc.go:349
#1 0x000000000042e5fe in runtime.netpollblock (pd=<optimized out>, mode=<optimized out>, waitio=<optimized out>) at /home/dou/work/go/src/runtime/netpoll.go:445
#2 0x000000000045c489 in internal/poll.runtime_pollWait (pd=<optimized out>, mode=140736683706112) at /home/dou/work/go/src/runtime/netpoll.go:229
#3 0x000000000048b0b2 in internal/poll.(*pollDesc).wait (pd=<optimized out>, mode=140736683706112, isFile=2) at /home/dou/work/go/src/internal/poll/fd_poll_runtime.go:84
#4 0x000000000048ba1a in internal/poll.(*pollDesc).waitRead (isFile=2, pd=<optimized out>) at /home/dou/work/go/src/internal/poll/fd_poll_runtime.go:89
#5 internal/poll.(*FD).Read (fd=0xc0001a8000, p=..., ~r1=<optimized out>, ~r2=...) at /home/dou/work/go/src/internal/poll/fd_unix.go:167
#6 0x00000000004ac629 in net.(*netFD).Read (fd=0xc0001a8000, p=...) at /home/dou/work/go/src/net/fd_posix.go:56
#7 0x00000000004b6965 in net.(*conn).Read (c=0xc000094008, b=...) at /home/dou/work/go/src/net/net.go:183
#8 0x00000000004c1d2e in net.(*TCPConn).Read (b=...) at <autogenerated>:1

补充一点:gopark 的核心逻辑是,切换到 g0 栈,执行 park_m。在 park_m (g0 栈)中再把当前 G 挂起。

恢复执行

上一步的 park_m 函数,除了会挂起当前 G,另外一个重要的任务就是执行 schedule 函数,挑一个新的 G 开始运行。

在上一篇介绍过,挑选一个新的 G 的过程中,就有 检查 netpoll 这一步。
我们可以在 gdb 中看到如下的调用栈:

1
2
3
4
5
#0  runtime.netpollready (toRun=0x7fffcaffc6c8, pd=0x7fffd00606d8, mode=119) at /home/dou/work/go/src/runtime/netpoll.go:372
#1 0x000000000042f057 in runtime.netpoll (delay=<optimized out>) at /home/dou/work/go/src/runtime/netpoll_epoll.go:176
#2 0x000000000043abd3 in runtime.findrunnable () at /home/dou/work/go/src/runtime/proc.go:2947
#3 0x000000000043bdd9 in runtime.schedule () at /home/dou/work/go/src/runtime/proc.go:3367
#4 0x000000000043c32d in runtime.park_m (gp=0xc000202000) at /home/dou/work/go/src/runtime/proc.go:3516

netpoll 中的核心逻辑即是调用 epoll_wait,获取一批已经准备就绪的 events,恢复这批 G 到 runable 状态,并运行第一个 G。

总结

对于非阻塞网络的实现,核心点是 EAGAINepoll_wait
go 语言把这个细节隐藏到了语言/标准库内部,确实很大程度的降低了程序员的心智负担。

如上文 go 语言学习 中提到,GMP 是 go 语言的一个核心设计。对于 Goroutine 的调度机制,一直非常好奇,最近翻阅了源码,也做了一些小实验,算是有了个基本的理解,解释了心中的很多疑惑。

GMP 解释

G:Goroutine,用户态协程,表示一个执行任务,实际上是一个 Goroutine 数据结构 + 一段连续的内存当做 stack
M:Machine,实际是一个系统线程,操作系统调度的最小单元
P:Processor,虚拟处理器,实际上只是 runtime 中的数据结构,用于限制当前正在运行的 Goroutine 数量

几个知识点:

  1. P 通常跟 CPU 核心数一样多,表示当前这个 go 程序可以占用几个 CPU 核心。
  2. P 是需要绑定一个实际的 M 才能运行,毕竟系统线程才能真正的在物理 CPU 上执行任务。
  3. 但是,M 通常会比 P 更多一些,比如碰到阻塞的操作,P 就会和 M 分离,如果有很多阻塞任务,M 就可能会非常多。

为什么需要调度

Goroutine 虽然底层 主要 是协作式的调度,但是调度的细节对于使用者则完全屏蔽了。
从这个角度看,Goroutine 很像操作系统的线程,即使底层有调度切换,但是让使用者认为 Goroutine/系统线程 是一直在运行的。

对于使用者而言,可以不关心,Goroutine 是何时被挂起的,又是何时恢复执行的(当然,为了写出更好的代码,我们肯定还是需要了解这些实现机制的)。

对于上层屏蔽了,就需要语言层面来处理了,也就有 runtime 里的调度机制了。

1
go 语言暴露的 runtime.Gosched 只是让当前 Goroutine 临时让出执行权,避免大计算任务的长期阻塞,并不是严格的挂起。跟 OpenResty 中 ngx.sleep(0) 的作用一样。

对比 Lua

相对于 Lua 这门嵌入式语言而言,则是完全把控制权交给了宿主程序。
具体来说,Lua 对外提供了挂起 yield 和 恢复 resume 的 API,由上层使用者来控制 coroutine 的执行。
当然,虽然需要使用者操心的事情更多,不过在嵌入式场景中,对于宿主程序某些方面反而更友好,可以更精确的控制程序的执行。
比如,并发协程的调度优先级,对于协程中执行的不同类型任务,我们希望可以有不同的优先级。
但是,Goroutine 是完全被平等对待的,上层没有办法控制调度的优先级;Lua 里则不存在这个问题,直接通过 lua_resume 恢复执行某个协程即可。

对比 OpenResty uthread

Goroutine 的使用体验 和 OpenResty 中的 uthread 使用非常类似,同步非阻塞,即:以同步的方式写代码,实际上是则是以异步非阻塞的方式执行。

Goroutine 是由 runtime 来驱动运行,OpenResty uthread 则是由 Nginx 的事件循环来驱动运行。
当然,实际上这两还是有些区别的,Goroutine 抽象在语言层面,对于很多的非网络系统调用,都实现了非阻塞的调度(虽然比较重,底层还是通过堆线程来实现);OpenResty uthread 则只是对网络调用封装了同步非阻塞,对于其他的阻塞式系统调用,还是会阻塞当前 worker 进程的。

调度器的任务

对于协程调度而言,核心就两个任务:

  1. 挂起
  2. 恢复

挂起的核心在于选择时间点,Goroutine 有这么几种方式会挂起:

  1. 协作式的主动让出
  2. 进入系统调用发生了阻塞,被监控线程强制切走
  3. Goroutine 执行时间太长,被中断信号切走

恢复逻辑则比较的固定,按照这个顺序查找可以执行的 Gorounine (runable)。

  1. P.runnext 指向的 G
  2. 本地队列
  3. 全局队列
  4. 检查 netpoll(timeout = 0)
  5. 从其他 P 去偷 G
  6. 检查 netpoll (timeout > 0)

对照如下经典的 GMP 模型图,会更好理解一些。

GMP 模型图

Goroutine 切换发生了什么

首先 Go 编译生成的机器指令,操作 Goroutine 栈的方式,跟 C 语言很像。
比如在 X64 CPU 架构下,通过 rsp 寄存器来指向栈顶,然后通过 rsp 的相对位置来操作栈内存。
所以,在 Goroutine 切换的时候,肯定会发生栈切换。

对比之下,Lua coroutine 的栈,则只是纯粹的一段内存,Lua coroutine 切换,并不需要改变 rsp 的值。

另外,每个 M 都有一个固定的 g0 栈,Goroutine 的切换实际上存在两次切栈操作,比如 g1 切换到 g2 的时候,会发生:

  1. g1 让出执行权,切换到 g0 栈
  2. g0 执行 scheduler,找到 g2 开始执行

因为 scheduler 的逻辑还是比较复杂的,不适合在 g1 上执行了。
要在 g1 上执行的话,至少需要在 g1 上预留比较多的空闲栈内存空间,否则就可能会栈溢出了。
当然 g0 还有其他用处,这个以后再说。

实际案例

准备了两个小例子,实际看看 Goroutine 是如何切换的。

  1. 网络调用
  2. 调用 C 函数

且听下回分解。

go 语言火了有很久了,一直也没有认真的学习,刚好赶上最近的空档期,完整了学习了一遍。
肯定是谈不上深入了,不过对其也有了大致全面的认识,也有比较多的感触,所以有了这篇文章。

前言

在动手记录这篇文章时,我并没有实际的 go 语言开发经历,只是阅读了两本书,结合了自己的理解,记录下的这些感悟。
应该会有不少理解不到位,甚至南辕北辙的点,这些留到以后来纠正吧。

编程语言的设计,是抽象和折中的艺术

首先,最大的感悟就是这句话,应该是原创,吃饭的时候自己悟出来的一句话。

抽象非常重要,对于要用这门语言来解决的问题,语言层面合适的抽象,会让语言的使用者非常舒服。
比如,很多领域内的小语言,DSL,会让使用者感觉非常自然。

当然,抽象也并不是没有代价的,这种时候就考验折中的能力了。

GMP

GMP 模型,是 go 对并发模型的抽象,确实挺好的,虽然 主要 也是协作式的调度方式,但是抽象到了语言/标准库层面,所以用起来更自然一些,而且还一定程度的屏蔽了多线程的并发,对于构建高并发业务系统,确实挺方便的。

G 表示协程,M 表示线程,P 表示逻辑处理器;仔细想想确实很巧。

协程

首先要拼高并发,肯定得是用户态协程,这是很多语言早就实践过的。

go 语言的协程,比操作系统线程肯定是要轻量很多,不过还是相对 Lua 的协程而言,还是要略重一些的。
一个 Goroutine 的初始 stack size 是 2K,Lua coroutine 的 stack size 只要几百个字节。

调度

为了很自然的将一些正在阻塞的协程挂起,go 语言有一个设计精巧,也比较重的协程调度功能。
而且还实现了抢占式的协程调度,功能确实很棒,不过应该也是比较重的实现,以后找时间仔细分析下性能。

简而言之,就是将重要的 G 分配到 P 上去运行,然后 P 则依托一个实际的 M 来运行。

至于具体实现上,有一些很重要的优化,比如每个 P 都有本地队列,都是为了让这个关键的 scheduler 可以运行的更快。

通道

语言层面抽象出来的,协程间的通讯机制,这个可以很大程度的屏蔽了跨进程通讯的事实。很大程度的降低了程序员的心智负担。
GMP + CPS 感觉真的很棒。

接口

go 抽象了接口机制,对我而言,感觉还挺新颖的。
等后面动手实践的时候,好好体验下的。

GC

go 作为静态语言,还提供了垃圾回收机制,很早以前的时候没有想明白,感觉怪怪的。
虽然后来也好像慢慢懂了点,不过也没有去细想,现在感觉应该是懂了,不会觉得怪异了。

以我的理解,核心就两个点:

  1. 引入 GC 机制,不再需要显式的释放内存,是可以降低程序员管理内存的心智负担
  2. 只要 GC 对象之间的引用关系,就可以完成垃圾回收;指针也可以产生一个引用关系。

另外,go 语言将小对象,直接放到栈上面,也是挺新颖的一个搞法,比较好的减少了临时小对象数量。对于垃圾回收器而言,大量的临时小对象,也是一个很重的负担。
印象中,好像其他新语言,也会有类似这样的搞法,感觉是个好路子。

似曾相识

在学习 go 语言的时候,有些地方让我感觉似曾相识。

打个岔,感慨一下,人类的进步就是这么一步步的往前拱:站在巨人的肩膀上,再上一些自己的创新。

举几个例子:

  1. CPU profile 采样频率是 100HZ
  2. 默认的 GC 启动条件是当前 GC size 达到上一次完成后的 GC size 的两倍

其他

语言的设计仅仅是偏理论上的一面,语言的实现功底也是决定成功的重要一环,后面有计划学习下 go 语言的源码。

一个语言想要成功,除了设计要足够好,还需要长期持续的投入,即使有眼前的苟且,也需要让使用者能看到远方的未来。这个时候还是得感慨一下,有 Google 支持的巨大优势了。

前言

最近被拉去分析了 kubernetes ingress-nginx 的一个 segfault issue,最后发现是我自己多年前手残留下的低级 bug (果然出来混迟早是要还的)。

虽然是一个足以写进教科书的低级 bug(没有判断边界,数组越界),但是有一些排查方法,还是值得分享一下的。谁还没有个手残的时候呢,汗。

问题描述

所有信息都在 kubernetes ingress-nginx 的这个 issue 里:https://github.com/kubernetes/ingress-nginx/issues/6896

汇总下来,有这么几个信息:

  1. 从 0.43.0 升级到 0.44.0,会偶尔出现 Segmentation Fault
  2. 从调用栈看,是从 LuaJIT 通过 ffi 调用 C

segfault

对于 segfault 的错误,可以分两种情况:

  1. bug 代码,产生了一个非法的读/写,这个时候立即就产生了 segfault
  2. bug 代码,产生了一个错误的写,但是并没有发生 sefault,而是后续没有 bug 的代码,在执行过程中,因为之前写入的错误数据,从而触发了 segfault

对于 1,容易复现,也很好搞定,直接根据调用栈查代码就行,多见于开发环境。
对于 2,其中包含了两个时间点:
1> bug 代码产生了错误的写
2> 正常代码使用了错误的数据,产生了 segfault

如果运气好的话,这两个时间点,离得不远,还是比较好排查的。
但是,这两个时间点离得特别远的话,那就很难查了,特别还有可能是连环 bug。因为理论上来而言,所有其他所有代码都值得怀疑,只要是写的进程内的合法地址,操作系统就不会抱怨。

不过,好在,实际上而言,也还是有一些规律可循。

分析过程

一开始,尝试让反馈问题的人提供 core 文件,但是争取了一两周,包括提供了分析思路以表诚意,甚至愿意签 NDA,并没有人愿意提供。

缺失的调用栈

回到问题本身,issue 中有提供调用栈,其中关键的是这两帧:

1
2
#2  __libc_free (p=0x7f03273ec4d0) at src/malloc/mallocng/free.c:110
#3 0x00007f0327811f7b in lj_vm_ffi_call () from /usr/local/lib/libluajit-5.1.so.2

直接从调用栈来看,这个表示 ffi 在调用 free 函数,但是以我的个人经验而言,通常我们并不会通过 ffi 直接调用 free,所以怀疑是中间有调用栈缺失了。

lj_vm_ffi_call 是 LuaJIT 中手写汇编实现的一个函数,通过 disas 分析其中的机器指令,可以看到这个关键指令:

1
call   QWORD PTR [rbx]

同时由于 rbx 是 callee-saved 寄存器,我们通过 frame 3,gdb 会帮我们恢复出当时 rbx 的值,然后我们通过 info symbol *(long *)$rbx 就可以知道 ffi 当时在调用哪个函数。当时得到的反馈信息:

1
2
3
4
5
(gdb) frame 3
#3 0x00007f260c3cdf7b in lj_vm_ffi_call () from /usr/local/lib/libluajit-5.1.so.2

(gdb) info symbol *(long *)$rbx
chash_point_sort in section .text of /usr/local/lib/lua/librestychash.so

好家伙,chash_point_sort 不是我写的 resty.chash 里的函数么,更得抓紧修复了。

分析代码

通过看代码, chash_point_sort 中有且仅有一处调用 free,所以 gdb 显示的调用栈也是对的,只是少了中间关键的一帧。
同时,发生错误的范围,基本可以锁定在 chash_point_sort 的内部了。这个函数内申请了一块临时内存,又在最后阶段完成了释放。

这个属于运气比较好,写坏内存的点,与发生 segfault 的点,离得不远,发生在同一个函数内。同时因为是我自己写的代码,虽然年代有些久远,不过也还是能想得起来,所以最终没花多久就修复了,倒是为了构造可以在 ci 上跑的测试,花了不少时间。

具体的 bug 其实很简单,没有判断边界,数组越界了,写坏了内存。

细究根源

至此,肯定是修了一个 bug,只是还需要搞清楚,为什么以前的版本没有发生 segfault,因为 resty.chash 这期间也没有变更。

通过追踪 musl-libc 的变更历史,发现 musl-libc 去年搞了一个新的 malloc 实现,在 chunk 的末尾放了一些元数据,这个会更容易让 chash_point_sort 触发越界的条件 ,而且因为是新实现的,加了非常多的 assert 检查,所以就暴露了这个 bug。

仔细想想,其实有些时候系统的稳定性,并没有我们以为的那么好。这么低级的 bug,竟然在那么多的生产环境跑了这么多年了。

总结

有一些经验,还是值得记录一下的:

  1. 调用栈不全/准的时候,分析下机器指令,充分利用好寄存器的数据,尤其是那些 callee-saved
  2. 排查问题,多自己推理,多数时候盯着代码看,是更高效的笨办法。
    之前看过云风的一篇文章,他是推荐尽量不要依赖调试器,多让代码在脑子里运行,确实是有道理的。
  3. 写代码的时候,必要的 assert 还是需要的,这是个好习惯。
    之前分析 LuaJIT segfault 的时候,看 LuaJIT 代码中也充斥着很多关键的 assert。

接触开源

我从接触计算机开始,就接触到了开源。

大一在宏福校区,那会算是北京正经的郊区,周边确实没啥好玩的,大家都热衷于参加各种社团活动。我也不例外,不过我参加的活动中,开源相关的活动更多一些,校内校外的都热衷于参加。

当时参加的活动中,给我印象最深刻的有三个人。

邓楠,应该是比我大两届的师兄,很牛的技术极客。当时在校就可以接一些知名互联网公司项目,能够赚外快,很是羡慕。他也很喜欢开讲座,搞分享。我记得当时他有一个讲座,分享的大致是信任链这么个主题,多年后的我回想起来,那应该就是区块链的分享。

王开源,他来学校搞了一次分享,印象中 500 人的会议室都挤满了。讲起当时比尔盖茨来中国演讲的时候,他跑上去砸场子,也让我见识了还有这样为开源疯狂的人。

Richard Stallman,长胡子美国大叔,自由软件的发起人,随身背着个小笔记本,挂在肚子前面敲代码,据说 CPU 是龙芯的。参加过两次他的分享会,还买过一本他的书,有他的亲笔签名(happy hacking)。当时问过他一个问题,“opensource” 和 “free software” 会共存么?他的回答也很直白,只有 “free software” 才有未来,这大叔对自由的追求真的是非常极致 …

除此之外,还记得北师大的一个女老师,貌似是 ”庄“ 老师,还有一个在中国上班的”挪威“程序员…

不过,当时参加的分享活动,讨论的比较多的是意识形态上的东西,很少有具体的适合我参加的开源项目(主要也是当时自己太弱)。

Open Source VS Free Software

首先 Open Source 与 Free Software 肯定还是有区别的。虽然现在提 Free Software 的人明显不多了,但是从根上来说,还是有些区别的。

以我浅显的理解,Free Software 的初衷是技术极客对于自由的极致追求。
设想一下,如果自己使用的商业软件有不爽的地方,然而受制于商业软件的版权限制,又不可以自己破解修改,对于技术大神来说,我想应该是吃了苍蝇的感觉,而且可能是每天都需要吃,确实很让人沮丧。

Free software 具体点来说,是指 GPL 严格的许可证协议。这种协议是带病毒传播性质的,如果对使用这种协议的软件做了修改,那么修改本身也必须继承相同的协议,也必须开源。
这一点,很大程度上限制了 GPL 软件的商业化运作。实际上,使用 GPL 协议的软件,商业化成功的模式,估计也只有红帽这一个典型。

Open Source 相对来说就宽松多了,除了 GPL 这种严格的许可证协议之外,还有很多宽松很多的许可证协议。大部分的协议,允许你自己的修改,并不开源,这样就非常适合,使用开源软件搞一个定制版的商业软件,在商业上的想象空间就大很多了。

对开源的理解

不管是 Free Software 也好,Open Source 也罢,我觉得其内核还是非常吸引人的。印象中,当年在讨论开源意识形态流的时候,可是跟切格瓦拉,共产主义,天下大同,这种高大上的词汇一起讨论的。

人人为我,我为人人,分享精神,我觉得是开源精神的内核。

这也是为什么开源可以「共产主义」拉上关系的基础,参与开源项目的人,得是有助人为乐的精神,不能只想着自己的营营小利。

开源是一种开放的协作方式,参与者们通常是自愿加入,秉着分享互助的精神,一起参与项目的建设。这个协作方式,可以对抗内卷,很好的提高整个社会的生产效率。

开源商业化

那么搞开源的都是活雷锋么?肯定也不是,参与开源项目也是有很多现实收益的,自我价值的实现,商业利益的实现,都可以通过开源项目来达成。简单点来说,开源也是一个名利场,追名逐利才更是人性使然。

说到开源商业化,肯定有人吐槽这种粘上了铜臭味的开源,吐槽开源不够纯粹了,背地里还是图着自己的商业利益。

依我之见,开源商业化肯定是好事,有资本/商业利益的驱动,可以更快的促进整个开源的发展。对于大多数的开源项目,没有商业公司在支持,估计大概率只能靠零星的赞助维持,对于项目的维护者而言,估计也大部分只有“名”上的收益,没有一点用爱发电的精神,其实比较难长期的维持。

至于商业公司的谋利属性,我们理解就行。就市场需求而言,如果有公司使用了一个开源项目,这个公司其实是有足够的动力,付费来购买商业公司的支持,毕竟对于非本公司主营业务的,成熟的商业逻辑肯定是花钱搞定,只要价钱合理。

纵观现在的开源项目,似乎也只有比较底层的基础设施,比如 Linux 操作系统这种的,才会走到捐款 + 基金会这种形式。这种基金会充当了中间角色,不受某一家公司的控制,也没有了盈利属性,就是大家共同协作一个载体。

追逐商业利益,与开源的内在精神,其实并不冲突,是可以共存促进的。一定要那么”纯粹“的开源么?我觉得大可不必。

背景

WebAssembly 简称 Wasm,最早起源于前端技术。
即使在有了 JIT 加持之后,js 在大计算量的场景,性能还是不够理想,经过了 asm.js 的尝试,最后以 Wasm 定型,得到了四大浏览器的支持。
最初的 Wasm 主要是应用于 WEB 应用,后续随着 WASI 的诞生,又扩展到了更宽的场景,比如服务端技术。

Wasm 是什么

Wasm 并不是一门常规意义上的语言,而只是一个基于栈式虚拟机的二进制指令标准。
比如,Lua 是一门语言,因为其具有可编程能力,而 Lua 字节码,则几乎不具备可编程能力(一定要手写也不是不可以)。
Wasm 就类似于 Lua 字节码这种位置,只是它更相对更底层一些,适用范围也更广。

Wasm 设计目的,就是成为其他语言的编译目标,目前支持比较好的有 C/Rust 等。

Wasm 如何运行

由于 Wasm 只是一个标准,具体的执行是由虚拟机来完成的,而虚拟机的实现就又有很多个,类似于官方 Lua 与 LuaJIT 这种。
具体的运行方式也有多种:interpreter,JIT,AOT。比较有意思的是,在 Wasm 圈里,似乎 AOT 技术相对其他语言更流行一些。

具体的虚拟机实现细节,我们可以以后再介绍了。

Wasm 的特点

Wasm 有优秀的设计理念,有其明显的优势,不过优势有时候也需要付出一些代价。

高性能

这是 Wasm 的设计初衷之一,是有接近 native 性能的,当然也依赖虚拟机的具体实现。
从指令设计上而言,Wasm 足够底层,简单,所以理论上是可以接近 native 性能的。

内存安全

Wasm 被设计为内存安全的,尤其在 WEB 场景,很多时候执行的代码都不知道来自谁,底层安全是很重要的。
具体而言,Wasm 的内存模型很简单,只有一个 linear memory,Wasm 能操作的内存的读写都发生在这个 memory 范围内。
Wasm 是不会出现指针飞来飞去的,有的只是 offset,目的是恶意的 Wasm 执行的时候,也不可能读写进程内任意的数据内存。

当然咯,代价也是有的,灵活性会有一些折扣,很多时候需要多一次内存拷贝。

沙箱

Wasm 是运行在一个沙箱环境,其所具备的能力是受限的,需要的一些外部调用,是外面的宿主提供给它的。
比如 Wasm 需要读文件,那也是需要运行 Wasm 的宿主环境,给其提供对应的 API 才可以的。

跨语言

跨语言是 Wasm 的一大亮点,依我之见,可以某种程度上的降低语言之争(语言之争,其实也是个蛮有意思的话题,得空可以聊一聊)。

Wasm 作为中间的标准产物,可以对接两头的开发者:

  1. 上层的应用开发者
  2. 底层的服务开发者

底层服务开发者,只需要为其提供运行 Wasm 的沙箱环境,包括运行 Wasm 的虚拟机,以及暴露服务的某些能力在沙箱中。
上层应用开发者,则可以选择自己喜欢的语言,以及对应语言的 Wasm-SDK(对应暴露在沙箱中的服务基础能力)即可生成 Wasm。

理论上是一个美好的方案。

最后

Wasm 也是一种嵌入式的方案,某种程度上跟 Lua 很类似。

依我之见,少年期的 Wasm 还有比较长的一段路要走

  1. 底层的能力还有待增强,比如带 GC 的语言,生成 Wasm 就是一个难题。
  2. 周边生态也有待发展,目前还属于初级阶段,虽然能看到一些设计雏形。