0%

注明:这里说的云原生,是指的以 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 的调用开销和内存拷贝的开销之后,我们认为一次性全拷贝是更优的选择。

最后

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

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

cgo 优化被合并主干之后,很高兴的写了篇流水账,倾诉了一番心酸史,也感谢各位大佬的转发,收获了写公众号以来最多的围观

然而打脸来得也真快,就在昨儿,愚人节的早上,被 revert 了 …

好在 Cherry 大佬还有意继续推进,搞了个 CL 481061 ,Michael 大佬也给了 +2 approve,希望能合回去,赶上 1.21 这个版本。

挖坑小能手

我们看看 Cherry 大佬最新这个 CL 的 commit log 中的描述:

1
2
3
4
5
6
7
8
CL 392854, by doujiang24 <doujiang24@gmail.com>, speed up C to Go
calls by binding the M to the C thread. See below for its
description.
CL 479255 is a followup fix for a small bug in ARM assembly code.
CL 479915 is another followup fix to address C to Go calls after
the C code uses some stack, but that CL is also buggy.
CL 481057, by Michael Knyszek, is a followup fix for a memory leak
bug of CL 479915.

最初我搞的 CL 392854 被合并之后,大佬们已经帮忙搞了三个 bugfix 了,真是惭愧…

最开始的 CL 479255,是一个低级 bug,arm 的汇编少复制了一行,没啥好说的

morestack on g0

CL 479915 则是费了一番功夫才搞明白…

Cherry 大佬的描述在这个 issue 59294

g0 是干啥的

要讲清楚这个 bug,得先介绍一下 g0

每个 M 都有一个 g0,来处理一些特殊的事情,比如扩栈的时候,newstack 函数就运行在 g0 上。

如下 morestack 的代码:

1
2
3
4
5
6
7
// Call newstack on m->g0's stack.
MOVQ m_g0(BX), BX
MOVQ BX, g(CX)
MOVQ (g_sched+gobuf_sp)(BX), SP
MOVQ (g_sched+gobuf_bp)(BX), BP
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // crash if newstack returns

这里是先切换到 g0 栈,再运行的 newstack,结合 g 的数据结构,核心就是这行伪代码

1
SP = g0.sched.sp

问题

但是,在 cgo 启用的时候,g0 并不会像普通的 g 一样,拥有自己的 stack 空间,而是会复用 C 线程的 stack 空间。

我们可以看 needmg0 栈的处理,g0 栈就是当前 C 栈顶往下的 32 kb 地址空间

1
2
3
gp.stack.hi = getcallersp() + 1024 // 这个 1024 没搞懂是为啥
gp.stack.lo = getcallersp() - 32*1024
gp.stackguard0 = gp.stack.lo + _StackGuard

优化之前,每次 c 调用 go 都会执行 needm,也就是 g0 的栈会根据当前 c 栈来动态计算;

但是,优化之后,并不是每次 c 调用 go 都会执行 needm 了,也就是 g0 栈固定在第一次进入 go 时计算的栈空间了。

也就是 stackguard0 是固定的了,如果后续 c 调用 go 的时候,c 栈比第一次高很多,这可能就会导致,runtime 在栈检查的时候认为 g0 栈 overflow 了;而 g0 的栈是不能扩的,也就会抛 morestack on g0 的异常。

栈检查

这里稍微解释下 Go 栈检查的逻辑,比如 amd64 上,我们经常会看到这样的指令:

1
2
3
4
5
6
000000000008b100 <runtime.main.func1>:
8b100: 49 3b 66 10 cmp rsp,QWORD PTR [r14+0x10]
8b104: 76 2d jbe 8b133 <runtime.main.func1+0x33>
...
8b133: e8 e8 8a 02 00 call b3c20 <runtime.morestack_noctxt.abi0>
8b138: eb c6 jmp 8b100 <runtime.main.func1>

核心是这样的伪代码:

1
if rsp <= g.stack.stackguard0; { runtime.morestack_noctx() }

也就是拿栈顶 rspstackguard0 对比,小于 stackguard0,则需要扩栈。

对应上面问题的场景,也就是 stackguard0 是固定的,但是 rsp 则是每次不一样的。

解决办法

