0%

第二个 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 的场景,欢迎联系,一起交流学习

这两天偶然间发现,Go 1.21 版本,将增加 runtime.Pinner类型。简单看了下,想要解决的问题,跟我们搞 Envoy Go 扩展的时候,非常接近。

cgoCheckPointer

得先从 cgo 的一个限制说起:

如果将一个 Go 对象的指针传给 C,那么这个 Go 对象里,是不能再有指针指向 Go 对象的(空指针可以)。

这个检查是在运行时进行的,cgoCheckPointer,如果检查到了,就抛 panic: cgo argument has Go pointer to Go pointer

举个简单的例子,如果我们将一个 Go string 的指针,传给 C,就会触发这个 panic。因为 string 的实现里,就有一个 data 指针,指向了字符串的内容

原因

之所以加这个限制,主要是为了安全:

  1. 如果在 C 里面读取这些指针值,但是,Go 侧也有可能改写这些指针值,从而导致原来这些指针指向的对象,已经被 GC 释放了,C 侧就可能会有非法读了
  2. 甚至,C 侧还可能写这些指针值,如果 GC 已经标记一个 Go 对象可以被删除了,但是 C 又将一个指针指向了这个对象,这就会在 Go 侧产生一个非法引用。

简单说,因为 Go 里面是用指针来表示引用关系的,而 Go 的 GC 是有一套完整的逻辑的,但是 C 代码又不受 Go 控制,所以,就干脆禁止了,也是够简单粗暴的。

原玩法

正如这个 issue 描述的,https://github.com/golang/go/issues/46787

以前这种时候,Go 官方推荐的做法是,在 C 里面申请内存,将 C 的指针返回给 Go,然后 Go 再复制成 Go 对象。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
void *get_string(int len) {
// 在 C 侧申请内存
p = malloc(len);
return p;
}
*/

function getStringFromC() {
p := C.get_string(len)
// 复制一份为 Go string
str := C.GoStringN(p, C.int(len))
// 释放 C 侧内存
C.free(p)
}

这里有两次内存复制,小内存可能还好,如果是大内存,性能影响就比较大了。

新玩法

新的 runtime.Pinner 类型,提供了 Pin 的方法,可以将一个 Go 对象 Pin 在这个 Pinner 对象上。

然后,cgoCheckPointer 检查的时候,如果一个指针指向的对象,是一个被 Pin 住了的,就认为是合法的。

Pin 方法

具体实现上,其实也简单:

Pin 干了两件事情:

  1. 将指针指向的 Span,标记为 pin,以便 cgoCheckPointer 来判断是否已经 Pin 住了
  2. 将指针挂在自身的 refs 切片上,也就是构建了 Pinner 对象和这个 Go 对象的引用关系

第一步,其实并没有真实用途,只是告知检查函数而已。
第二步,则是保证指针指向的 Go 对象,不会被 GC 调用,这样在 C 侧读/写这些指针值,都是安全的。

Unpin 方法

当然,这里面有一个前提是:

  1. C 只能在 Pinner 对象没有被 GC 之前使用这些指针
  2. 以及,用完之后,需要显式的 Unpin,解除对象的 Pin 关系

所以,Go runtime 加了一个检查,如果 Pinner 对象在 GC 的时候,还有 Pin 引用关系,则会抛异常,检查你是否忘记了 Unpin.

示例

如下示例,在 Go 侧预先申请好内存,C 侧完成内存赋值。

虽然道理比较简单,但是写起来,还是比较绕的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
void get_string(void *buf) {
GoSlice* slice = (GoSlice*)(buf);
// 在 C 侧完成内存赋值
memcpy(slice->data, ...);
}
*/
func getStringFromC() {
buf := make([]byte, len)
ptr := unsafe.Pointer(&buf)
sHeader := (*reflect.SliceHeader)(ptr)

// 将 slice 中的 data 指针指向的对象 Pin 住
var pinner runtime.Pinner
defer pinner.Unpin()
pinner.Pin(unsafe.Pointer(sHeader.Data))

// 可以将 slice 对象的指针,安全传给 C 了
C.get_string(ptr)
}

Envoy Go 的玩法

在之前的 内存安全 中,我们有介绍到:

我们采用的是在 Go 侧预先申请内存,在 C++ 侧来完成赋值的方式

