在 Go 中实现 JIT 规则引擎 | 青训营笔记

958 阅读4分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天

本文以 CC-BY-SA 4.0 发布。

参考资料:

概念:规则引擎

前几天的青训营课程讲了规则引擎(Rule Engine)。 但是,不同库、不同平台的“规则引擎”其功能都不太一样。 要为它找个定义,我们可能可以从 JSR-94 里的描述着手:

规则引擎可以被看作一些复杂 if/else语句的解释器。 这些被执行的 if/else 语句便被称为“规则”。

  1. 规则引擎可以将事务逻辑、应用逻辑外部化,提倡描述式编程。
  2. 所用的规则可以通过指定的文件格式或是提供的工具进行编写;这些规则通常是和应用程序本体是分开的。
  3. 这些规则处理外部来的输入,并对应产生输出;这些输出常被称为“结论”或是“推论”。
  4. 除此之外,不同的规则引擎可以有不同的执行方式,甚至可能产生除求值以外的各种副作用。

在某种程度上,规则引擎其实是一个极度简化的脚本语言,实际应用中其主要目的是分离程序本体以及事务逻辑。 那么为什么我们不用真正的脚本语言呢?

这里记录一个课上留下印象的 Q&A:

Q: 为什么用规则引擎而不是 Lua 等脚本语言?

A: 因为规则引擎面向的不只是编程人员,更多可能是非编程方向的其它部门的人员。 此时,引入 Lua 等脚本语言就大大加大了这些人的负担,失去了分离程序本体(由程序员负责) 以及事务逻辑(希望能够由其它部门人员直接管理)的意义。

但也正因其简单性,规则引擎也许非常适合用作编译器的 Hello World。

Go 与 JIT

但 Go 与 JIT 的相容性并不好,而现有的 Go 的规则引擎库似乎也没有进行 JIT 化的。其原因大概如下:

  1. 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 的老办法限制并发量。)

  2. 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 生成。

  3. Go 的 ABI 和一般的(如 System V AMD64 ABI)不一致:

    不考虑编译器优化,Go 只使用栈传递参数,而常见的 ABI 几乎都会使用一些寄存器来传递参数。 这点并不太难,几句汇编就可以把对应的参数放到寄存器上了。

  4. 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 编译出的函数。最终性能结果如下:

ExpEng.png

当然,LibJIT 看起来并没有给出编译出的函数的栈空间占用,所以这个实现还是存在着栈溢的风险的,可能不适用于真实场景。

代码放在了 gruel - GitHub