Cherry 大佬在 issue 里说的解法有两种:

  1. 每次 c 调用的时候,重新为 g0 计算栈空间,这样就跟优化之前保持一致的行为。
  2. g0 的栈顶地址,设置为 c 的栈顶;本质上就是把 g0 的栈空间搞到最大,这样就不容易达到扩栈的条件

大佬在 CL 479915 选择了第二种解法,patch 都搞到 13 个版本才合并,我一路看下来,最大的感觉是,系统兼容性真难搞。

不过,这里还有一个小隐患,也就是栈顶是直接拉满够用了,但是栈底实际上还是不对的,比如 morestack 切栈的时候,会不会切到 c 栈使用了的空间,把 c 栈写坏了呢?

目前看下来,即使在复用 M 的时候,也会有这段逻辑,也就是会将真实的 rsp 存入 g0.sched.sp,所以看起来貌似还好。不过这个还是比较的 tricky,会不会有其他的坑,其实也说不太好。

1
2
3
4
MOVQ	m_g0(BX), SI
MOVQ (g_sched+gobuf_sp)(SI), AX
MOVQ AX, 0(SP)
MOVQ SP, (g_sched+gobuf_sp)(SI)

又来 revert

而然在 CL 479915 合并之后第二天,Cherry 大佬就来了个 revert CL ,直接把 cgo 优化全 revert 了。原因是新的修复,发现了更多了 breakage。

不过是啥错误,也没有细说,不过,Michael 大佬提了另外一个 CL 481057 ,看起来是内存泄漏导致了 sanitizers 异常。

好在呢,Cherry 大佬又提了个 CL 481061 ,打算重新把 cgo 优化合进来,虽然 Michael 大佬已经给了 +2 approve,但是还没合。

比较让人担心的是,不清楚 google 内部的测试,是否还有其他异常,希望顺利吧 …

最后

其实,之前对于 g0 的理解是不到位的,以为是分配了单独的栈空间,实际上开启 cgo 时 g0 是复用的 c 线程栈。

果然,没理解到位的,终将是要付出代价的。辛苦了大佬们帮忙填坑,哈哈

这是第二次 CL 被 revert 了,真心不好玩,不过,确实是自己没搞好,也挺感谢有这些测试,也很感叹工程化能力真牛,respect!

从去年 3 月 15 日第一次提交,到昨天被合并,预计下一个 1.21 版本可以发布,这个 cgo 优化 搞了一年多,经历了各种波折坎坷,从一个 600 多行的补丁,折腾到了 200 多行,最后到合并的时候,又成了 700 多行,还是挺不容易的。

尤其是,这周是 Go team 的 Quiet Week,一般是不处理外部事务的,但是 Cherry 大佬还是一直在 review,也是挺让我感动的,respect

按照崔老师的话说,这波是逆风局了,因为这个优化可能并不是 Google 内部需要的,或者 Go team 所看重的,能走到现在也是受了很多人的帮助,这里记录下过程,踩过的坑,也对给予过帮助的人,表达谢意

起因

前年底加入 MOSN 团队,开始搞 MoE 以来,就一直有在折腾 cgo,因为 MoE 是重度依赖 cgo,并且是业界少见的,有点把 Go 当做嵌入式,这么频繁的跟 C 宿主交互的玩法。

去年写过这篇文章,详细介绍过这个优化 ,感兴趣的可以去仔细了解。

简单来说,就是 c 调 Go 的时候,需要在 C 线程上伪装 Go runtime 所需要的 GMP 环境,每次从 C 进入 Go,会用 needm 来获取 M 和 g,从 Go 返回 C,会用 dropm 来释放 M 和 g。然而,needmdropm 很重的操作,这个优化也很简单,就是复用,只在 C 线程退出的时候,才释放。

PreBindExtraM C API

起初,对这块机制也不是那么清楚,就搞了个 新增 API 来主动开启优化的提案 ,也就是 C 可以主动调用 PreBindExtraM 来提前绑定 M,然后这个 M 就一直不会被释放了。

经过 ian 和 aclements 两位大佬的提醒,原来注释里 rsc 大佬提了一个 TODO,从 Go 返回 C 的时候,可以不释放 M,但是前提是,需要用 pthread_key_create 注册一个 destructor,在线程退出的时候,可以释放 M,否则,M 就可能会泄漏了。

