0%

前一阵吹水的 cgo 内存优化,也被干掉了,无缘 golang 下一个版本 1.22 …

不过,rsc 大佬说 1.23 会带上,好吧,再耐着性子等个半年吧

原因

rsc 大佬解释说是向后兼容性问题,不过,实际上,并不是这个 cgo 内存优化的补丁,自身有啥兼容性问题

因为这个优化只是新增了两种 #cgo 指令的支持,新增特性一般是没有向后兼容性问题的

而是,因为 golang runtime 中,调用 boring ssl 的 crypto 模块,使用了这两个新 #cgo 指令,然后就导致了 google 内部的测试集跑失败了 …

具体也不知道是啥样的测试集,产生了啥样的错误(之前搞的 cgo CPU 优化,google 内部的测试集就暴露了不少真实的问题)

只有这么个 issue:
https://github.com/golang/go/issues/63739

envoy Go

之前在 Envoy 社区,也有人反馈老版本 go 在跑 go mod vendor 的时候,因为不认识那两个新增的 #cgo 指令,导致 vendor 失败
https://github.com/envoyproxy/envoy/issues/30208

当时我们的解法是,直接把这部分优化给干掉了,想着等 1.22 发布之后再加回来

目前看起来,得等到 1.23 发布了,才能用上这个优化咯

估计 Google 内部的测试失败,也是类似的吧

解决方案

所以,目前 golang 的解法是:

  1. 把 crypto 中使用新 #cgo 指令的优化给 revert 了

  2. 把 cgo 优化给 disable 了,好在还不是 revert

    也就是,语法解析阶段,可以解析新增的 #cgo 指令,不过编译会报错了

期望的效果是,以后 Go 1.22 再跑 go mod vendor 的时候,即使遇到这两个新的 #cgo 指令也不会报错了

但是,Go 1.21 以及之前的,那就不管了

理论上来说,如果只是解决 google 内部测试集失败的问题,也没必要 disable 这个优化,只要 revert 掉 crypto 那个优化就行了

估计是 rsc 觉得,一旦正式提供了这个用法,很多三方库跟进使用这个特性,回头更多用户踩到这个 vendor 的坑,还是会来吐槽 golang 的向后兼容性

哈哈,当然只是我的猜测

彩蛋

最后,我也是才知道 crypto 也用了这个优化,仔细看了下补丁:
https://go-review.googlesource.com/c/go/+/525035

原来,他们对于 cgo 内存优化解决的那个问题(指针传给 C 的 Go 对象总是会被逃逸到堆上)也是心里苦

他们为了少一个对象逃逸,甚至包了一层 C 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int EVP_AEAD_CTX_open_wrapper(const GO_EVP_AEAD_CTX *ctx, uint8_t *out,
size_t exp_out_len,
const uint8_t *nonce, size_t nonce_len,
const uint8_t *in, size_t in_len,
const uint8_t *ad, size_t ad_len) {
size_t out_len;
int ok = _goboringcrypto_EVP_AEAD_CTX_open(ctx, out, &out_len, exp_out_len,
nonce, nonce_len, in, in_len, ad, ad_len);
if (out_len != exp_out_len) {
return 0;
}
return ok;
};
};

因为原始的 boring 函数,需要返回多个值,除了 ok 之外,还有 out_len 这种指针传参

用了这个优化之后,就可以干掉这种包装 C 函数了,代码可以清爽一些

哈哈,原来 golang runtime 为了性能,也搞这种骚操作 …

只能说,咱们这个优化,还是有点普世价值的 …

至少,我是愿意相信的

而且,我也盲目乐观的认为,即使有一天,硅基智能完胜碳基智能,人类也可以找到合适的相处方式

天外有天

一直以来,有一个儿时的场景,让我记忆犹新

即便很多儿时的记忆已经忘却,这个场景确依旧清晰的保留在我脑海中

大约是小学二三年级的样子,一个夏日的傍晚,我打开水龙头,往水缸中注水

由于是水是斜着注入圆形水缸,在水缸中形成了大旋涡

当我就着微弱的夕阳,凝视水缸中的旋涡时,也不知哪里来的思绪,突然冒出一个问题,会不会有一个超级巨人,也是像我一样,凝视一个类似的旋涡,只不过,我们这个地球,就只是他那边旋涡里的一个水分子

我也不记得,儿时那会的知识储备能到什么程度,有没有学过地球围绕太阳公转这样的知识,不过确实有这个场景,却一直清晰的保留在我的脑海中

甚至,在我中学的时候,学过显微镜可以观察细菌生长时候,那个巨人的疑问就变成了,我们这些人类会不会是巨人培养的细菌…

虽然,随着见识的增长,至少目前我接受到的教育是,还没有发现儿时想像的那个巨人,但是,在我的心底里,我依旧觉得那个巨人还是有可能存在的

为什么愿意相信

好吧,回到 AGI 这个问题

按照当前主流的假说,人类这种碳基智能,是从地球上进化出来的,那么为什么不可以再进化出硅基智能呢

听起来是有点匪夷所思,但是,人类的出现,已经是很匪夷所思的事情了

从大猩猩到现在的人类,就挺费解的了,更何况还有更之前的生命起源之谜,宇宙大爆炸之谜

如果能承认这些生命/智能的跳跃式发展,从碳基智能到硅基智能这种跳跃,又有何不可呢

虽然,目前对脑科学还未完全破解,但是,基本已知的是千亿级的神经元,以及神经元之间的连接

神经元网络具备存储能力,以及电信号驱动的计算能力,这个跟硅基计算机是类似的,所以,我是比较相信,人脑作为智能的载体,是可以用计算机模拟构造出来的

并且,当这种硅基智能构造出来之后,进化速度是碳基智能无可比拟的

因为,碳基智能只能依托于碳基载体,碳基智能的发展,是依靠碳基生命的繁衍来迭代延续,而,硅基智能完全可以摆脱碳基生命的这种限制,进化速度或许是不可想象的

