0%

限流是网关代理的核心能力之一,Nginx 和 Envoy 都有多种限流机制,来应对不同场景的限流需求

准备挖一个新坑,系列性的介绍 Envoy 的各种限流策略,大致按照这个思路:

  1. 功能介绍
  2. 核心代码实现
  3. 与 Nginx 对比

本文作为开头,先水一篇,来点概览性的介绍 ^_^

限制对象

从限制的对象来说,限流分为三种:

  1. 连接,包括:

    1. 新建连接频率

    2. TLS 握手频率

    3. 并发连接数量

  2. 请求

    一般是请求速率

  3. 带宽

    包括单连接级,和某个范围聚合的

限流生效范围

从限流策略的生效范围来说,又分为两种

  1. 本地限流

    也就是网关代理的单机级别,因为不需要远程通讯,执行效率最高,是很常见的一种方式,不过依赖流量在各个网关实例上相对平均

  2. 全局限流

    跨越网关单机,全局级的限流,因为依赖远程通讯,执行效率会低一些,但是限流更准确

限流用途

限流有两类用途:

  1. 保护上游后端

    因为上游后端的服务能力是有上限的,保证转发给后端的请求量,在后端的服务能力之内,从而保证服务的稳定,不至于产生过载/雪崩,导致服务不可用

  2. 防 CC 攻击

    在对公网服务的网关中,很常规的保护机制,因为公网的流量可以认为是不授信的,而且恶意请求也是经常会发生的事情

Envoy vs Nginx

Nginx 开源已经 20 年了,Envoy 也开源 8 年了,年头都不算小了

我的个人观点,各有优劣势,也都有不够完善的地方,具体的细节,后面我们慢慢分析

在我看来,很大的一个区别是:

Nginx 作为成熟的南北向网关,在防 CC 攻击这块是更成熟的,Envoy 虽然也有用于南北向网关,不过,更多是在东西向网关,在防 CC 攻击这块,是明显更弱的

比如,防 CC 攻击,最常见的的策略是,针对每个 IP 限流,Envoy 还是不支持的(严谨的说,是官方还没有,第三方插件还是可以搞的)。因为在内网调用中,异常的调用来源,都是内部服务,可以追溯到应用 owner 来背锅的

如果是来自公网的异常调用,是很难找到攻击者来背锅的,这就必须依赖网关来做好基础防护了

好久没折腾 cgo,上一篇已经是去年了,cgo 内存优化无缘 golang 1.22 中提到,golang 1.23 会合并回来

眼看 golang 1.23 即将 freeze,于是提了个 PR,想着开启内存优化

还有 bug

很不幸的是,rsc 说之前 boringcrypto 使用了这个优化,导致了一个 CI 失败,需要先修复了

好吧,原来上一篇里有个乌龙,上次我们说,被 revert 的原因是,#cgo 指令的向后兼容性的问题

实际上并不只是这一个原因,而是,确实还有个 bug …

仔细看了那个 issue,是在 arm64 机器上,并且开启 boringcrypto 特性的时候,才会偶发出现的错误

心想这不会是个 arm64 上的坑吧,难道又要挨个翻 arm64 的指令了…

于是,在阿里云上搞了个 arm64 的机器,发现确实有小概率会测试失败

好吧,能复现就是好的开始,虽然是小概率随机

原因

分析过程就不展开了,有点繁琐,咱们直接说原因

首先,我们这个优化,是让编译器,将内存放到栈上,C 直接使用 Goroutine 栈上的地址,来减少 GC 的开销

然后,问题就是,Gorontine 的栈是会移动的,地址变了,导致 C 使用的地址就是非预期的了

copystack

对于栈移动这种场景,之前也是分析过的,应该是没问题的才对的

因为 runtime 移动栈的操作,也就是 copystack 这个函数,是会处理栈上指针的,让新栈上的指针指向新的地址

stackmap

具体的指针调整,涉及的点还比较多,核心的还是每个栈帧的处理,这里就涉及到 stackmap

大致可以这么理解,每个函数的栈空间是固定的,stackmap 就是描述这个栈空间上对象的信息,比如是否为指针

其中,有一个部分就是存了函数的参数信息,到底是一个 pointer 还是 scalar

这次的问题就出在这里,有些代码上看起来是 pointer 的参数,被编译器认为是 scalar

cgo wrapper

还得先回到 cgo 编译器的实现,比如这样一个 Go 调用 C 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
int pointer3(int *a, int *b, int *c, int d) {
return *a + *b + *c + d;
}
#cgo noescape pointer3
#cgo nocallback pointer3
*/
import "C"

//go:noinline
func testC() {
var a, b, c, d C.int = 1, 2, 3, 4
C.pointer3(&a, &b, &c, d)
}