除了像 Windows 这种,不支持注册 pthread destructor 的系统,都启用优化,也就是第一次获取到 M 之后,就不再释放,直到 C 线程退出。

全部 CPU 体系跑通

接下来的主要工作,主要是通过 destructor,在线程退出的时候,释放 M 了。

因为 destructor 是 os 触发的,使用的是 C function call ABI,但是 dropm 是在一个 Go 函数,自然是 Go function Call ABI,这两者的寄存器使用是两套约定。

所以,参考了 C 调用 Go 会用到的 crosscall2 的实现,引入了 cgodropm 函数,将 C 里面的 callee-save, Go 里面的 caller-save 全部压栈,再调用 dropm。

在 ian 的帮助下,先是跑通了 amd64,然后又跑通了十来个 CPU 体系结构。此时已经是 600 多行的补丁了。

虽然,大部分的汇编是从 crosscall2 抄过来,不过也踩了一些坑,其中印象最深的是 arm64,stack pointer 必须是 16 byte 对齐的,否则会抛 bus error 异常了。

复用 crosscall2

然后是 Michael 大佬来了,他坚持不想要这个大段汇编的 cgodropm,改为复用 crosscall2 + hack cgocallback

这… 几百行的汇编,也没办法,大佬坚持要改,只好重新改了。改完之后,又瘦身到 200 多行的补丁了。

不过,这里依然有一个问题,crosscall2 只是导出给外部 link 的 C 程序使用,并没有导出给 runtime 的 C 程序使用,在这里又开始折腾了好久。

为此研究了一番,Go 和 C 之间的符号导出机制,link 的工作机制。

折腾了一番,搞了个 cgo_crosscall2 的 wrapper 函数,导出给 runtime 的 C 程序使用,但是大佬不认。

runtime C 直接调用 crosscall2

这时 Cherry 大佬出来了,坚持认为应该在 runtime C 代码里面直接调用 crosscall2,如果有问题的话,就是 compiler 或者 link 哪里有问题。

好吧,只提了要求,但是也没有太具体的指导,还是只能自己折腾。

这回是研究了 compiler 的流程,cgo compiler 编译出来的中间结果,会通过 gcc ld 来会判断是否依赖外部符号,原来的 runtime C 是不会引用外部函数的,但是 crosscall2 是在汇编里实现的,在 runtime C 里加了 crosscall2 的调用,这下就捅了马蜂窝了,gcc ld 认为是依赖外部符号的,发生了连锁反应,后面的编译测试都有问题了。

经过了一通折腾,最后在 Cherry 大佬的提议下,搞了一个小 hack,在 cgo compiler 的时候,临时搞个假的 crosscall2 欺骗 gcc ld。

然而,Cherry 大佬最后又反水了,不想这么搞了,因为会在 compiler 流程里,做一些 hardcode 的 hack …

函数指针变量

这时候,Cherry 大佬,希望让我试下,通过汇编将 crosscall2 的函数地址,写入到 C 里面的函数指针变量。

这… 不是跟上一版差不多么?然而,大佬坚持,而且也表达了歉意,那也没辙了,继续折腾。

此时,Go 摸得也差不多,并且大佬也给出了比较具体的建议,所以搞起来,也还算比较顺利。

这一版里,Go 不需要获取 crosscall2 的值,改为提供一个汇编函数,将 crosscall2 写入一个 C 函数指针变量,确实看起来也更干净了。

虽然也费了不少劲,但是还算比较顺利,又通过了测试了。并且 Cherry 和 Ian 两位大佬都给了 approve。

Slow trybots

看起来比较接近了,这次热心网友 thepudds 提议要不要跑一下 slow trybots,也就是一些比较少见的测试环境,包括一些听都没听过的操作系统,CPU 架构。

说到 thepudds,也挺让我感动的,从 PR 一开始就有参与讨论,可前期并没有参与,但是到了后期,我提了 patch,基本很快就帮我 run trybots。

Go 并不是默认就会跑测试,而是需要有权限的人来触发,如果要等 Go team 的人来确认,又是多了一天的往返,所以,有段时间我是请崔老师帮忙,thepudds 热心之后,崔老师也没那么烦我了,哈哈