没准,哪一天,硅基智能或者更高级的智能,能解答我们起源的疑惑,甚至达到跟儿时巨人对话的高度

当然,再往下想的话,估计就是科幻作家的发挥空间了,至少对我而言,我是保持盲目乐观的

包括,硅基智能的出现,以及出现后碳基和硅基的存在关系

怎么办呢

接着奏乐接着舞,该干啥干啥,不能装鸵鸟,也不必焦虑

不能装鸵鸟

人类的命运咱也犯不着操心,让大佬们去操心吧,咱作为小老百姓,还是先过好自己的生活

而,如今的大模型确实展示了让人眼前一亮的智能,此时的我们,即使为了手中的饭碗,也应该积极的去拥抱它,积极跟它交朋友

至少多用起来,最简单的,用它当做生产力工具,帮助我们更好的完成工作

甚至,我是很认可眼下的窗口期,是可以有比较多的机会,将这种能力帮助到其他人,从而实现商业价值

只不过,于我个人而言,还是老老实实干好自己的网关软件来得实在

不必焦虑

最后呢,咱也不必焦虑,哪一天饭碗被端走了,只要咱们积极拥抱这一切,还是能找到合适的饭碗,没准还可以吃到红利

毕竟,如果把现在这种,大部分人口已经脱离耕地的情况,放在 200 年前,肯定社会是没法运转的

如今面对大模型这种初级硅基智能的影响,我依然盲目乐观的认为,一样的能混口饭吃,哈哈

十一期间,对 Envoy Go 扩展的 cgo API 进行了一波调整

我们之前是直接将 Go 里面 stringslice 等类型的内存地址传给 C,虽然是足够高效了,但是呢,这种是不能开启 cgocheck 的。

上一次还搞了个提案,想让 Go 开一个口子,可以在函数级别关闭 cgocheck,但是,被教育了,哈哈

所以,咱们还是老老实实的,搞成 cgocheck 安全的方式。今天这篇文章,就来分享下实现方式,如果有不对的地方,欢迎拍砖。

说明

先说明几点:

  1. 这里的写法,影响的是每调用 10-100ns 这个量级的性能

    如果不是足够广泛使用的代码,不关心这点性能,大可忽略这些奇技淫巧

  2. 有些方式,依赖较高的 golang 版本,如果想实际应用,越新的版本越好

    最好是用 1.22,是的,下一个要发布的版本

  3. 这里假设我们对 C 函数也有完全的掌控力,可以按照我们期望的方式来随意调整

    如果是现有的 C 函数,不能调整,那就是另一回事了

还债

早在去年,刚开始分享 cgo 的时候,就有人在抱怨,cgo 需要内存拷贝,当时回复的是,以后会分享,这次也算还债来了。

评论

场景

接下来,就用几个典型的示例来说明。

将一个 Go string 传给 C

常规写法

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
void readCString(const char* s) {
printf("%s", s);
}
*/
import "C"

func passGoStringToC(s string) {
cs := C.CString(s)
defer C.free(unsafe.Pointer(cs))

C.readCString(cs)
}

这是常规的搞法,在 Go 侧调用 C 的 malloc 申请堆上内存,然后将 Go string 拷贝到 C 内存,最后才将 C 内存传给 C。

这种写法,对 C 程序来说,是最友好的,标准的 C 字符串,以及完整可控的内存生命周期,只要 Go 还没调用 C.free,C 侧就可以一直使用。

不过,这里需要两次 Go 调 C 的 cgo 调用,也就是 mallocfree 的调用;以及一次内存拷贝,所以性能并不是最优解。

优化写法

1
2
3
4
5
6
7
8
9
10
11
12
/*
#cgo noescape passGoString
#cgo nocallback passGoString
void passGoString(void *str, int len) {
// read memory in the pointer *str
}
*/
import "C"

func passGoStringToC(str string) {
C.passGoString(unsafe.Pointer(unsafe.StringData(str)), C.int(len(str)))
}

在 Envoy Go 扩展中,我们将 Go string 实际的 data 指针,以及字符串长度传给了 C,在 C 直接读取 Go string 的内存。

整个过程没有内存拷贝,甚至,在由于 cgo compiler 的优化,也没有 cgocheck 的检查。

注意:noescapenocallback 需要 Go1.22 版本才支持,可以避免将 data 强制 escape 到堆上,具体见这篇 cgo 内存优化

不过,这里也有一定的局限性,也就是不能灵活控制内存的生命周期,C 侧一定不能保存 Go string 的内存地址,因为这个 C 函数返回之后,Go string 的内存就可能被释放了。

好在通常来说,这已经很足够了,所以,通常情况下,这种写法是最高效的。

从 C 获取一个未知长度的 string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
#cgo noescape getGoString
#cgo nocallback getGoString
void getGoString(unsigned long long *data, int *len) {
*data = (unsigned long long)(&"foo");
*len = 3;
}
*/
import "C"

func getGoStringFromC() string {
var data C.ulonglong
var len C.int
C.getGoString(&data, &len)
unsafeStr := unsafe.String((*byte)(unsafe.Pointer(uintptr(data))), int(len))
return strings.Clone(unsafeStr)
}
  1. 首先,我们直接获取 C 侧内存中,字符串的地址,以及长度

  2. 因为 C 只能有一个返回值,所以我们传了两个变量地址,让 C 来写入

  3. 然后,根据地址和长度,构建 unsafe string,此时引用的是 C 内存

    如果你确定在使用这个 Go string 的时候,这个 C 内存不会被释放,那么使用这个 unsafe string 也是安全的

  4. 如果不能的话,需要在 Go 侧 clone 一份,新的 Go string 使用的 Go GC 的内存了,C 内存可以被释放了。

不过,这里需要注意的是,在 Go clone 完成之前,C 侧字符串内存是不能释放的

从 C 获取一个已知长度的 string

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
void getGoStringByLen(void *data, int len) {
memcpy(data, "foo", len);
}
*/
import "C"