这里跟 Pinner 要解决的问题是一样的,我们不希望多一次的内存拷贝,但是我们的搞法更加简单粗暴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (c *httpCApiImpl) HttpCopyHeaders(r unsafe.Pointer, num uint64, bytes uint64) map[string][]string {
strs := make([]string, num*2)
buf := make([]byte, bytes)
// 在 Go 侧预先申请内存
sHeader := (*reflect.SliceHeader)(unsafe.Pointer(&strs))
bHeader := (*reflect.SliceHeader)(unsafe.Pointer(&buf))

res := C.envoyGoFilterHttpCopyHeaders(r, unsafe.Pointer(sHeader.Data), unsafe.Pointer(bHeader.Data))
handleCApiStatus(res)

m := make(map[string][]string, num)
// build a map from the strs slice ...
// 确保 buf 还是被 GC 引用的
runtime.KeepAlive(buf)
return m
}
  1. 我们直接 disable cgocheck(此处其实不依赖,我们其他地方有依赖)
  2. 引用关系,我们自己维护,确保在 C 返回之后,所用到的指针,都是被 Go GC 所引用的

最后

严格来说,Go 调用 C 是没法从语言层面保证内存安全的,cgo 只所以默认就开启了 cgocheck 的指针检查,只是为了尽量避免一些低级的错误;以及为了让后续的 Go 有更大的改动空间,毕竟 Go 还是挺看重向后兼容的。

所以,我们搞 Envoy Go 就采取了比较简单粗暴的玩法,当然,因为我们涉及的 cgo API 并不多,经过严谨的考量实现,是可以保证安全的,同时性能也是更好的。

最后,很高兴看到 cgo 的持续优化,Pinner 的这个改动,其实也不小的,搞了一年多才合并,也挺不容易的。

注明:这里说的云原生,是指的以 k8s 为基础的狭义云原生。

从微服务到云原生,是随着软件行业分工的细化,通用能力的下沉,基础设施的向上抽象,产生的软件架构的又一次演进升级。

  1. 微服务,更像是一个开发框架,开发者是使用框架来开发服务。
  2. 云原生,则是将包含框架在内的底层基础设施,标准化,向上抽象为了平台服务,开发者开发的时候,不需要关心框架的实现,使用平台服务来发布,管控服务。

微服务框架提供了什么

当从单体应用,拆分为微服务之后,需要配套的基础能力,来支撑整个微服务架构。

比如:

  1. rpc 框架,来完成服务之间的调用
  2. 注册中心,用于注册和发现服务
  3. 配置中心,用于提供中心化的配置能力
  4. 限流组件,用于保护服务的稳定性
  5. API 网关,用于对外暴露服务接口

诸如此类,在微服务体系下,相对通用的能力,都可以抽象为一个组件,通过 sdk/接口的方式,让开发者来集成/对接。
比如,对于分布式事务场景的业务,还可以使用分布式事务组件,来简化业务代码的编写。

给开发者的体感,比较接近于单体应用时期的开发框架,比如 PHP 的 Laravel/CodeIgniter。

云原生的变化

云原生是微服务之后的进一步演进,一开始,大家体感比较多的是,向下抽象了硬件资源,微服务不需要部署在物理机上,而是打包成镜像,部署在 pod 里面了;并且,开发者只需要通过 deployment 申明最终的部署形态,k8s 来负责自动完成部署,这是一个很好的向下抽象模型。

另外,我觉得云原生还有一层,它也在向上抽象,将微服务框架的能力进行标准化,基础能力被抽象为标准的模型,最终以申明式的资源形式,暴露给开发者。

效果就是,应用,作为云原生时代的基本单元,所需要的能力模型,愈加的完善,标准。

我们可以通过 deployment 为应用部署,通过 service 来暴露服务,通过 configmap 来申明配置,通过 ingress/k8s gateway api 来构建对外的服务。

标准化,是云原生的一个重要特征,标准化之后,开发者不再需要集成/对接开发框架,在开发应用的时候,可以简单的依赖标准化的能力,减少了一些对接的工作。因为开发框架,通常是跟实现强相关的,不同的框架,实现和能力会有差异。

协作关系的变化

由此,带来了更深层的是变化,是协作关系的变化。

之前写过一篇,云原生是一场协作关系变革,那里重点讲述的是开发者和基础平台的协作关系。

