这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天
本文以 CC-BY-SA 4.0 发布。
参考资料:
- 【后端专场 学习资料三】第五届字节跳动青训营
- rustgo: calling Rust from Go with near-zero overhead
- LibJIT - GNU Project - Free Software Foundation
- Sonic - GitHub
概念:规则引擎
前几天的青训营课程讲了规则引擎(Rule Engine)。 但是,不同库、不同平台的“规则引擎”其功能都不太一样。 要为它找个定义,我们可能可以从 JSR-94 里的描述着手:
规则引擎可以被看作一些复杂
if/else语句的解释器。 这些被执行的if/else语句便被称为“规则”。
- 规则引擎可以将事务逻辑、应用逻辑外部化,提倡描述式编程。
- 所用的规则可以通过指定的文件格式或是提供的工具进行编写;这些规则通常是和应用程序本体是分开的。
- 这些规则处理外部来的输入,并对应产生输出;这些输出常被称为“结论”或是“推论”。
- 除此之外,不同的规则引擎可以有不同的执行方式,甚至可能产生除求值以外的各种副作用。
在某种程度上,规则引擎其实是一个极度简化的脚本语言,实际应用中其主要目的是分离程序本体以及事务逻辑。 那么为什么我们不用真正的脚本语言呢?
这里记录一个课上留下印象的 Q&A:
Q: 为什么用规则引擎而不是 Lua 等脚本语言?
A: 因为规则引擎面向的不只是编程人员,更多可能是非编程方向的其它部门的人员。 此时,引入 Lua 等脚本语言就大大加大了这些人的负担,失去了分离程序本体(由程序员负责) 以及事务逻辑(希望能够由其它部门人员直接管理)的意义。
但也正因其简单性,规则引擎也许非常适合用作编译器的 Hello World。
Go 与 JIT
但 Go 与 JIT 的相容性并不好,而现有的 Go 的规则引擎库似乎也没有进行 JIT 化的。其原因大概如下:
-
CGO 可能会使 Go 失去大并发的优势:
CGO 是 Go 程序调用 C API 的一种方式。但是,CGO 会带来一定的开销: 在当前 goroutine 由 CGO 调用 C API 时,Go 会分配一个专属的操作系统线程给当前 goroutine。 这个其实是任何的用户态携程的实现都无法避免的(Java 的 Project Loom 也是): C API 可能会直接调用操作系统的阻塞 API;而在被操作系统阻塞后,Go 就没有办法把对应的线程 分配给其它 goroutine 了,所以 Go 选择预先分配一个专属线程。
在 CGO 调用不太多时这样还好,但是如果有几百几千个 goroutine 同时进行 CGO 调用呢? (当然,我们还是可以利用一些线程时代的 semaphore 的老办法限制并发量。)
-
Go 的汇编格式也与主流汇编语言不同:
直接写汇编可以不经由 CGO 而避开上面的开销。但是这样的风险也很大:你很难知道某一个函数会不会阻塞。 (例如
printf大概率就会阻塞。用到大些的库的话,说不定哪里就会有阻塞的输出日志代码。) (更多讨论请看 proposal: a faster C-call mechanism for non-blocking C functions。)但如果我就是知道它不会阻塞呢——这函数是我亲手 JIT 编译的,绝无可能阻塞? 那么你将需要学习一些奇怪的汇编语法:
TEXT ·CallJit(SB), NOSPLIT, $512-40 // ... CALL call_jit_function+0x00(SB) // ...是的,甚至里面有一个键盘打不出来的非 ASCII 的点
·(0xB7)。 如果真的要写的话建议可以用 mmcloughlin/avo 生成。 -
Go 的 ABI 和一般的(如 System V AMD64 ABI)不一致:
不考虑编译器优化,Go 只使用栈传递参数,而常见的 ABI 几乎都会使用一些寄存器来传递参数。 这点并不太难,几句汇编就可以把对应的参数放到寄存器上了。
-
JIT 时我们需要手动管理栈:
传统 C 语言环境里,操作系统分配的栈大小不可改变,一般操作系统分配的栈不会太小,但真要栈溢了也没办法。 而在 Go 里,每个 goroutine 有自己的栈。Goroutine 栈一开始一般较小,不够了 Go 会进行扩增, 甚至栈继续增大的话还可能会分裂成两个。这些栈操作都是在传统语言里不需要考虑,也是想都不敢想的。 现在,写汇编的时候你要自己管理这些了。(使用
runtime·morestack_noctxt。)
下面来看一个例子,这是字节跳动 sonic 库(使用 JIT 的 Go 的 JSON 库)的部分汇编代码:
TEXT ·__html_escape(SB), NOSPLIT | NOFRAME, $0 - 40
NO_LOCAL_POINTERS
_entry:
MOVQ (TLS), R14 // 这里以及最后的
LEAQ -64(SP), R12 // morestack_noctxt
CMPQ R12, 16(R14) // 负责手动在栈不足时
JBE _stack_grow // 将栈扩容
_html_escape:
MOVQ sp+0(FP), DI //
MOVQ nb+8(FP), SI // 这部分代码
MOVQ dp+16(FP), DX // 是 ABI 转换
MOVQ dn+24(FP), CX //
// =============================
CALL ·__native_entry__+10416(SB) // _html_escape
// =============================
MOVQ AX, ret+32(FP) // ABI 转换
RET
_stack_grow:
CALL runtime·morestack_noctxt<>(SB)
JMP _entry
虽然不太美观,但说到底写起来也不是很难,只是网上资料稀缺罢了。
更多的代码请看 rustgo: calling Rust from Go with near-zero overhead。
结果
就不说中途的各种折腾了吧。代码中使用汇编调用了 C 语言侧利用 LibJIT 编译出的函数。最终性能结果如下:
当然,LibJIT 看起来并没有给出编译出的函数的栈空间占用,所以这个实现还是存在着栈溢的风险的,可能不适用于真实场景。
代码放在了 gruel - GitHub。