func getGoStringFromCByLen(len uint64) string {
slice := make([]byte, len)
str := unsafe.String(unsafe.SliceData(slice), len)
C.getGoStringByLen(unsafe.Pointer(unsafe.StringData(str)), C.int(len))
return str
}

如果是已知的长度,我们可以在 Go 侧需要分配好内存空间,将 Go 内存地址传给 C,在 C 侧完成内存拷贝

这样 C 侧的内存管理就很简单了,不需要在 clone 之前还得保留内存

传一批 Go string 给 C

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
void passGoStrings(void *data, int len) {
_GoString_* s = data;
for (int i = 0; i < len; i++) {
printf("str: %.*s\n", s[i].n, s[i].p);
}
}
*/
import "C"

func passGoStringMapToC(m map[string]string) {
s := make([]string, 0, len(m)*2)
var pinner runtime.Pinner
defer pinner.Unpin()
for k, v := range m {
s = append(s, k, v)
pinner.Pin(unsafe.StringData(k))
pinner.Pin(unsafe.StringData(v))
}
C.passGoStrings(unsafe.Pointer(unsafe.SliceData(s)), C.int(len(s)))
}

这里入参是一个 map,我们没办法直接把 Go map 传给 c 来用

  1. 先转成一个 slice,也就是 C 认识的数组
  2. 由于 cgocheck 默认开启,我们需要将 Go string 中的 data 指针给 pin 住

cgocheck 开启的模式下,相对于使用 C.CString 拷贝内存,使用 runtime.Pinner 是性能更优的方式。

不过,需要注意的是,Go 1.21 的 runtime.Pinner 只能 pin Go GC 的内存,如果 Go string 是常量,那么 pin 会 panic。但是,我们作为底层库的实现者,并不知道这个 Go string 是常量还是变量。

也就是说,只有 Go 1.22 才能安全的使用 runtime.Pinner,具体见 这个补丁

所以,Envoy Go 扩展的实现里,我们还是用的 C.CString,毕竟 Go 1.22 还没发布…

PS:如果关闭 cgocheck,就不需要 pinner,性能可以有成倍提成,但是,没有 pinner,以后实现 moving GC 了就可能有风险,如果不是特别关注这些性能,最好还是留着 pinner

总结

  1. 可以将 Go 内存传给 C,但是,最好这个内存中,不要再包含指针
  2. 如果场景,必须得包含指针,那就优先考虑 runtime.Pinner
  3. C 函数的函数,尽量是用类型明确的指针,比如 int *,而不是 void *,这样可以 cgo compiler 有可能会帮我们优化

最近翻了翻 cgo compiler 在生成 cgocheck 的代码,也是有考虑优化的,并不是所有内存都会执行 cgocheck 检查,而是会根据 C 函数的参数类型选择性的进行检查。

所以,实际上,也是有一些骚操作,可以欺骗 cgo compiler 来绕过 cgocheck,不过嘛,咱们还是不这么玩了,哈哈

最近混到了两个 title,也算不上啥好值得嘚瑟的

不过嘛,也算是来自外部的认可,咱心里还是挺开心的,不妨继续吹水,继续嗨 o(*≥▽≤)ツ┏━┓

Envoy Senior extension maintainer

Envoy Senior extension maintainer

https://github.com/envoyproxy/envoy/blob/main/OWNERS.md

Envoy 的 maintainer 也分为好几种,相对于 Senior maintainer 和 maintainer,这个 Senior extension maintainer 的含金量是更低的

不过嘛,至少也是 Envoy 官方对于 Golang 扩展的一个认可,以及对未来发展的期许

随着 Envoy Golang 扩展的发展完善,开发者和用户的增多,Envoy 官方对于 Golang 扩展的认可度也越来越高,这个 title 也算一个侧面印证吧

Envoy 社区体验

Envoy 玩了差不多一年了,感觉社区氛围还不错的,比较开放 & 友好

给我感觉比较好的点:

  1. 每周轮流有值班的 maintainer 来及时处理 issue/PR

  2. 如果是赶上深度的领域问题,也会有对应的 maintainer 被拉进来,这种回复不一定会很快,但是,通常都会有回信

Golang contributor

golang cn club

https://golangcn.org/

哈哈,不用找,最后一个才是我。

通常来说,contributor 这种 title 比较的模糊,门槛可高可低,不过,Golang China Club 认可的,咱也算心里不虚了吧~

我没理解错的话,Golang China Club 应该算是国内 Golang 开发者组织的一个民间组织,貌似也不是 Golang 官方的分支组织。

不过呢,Club 里面牛人大佬还是挺多的,能混进去学习,已经是很大的荣幸了,哈哈

之前进 Club 的条件还比较高,要有 10 个 golang/go 主仓库的 effective commits(不能是 typo fix 这种的),后来崔老师说,golang 组织下的其他仓库也可以算了

而我刚好之前搞内存分析工具,给 golang/debug 搞过几个补丁;搞 cgo 优化,给 golang/go 也搞了几个补丁,勉勉强强算凑够十个,哈哈

进群学习

前一阵,Club 群里有个大佬在参加 gophercon,说字节准备把他们搞的 moving GC 贡献给 Golang 官方上游

而,前一阵我在看 cgo 的时候,还觉得 moving GC 这个坑太大了,真搞出来的概率不会很高 …

那篇文章刚发完,打入字节内部的老纪,就透露说,字节真的搞了 moving GC,还看过代码…

现在又公开说要开源了,虽然还没有说时间点,但是既然都公开说了,那应该是也是真动了心思的了。

所以,咱们也还是老老实实为迎接 moving GC 做好准备,哈哈

最后

今天是十一国庆节,祝大家节日快乐~

前一阵又搞了个 cgo 提案,但是,被轮番教训了…

也不知道哪里勾起来他们的兴趣,一度让我怀疑是遇到了杠精,哈哈,可能对面的哥们也是一脸无语 …

背景

Golang 默认对于从 Go 传入到 C 的内存会进行检查,如果内存中有 Golang 指针(指向 Golang GC 管理的内存),就会报 panic。