cgo 编译器会生成这样的 wrapper 函数:

1
2
3
4
5
6
7
//go:cgo_unsafe_args
func _Cfunc_pointer3(p0 *_Ctype_int, p1 *_Ctype_int, p2 *_Ctype_int, p3 _Ctype_int) (r1 _Ctype_int) {
_Cgo_no_callback(true)
_cgo_runtime_cgocall(_cgo_cab107a710a2_Cfunc_pointer3, uintptr(unsafe.Pointer(&p0)))
_Cgo_no_callback(false)
return
}

重点在于,虽然参数有 4 个,但是函数体中只使用了 p0 这一个。

导致编译器 SSA 推导优化之后,后面 3 个都是 non-alive 的了,也就在 stackmap 中被标记为 scalar 了,从而在 copystack 中,后面几个指针值就没有被正确处理了

修复方案

知道了原因,其实修复也比较简单了,最早想的是直接用 runtime.Keepalive,不过生成的 cgo wrapper 包里不能用 runtime 包

最后是参考 _Cgo_use 搞了 _Cgo_keepalive,本质上也还是欺骗下 golang 编译器,让它认为后面的参数是有用的,也就不会被分析为 non-alive 了

最终效果,就是生成了这样的 wrapper 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
//go:cgo_unsafe_args
func _Cfunc_pointer3(p0 *_Ctype_int, p1 *_Ctype_int, p2 *_Ctype_int, p3 _Ctype_int) (r1 _Ctype_int) {
_Cgo_no_callback(true)
_cgo_runtime_cgocall(_cgo_cab107a710a2_Cfunc_pointer3, uintptr(unsafe.Pointer(&p0)))
_Cgo_no_callback(false)
if _Cgo_always_false {
_Cgo_keepalive(p0)
_Cgo_keepalive(p1)
_Cgo_keepalive(p2)
_Cgo_keepalive(p3)
}
return
}

是的,多了一些实际上不会执行的 _Cgo_keepalive 的函数调用

shrinkstack

上面分析的是扩栈时的问题,那会不会这种情况呢:

从 Go 进入 C 之后,执行 C 代码的时候,Go runtime 来了个 GC,对 Goroutine 进行缩栈操作呢?

答案是不会的,这个倒是最早在 提案 里就有讨论过的,这种场景下,Goroutine 不会执行 shrinkstack,所以也是安全的

最后

PR 是修复了一版,也请崔老师 trybot 跑了 CI 了,应该问题不大了

不过,Go 1.23 是赶不上了,估计也只能等 Go 1.24 了

不得不说,还是得多谢在 boringcrypto 中尝鲜这个特性的老哥,要不然这个 bug 确实不太好发现

可以想象一下,在 Envoy 的运行过程中,偶发的 panic,比起纯 Go 的测试环境,那查起来是要酸爽很多的了

最近 Envoy Go 扩展有一个比较大的改动:支持全双工流式处理(原来只支持半双工流式处理),趁此做些简单的介绍。

先搞清几个基本概念

什么是流式处理

与流式相对应的就是全缓冲

比如下载一个大文件,全缓冲是完整收到整个文件之后,才会转发给客户端

而流式则是收到一部分,也立即转发多少给客户端

在 nginx 里,默认是全缓冲,proxy_buffering off 就是开启流式处理

envoy 里底层默认是流式处理,除非有 filter 插件不支持流式,必须全缓冲处理

什么是异步处理

流式处理确实也算网关标配了,不过流式加异步就不是了

还是下载一个大文件,我们可以在网关上实现 gzip 压缩,减少网络带宽。

这个 gzip 压缩也是流式处理,但是并不要求异步,因为 gzip 是纯 CPU 计算任务,且 gzip 也有流式的支持。

但是,如果想给 AI 大模型 的流式响应,加上实时的安全审查。

此时,则需要将流式的内容,实时发送给远程审查服务,审查服务返回通过之后,才发给客户端。

这个安全审查,则是需要异步处理了。

这时 Nginx 底层的流式处理机制就玩不转了,因为 nginx 不支持 body 处理阶段挂起请求。
(当然,利用 lua cososcket 来替换 proxy 还是可以玩的)

Envoy 这块的底层机制要灵活一些,body 也支持异步处理,可以随时挂起请求。

Envoy Go 提供了流式的异步处理机制,我们可以使用 golang 的网络库,调用远程的服务,从而轻松的实现上面的安全审查,这是本次修复之前,也能支持的。

什么是全双工

对于普通的 http 请求,可以当做为半双工,也就是,客户端发出请求之后,等待响应之后,才会继续发送请求。
(http pipeline 有点不一样,但是对于 web server 来说,还是一个个来处理的,本质上差异也不大)

