0%

前两天,在 envoy-wasm Slack 群里看到,有个 Google 大佬 Yan,又在提把 Rust 搞到 Envoy 里来

这次他提到了 Google 的 crubit,这个 C++ Rust 双向互操作工具,看着还蛮有意思的样子,简单记录下的

用 Rust 开发 Envoy

我印象中,这个在 Envoy 社区其实一直有讨论,包括 Envoy 的创始大佬 Matt 也是 Rust 的粉丝

有两个方向,一个是用 Rust 来写 Envoy 核心,另一个则是写扩展

前者是很激进的,我还没看到有人在尝试,后者则相对容易一些,至少可以看到 snowp 大佬尝试的 PR:用 Rust 来写了个 echo 的 demo,https://github.com/envoyproxy/envoy/pull/25409

哈哈,有点类似于,当年春哥给 Nginx 写的 echo-nginx-module:https://github.com/openresty/echo-nginx-module

不过,这个尝试已经弃坑了,Rust 与 Envoy 这种大型 C++ 交互,并不那么简单

Why not Wasm

Rust 可以编译为 Wasm,并且 Wasm 在 Envoy 也已经有集成,为什么还有人想折腾 Rust 原生扩展呢

群里大佬们也说了不少,结合我的理解聊聊:

  1. Wasm 需要拷贝内存

    Wasm 也有内存安全机制,字节码读写 VM 内的线性内存地址,所以拷贝是少不了的,现实的实现中,一次读取可能会有多次拷贝

  2. API 能力有限

    必须 proxy-wasm-cpp-sdk 包一层 API,Wasm 才能调用,封装链路很长

  3. Rust 语言特性支持不完善

    比如异步函数支持就不够好,需要 Wasm Vm 对异步运行时有支持

Crubit 解决什么

Crubit:C++/Rust 双向互操作工具,这是来自官方的定义:https://github.com/google/crubit

具体来说,包括两个点:

  1. 函数互相调用

    C++ 和 Rust 之间的函数可以互相调用

  2. 内存互相访问

    C++ 和 Rust 之间的数据结构,可以互相访问

理想的期望效果就是,Rust 可以像现在 C++ 一样方便来开发扩展

对比来说,可以简化的是:

  1. 不需要包一层 FFI API 来做跨语言调用,这是现有的非 C++ 扩展都面临的问题
  2. 不需要手写内存 Struct 的映射,甚至可以直接跨语言读写内存

工作机制

简单说,就是分析源码,自动生成 FFI Wrapper 代码

以 struct 为例,从这个 C++ struct:

1
2
3
4
struct Position {
int x;
int y;
};

会自动生成如下的 Rust struct:

1
2
3
4
pub struct Position {
pub x: ::core::ffi::c_int,
pub y: ::core::ffi::c_int,
}

这样,写 Rust 代码的时候,就可以像 Rust 一样来操作 C++ Struct 了,还是挺方便的

尤其是,像 Envoy 这种大型 C++ 工程,有很多的 Struct 嵌套,手写是个不小的工程,且维护成本也不小的

前景如何

首先 Crubit 这个项目目前还是初始 “MVP” 版本,看文档貌似还有不少的限制

并且,Yan 大佬也还只是一个想法,也还没有玩起来,估计任重而道远

如果真搞成了的话,应该用起来会方便很多了,估计会有新扩展用 Rust 来实现了

并且,如果走出去了这一步,以后没准越来越多的 Envoy 核心代码,也会由 Rust 来写了

那就让我们拭目以待吧~

还有硬骨头

不过,即使搞定了 C++ 和 Rust 的互相操作,还有一个硬骨头在前面等着,也就是异步调度

因为,Envoy 有一套基于单线程的异步并发模型,Rust 也有自己的异步抽象,这两个如何顺利的配合呢

简单说,前面解决的都是 Rust 的同步函数在 Envoy 上运行的问题