但是,我们在 Envoy Go 扩展里,为了性能和使用的便捷性,并不想要有这个检查,所以我们是依赖 GODEBUG=cgocheck=0 这个环境来关闭检查。

虽然 Go1.21 引入了 runtime.Pinner,可以通过显式 Pin 的方式,将这些指针也传入 C。

提案

但是,上一次搞 cgo 内存优化 的时候,又想到了一个更好关闭这个检查的方式:

通过 #cgo nocheckpointer functionName 这种编译指令,来函数级别的关闭检查。

这个对我们来说,是更好的选择,上一次也写过好处:

  1. 可以在 C 函数级别指定生效,影响域足够小
  2. 可以写在 Envoy Go 的源码里,完全不需要用户关心

所以,有了这个提案:
https://github.com/golang/go/issues/62425

分歧

很快,还没等到官方回应,就有人出来教育我了。

提炼一下有效的分歧是:只有 cgocheck=0 才能运行的代码,是否是安全的

经过的一系列的正反方的拉锯:

反方:依赖 Go 内置类型的内存布局是未定义的行为,不靠谱

正方:cgo 会生成 _GoString_ 这些 struct 给 C 来使用,依赖是合理的

反方:没有 Pin 的内存,在 C 使用的时候,可能已经被 GC 释放掉了

正方:引用关系还在的,我们在当前这次 C 函数使用是安全的

反方:那 GC 还有可能移动对象呢

正方:至少目前的 GC 是不会移动的,Go 这种指针暴露给用户了的,大概率以后的 GC 也不会搞成移动的;就算以后搞了,大概率也不好保证完全向后兼容,既然到时候会破坏向后兼容,那破坏下这一个编译指令,也不是什么大事了

反方:累了,懒得跟你说了 …

收场

刚开始的时候,ian 大佬还把这个提案,放到了 Incoming proposal 里。

但是,经过一个周末的来回 pk,ian 大佬还是出手把这个给关了。

给出的理由是:making it easier to break the rules,也就是希望更多保持现有规则。

至于规则的原因,甩了一个当时他设计这个规则时的讨论贴:
https://github.com/golang/go/issues/12416

原因

过了一遍 issue,总结这么几个点:

  1. 目前的规则,就是考虑了未来,为 GC 实现移动对象留下空间
  2. 虽然现在可以直接将指针传给 C,未来实现移动 GC 的时候,cgo 编译器会为这些指针生成 Pin 代码,让 GC 不移动这些指针
  3. 但是,不允许指针指向的内存中再含有指针,是一个折中考量。这种情况出现的不多,如果以后也自动 Pin 的话,可能导致实现比较复杂

好吧,这回算是搞清楚了。

这个 cgocheck 检查就是为了未来的移动 GC 而预留的,所以,关闭 cgocheck 检查,至少现在还是安全的。

虽然从我个人的角度看,以后改成移动 GC 的可能性不大,但是官方大佬并不希望给自己埋雷。

填坑 runtime.Pinner

好吧,既然不让搞,那就还是老老实实用 runtime.Pinner 吧。

对于提案中,提到的 runtime.Pinner 的坑:

Pin 指针的时候,指针必须指向 Go GC 中的地址,如果不是的话,会直接 panic。

这个让使用 Pin 变得很难的,比如,常量 string 的 data 指针,就直接指向 rodata 段中的内存,这个作为普通用户是很难判断的。

刚好看到也有个 issue 在抱怨,rsc 大佬说,这种情况可以直接忽略,于是又搞了这个补丁:
https://go-review.googlesource.com/c/go/+/527156

好在这个改动没啥分歧,比较快就被合并了。

Envoy Go

至此,应该可以基于 runtime.Pinner 来实现 cgocheck clean 的 Envoy Go extension 了。

至少默认情况下,可以不依赖于用户手动设置 GODEBUG 环境变量了,毕竟,很多时候一不小心就忘了,而且很多时候,或许大家也不是那么的关心这一丢性能。

对于一些将 string 指针传给 C 的,可能就老老实实的改成 data 指针 + 长度,分开两个参数来搞。对于复杂的传参,那就用 runtime.Pinner 吧。

至于,是否提供一个可选的编译指令,来直接跳过 runtime.Pinner 的开销,这个还得后面再压测一把看看的了。

最后

哈哈,虽然这个提案被喷惨了,不过好歹咱也是玩过微博,混过社区的人,这点破事也算不上啥。

虽然没人喜欢被怼,但是,折腾一波,也搞清楚了 cgocheck 的前因后果,也不白折腾。

咱也就不纠结了,毕竟这种折中取舍的事情,主要看投票权的,估计也就是 ian 大佬说了算了。

cgo 内存优化合并之后,本来说,最近太卷了,等有空一点了,再把这个优化集成到 Envoy Go 里面去。

但是,看到这个优化,还比较的受欢迎,比如:golang-fips/openssl 已经用上了,tinygo 也增加了这个语法支持。

一想,作为始作俑者,咱也不能落后呀,于是又卷了一把自己,趁着周末搞了一把优化,具体见:
https://github.com/envoyproxy/envoy/pull/29396

优化实现

因为这个 cgo 这个优化,只是加了一些指令,帮助 cgo 编译器不再强制将参数 escape 到堆上,所以,我们只需要给一些 C 函数加上新的指令即可。

比如这些有将 Go 指针传入到 C 的:

1
2
3
4
#cgo noescape envoyGoFilterHttpCopyHeaders
#cgo nocallback envoyGoFilterHttpCopyHeaders
#cgo noescape envoyGoFilterHttpSetHeaderHelper
#cgo nocallback envoyGoFilterHttpSetHeaderHelper

不过,还是有两个小的点可以分享一下的。

版本兼容性

因为新增的指令在 1.21 之前是没有的,老版本编译器看到这些会报错,所以,我们需要针对 1.22 才生效。

所以,单独搞了个文件,并加了上编译指令:

1
//go:build go1.22