今天,想聊聊另外一个协作关系,是基础平台各个组件的协作关系。

以前各个基础组件,是相对独立的发展思路,只有部分强相关的组件,会有协作关系,比如注册中心和 rpc 框架,协作的实现,也比较依赖于两个组件的对接实现,比如通过接口调用。

当这些组件,都被抽象为标准化资源之后,那么协作关系就变得简单了,可以不依赖接口调用,可以直接在申明式资源上,进行协作。

此时,k8s 的 api server 就成为了一个集中式数据库,各个组件,可以通过一套统一的 watch api,来监听资源的变化,从而实现协作,这也是 k8s 作为基础平台的价值。

举一个简单的例子,当应用扩容的时候,api 网关可以监听到新的 pod 产生,从而更新路由规则,将流量导入到新的 pod 上。

当然,这种协作方式,也有其局限性,至少目前看起来,k8s api server 这个集中式数据库,由于承接了很多组件资源的写入,变更,订阅,所以会有比较大的性能压力,目前的 k8s 单集群规模是受限的,常见是,5000 个节点,100000 个 pod。

最后

当前云原生还是一个发展期,标准化的能力还在不断完善,从我个人的体感来看,向下抽象的能力,相对比较完善了,向上抽象的能力,还有很大的提升空间。

当能力完善之后,应用开发者的体验会更好,应用开发可以更好的发展;基础平台的开发,反倒会进入一个稳态。

不过呢,基础平台也还会继续向上抽象,比如 FaaS。相对于 PaaS 时期,应用作为基本单元,FaaS 的抽象粒度更细,函数作为了基本单元,向上包装了更多的基础能力。向上抽象,估计就是基础平台一直可以吃的饼。

很荣幸第一次上 QCon 分享,2023 QCon 广州站,在编程语言实战专场,搞了个主题分享:解密 MoE - 将 Golang 嵌入 Envoy(C++)。

也趁着这次机会,参加了 QCon 两天的会议,总体而言,收获不少的:

  1. 蹭了两个饭局,认识了不少的老师,听听他们的故事,挺有意思的
  2. 听了两天分享,学习了不少业界的玩法,大家在玩什么,怎么玩的

趁着热乎,简单来篇流水账 :)

第一天

上午的主会场分享,印象比较深的是,商汤关于大模型的分享,作为上一波 AI 兴起的公司,确实能看到商汤在 AI 技术的持续投入。眼下 OpenAI 搞的语言大模型的走得更快,更前面去了,不过嘛,国内的公司,我觉得还是会追上来的,也是必须要追上来的。

编程语言实战专场

下午就是语言专场了,出品人是 Loretta,大家叫她 Lou 姐,一听这名就很霸气,哈哈

第一位张宏波老师,分享了他们新搞的 Moonbit 语言,主要是生成 Wasm。看得出,张老师是搞编译器的大佬,也很重视一些细节,比如 Moonbit 对于 IDE 的支持,还秀了一把修改函数名,整个工程全局生效的效果,确实很赞;编译速度,也搞得飞快。如果没有一点工程师的偏执,不会认真搞这些细节的。个人觉得,Moonbit 是有不错的机会。

其中很巧的是,张老师说还搞过一门 Fan 语言,做元编程的,也就是用于实现其他小语言的,而当年在OpenResty Inc. 春哥也是这么玩的,名字都一样,也是叫 Fan 语言,也是元编程,哈哈。

第二位贺师俊老师,JavaScript 语言标准委员会的大佬,分享了大前端在 js 这个生态搞的,各种语言的尝试,从 alt-JS 到 var-TS,各种语言如数家珍。给我最大的一个印象就是,玩得真花哨。个人感觉,可能是前端更贴近业务描述,对语言的表达能力,要求更好一些。

第三位刘鑫老师,讲的相对具体一些,因为不太熟,不是很有体感,大致是搞了一个 scala 的库,来封装了 Apache commons 这个基础库,优势在于方便的对接 scala 的 option 类型,方便使用。核心就在于,输入是很多类型的,但是自己封装的时候,又不想太累,所以搞了个 Simple ADT。

最后就是我了,前面几位都玩得很花哨,我这个就比较接地气了,属于工程实践类,重点是把 Golang 当嵌入式语言所面临的挑战,以及在跟 Envoy 这个宿主配合时的一些解决思路。

