关于 FFI 的思考
2021-03-22
跨语言互调用是很多场景都会遇到的一个问题。刚刚看到一篇讨论相关方案的内部文档,引发了对 FFI 的一点思考。场景是这样的,假设有一个主体的功能需要扩展,需要调用外部的一些东西。主体是 Go 语言实现的,外部对接的东西不确定,有可能是 c,也有可能是 python 或者 Go,或者其它的,whatever。
plugin
如果外部也是 Go 写的,那可以考虑 plugin。我们目前的实现就有一套 plugin 的支持,不过有一点蛋疼的是对版本变化特别不友好。它会要求 plugin 的编译版本跟主体的编译是无变化的。否则 plugin 无法加载成功。基本上限制了 plugin 的二进制一起编译一起提供,跨版本或者随便小改一点什么都不兼容了。
cgo
我个人是比较抵触 cgo 的,对于托管内存的语言和 C 语言同一进程内直接交互,在跨语言边界处的资源管理特别恶心,是需要很多心智负担都难以保证不泄漏的。我觉得唯一把这件事情做对了的,是 lua。而 cgo 在我看就是一个不到万不得以不要去碰的东西。
WASM
WASM 还是很有希望的。举个例子在 TiDB 里面的有个计算下推的东西(coprocessor),它把上层计算节点的逻辑计算推到下层数据存储节点,从而实现分布式计算的能力。支持计算下推这一套相同的逻辑就要写两遍了,上层 Go 语言做一遍,下层 rust 语言还要再搞一遍。出了 bug 还得修几个地方,维护成本特别高。搞 TiFlash 之后说不定还要 C艹 再搞一遍,想想都蛋疼。但是如果用 WASM 只实现一套,不同语言共用,就很美好了。
不好 WASM 对网络使用好像有一点限制。另外,今天的主题不是不同语言都统一到 WASM,而是世界上本身已经存在很多语言了,它们之间怎么样去做 FFI。
grpc
文本是唯一通用化的接口,跨了网络的时候,grpc 是一个选项。marshal 了发过去,再把结果发回来 unmarshal。 比如争议的点一个是单机这样走协议是否必要,另一个使用起来比较蛋疼的,就是必须约定 protocol。
关于文本接口这个,unix 一直都是这样子设计的。像 oberon 那种做成整体操作系统是单进程多任务的,或者是 lisp machine 一类就可以不用文本协议了。当然,扯远了一点,回到现实就是 linux 已经统制世界了,只有文本这一种唯一的通用接口。
接下来是我自己对 FFI 的想法,我觉得靠谱的方式应该是约定一套基于栈交换的调用协议,然后如果跨了进程就走文本交互。这篇文章的做法是对的。
先说说基于栈交换的调用协议。前面说到 lua 是唯一把跨语言调用这个事情做对的,为什么?它的协议设计就是一套基于栈交换的调用协议!通过这个栈协议,它把语言边界的资源管理处理得特别好。举一个例子,假设在一个内存托管语言里面的一个对象 obj,作为通过 FFI 调用传递给调用者,然后被调用者存储起来,供之后调用。会发生什么事情?crash,并且是某个意想不到的地方。这个原因是,由外部语言引用到的 obj 对象,在托管内存语言那边,它并不知道有人引用。于是某个时间 obj 可能就被 GC 掉了。而 FFI 把 obj 存储了下次调用的时候,那个指针出去的可能是被释放(甚至被覆盖写过)的空间。lua 的调用协议层就避开了这个坑:在 c 那边无法用 API 把栈上的对象拷贝出来保存。
然后说跨进程的交互。要求所有东西都用 Go 语言是不现实的,而要求一个人维护多种不同的语言也很不舒服。交互尽管有 protobuf 这种东西,要提交约定接口还是很烦。所以 FFI 在协议层只约定前面说的栈交互的协议。其实是内部定一个简单的栈虚拟机,这个太简单了以至于用任何语言写都非常 easy。FFI 其实是把函数调用转换成栈操作的文本协议,然后跨进程发到被调用者,被调用者就把发过一的文本,当作虚拟机指令去操作栈。
这里是有一个例子,计算 cos(1.2) 的调用
d1.2d0d0w1Cp0w3McosSco
- d1.2 进栈一个 double 类型的值 1.2,这个是作为函数参数
- d0d0 进栈两个 double 类型的零,这个是函数签名,接受一个 double 并返回一个 double
- w1 进栈一个 32 位的 1,表明函数接受一个参数
- C 创建一个调用实例(call instance)。前面的 1 和两个 0 参数会被使用掉,调用实例的 handle 会进栈。这个 handle 可以保存下来,后续使用。
- p0 进栈一个 NULL
- w3Mcos 进栈一个字符串 "cos"。首先是进栈一个数字 3,然后 M 用于读取后续的 3 个字节
- S 调用 dlsym() 它会消费掉栈上面的参数 NULL 和 "cos",并且将函数的 handle 压栈。此时栈上面的内容是 1.2,调用实例的 handle,以及函数 handle。
- c 调用栈顶的函数。它会消费掉栈顶的对象,以及下面一个调用实例,调用实例里面有参数个数信息,这里是一个参数。函数调用的结果会放到栈顶
- o 将栈顶的值出栈,发送回调用者
这套机制是很通用的,不依赖于具体的实现语言。一个进程通过向另一个进程发送文本消息,来操作对应的栈,结果也是通过文本返回,再解码。提供 FFI 调用的那个进程,实现一个小的栈操作的代码,以及具体的操作。一般都是其它语言调用 c 的,那这里具体操作就可以是动态链接里面的函数。
上层可以做一个包装,提供更用户友好一些的 API,把函数调用编码成文本的过程封装掉。用户看到的就是这样的调用
(ffi-call "libm.so" "cos" [:double :double] 1.2)
前面参数是动态链接库文件,要调用的函数,第三个参数是签名,输入一个 double 返回一个 doulbe,最后是传递给被调用函数的参数。