slice 内存优化

在获取 Header 的 API 中,我们是预先在 Go 侧申请内存,也就是通过 make slice 的方式。

如果我们想让这个内存也留在栈上,除了加上新的指令,还有需要一个改动。

原因是 make slice 的时候,如果长度是一个变量,那么 Go 编译器就会将这个 slice 给 escape 到堆上,因为函数栈空间大小是编译期间就计算好的,没法动态算的。

所以,我们这里取舍一下:

1
2
3
4
5
if num <= maxStackAllocedHeaderSize {
strs = make([]string, maxStackAllocedSliceLen)
} else {
strs = make([]string, num*2)
}

对于大部分 header 数量少于 maxStackAllocedHeaderSize 的,则直接使用栈空间,如果超过了,则还是动态申请,用堆上内存。

这个 maxStackAllocedHeaderSize 太大了也不好,因为是函数栈空间大小是预先计算的,每次执行函数都会预先准备的,虽然相对开销低,但是也不是完全零成本,太大了浪费成本也不能忽视。

所以,现在目前是拍脑袋定的 16,没有仔细的调研/压测过,以后有空再对比下的,哈哈

不过,这种值也只能是尽量适用于大多数场景了,没法完全通用的了。好在这只是很小的一个优化,在多数情况下,其实对整体影响并不大。

优化效果

说实话,我们对这个效果是有预期的,铁定高不了多少。

之前搞的 cgo CPU 优化,那是每次 C 调用 Go 能提升 10+ 倍,减少 1000+ ns,在压测的时候能让简单场景下的 qps 提升约 10%。

而这次的内存优化,在上一篇介绍过单个 string 指针参数的 benchmark,只能提升约 20%,减少 20+ ns,这个量级差距还是很明显的。

这次的压测场景是,在 DecodeHeaderEncodeHeader 都有类似的逻辑:获取 Header 和设置 header,具体可见这里的代码:
https://github.com/doujiang24/envoy-filter-benchmark/tree/main/golang-header-get-set

还是上一次压测的环境,阿里云 2c4g,加 wrk 压测。

QPS 提升约 0.5-1.0%,GC 次数减少约 50%

怎么说呢,虽然确实不太明显,但是很符合预期

网关这种场景里,有很大部分的基础开销是 proxy 的基础逻辑,比如接受网络请求,发送网络请求。要搞一些底层的优化,对整体有较大提升的,其实还是比较难的了。

第二个 cgo 优化的补丁 合并 Go 官方仓库了,哈哈,照例写个文章吹吹水 :)

嗯,之前一波三折的 CPU 耗时优化 算第一弹,这个就算第二弹了~

背景

这个内存优化的背景,之前也写过一篇,记一个 Go 编译器优化提案,详细描述了为什么要搞这个优化。

简单说就是,希望 Go 对象传给 C 的时候,不要强制把 Go 对象逃逸到堆上。因为一个对象能保留在栈上,性能开销是更低的。

具体的做法呢,提供了两个 annotation 标记:

  1. #cgo noescape functionName

    表示传给 functionName 这个 C 函数的 Go 对象,不需要强制逃逸到堆上

  2. #cgo nocallback functionName

    申明 functionName 这个 C 函数,不会有回调 Go 函数,此时 runtime 会加入一个检查,如果实际检测到这个 C 函数回调了 Go 函数,那就直接 panic

想了解详情的朋友,可以回看之前的文章。今天这篇呢,主要记录下实现机制。

实现机制

首先,交代一个背景知识,cgo是 golang 自带的一个小编译工具,比如,我们写的 Go 调用 C 的代码,会先被 cgo预编译一次,然后才用标准的 Go 编译器编译。

感兴趣的朋友,可以移步这篇 从编译器视角看 cgo

了解这个背景之后,相对来而言,这个补丁逻辑是比较简单的了:

  1. 提取 annotation 信息
  2. 跳过生成 _Cgo_use 代码,这样 noescape 的效果就实现了
  3. 从 Go 进入 C 的时候,在 goroutine 上打个标,从 C 调用 Go 函数的时候,检查这个标,这样就可以实现 nocallback的运行时检查

简单来说,这个补丁的改动,基本都在 cgo 这个子编译工具里。

提取 annotation

cgo 这个编译工具,逻辑上来说,也是比较简单的,因为 Go 语法解析生成 AST,还是复用的标准 Go 的那一套。

并且,对于 Go 源码中,在注释中申明的 C 代码,也并没有一个语法解析器,只是简单的按照文本匹配的方式在处理。

至于 C 代码的合法性,以及 C 代码中的函数等信息,Go 直接调用 C 编译器来编译,然后解析 C 编译器产生 dwarf 信息来提取,也不失为一种好的选择。

所以,提取 annotation,我们也只是按照文本匹配的方式处理,找到对应的地方,实现就很简单了。

跳过 _Cgo_use

这个是在 cgo 编译器的输出阶段,根据提取的 annotation 信息,选择性的跳过即可。

不过,这地方有个小插曲,当时跳过了 _Cgo_use 之后,发现还是会强制逃逸到堆上。

经过一番分析,发现还有一个地方,也会触发强制逃逸:func _cgoCheckPointer(interface{}, interface{})

这个跟 _Cgo_use(interface{}) 是一个效果,指针类型的对象转为 interface{} 类型的时候,就会逃逸。

经过 Ian Lance 大佬指点,为 _cgoCheckPointer 加上了 //go:noescape,这事才算了了。

关于 //go:noescape 其实也值得说道说道,不过今天就不聊了,以后有空再说吧。

运行时 nocallback 检查

如果对 C 调用 Go 和 Go 调用 C,这两个 cgo 的调用流程比较清楚的话,这个实现也就比较简单了。

主要是实现了一个 cgoNoCallback(v bool) 的 runtime 函数,通过它来设置 goroutine 中的nocgocallback 的标

  1. 在 Go 调用 C 的时候,调用 _Cgo_no_callback(true)

  2. 从 C 返回 Go 的时候,_Cgo_no_callback(false)