现在要解决的是,Rust 的异步函数如何在 Envoy 上运行的问题

tokio 可以么

直接用 tokio 这种异步运行时可以么?答案是不太好搞

tokio 是一个多线程的运行时,有自己的调度机制,我们可以简单类比为 Golang 的 runtime 调度

一个异步函数,有可能会被调度到不同的线程上来执行,这个就会打破 Envoy 的单线程并发模型的约定

(当然,理论上也是可以解决,每个 Envoy 线程,绑定一个单线程的 tokio 运行时)

并且,tokio 需要有自己的主循环来触发 epoll_wait,这个与 Envoy 自己的 epoll_wait 会有冲突的

除非将两者合并,或者 Envoy 自己撸一个 Rust 的异步运行时

这其中的工作量嘛,想想都头大

跟 MoE 有啥区别

哈哈,我的主业是搞 MoE 的,自然是需要拿出对比一番的

MoE 是把 Golang 嵌入到了 Envoy,跟这里聊的 Rust 嵌入到 Envoy 是同一个领域的不同方案

首先,我们碰到的问题是一样的

  1. 跨语言的函数调用,内存操作,需要一些 Wrapper 胶水代码
  2. Envoy 有单线程并发模型约定

但是,解题思路不太一样

互操作

MoE 是老老实实自己写 Wrapper 代码,因为定性是扩展开发,其实需要用的 API 并不会太多,总量也比较有限

Rust 方案,其实后续有进入 Envoy Core 代码的可能性,所以这块期望会更高

如果有了更通用的方案,以后 Envoy Core 中的 C++ 代码,慢慢被 Rust 替换掉,也未可知

单线程约束

MoE 比较取巧的绕过了单线程的约束,保留 Goroutine 自由的被调度到其他线程,也就是不需要 Golang runtime 加额外的限制

这样我们可以支持标准的 Golang runtime,现有的 Golang 库直接拿过来就可以用,而不需要改造

但是,我们自己写的 Wrapper 框架代码,又会同时保证 Envoy 的单线程并发约定,所以,也不会有并发问题

而 Rust 的异步机制,并不像 Golang 的 Goroutine 这种完善的重封装,本质上有点类似的 Lua 的协作式协程

甚至,异步运行时都交给第三方库来实现了(好歹 Lua 还是内置提供了 resume 和 yield 这样的调度 API)

所以,Rust 是有机会像 OpenResty 一样,将语言的异步调度和宿主的事件循环结合起来的

最终效果

MoE 一大亮点就是支持原生的 Golang,现有的 Golang 库直接拿来就可以用,而不需要改造

Rust 方案,如果真搞成的话,普世性会比 OpenResty 嵌入 Lua 的效果更好一些

因为 OpenResty 中 Lua 非阻塞库,需要依赖 Nginx 的 event loop 重写一次,比如网络库需要基于 cosocket 重写

而 Rust 的异步运行时,如果与 Envoy 的事件循环结合起来了话,应该现有的异步实现,也可以跑起来的

或许也不需要改造?估计还得取决于具体的实现

不过,虽然我对 Rust 并不熟,不过依然可以笃定这个坑小不了,没那么快到的

最后

还是很期待 Rust 进入 Envoy 的,虽然如果成了的话,会多了一个 Rust 扩展机制,跟 Golang 扩展机制竞争

不过,以我对 Rust 和 Golang 浅显的理解,这两个发展的路线并不太相同,Rust 更偏向于系统编程,Golang 更偏向于业务编程

也就是 Rust 更适合做 Envoy 核心,Golang 更适合做 Envoy 扩展

当然啦,纯粹个人 YY,欢迎大家技术交流~

一句话省流版:API spec 管理方式 + Consumer 类业务网关能力

说来惭愧,作为一个从事网关十来年的老炮,对于 API gateway 的认知却很迷糊,一直不得其要领

初次结缘