说到 slow trybots,去年提过一个补丁,合并之后,就是因为 slow trybots 失败了,直接被 revert 了。

有了这种惨痛教训,我也挺希望跑跑 slow trybots,实在不想再被 revert 了。

macOS m1

好吧,果然发现了一些失败,第一个就是 macOS m1。并不是 arm64 都有问题,只有 macOS m1 才能复现…

好在跟公司 IT 临时借了个 m1 …

最后定位是,macOS m1 上,对于 TLS 变量的顺序不太一样,看起来是先清理了 Go 侧的 TLS,然后才调用的 pthread 注册的 destructor …

好吧,又是一通改,那就先把 m 存到 C 侧的 TLS,不再依赖 os 的 clean 顺序了。

AIX ppc64

还有另一个失败,则是完全没听过的 AIX ppc64 环境,这是 IBM 搞的操作系统和 CPU。

只有 AIX 这个系统还有点文档,仔细查阅之后,也没有发现有啥差异 … ppc64 的机器,那是别想搞到手的了 …

就在一筹莫展之际,无意间发现 master 分支上,有一个 ppc64 相关的变更,一看是 ibm 的邮箱,并且还有 +2 的 approve 权限。

尝试发了个邮件,请求帮助,很幸运大佬回复了,在大佬的指引和帮助下,这个兼容性也修复了。

简单来说,AIX 使用的 function call 还是 ELF v1 版本,function call 并不是直接 call 函数地址,而是搞一个 function descriptor。

合并

搞完这些兼容性问题,就是上周了。

等了几天,Cherry 大佬还没回应,这周又 ping 了一次,thepudds 提醒这周是 Quiet Week,心想,好吧,再等一周吧。

很惊喜的是,Cherry 大佬居然出来了,又 review 几轮,基本每天一往返,非常高效。

就在昨天,就这么被合并了,哈哈,真心不容易。

并且,合并之后,还有一个 arm 的 slow trybots 失败了,大佬也很给力,没有 revert,直接帮忙给修了,欧力给!

早上起来发现补丁被合并之后,激动得立马请 chatgpt 写了一段彩虹屁,好好夸了一番,哈哈。

感受

最大的一个感受,be nice!

整体过程中,大佬们大多是不太积极的,不过也可以理解,大佬也是 Google 的打工人,有自己的工作,这种菜鸟提的 patch,谁知道你会不会弃坑呢。

好在从一开始,崔老师就给我打过预防针,这个会很难,有一定的心理预期,所以过程中,肯定有不爽的时候,尤其是各种 ping,发邮件都没有回应的时候,然而,keep nice 我想我还是做到了,哈哈。

好在最后大佬们看到我的耐心,也开始变得积极了,信任也是这么一点点积累出来的。

最后

哈哈,流水账记录了不少,虽然走了不少弯路,但是其中踩过的坑,都是成长,记录下来,也是希望能有一些借鉴意义。

总体来说,这个优化其实也不难,为了搞一个简单的优化,把 compiler,link,runtime 研究了一遍。

有点像,当年搞一个 LuaJIT 的小 bug,把 LuaJIT 的 c code,byte code,IR code,assembly code 研究了一遍。

虽然比较折腾,不过还是蛮有意思的,相信后面还可以给 cgo 搞更多的优化

如果你也重度依赖 cgo 欢迎一起交流~

上一篇我们体验了用 Istio 做控制面,给 Go 扩展推送配置,这次我们来体验一下,在 Go 扩展的异步模式下,对 goroutine 等全部 Go 特性的支持。

异步模式

之前,我们实现了一个简单的 basic auth,但是,那个实现是同步的,也就是说,Go 扩展会阻塞,直到 basic auth 验证完成,才会返回给 Envoy。

因为 basic auth 是一个非常简单的场景,用户名密码已经解析在 Go 内存中了,整个过程只是纯 CPU 计算,所以,这种同步的实现方式是没问题的。

但是,如果我们要实现一个更复杂的需求,比如,我们要将用户名密码,调用远程接口查询,涉及网络操作,这个时候,同步的实现方式就不太合适了。因为,同步模式下,如果我们要等待远程接口返回,那么,Go 扩展就会阻塞,Envoy 也就无法处理其他请求了。