讲完之后的合影,刘鑫老师,Winter 老师,Lou 姐,贺师俊老师,鄙人,哈哈,张宏波老师补觉去了。

语言专场合影

第一场饭局

讲完之后,就跟着 Lou 姐一路蹭吃,先是大堂小酌一杯,然后是主办方的晚宴,餐后又喝起,聊到 11 点多。

Lou 姐和其他几位老师,还有前端大佬 Winter 老师,他们都是以前就认识的,应该之前就约好了的,只有我是不小心混进去的。

面对面交流,各位老师都很比较随和,期间听了不少他们的故事,印象最深的是,类型体操,贺老师和 Winter 老师,关于一个语言的写法,讨论了好久,比如是不是函数式,好不好用之类的,哈哈,其实我不太有体感。

第二天

第二天就是安心的听分享了,主要是听阿里云季敏老师的专场,下一代软件架构,还有 AGI 之类的零散听了听,简单记录几个。

首先,是季敏老师开场,介绍了阿里云的微服务全家桶,Dubbo,OpenSergo,Seata,Higress 等,听下来,主要的变化是,他们打算将这些产品打通,把控制面统一起来,减少一些重复轮子,先做标准化,再做产品化。

我个人一直在思考,微服务成熟之后,是不是就像 k8s 一样,会将所有的这些资源化,标准化,然后再长一个面向开发者的产品层,也就是现在的平台工程。我觉得,这里面的资源化,标准化,才是云原生的精髓。会后也跟季老师做了一些交流,感觉大致思路会比较接近,不过季老师觉得产品化,会在 FaaS 层来实现,个人感觉是,出发点视角不太一样,核心应该是类似的。

腾讯的蔡东赟老师,分享了腾讯的零信任安全架构,听下来主要是面向人的场景,也就是对人的访问/操作,统一鉴权,搞了一个网关来做统一鉴权的事情,后面的服务,主要是办公 OA 类的,只接受从网关鉴权后的请求。针对内部微服务之间的请求调用,目前还很少接入这一套,蔡老师也很坦诚,这个还是业务价值的问题。相对而言,人访问的场景,安全风险更大,业务价值也更大;而服务之间的,由于已经有比较强的管控了,目前的安全风险相对较低,业务价值梳理需要更多时间,零信任的总体思路,还是一样的。

Mobvista 的蔡超老师,分享了GPT API 编程实践,他们是用了 Azure 云上提供的私有部署大模型,这样就不用担心数据安全了。另外,他们原来就有一个内部的 DSL,估计是有了这一层抽象,大模型就更好来完成转义输出了,也就是理解人类的问题,输出这个 DSL?蔡老师介绍了比较多如何 prompt 的场景,DSL 这块倒是介绍的不太多,估计是比较偏内部业务场景,不太方便具体介绍,只是总结了一些规律。

字节的邵杰,蒋林源两位老师,分享了用户体验中台的建设,虽然大部分还是常规的 NLP 技术,比如特征工程之类的,还没用上语言大模型(还在探索),不过,针对用户体验中台的系统化建设,还是挺让人震撼的,针对用户体验这样一个场景,深度挖掘数据,体系化的构建,可以对业务形成很好的支撑。这种事情,业务体量不够大,估计也是做不起来的。

平安的李杨老师,分享了资管投资交易系统的架构演进,听下来主要是业务流程梳理,服务拆分,这里最大的挑战就是,金融行业的背景,决定了稳定性是刚需,这种老系统的架构演进,是很考验全局掌控能力的,上下游的依赖关系,业务诉求的进度协同,等等。

最后一场是,来自虎牙的周健老师,主要是接入层的多云实践,充分利用云上的 IaaS 层资源,搞一个统一的接入层,这个大抵是大部分互联网公司在搞的,IaaS 层用公有云,PaaS 层自建,感觉是有一票互联网公司当下的选择。

第二场饭局

散场之后,在季老师的场里闲聊,居然还碰到之前酷狗的同事邹毅贤,哈哈,他们是约了一个饭局,我呢,就厚着脸皮硬蹭。

这是大参林的伍活欣老师的局,他可是老江湖了,当前在天涯干过总监,很早就在折腾技术架构,当然现在是主攻业务了。