关于 API gateway 最初的印象,还是 2015 年的 OpenResty Con,来自 Adobe 张帅的一个分享。他们实现了一个统一的 API 管理平台,把来自内部多个团队的对客 API,给统一管理起来了

当时的大致印象是,哦,一个基于 OpenResty 的网关,用 Lua 来实现认证鉴权之类。但是,对于他提及的 API gateway 却并没有什么认知,只是停留在 OpenResty 数据面的实现机制

妥妥的局限在一个 OpenResty 数据面开发人员的思维,汗…

算是认识

对于 API gateway 作为一个产品的认知,始于 2019 年,那会在春哥公司,搞 OpenResty Edge,有个客户点名想要 Azure APIM 那样的 API 网关

于是,适用体验了一番 Azure APIM,当时两个体会:

  1. 基础能力也没啥特别的,也就是网关标准的那些能力,OpenResty Edge 都能支持
  2. 主要区别是转发策略的管理方式不同,基于现有的底层能力,包一层皮也是可以实现的

现在看来,其实还是没看懂,局限在接入网关的思路,并且还是开发人员底色。完全没有意识到,管理方式不同,对用户意味着什么

很可惜,后来考虑到 ROI,这个客户没有继续下去,我对 API gateway 的认知也就停留在这里了

用户视角

直到去年,因为项目上线,我们一个服务需要对客提供接口,需要经过统一的 API 网关

于是,作为用户,使用了内部 API 网关之后,给了我很强的冲击,第一次完整的从用户视角,从产品的角度来思考 API gateway

这才有了今天这篇文章,也就是,以我入坑接入网关太深的视角,谈谈 API gateway 到底有什么差异

从我的视角(误区)来看,主要是两个差异

1. 管理方式

表面上看,产品提供给用户的管理方式不同,实际上对应的是,用户群体的不同

  1. 接入网关,更多的还是系统运维的视角,更加全局一些
  2. API gateway,侧重的是应用开发者的视角

作为应用开发者,最直观的概念还得是 API(路由这种概念,本质上来自网关自身的实现)

在应用开发者的工作流里,一直是围绕着 API 进行的,设计评审,质量验收,安全验收,都是基于 API 进行的

并且,对于 API 的描述,业界也有了一些通用的标准,比如 OpenAPI Specification

那么开发完成之后的发布环节,最自然的也还是继续使用 API 这个概念,通过 API spec,就把 API 发布出去了,这个体验才自然

另外,对于域名,证书什么的,网关最好直接托管了,用户可以不需要操心。对用户来说,有的用,符合公司统一管控规则就行,具体是什么,其实并不太关心

体验了完整的应用开发流程,当了一回用户之后,管理方式的不同,对用户意味着什么,给我的冲击是最大的

本质上来说,软件架构发展,分工细化的演进结果。服务之间通讯是基于 API 的,不同角色之间沟通也是基于 API 的,网关没道理不是基于 API 的

2. 业务网关

上面是基于产品对客,最直观的管理方式,接下来是网关产品能力的了

接入网关侧重于流量接入,更多承载的是公司级的统一管控策略,看重是性能,稳定性

API gateway 侧重于业务网关,为业务服务的角色,承载的业务级别的通用能力

Consumer

举一个常见的例子,API 发布之后,就会有人来调用,对调用方就需要进行认证鉴权

以接入网关的思路,提供一个认证鉴权的插件能力,已经算到头了

API gateway 则是更近一步,抽象了 Consumer 的概念来进行管理

本质上来说,一个 Consumer 就是一个认证鉴权后的身份 ID,初步看起来也没啥差异。但是,我们还可以基于 Consumer 来进行不同的配置,比如根据 Consumer 的等级,配置不同的限流值

对于业务系统来说,已经算是通用的逻辑了,就可以放到 API gateway 上来承载,但是,对于全站级别的接入网关而言,或许就算不上那么通用了

业务插件能力