所以,我们需要一种异步模式:

  1. 我们在 Go 扩展中,启动一个 goroutine,然后立即返回给 Envoy,当前正在处理的请求会被挂起,Envoy 则可以继续处理其他请求。
  2. goroutine 在后台异步执行,当 goroutine 中的任务完成之后,再回调通知 Envoy,挂起的请求可以继续处理了。

注意:虽然 goroutine 是异步执行,但是 goroutine 中的代码,与同步模式下的代码,几乎是一样的,并不需要特别的处理。

为什么需要

为什么需要支持 Goroutine 等全部 Go 的特性呢?

有两方面的原因:

  1. 有了 full feature supported Go,我们可以实现很非常强大,复杂的扩展
  2. 可以非常方便的集成现有的 Go 世界的代码,享受 Go 生态的红利
    如果不支持全部的 Go 特性,那么在集成现有 Go 代码的时候,会有诸多限制,导致需要重写大量的代码,这样,就享受不到 Go 生态的红利了。

实现

接下来,我们还是通过一个示例来体验,这次我们实现 basic auth 的远程校验版本,关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.StatusType {
go func() {
// verify 中的代码,可以不需要感知是否异步
// 同时,verify 中是可以使用全部的 Go 特性,比如,http.Post
if ok, msg := f.verify(header); !ok {
f.callbacks.SendLocalReply(401, msg, map[string]string{}, 0, "bad-request")
return
}
// 这里是唯一的 API 区别,异步回调,通知 Envoy,可以继续处理当前请求了
f.callbacks.Continue(api.Continue)
}()
// Running 表示 Go 还在处理中,Envoy 会挂起当前请求,继续处理其他请求
return api.Running
}

再来看 verify 的代码,重点是,我们可以在这里使用全部的 Go 特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 这里使用了 http.Post
func checkRemote(config *config, username, password string) bool {
body := fmt.Sprintf(`{"username": "%s", "password": "%s"}`, username, password)
remoteAddr := "http://" + config.host + ":" + strconv.Itoa(int(config.port)) + "/check"
resp, err := http.Post(remoteAddr, "application/json", strings.NewReader(body))
if err != nil {
fmt.Printf("check error: %v\n", err)
return false
}
if resp.StatusCode != 200 {
return false
}
return true
}

// 这里操作 header 这个 interface,与同步模式完全一样
func (f *filter) verify(header api.RequestHeaderMap) (bool, string) {
auth, ok := header.Get("authorization")
if !ok {
return false, "no Authorization"
}
username, password, ok := parseBasicAuth(auth)
if !ok {
return false, "invalid Authorization format"
}
fmt.Printf("got username: %v, password: %v\n", username, password)

if ok := checkRemote(f.config, username, password); !ok {
return false, "invalid username or password"
}
return true, ""
}

另外,我们还需要实现一个简单的 HTTP 服务,用来校验用户名密码,这里就不展开了,用户名密码还是 foo:bar

完整的代码,请移步 github

测试

老规矩,启动之后,我们使用 curl 来测试一下:

1
2
3
4
5
6
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200"
HTTP/1.1 401 Unauthorized

# valid foo:bar
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200" -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 200 OK

依旧符合预期。

总结

在同步模式下,Go 代码中常规的异步非阻塞,也会变成阻塞执行,这是因为 Go 和 Envoy 是两套事件循环体系。

而通过异步模式,Go 可以在后台异步执行,不会阻塞 Envoy 的事件循环,这样,就可以用上全部的 Go 特性了。

由于 Envoy Go 暴露的是底层的 API,所以实现 Go 扩展的时候,需要关心同步和异步的区别。

当然,这对于普通的扩展开发而言,并不是一个友好的设计,只所有这么设计,更多是为了极致性能的考量。

大多数场景下,其实并不需要到这么极致,所以,我们会在更上层提供一种,默认异步的模式,这样,Go 扩展的开发者,就不需要关心同步和异步的区别了。

欢迎感兴趣的持续关注~

上一篇我们用 Go 扩展实现了 basic auth,体验了 Go 扩展从 Envoy 接受配置。