检查则是在 cgocallbackg 中完成,这是 C 调用 Go 中的一个入口函数。

如果对 cgo 调用流程不够了解,且有兴趣的话,可以移步这两篇,C 调用 GoGo 调用 C

效果

搞了一个 benchmark,对于将一个 Go string 传入 C 的场景,87 ns vs 61 ns 提升了 20+ ns,这个效果比我预想的略好一些,哈哈。

1
2
3
4
BenchmarkCgoCall/string-pointer-escape
BenchmarkCgoCall/string-pointer-escape-12 67731663 87.02 ns/op
BenchmarkCgoCall/string-pointer-noescape
BenchmarkCgoCall/string-pointer-noescape-12 99424776 61.30 ns/op

至于在 MoE 框架中的真实效果,后面得空在 Envoy Go 中集成了这个优化,测试一把之后,再来吹水了。

值得一提的是,这个优化的是 Go 调用 C 的场景,也就是当 Go 代码中有比较多的调用,比如 getHeadersetHeader 这种操作的时候,效果才出得来。如果是简单的 Passthrough 场景的压测,那应该是看不出来区别的。

也就是,复杂的场景,交互比较多的场景,效果会更明显。

彩蛋

在实现的过程中发现,对于 Envoy Go 目前依赖 GODEBUG=cgocheck=0 这个环境变量来关闭 cgocheckpointer 的坑,或许有了新的更好的解法:

#cgo nocheckpointer functionName 申明这个 C 函数的参数,不需要 cgocheckpointer,好处是:

  1. 可以在 C 函数级别指定生效,影响域足够小
  2. 可以写在 Envoy Go 的源码里,完全不需要用户关心
  3. 实现也简单,跳过 _cgoCheckPointer 的生成就行

以后有空了,可以试试再搞个 proposal,最近太卷了,没得空玩了~

最后,如果你对 cgo 也感兴趣,欢迎一起交流~

今天聊一个小众的专业话题,L7 网关路由的优先级。

(虽然是个小的点,最后呢,我们也希望以小窥大,从历史的演进中,咂摸出一些道理)

以接入层为例,www.test.com 这一个域名上,可能有多个路由,比如以 /user/ 为前缀的请求,路由到后端的用户服务,以 /order/ 为前缀的请求,路由到后端的订单服务。

很显然,这两条路由规则,是没有冲突的,但是,如果是 /user//user/login/ 这两个前缀呢?那就必须有一套优先级机制。

接下来,看看大家都是怎么玩的,最后,我们再来对比分析。

NGINX

首先,我们看看老派的 NGINX

NGINX 的路由规则,是以 nginx conf 中的 location 配置来表达的,而 NGINX 的 location 匹配优先级,还是比较复杂的,很多用了几年的 NGINX 玩家,也不一定搞得清楚。

汗,作为一个玩了十来年的老炮,我也是搜索才找到的这份规则(这两年没玩了,就忘了,其实以前主要玩 OpenResty,用 NGINX 这套的机会也不多)

1
2
3
4
5
6
location = /uri       # 精确匹配
location ^~ /uri # 前缀匹配,并且在正则之前
location ~ pattern # 区分大小写的正则匹配
location ~* pattern # 不区分大小写的正则匹配
location /uri # 前缀匹配,但是在正则匹配之后
location / # 通用匹配

NGINX 的优先级,是按照:

  1. 精确匹配
  2. ^~ 前缀匹配,前缀越长优先级越高
  3. 正则匹配,按照在配置文件的书写顺序
  4. 普通前缀匹配,还是长度优先
  5. 通用匹配

Envoy

作为云原生时代的后起之秀,Envoy 的处理策略就简单多了,直接甩锅给控制面。

Envoy 作为数据面,只是按照 rds 配置中的路由顺序,来顺序执行。

对,就是这么简单粗暴。

Istio

那么,Envoy 的控制面搭档 Istio 是如何接锅的呢?很遗憾,至少 Istio 定义的 VirtualService 没有接锅。

按照 VirtualService 的推荐玩法,同一个域名的路由,推荐是合并到写到一个 VirtualService 资源,也就是继续甩锅给上层。

谁是上层,写 VirtualService 的那就是人类用户,或者更上层的 console 咯。

如果将同域名的路由写入到不同的 VirtualService,Istio 倒也是会做合并操作(至少 Gateway 场景的 VirtualService 是有合并的),但是呢,优先级排序是基本不管了,唯一的处理是,把通配的路由放到最后。

k8s Gateway API

Istio 的 VirtualService 是继续甩锅,我们继续看看新一代标准,k8s Gateway API

终于,这次没有甩锅,而是做了相对明确的优先级约定:

  1. 精确匹配
  2. 前缀匹配,前缀越长优先级越高
  3. 请求方法匹配
  4. 请求头匹配,数量大的优先
  5. 请求参数匹配,数量大的优先

不过,对于比较复杂的正则匹配,也没有做约定,明确甩锅给实现者了

Ingress

最后,我们看看上一代的 k8s 标准,Ingress。

Ingress 明确约定的匹配方式,只有 URI 精确匹配,和 URI 前缀匹配。

虽然我没有找到明确的优先级规定,但是,这两个的优先级顺序,是有比较强共识,没有啥歧义的。

并且,上一代 Ingress 标准,主要以 NGINX 为数据面,所以,Ingress 路由的优先级,大概率也是跟着 NGINX 走了。

个人点评

个人观点,难免有些偏颇,如有不同意见,欢迎拍砖

NGINX

总体来说,NGINX 的路由优先级是比较完备的,综合考虑到了各种场景(不愧是老牌王者)

比如前后端还没有分离清楚的应用,就会有 /user/ 这种前缀匹配,还有 *.js 这种后缀匹配(也就是正则匹配),那么前缀匹配和正则匹配,也需要有优先级的约定了。

并且 NGINX 还有两种前缀匹配,可以在正则前,也可以在正则后,还是比较完备的了。