除了 Consumer 这种绝大部分 API gateway 都会抽象出来的产品能力,还有很多垂类的业务场景的产品能力

比如,眼下很火的 AI 大模型,对客提供的也是 API,那么,API gateway 也是可以承载一些通用的插件能力的

例如:

  1. 统一的 API 协议,屏蔽各家大模型提供商的接口差异
  2. 统一的 token 二次管理,调用方一个 token 调遍所有大模型
  3. 以及,各种调用 metrics
  4. 甚至,token 的计量

这种对于接入网关来说,这种属于业务逻辑了,太偏业务了,但是对于 API gateway 这种业务网关来说,那就很合适了

极端点说,有两个业务方需要的通用能力,就可以放到 API 网关来承载…

MoE 硬广时间

由此可见,对于 API gateway 来说,插件扩展能力,会是一个刚需,易用且强大的扩展能力,将是 API gateway 的核心卖点之一

嗯,就这么丝滑,到了 MoE 硬广时间了

MoE 将 Golang 嵌入了 Envoy,我们可以通过 Golang 来实现网关插件,这是研发性能和性能的双赢组合

还不了解的,可以看看去年的几篇旧闻,感兴趣的欢迎技术交流~

今年,我们除了继续完善优化,还会继续往上走,提供一个更高阶的产品出来,让我们拭目以待 😄

最后

其实 API gateway 也很好理解,就是一个以 API 为核心的业务网关,就像它的名字那么简单

上面掰扯这么多的差异分析,基本来自我多年作为网关 developer 的偏见…

看不见、看不起、看不懂、不知道现在补上,来不来得及,哈哈~

又是一年结束了,照例来总结总结~

输出是为了更多的思考

算上这篇,2023 一共写了 26 篇文章,很好的完成了计划:大致一个月两篇,有想法就多写写,不想写就歇着,这点我还是挺满意的

因为要输出,平常就会多思考,往深度了想,并且,文章算是一种相对系统性的表达,写作的过程也会让思考更加系统。有时候,写的过程中,还能发现一些理解错误

通常写一篇文章,也得花上个好几个小时,这种思考的深度还是有一些的

哈哈,当然也不是啥精雕细琢的,多是当前所做所想的一些总结而已

我给自己的定位是,主要是从自我总结的角度,来把事情讲清楚,要正确到位,并没有一定要让尽量多的人读懂

哈哈,当然也会尽量写得更清晰一些,只是主题内容本身多是一些技术细节,本来受众也不会多的

目前还没有打算写一些相对普世的科普文章(今年的 MoE 系列可能也算),或许明年会有一些尝试,比如,Envoy 的科普介绍

哈哈,本来今年有这个想法的,只能说太卷了,忙不过来了,哈哈~

效果

哈哈,好在各位看官捧场,今年写的文章还是有一些阅读量,尤其是几篇关于 cgo 的文章,估计是被平台推荐了

年中还舔着脸开过一阵打赏,收到了一批土豪老板的馈赠,搞得我都不太好意思了

后来开了文末广告,也能有一丢丢收入,几杯奶茶钱,主打一个体验生活了,哈哈

最让我看重的是,能吸引有一些朋友来交流,做技术还是蛮孤独的,碰上个同道中人,要懂得珍惜,哈哈~

QCon 广州

今年上半年还参与了一次 QCon 分享,终于是线下的了,能见到真人了,还是有点激动,厚着脸皮蹭了几个饭局,哈哈

QCon 这种输出要求会更高一些,准备自然也会更充分一些

对我来说,一方面是要更加体系化,另外更重要的是,让听众也能有所收获,也不能太随着自己性子来了

哈哈,我自己总体感觉也就还凑合,主办方还给发了个明星讲师,我也是受宠若惊 …

上下半场

哈哈,不吹水了,总结下今年的工作先

以半年为界,今年的上下两个半场,是肉眼可见的的状态不一样了

上半场 - 继续打野