期间,大家对于搞 IT 基础设施 / 数字化,都有一个比较明显的感觉,在降本增效的大环境下,通过 IT/数字化来降本,也是一个属于一个阶段的红利,如果业务不增长,最终降本会降到 IT 部门的头上。

伍老师从更高层的视角,给了一个清晰的比喻,IT 跟业务是有一个心跳关系的,当业务发展的时候 IT 才需要扩张,当业务不发展的时候,IT 的总量一定是趋于收缩的。

最后来一张合影,哈哈

第二场饭局合影

最后

这次 QCon 收获很多,技术人难得的一次 social,靠着脸皮厚,硬蹭了两个局,哈哈

了解了大家的一些玩法,对于行业全局发展有个更清晰的认识,跟大家交流,也更加确信了一些方向。

对于未来,我还是比较期待的,无论是云原生这一波技术演进,以及 AI 大语言模型,还都是有机会的。

最后,感谢 QCon,感谢公司,感谢各位老师,期待下回再见。

PPT

解密 MoE - 将 Golang 嵌入 Envoy

前两篇介绍了内存安全和并发安全,今天来到了安全性的最后一篇,沙箱安全,也是相对来说,最简单的一篇。

沙箱安全

所谓的沙箱安全,是为了保护 Envoy,这个宿主程序的安全,也就是说,扩展的 Go 代码运行在一个沙箱环境中,即使 Go 代码跑飞了,也不会把 Envoy 搞挂。

具体到一个场景,也就是当我们使用 Golang 来扩展 Envoy 的时候,不用担心自己的 Go 代码写的不好,而把整个 Envoy 进程搞挂了。

那么目前 Envoy Go 扩展的沙箱安全做到了什么程度呢?

简单来说,目前只做到了比较浅层次的沙箱安全,不过,也是实用性比较高的一层。

严格来说,Envoy Go 扩展加载的是可执行的机器指令,是直接交给 cpu 来运行的,并不像 Wasm 或者 Lua 一样由虚拟机来解释执行,所以,理论上来说,也没办法做到绝对的沙箱安全。

实现机制

目前实现的沙箱安全机制,依赖的是 Go runtime 的 recover 机制。

具体来说,Go 扩展底层框架会自动的,或者(代码里显示启动的协程)依赖人工显示的,通过 defer 注入我们的恢复机制,所以,当 Go 代码发生了奔溃的时候,则会执行我们注入的恢复策略,此时的处理策略是,使用 500 错误码结束当前请求,而不会影响其他请求的执行。

但是这里有一个不太完美的点,有一些异常是 recover 也不能恢复的,比如这几个:

1
2
3
4
5
Concurrent map writes
Out of memory
Stack memory exhaustion
Attempting to launch a nil function as a goroutine
All goroutines are asleep - deadlock

好在这几个异常,都是不太容易出现的,唯一一个值得担心的是 Concurrent map writes,不熟悉 Go 的话,还是比较容易踩这个坑的。

所以,在写 Go 扩展的时候,我们建议还是小心一些,写得不好的话,还是有可能会把 Envoy 搞挂的。

当然,这个也不是一个很高的要求,毕竟这是 Gopher 写 Go 代码的很常见的基本要求。

好在大多常见的异常,都是可以 recover 恢复的,这也就是为什么现在的机制,还是比较有实用性。

未来

那么,对于 recover 恢复不了的,也是有解决的思路:

比如 recover 恢复不了 Concurrent map writes,是因为 runtime 认为 map 已经被写坏了,不可逆了。

那如果我们放弃整个 runtime,重新加载 so 来重建 runtime 呢?那影响面也会小很多,至少 Envoy 还是安全的,不过实现起来还是比较的麻烦。

眼下比较浅的安全机制,也足够解决大多数的问题了,嗯。

前一篇介绍了 Envoy Go 扩展的内存安全,相对来说,还是比较好理解的,主要是 Envoy C++ 和 Go GC 都有自己一套的内存对象的生命周期管理。

这篇聊的并发安全,则是专注在并发场景下的内存安全,相对来说会复杂一些。

并发的原因

首先,为什么会有并发呢?

本质上因为 Go 有自己的抢占式的协程调度,这是 Go 比较重的部分,也是与 Lua 这类嵌入式语言区别很大的点。

细节的话,这里就不展开了,感兴趣的可以看这篇 cgo 实现机制 - 从 c 调用 go