但是呢,还是有点复杂的,没有仔细看过,还挺容易踩坑。

Envoy

反观 Envoy 这个后期之秀,就是甩锅好手了,哈哈。

这里的甩锅并非贬义,在云原生的时代,多了一层独立的资源抽象层,底层的简单粗暴,倒也是给上层留下的足够的灵活性,这么做也有相当的合理性。

Istio

不过,Istio 这锅甩得嘛,作为站着说话不腰疼的人来说,可以吐槽这是 Istio 不作为。

但是呢,Istio 也是精力有限,主要投入在 Service Mesh 这块新生市场,而对于 Mesh 场景,一般都没这么些个复杂的路由规则。

k8s Gateway API

好在 k8s Gateway API 顺利接上了这个锅,随着 Istio 对于 k8s Gateway API 支持的跟进,Istio 也算接上了这个锅。

对于 Gateway API,我的理解,就是 Envoy/Istio 这批新生代,在奠定了 Mesh 的主导地位之后,杀向 Ingress 接入场景的冲锋号。所以,在 Gateway API 的标准里,我们能看到 Envoy/Istio 背后技术理念的呈现,以及,背后与 NGINX 派的折中/妥协。

总体来说,我觉得是好事,只有大家坐下来,一起构建统一标准 API 的时候,云原生的技术红利才能更大程度的普及。

Ingress

至于 Ingress,我觉得很像是,在早期 k8s 构建完整版图里,快速推上去的一个方案,有很多的局限性,迟早是要被淘汰的。

不过,应该也不会很快,至少从现在 k8s Gateway API 的推进速度来看,估计还有个两三年才会形成市场趋势(技术趋势其实是很明显的了)。

最后

抽象成标准化资源,是云原生很核心的一点,而然,标准的形成,非一朝一夕之功。

要推进行业级共识,需要这份标准有足够强的适用性,覆盖足够多的场景。而对于,标准制定者/发起人,则需要对行业应用场景有足够的洞见,对上下游环境,以及未来发展趋势也有足够精准的理解。

k8s Gateway API 未来要走的路,依然很长,很多前人(Ingress,Istio CRD)做得不够好的地方,确实看到一些改进。不过,从我个人的角度看,还只是摘了一些比较低垂的果实,更多是沉淀了一些先人的成功经验,还有很多硬骨头要啃。

如果对这块感兴趣的,欢迎一起交流~

MoE 系列的前 7 篇主要介绍了用法,以及安全方面的实现机制。回答的是,如何使用,能否放心用的问题。随着用的人越来越多,咨询性能的问题也多起来了。

本文将提供一个经典网关鉴权(basic auth)的性能压测数据,希望能给大家一些参考。

需要说明的是,性能压测是一门很深的学问,也是跟场景强相关的,不仅仅跟被压测的场景有关,甚至跟运行时的系统环境也强相关。

本文并不旨在提供一个简单粗暴的结论,也不希望通过极端场景来带节奏,而是希望通过一个常见场景的数据,给大家一些相对客观的体感,以及一些定性上的参考。

压测场景

本次压测,选了两个插件场景,作为对比来体现不同方案的性能消耗点

  1. Passthrough

    插件里什么事情也不做,目的是体现插件框架的基础开销

  2. Basic auth 鉴权

    这是一个比较简单的场景,主要逻辑是,获取请求头,按照 basic auth 协议解析,主要是 base64 解码操作,做用户名 & 密码匹配

插件实现方案

除了 Golang 扩展,也选了 Lua 和 Wasm 作为对比,给大家一些参考

  1. Go 实现

    Go 代码只有一份,不过我们压测了 Go 1.20 和 1.21 rc 两个版本,因为 1.21 rc 里包含了一个我们搞了一年多的 cgo 优化
    这次我们也可以实际看看那个 cgo 优化的实际效果

  2. Lua 实现

    最开始只找了一个纯 Lua 的 base64 decode 函数,不过发现性能不太好,又找了个可以被 JIT 的 decode 函数,所以也是两个版本

  3. Wasm 实现

    基于 tetratelabs 开源的 proxy-wasm-go-sdk ,用 tinygo 来编译

代码实现:https://github.com/doujiang24/envoy-filter-benchmark/

值得一提的是,这些插件代码,都只是简单的复用了现成的代码,并没有经过特定的优化,主打的是快速实现。

压测环境

机器:阿里云 ecs.c7.large 机型,2 core CPU & 4 G memory

Envoy 版本:1.26.1

代理上游:Nginx,响应返回 Hello, world

压测取值:CPU 打满,QPS 极限值

统一使用 wrk 压测,单独的机器上执行

1
wrk -d 3 -t 4 -c 100 http://localhost:10000 -H 'Authorization: Basic Zm9vbmFtZTp2YWxpZHBhc3N3b3Jk'

如果有兴趣的话,可以通过上面的代码仓库来复现压测结论

压测数据

场景 极限 QPS 相对基准
裸 Envoy 反向代理 23670 100%
Passthrough - Golang 1.20 16454 70%
Passthrough - Golang 1.21 rc 17877 76%
Passthrough - Lua 插件 21195 90%
Passthrough - Wasm 20802 88%
Basic auth - Golang 1.20 15445 65%
Basic auth - Golang 1.21 rc 16975 72%
Basic auth - Lua 插件 14622 62%
Basic auth - Lua 插件(JIT) 19434 82%
Basic auth - Wasm 14031 59%

数据结论

  1. 从 Passthrough 的对比看,Lua 和 Wasm 的基础消耗比 Golang 低
  2. 从 Basic auth 的对比看,Golang 的整体性能反超 Lua 和 Wasm
  3. 切换到可以 JIT 的 Lua 代码,性能比 Golang 还好
  4. Golang 1.21 比 Golang 1.20 提升了 ~10%,cgo 优化效果确实是不错的