只所以这么设计,是想复用 Envoy 原有的 xDS 配置推送通道,这不,今天我们就来体验一番,云原生的配置变更。

前提准备

这次我们需要一套 k8s 环境,如果你手头没有,推荐使用 kind 安装一套。具体安装方式,这里就不展开了。

安装 Istio

我们直接安装最新版的 Istio:

1
2
3
4
5
6
7
8
9
10
# 下载最新版的 istioctl
$ export ISTIO_VERSION=1.18.0-alpha.0
$ curl -L https://istio.io/downloadIstio | sh -

# 将 istioctl 加入 PATH
$ cd istio-$ISTIO_VERSION/
$ export PATH=$PATH:$(pwd)/bin

# 安装,包括 istiod 和 ingressgateway
$ istioctl install

是的,由于 Go 扩展已经贡献给了上游官方,Istiod(pilot)和 ingressgateway 都已经默认开启了 Go 扩展,并不需要重新编译。

Istio 配置 Ingress

我们先用 Istio 完成标准的 Ingress 场景配置,具体可以看 istio 的官方文档

配置好了之后,简单测试一下:

1
2
3
4
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200"
HTTP/1.1 200 OK
server: istio-envoy
date: Fri, 10 Mar 2023 15:49:37 GMT

基本的 Ingress 已经跑起来了。

挂载 Golang so

之前我们介绍过,Go 扩展是单独编译为 so 文件的,所以,我们需要把 so 文件,挂载到 ingressgateway 中。

这里我们把上次 basic auth 编译出来的 libgolang.so,通过本地文件挂载进来。简单点搞,直接 edit deployment 加了这些配置:

1
2
3
4
5
6
7
8
9
10
11
12
# 申明一个 hostPath 的 volume
volumes:
- name: golang-so-basic-auth
hostPath:
path: /data/golang-so/example-basic-auth/libgolang.so
type: File

# 挂载进来
volumeMounts:
- mountPath: /etc/golang/basic-auth.so
name: golang-so-basic-auth
readOnly: true

开启 Basic auth 认证

Istio 提供了 EnvoyFilter CRD,所以,用 Istio 来配置 Go 扩展也比较的方便,apply 这段配置,basic auth 就开启了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: golang-filter
namespace: istio-system
spec:
configPatches:
# The first patch adds the lua filter to the listener/http connection manager
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: "envoy.filters.network.http_connection_manager"
subFilter:
name: "envoy.filters.http.router"
patch:
operation: INSERT_BEFORE
value: # golang filter specification
name: envoy.filters.http.golang
typed_config:
"@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config"
library_id: example
library_path: /etc/golang/basic-auth.so
plugin_name: basic-auth
plugin_config:
"@type": "type.googleapis.com/xds.type.v3.TypedStruct"
type_url: typexx
value:
username: foo
password: bar

虽然有点长,但是,也很明显,配置的用户名密码还是:foo:bar

测试

我们测试一下:

1
2
3
4
5
6
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200"
HTTP/1.1 401 Unauthorized

# valid foo:bar
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200" -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 200 OK

符合预期。

接下来,我们改一下 EnvoyFilter 中的密码,重新 apply,再测试一下:

1
2
3
# foo:bar not match the new password
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200" -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 401 Unauthorized

此时的 Envoy 并不需要重启,新的配置就立即生效了,云原生的体验就是这么溜~

总结

因为 Go 扩展可以利用 Envoy 原有的 xDS 来接受配置,所以,从 Istio 推送配置也变得很顺利。

不过呢,Istio 提供的 EnvoyFilter CRD 在使用上,其实并不是那么方便 & 自然,后面我们找机会试试 EnvoyGateway,看看 k8s Gateway API 的体验咋样。

至此,我们已经体验了整个 Envoy Go 的开发 & 使用流程,在云原生时代,人均 Golang 的背景下,相信可以很好的完成网关场景的各种定制需求。

下一篇,我们将介绍,如何在 Go 扩展中使用异步协程。这意味着,我们可以使用的是一个全功能的 Go 语言,而不是像 Go Wasm 那样,只能用阉割版的。

敬请期待 ~

去年 10 月搞的提案,最近终于被接受了,第一个被接受的提案,写篇文章记录下的,嘿嘿