上半年整体是去年的延续,玩玩新东西,搞搞开源,不亦乐乎

虽然年初也有定下今年要内部落地目标,但是呢,现实是推进并不太顺利

大家的精力比较分散,背着更重要的事情要忙,我呢,能推多少算多少,主要产出还是打野~

经过去年的体验把玩,今年打野感觉也更顺畅了一些,目的性更强了,推进力也更强了~

今年开源算是整个几个大活,不过,基本都发生在上半年~

从 github 的统计数据看,上半年的密集程度明显更高~

github-2023

Envoy Go

数了一下,2023 一共给 Envoy 提了近 50 个 PR,主要集中在上半年,基本把 Envoy Go 给怼到了成熟稳定的状态。

也有幸吸引了一些社区玩家,甚至他们还能帮忙发现一些 bug,让我深感幸运的同时,也觉得有些愧疚。好在都能给快速修复,也给足大家信心

除了更成熟稳定,也解决了原来依赖 cgocheck=0,这个使用上的容易踩的坑。这个说实话,多少有点设计上的失误,主要还是对 cgo 了解的不够深入的情况下,对性能的过于执着 …

持续的迭代改进,Envoy 官方也对 Golang 扩展有了更多的认可

下半年我们先是申请了 extension maintainer,官方也是爽快的答应了

不过,发现 extension maintainer 用处比较有限,再申请 maintainer 的时候,Matt 大佬说,还要再多玩一玩其他的模块先,哈哈~

CGO

今年对 cgo 的研究更深入了一些,两个 cgo 优化怼进了 golang 主干

其中 CPU 的优化,去年已经怼了大半年了,今年也是想一鼓作气怼到底的

不过,我感觉,官网对 cgo 并不是很重视的,期间有一段时间感觉很简单。好在后面 iant 和 Cherry Mui 两位大佬都很给力,respect~

明年希望有空搞一搞 extra P 的优化,这个算是 cgo 头上的一朵乌云。

下半场 - 一卷到底

到了下半年,主要是转到内部落地的项目,这次更方面条件合适,机会难得,不搞则以,搞就必须搞成

对我而言,打野快两年了,也该搞点事情了,内部沟通的时候,我也是表了决心的。如果放在战场,那就立下军令状的了,哈哈~

于是,下半场就开始卷起来了,嗯,卷飞了的那种,在广州办公区,我已经算是卷的那一批了(不过,算不上卷王,总还有人比你更卷,哈哈)

好在卷归卷,落地目标也算达成了,我觉得也是一次不错的体验,也有比较多的感悟,挑两个感触比较深的说说~

拿结果

推内部项目是目的性很强的,一切为了拿结果

哈哈,下半年最大的变化估计就是摇人了,在大公司里干活,遇到问题能摇对人,摇得动人,已经是生存的核心竞争力了~

当然还有,各种拉通对齐也是少不了的,每个人都有自己的目标结果,甚至还有一些屁股问题,要推动别人干活,也是不容易的~

规模化作战

当然,以上并没有揶揄的意思,大公司的协作机制就是不一样的

经过下半年一番折腾,也算是比较有深度的体验了,这种人挨人的规模化作战方式

以我浅薄的理解,大公司的好处就是人多,可以规模化作战,此时人与人之间的协作距离就很近了,这种就免不了一定的摩擦

拍脑袋的数据,如果 5 个人的团队,能发挥 4 个人的战力值,也就是人效比 0.8,应该也是不错的了

相对而言,创业公司就比如特种兵,每个人的空间通常都比较大,但是呢,打法肯定是不一样的了

AI

以 ChatGPT 为代表的 AI,确实一直都在持续给我们带来震撼

今年我也一直有在关注了解 AI,不过一直也没有躬身入局进去玩一玩

对我最大的变化就是,搜索引擎用得少了,公司内网的 GPT 反倒是首选的了(感谢公司提供的 GPT,最近还给升级到了 GPT 4.0 Turbo)