但是,对于 websocket,HTTP connnect,GRPC bidirectional 等就不一样了,这类协议里有像 tcp 那样的全双工通信机制,随时都能双向发送数据。

这次修复的问题,就是全双工才会遇到的问题,也是之前没有考虑到位的点。

原因

之前在介绍 并发安全 的时候,提到我们有设计一个所有权机制来解决并发问题,这里的所有权依赖的是一个请求处理的状态机。

而之前没有考虑到全双工的场景,这里的状态机在 c 和 Go 的交互期间,只能有一个存在,也就是,要么在处理请求,要么在处理响应。

所以,在全双工场景,状态机就玩不转了。

修复方式,也比较简单,也就是 c 和 Go 之间,使用两个状态机,请求和响应分别一个,这样就不会冲突了。

只是,知易行难,改动量还是有点大的 …

加上最近内部业务压力有点大,断断续续搞了两个月才搞定 …

演示

这里我们以 websocket 为例,来体验一下 Envoy Golang 对于全双工流式的处理。

这里的后端服务是 gorilla/websocket 的 echo 服务,也就是简单的将客户端发送的数据,原样的返回,这是后端的演示效果:

我们在浏览器端,发送了 foobar,收到的也是 foobar

websocket

流式修改内容

我们在中间加入一个 Envoy 的代理,并且开启我们的 Golang 插件示例

我们在 Golang 插件里,演示一下动态修改 websocket 的数据内容,在客户端发给 server 的数据里,加上 Hello, 前缀,在 server 返回给 client 的数据,加上 , World 后缀

如下图所示,我们在浏览器里发送 foobar,收到的就是 Hello, foo, WorldHello, bar, World

streaming-update

我们概览下 DecodeData 的核心代码:

因为 DecodeData 收到的数据,是原始数据,需要自己解析 websocket frame 的封包协议;也就是代码里的 readFrame,我们参考 gorilla/websocket 做了些小调整,就实现了一个简单的 frame 协议处理

1
2
3
4
5
6
7
8
9
10
11
12
13
bytes := data.Bytes()
f.reqBuffer = append(f.reqBuffer, bytes...)
var fr *frame
f.reqBuffer, fr = readFrame(f.reqBuffer)
if fr == nil {
// already cache into Golang side
data.Reset()
return api.Continue
}

newData := append([]byte("Hello, "), fr.GetData()...)
fr.SetData(newData)
data.Set(fr.Bytes())

调用远程检查服务

这个示例里,我们只是针对上行数据,也就是客户端发给服务端的数据,将每个包先发给一个远程的服务,示例里是 httpbin.org,如果返回了 200,我们将内容修改为 Authorized,否则就修改为 Unauthorized

所以,也就有了如下的演示效果:

streaming-async

同样,概览下 DecodeData 的核心代码:

这里的主要区别是,我们启动了一个新的启程,来做异步的任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bytes := data.Bytes()
f.reqBuffer = append(f.reqBuffer, bytes...)
var fr *frame
f.reqBuffer, fr = readFrame(f.reqBuffer)
if fr == nil {
// already cache into Golang side
data.Reset()
return api.Continue
}
go func() {
bytes := fr.GetData()
ok := checkData(bytes)
if !ok {
bytes = []byte("Unauthorized")
} else {
bytes = []byte("Authorized")
}
fr.SetData(bytes)
data.Set(fr.Bytes())
f.callback.DecoderFilterCallbacks().Continue(api.Continue)
}()
return api.Running

再看看 checkData 的实现,因为 Envoy Go 支持全功能的 Golang,我们可以直接使用 Golang 的网络库来发请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Just a demo to request a remote server
func checkData(data []byte) bool {
req, err := http.NewRequest("POST", "https://httpbin.org/post", bytes.NewBuffer(data))
if err != nil {
api.LogDebugf("new request error: %v", err)
return false
}

httpc := &http.Client{}
resp, err := httpc.Do(req)
if err != nil {
api.LogDebugf("query request error: %v", err)
return false
}
resp.Body.Close()

return resp.StatusCode == 200
}

完整的示例代码可见:https://github.com/doujiang24/envoy-go-websocket-example

最后

网关的流式处理确实也不是什么新鲜的事了,大文件处理场景就已经是刚需了。

只是在 AI 大模型 的生成速度比较慢,又多了一个刚需的场景。

最后,支持全功能的 Golang 语言,优势还是很明显的,现成有的 Golang 库,都能直接使用,没有 tinygo 的限制,哈哈

前两天,在 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,不过嘛,咱们还是不这么玩了,哈哈