申明:上面的这些绝对数字,意义都不是很大,不同的运行环境可能都会有不少的差距

  1. Go1.20 中 cgo 涉及到的系统调用,在不同的系统上,性能损耗是不太一样的,有时候会有很大的出入
  2. Golang filter 的基础开销,也是跟系统相关的,之前压测过其他的系统,没有这么大(20%+)

个人点评

Lua

Lua 确实很小巧,基础开销不高,很适合这种嵌入式的场景。

如果你的需求很简单,又或者你对 Lua 代码的优化比较擅长,比如善于利用 LuaJIT,用 Lua 来实现插件应该是不错的

不过,通常来说,对于复杂的场景,要写出性能好的 Lua 代码,挑战还是比较大的,甚至还需要搞些 C 代码给 Lua 来调用

Golang

如果需求比较复杂,对于性能优化的奇技淫巧不太擅长,那么用 Golang 来实现插件就是很合适的选择

虽然,Golang 的基础开销要高一些,不过呢

  1. 在真实的场景里,还会有一些其他的业务插件逻辑,相对来说,这些基础开销比例就不那么高了
  2. 这些基础开销,我们也会持续优化,比如这次 Go 1.21 rc 效果就很明显

通常情况下,我们的需求都会比 basic auth 要复杂的,甚至我们还可能有多个需求,如果都是用 Go 来实现,那么这些基础开销,就会被分摊了。所以,复杂场景下,Go 的性能优势会更加明显。

Wasm

Wasm 的基础开销也不高,不过加上业务代码,性能就不太好了,比纯 Lua 还略差一点

或许是 tinygo 生成的 Wasm 不够高效,没有进一步分析了

最后

性能表现我们从一开始就很重视,也一直在优化,包括 Envoy Golang 自身的实现,以及 cgo 的优化。我们还有更多的优化在路上,或者在规划中。

本次的性能压测数据,只是披露了冰山一角,通常的性能压测还会关心内存,延时等,本文没有涉及,并不代表不重要。后续有时间,也会提供相关的压测分析。

最后,我是这么看待 Envoy Golang 的性能的:

  1. 眼下的性能,也是足够好的了,虽然有相对较高的基础开销,但是整体性能还是不错的
  2. 未来优化的空间是不小的,争取基础开销能降低到 Lua / Wasm 一个水准

如果你对本文内容有兴趣,欢迎联系,一起交流~

没错,这就是之前介绍过的 C 调用 Go 快十倍的优化,也是 很快被打脸 的后续。

算上第一次被打脸,这个补丁一共被踢出来三次,好在第四次合入之后,目前已经进入了 Go 1.21 rc1 版本,后续再被踢出来的概率应该比较低了。

第一次被踢

回顾下第一次被踢,简单说,是因为 g0 栈的 stack.lo 只是在当前栈顶往下多留了 32k,只适用于当前这一次 cgo 调用,后续的 cgo 调用,栈顶位置可能已经超出 stack.lo 了,就直接栈溢出了。

Cherry 大佬的修复方案是,将 stack.lo 设置为当前 C 栈的栈顶,也就是 g0 的栈顶,直接跟 C 栈的栈顶对齐了,也就是 g0 的 stack.lo 直接拉到最低。

具体细节,这里就不做过多介绍了,可以看 之前打脸的文章

第二次被踢

第二次是因为 TSAN 检测到了 race,具体情况是:

  1. 从 C 的角度来看:不同的 C 线程在获取 M 的时候,有可能会竞争写同一个 M 的 M1.g0.stack.lo,导致 race。
  2. 但是呢,TSAN 只分析了 C 代码,并不知道在 Go 侧获取 M 是有锁的

所以,这是实际上是 TSAN 的一个误报,具体细节可以看 Michael Pratt 大佬的分析:
https://github.com/golang/go/issues/59678#issuecomment-1512114382

解决办法是,不再传 g0 的地址到 C 了,而是传一个 stack.lo 的地址给 x_cgo_getstackbound 这个 C 函数,这样 C 侧看到的就是两个地址了,就没法分析竞争了,也就不会误报了。

具体可以见这个 CL:
https://go-review.googlesource.com/c/go/+/485500

第三次被踢

第二次被踢还是有点无辜,第三次被踢则是真的有两个 bug:

Go 线程数限制

Go 默认是有最大 10k 线程数的限制,是通过 M 的计数来实现的。

不过原来 extra M 也是计算在内的,因为我们的 extra M 会被 C 线程绑定,这会导致一种情况,如果有 10 k 个 C 线程绑定了 extra M,那么 M 数量直接就到了 10 k 了。

这里的解决方案是,计算 Go 线程数量的时候,extra M 不计算在内,这样就只限制 Go 线程的数量,C 线程的数量,Go 本来也控制不了的。

具体可以见 Michael Pratt 在这里的描述:
https://github.com/golang/go/issues/60004

stack.hi

第一次踢出来是因为 stack.lo 栈顶,那是在 Go 在动态检测是否需要扩栈的时候用到的。

这次是则是因为 stack.hi 栈底,这个则是在一个比较冷门的地方用到的,在 Go 收到系统信号的时候,就有一处判断 adjustSignalStack 是,当前栈帧 sp,是否在 Go stack 中,也就是 sp >= stack.lo && sp < stack.hi,来判断是否为来自非 Go 代码的触发。

具体可以见 Michael Pratt 在这里的描述:
https://github.com/golang/go/issues/60007

关于 cgo 场景下 Go 对于信号的处理,确实是比较绕的,之前也写过一篇文章介绍过:
https://uncledou.site/2022/go-cgo-c-to-go/

解决方案跟 stack.lo 也是类似的,也就是在 C 侧直接将 stack.hi 拉到最高,跟 C 栈的栈底对齐。

具体修复在这里:
https://go-review.googlesource.com/c/go/+/495855/

最后

相对来说,后面两次被踢信息量没那么大,还是 cgo 场景下,g0 这个特殊 goroutine 的栈空间问题。

最后,希望不要再被踢出来了… 确实也是够折腾的…

如果你也有 cgo 的场景,欢迎联系,一起交流学习