之前也写过一篇文章,我内心是愿意相信 AGI 的,但是嘛,眼下而言,我觉得:

  1. AI 好玩的应该是创新应用,基于大模型的能力,给我们的生活带来更美好的体验
  2. 我还是先搞好网关这个老本行吧,把网关搞好,来支撑 AI 创新应用,也算是为 AI 添砖加瓦了

出去走走

今年疫情算是彻底放开了,一家人也算是顺顺利利过来了

虽然上周家里娃赶上甲流,居家呆了差不多一周,不过,小孩好得也挺快的,倒是我感觉快被传染了,还好即时蹭了小孩的药吃了,哈哈~

今年安排了两次家庭出游,一次北京,终于带家里老人坐了飞机,去了北京兜了一圈,也算是完成了老人家的心愿

对我而言,去哪里玩倒不是那么重要,主要是能陪着他们走一走,对我这种常年在外的,这种专程陪伴也是难得

还有十一去了趟潮汕,算是休闲游,扔掉工作,丢带烦恼,享受岁月静好,哈哈~

希望明年也可以继续走起~

最后

啰啰嗦嗦写得有点多了,时间也不早了,就这么多吧,哈哈~

今年整体感觉还是不错的,虽然也有一些遗憾,不过该做的基本都做到了

明年,希望工作上,整体节奏把握得更好一些,更从容一些,不用卷得那么辛苦,也可以顺畅推进

当然,今年下半年开源搞得相对少了些,明年还是要继续玩起的。这不,这周六就要去 Gopher Meetup 深圳站吹水了,哈哈,欢迎面基约起~

生活上嘛,希望顺顺利利的吧,最好能降低点体重,哈哈~

发现年终总结还是工作居多,或许这就是打工人吧,哈哈~

看起来有点标题党的嫌疑,用了「下半场」这么个烂大街的词。

但是,从我个人的经历来看,又是一个非常贴切的描述。

个人经历

那么,就先说说我的个人经历

Nginx 老炮

我以前是搞 OpenResty/Nginx 的,玩了十来年,算是个老炮玩家。

最早接触 Nginx,是 2010 在淘宝实习,很荣幸就在春哥所在的量子统计团队。

不过,工作上跟春哥直接接触不多,好在,那会春哥很喜欢搞分享,听过春哥很多分享,也知道春哥在搞 ngx_lua module。

此后十年,算是亲历了 Nginx 的崛起,从给 PHP 当 webserver,到统一的网关接入,从 CDN 到数据中心,Nginx 已经是网关的主流方案。

而我个人,也在春哥的 OpenResty 社区,一路打怪升级,从开源迷弟,走到老司机,有幸成为了 OpenResty 的核心开发者。

哈哈,春哥是我的贵人,对我帮助非常大,这里暂且不表,以后有机会再单独

Envoy extension maintainer

差不多两年前,加入了蚂蚁的 MOSN 团队,主要搞 MoE 架构,也就是 MOSN on Envoy。

在 Envoy 里面,我们主要是搞 Golang filter 扩展,将 Golang 嵌入 Envoy,支持用 Golang 来写 Envoy 扩展。

在大家的通力协作下,我们也混了个 Envoy 的 extension maintainer。

最近几年,随着微服务的发展,Service Mesh 的兴起,内网的东西向流量,也开始被网关代理管理起来了。

作为后起之秀的 Envoy,也借势成为了东西向网络代理的首选。

下半场

为什么说是下半场了呢

现状

经过多年的赛跑,Envoy 在东西向已经站住了脚跟,在南北向虽然也有建树,但是王者还是 Nginx。

从我个人的体感来看,Envoy 和 Nginx 现在就是一个对象相持阶段。

像接入层这种关键性的基础设施,稳定是第一重要的因素,而从 Nginx 的各种宣发文章中,以及老用户的顾虑中,也可以看到这是 Nginx 的主要卖点之一。