这里简单交代一下的,因为 c 调用 go,入口的 Go 函数的运行环境是,Goroutine 运行在 Envoy worker 线程上,但是这个时候,如果发生了网络调用这种可能导致 Goroutine 挂起的,则会导致 Envoy worker 线程被挂起。

所以,解决思路就是像 Go 扩展的异步模式 中的示例一样,新起一个 Goroutine,它会运行在普通的 go 线程上。

那么此时,对于同一个请求,则会同时有 Envoy worker 线程和 Go 线程,两个线程并发在处理这个请求,这个就是并发的来源。

但是,我们并不希望用户操心这些细节,而是在底层提供并发安全的 API,把复杂度留在 Envoy Go 扩展的底层实现里。

并发安全的实现

接下来,我们就针对 Goroutine 运行在普通的 Go 线程上,这个并发场景,来聊一聊如何实现并发安全的。

对于 Goroutine 运行在 Envoy 线程上,因为并不存在并发冲突,这里不做介绍。

写 header 操作

我们先聊一个简单的,比如在 Go 里面通过 header.Set 写一个请求头。

核心思路是,是通过 dispatcher.post,将写操作当做一个事件派发给 Envoy worker 线程来执行,这样就避免了并发冲突。

读 header 操作

读 header 则要复杂不少,因为写不需要返回值,可以异步执行,读就不行了,必须得到返回值。

为此,我们根据 Envoy 流式的处理套路,设计了一个类似于所有权的机制。

Envoy 的流式处理,可以看这篇 搞懂 http filter 状态码

简单来说,我们可以这么理解,当进入 decodeHeaders 的时候,header 所有权就交给 Envoy Go 的 c++ 侧了,然后,当通过 cgo 进入 Go 之后,我们会通过一个简单的状态机,标记所有权在 Go 了。

通过这套设计/约定,就可以安全的读取 header 了,本质上,还是属于规避并发冲突。

为什么不通过锁来解决呢?因为 Envoy 并没有对于 header 的锁机制,c++ 侧完全不会有并发冲突。

读写 data 操作

有了这套所有权机制,data 操作就要简单很多了。

因为 header 只有一份,并发冲突域很大,需要考虑 Go 代码与 c++ 侧的其他 filter 的竞争。

data 则是流式处理,我们在 c++ 侧设计了两个 buffer 对象,一个用于接受 filter manager 的流式数据,一个用于缓存交给 Go 侧的数据。

这样的话,交给 Go 来处理的数据,Go 代码拥有完整的所有权,不需要考虑 Go 代码与 C++ 侧其他 filter 的竞争,可以安全的读写,也没有并发冲突。

请求生命周期

另外一个很大的并发冲突,则关乎请求的生命周期,比如 Envoy 随时都有可能提前销毁请求,此时 Goroutine 还在 go thread 上继续执行,并且随时可能读写请求数据。

处理的思路是:

  1. 并没有有效的办法,能够立即 kill goroutine,所以,我们允许 goroutine 可能在请求被销毁之后继续执行
  2. 但是,goroutine 如果读写请求数据,goroutine 会被终止,panic + recover,具体我们下一篇再介绍。

那么,我们要做的就是,所有的 API 都检查当前操作的请求是否合法,这里有两个关键:

  1. 每请求有一个内存对象,这个对象只会由 Go 来销毁,并不会在请求结束时,被 Envoy 销毁,但是这个内存对象中保存了一个 weakPtr,可以获取 C++ filter 的状态。

    通过这个机制,Go 可以安全的获取 C++ 侧的 filter,判断请求是否还在。

  2. 同时,我们还会在 onDestroy,也就是 C++ filter 被销毁的 hook 点;以及 Go thread 读写请求数据,这两个位置都加锁处理,以解决这两个之间的并发冲突。

最后

对于并发冲突,其实最简单的就是,通过加锁来竞争所有权,但是 Envoy 在这块的底层设计并没有锁,因为它根本不需要锁。

所以,基于 Envoy 的处理模型,我们设计了一套类似所有权的机制,来避免并发冲突。

所有权的概念也受到了 Rust 的启发,只是两者工作的层次不一样,Rust 是更底层的语言层面,可以作用于语言层面,我们这里则是更上层的概念,特定于 Envoy 的处理模型,也只能作用于这一个小场景。