故事

去年在搞 MoE,MOSN on Envoy 新架构,折腾了不少 cgo。在分析 cgo 实现的时候,发现了一个 GC 相关的优化点,于是就搞了个提案。

10 月份提交的,一直没动静。今年 2 月开始活跃了,经过简单几轮讨论,就被接受了,整体还算比较顺利的。

之所以会比较顺利,我估计也是搭上了 Go team 的便车,看起来是他们想优化一下 escape analysis,这个提案刚好可以 match,给 escape analysis 更多的信息。

场景

我们知道 C 函数只有一个返回值,Go 调用 C 的时候,如果需要多个返回值,那么 C 函数如何设计呢?

首先,我们希望 Go 和 C 之间的交互足够简单,每次调用都是独立的。比如,我们并不希望在 C 侧申请内存,然后给 Go 返回内存指针。因为这样的话,我们还需要显式释放内存。

那么,最好的办法,就是按照 C 的套路玩,将 Go 对象内存指针,通过参数传给 C,在 C 里给 Go 的内存对象赋值。比如,在 Envoy Go 扩展里,我们就是这么玩的,可以看这个简单的示例:

1
2
3
4
5
6
7
8
9
func (c *httpCApiImpl) HttpGetIntegerValue(r unsafe.Pointer, id int) (uint64, bool) {
var value uint64
res := C.envoyGoFilterHttpGetIntegerValue(r, C.int(id), unsafe.Pointer(&value))
if res == C.CAPIValueNotFound {
return 0, false
}
handleCApiStatus(res)
return value, true
}

这里,我们将一个 uint64 内存对象的地址传给 C,是不是看起来比较清爽了呢。

问题

单从 Go 代码来看,value 是可以放在 stack 上的,但是,由于有了 cgo 调用,目前的实现,会将 value escape 到 heap 上,这会加重 GC 负载。

尤其在 MoE 这种网关场景中,这种代码是跑在请求处理的热路径上的,并且 Go 代码中可能会比较频繁的进行这类调用,也就是有很多这类 GC 对象 escape 到 heap 中,因此造成的 GC 开销,应该也是不小的。

实现

为了确保总是会 escape,cgo compiler 会生成的这样的 Go 包裹代码:

1
2
3
4
5
6
7
func _Cgo_use(interface{})

func _Cfunc_xxx(xxx) {
if _Cgo_always_false {
_Cgo_use(p0)
}
}

目的是,欺骗 escape analysis,如果 p0 是指针,则总是会逃逸。

并且,由于 _Cgo_always_false 总是为假,在编译优化阶段,这个分支又被优化掉了。

原因

为什么有了 cgo 调用,就需要 escape 呢?

Go stack

这里需要先简单介绍一下 Go stack:

目前版本里,Go stack 大小是可变的,而且是连续内存,当生长/缩小的时候,会重新申请内存段,将老的内存从 old stack copy 到 new stack。

并且 copy 过程中会进行比较复杂的栈上指针映射转换,也就是说,stack 上也可以有指针变量指向 stack 上的地址。

但是,如果指针变量已经传给 C,那是没有办法做指针映射转换了,也就是栈发生移动的时候,C 拿到的地址就是非法的了。

时机

那什么情况下,会出现呢?

最开始想到的是,在进入 C 之后,如果 Go 发生了 GC,可能会触发缩栈,但是后来仔细看代码,进入 C 之后,缩栈是被禁掉了的。

另外一个是,不太常用的场景,C 如果又回调了 Go,那么在 Go 里面是可以伸缩栈了,如果在回到 C,此时 C 中的地址就是非法的了。

解决办法

增加 annotation,让编译器感知不需要 escape,不生成 _Cgo_use 就可以了。

一开始我提的是 go:cgo_unsafe_stack_pointer,Go team 觉得不好,最后是 rsc 大佬提的:#cgo noescape/nocallback

感兴趣的话,可以看 proposal 的讨论:
https://github.com/golang/go/issues/56378

最后

提案是接受了,实现还是有些工作量的,主要是这个 annotation 是放在 C code 里的,目前 Go 对于 C 代码并没有 parser。
剩下的部分,应该是比较简单的了。

后面有空在搞了。