这注定是一场攻坚战,要想决胜也不是一朝一夕之功。

所以,我觉得是下半场了,已经不是跑马圈地的阶段了,而是攻坚战了。

k8s Gateway API

作为下半场,我觉得有两个看点,其一就是,k8s Gateway API。

在 k8s 体系中,承担南北接入流量的是 Ingress,而 Ingress 的数据面实现,主流还是 NGINX Ingress Controller。

Ingress 确实由于早期设计的不合理,给了大家掀桌子,重新洗牌的机会。

在去年,我们内部有过一次关于 k8s Gateway API 的严肃讨论,那时我们注意到 k8s Gateway API 的玩家已经聚集了主要的网关玩家,包括 Nginx 和 Envoy 两大阵营,以及其他多路玩家。

让这些人排排坐起,把事情推进下去,k8s Gateway API 能做成也是必然的事。

而能让这些人排排坐起的主要动因,就是大家对重新洗牌的共同诉求。大家感兴趣的话,可以看看出力多的几家,那就是掀桌子的主力,哈哈。

对于 Envoy 而言,这也是进一步抢夺网关市场的机会,这将是重头戏。

东西南北融合

按照现在的主流选择,东西向用 sidecar,南北向用集中式网关。

而 sidecar 这种部署形态,并不太适合网络作为基础设置的定位。又催生出了 istio ambient mesh 这种架构,其中 waypoint 这个组件,也是以 Deployment 的形式部署了。

在蚂蚁,我们也早在 ambient mesh 之前,就在推动 NodeSentry 这种 Node 化的部署架构,说明大家面临的问题是一样的,sidecarless 也是人心所向。

除了数据面的部署形态的部分趋同,还有控制面的资源定义,k8s Gateway API 原本是为了南北向设计的,但是,以 linkerd 为代表的 Mesh 用户,希望 k8s Gateway API 也可以兼容 Mesh 场景,于是就有了 GAMMA,Gateway API for Mesh Management and Administration。这也将某种程度的,驱动东西南北的融合。

随着技术实现上的融合,使得业务上的融合也变得可能,我相信后续业务上的融合点,也会变得多起来。

做点什么

作为网关领域的从业者,我们注定要躬身入局的,那我们选择做点什么呢?

虽然,在云原生这一波浪潮中,网络作为基础设施,也是被标准化,资源化的重点。数据面的实现,并不是业务关心的第一要素。

但是,从技术的角度看,网关是从数据面为基础向上发展的,所以,我们第一阶段重心投入在数据面。

也就是我们今年在推动 Envoy Golang 扩展,这将很大程度的提升 Envoy 的可扩展能力,这是未来 Envoy 能否成为王者的重要因素。

因为,当资源标准化之后,对于标准的能力,大家都是标配了,能否具备高效的扩展方式,来解决长尾的定制扩展需求,将是未来网关选型的重要因素之一。

在 Envoy 数据面上立住脚之后,我们也在向上发展,投入控制面,做产品。

相信不久之后,大家就可以看到我们在控制面,产品层的产出了

未来

Nginx 和 Envoy 也只是目前网关市场的两个头部玩家,至少在开源圈子里是这样的。

以后会不会有冒出个新的头部玩家,也未可知。不过网关这种基础设施的变更周期也没那么快,没有足够的驱动因素,也很难达成掀桌子的共识。

不管后续又来了哪个玩家,上面这些发展趋势,我估计是很难撼动的了,游戏规则已经基本清晰,接下来就看刺刀怎么拼了。

至于未来谁是云原生时代的王者,作为一个用脚投票了的从业人员,我觉得依然有必要,保持开放的心态。

最终的王者,没准会是 AI,哈哈

前一阵吹水的 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 的基础逻辑,比如接受网络请求,发送网络请求。要搞一些底层的优化,对整体有较大提升的,其实还是比较难的了。