但是某种程度上,解决的问题,以及其中部分思想是一样的。

前面几篇介绍了 Envoy Go 扩展的基本用法,接下来几篇将介绍实现机制和原理。

Envoy 是 C++ 实现的,那 Envoy Go 扩展,本质上就相当于把 Go 语言嵌入 C++里 了。

在 Go 圈里,将 Go 当做嵌入式语言来用的,貌似并不太多见,这里面细节还是比较多的。 比如:

  1. Envoy 有一套自己的内存管理机制,而 Go 又是一门自带 GC 的语言
  2. Envoy 是基于 libevent 封装的事件驱动,而 Go 又是包含了抢占式的协程调度

为了降低用户开发时的心智负担,我们提供了三种的安全保障。有了这三层保障,用户写 Go 来扩展 Envoy 的时候,就可以像平常写 Go 代码一样简单,而不必关心这些底层细节。

三种安全

  1. 内存安全

    用户通过 API 获取到的内存对象,可以当做普通的 Go 对象来使用

    比如,通过 headers.Get 得到的字符串,在请求结束之后还可以使用,而不用担心请求已经在 Envoy 侧结束了,导致这个字符串被提前释放了

  2. 并发安全

    当启用协程的时候,我们的 Go 代码将会运行在另外的 Go 线程上,而不是在当前的 Envoy worker 线程上,此时对于同一个请求,则存在 Envoy worker 线程和 Go 线程的并发

    但是,用户并不需要关心这个细节,我们提供的 API 都是并发安全的,用户可以不感知并发的存在

  3. 沙箱安全

    这一条是针对宿主 Envoy 的保障,因为我们并不希望某一个 Go 扩展的异常,把整个 Envoy 进程搞奔溃了。

    目前我们提供的是,Go runtime 可以 recover 的有限沙箱安全,这通常也足够了。

    更深度的,runtime 也 recover 不了的,比如 map 并发访问,则只能将 Go so 重载,重建整个 Go runtime 了,这个后续也可以加上。

内存安全实现机制

要提供安全的内存机制,最简单的办法,也是(几乎)唯一的办法,就是复制。
但是,什么时候复制,怎么复制,还是有一些讲究的。这里权衡的目标是降低复制的开销,提升性能。

这里讲的内存安全,还不涉及并发时的内存安全,只是 Envoy(C++)和 Go 这两个语言/运行时之间的差异。

PS:以前混 OpenResty 的时候,也是复制的玩法,只是有一点区别是,Lua string 的 internal 归一化在大内存场景下,会有相对较大的开销;Go string 则没有这一层开销,只有 memory copy + GC 的开销。

复制时机

首先是复制时机,我们选择了按需复制,比如 header,body data 并不是一开始就复制到 Go 里面,只在有对应的 API 调用时,才会真的去 Envoy 侧获取 & 复制。

如果没有被真实需要,则并不会产生复制,这个优化对于 header 这种常用的,效果倒是不太明显,对于 body 这种经常不需要获取内容的,效果则会比较的明显。

复制方式

另一个则是复制方式,比如 header 获取上,我们采用的是在 Go 侧预先申请内存,在 C++ 侧来完成赋值的方式,这样我们只需要一次内存赋值即可完成。

这里值得一提的是,因为我们在进入 Go 的时候,已经把 header 的大小传给了 Go,所以我们可以在 Go 侧预先分配好需要的内存。

不过呢,这个玩法确实有点 tricky,并不是 Go 文档上注明推荐的用法,但是呢,也确实是我们发现的最优的解法了。

如果按照 Go 常规的玩法,我们可能需要一次半/两次内存拷贝,才能保证安全,这里有个半次的差异,就是我们下回要说的并发造成的。

另外,在 API 实现上,我们并不是每次获取一个 header,而是直接一次性把所有的 header 全复制过来了,在 Go 侧缓存了。
这是因为大多数场景下,我们需要获取的 header 数量会有多个,在权衡了 cgo 的调用开销和内存拷贝的开销之后,我们认为一次性全拷贝是更优的选择。

最后

相对来说,不考虑并发的内存安全,还是比较简单的,只有复制最安全,需要权衡考虑的则更多是优化的事情了。

比较复杂的还是并发时的安全处理,这个我们下回再聊。