为什么你应该在 OpenResty 项目中使用 lua-resty-core

lua-resty-core 是什么?

lua-resty-core 是 OpenResty 组件的一部分。它由两部分组成,一部分是 resty.core.*,提供了对 lua-nginx-module Lua 接口的替换实现;另一部分是 ngx.*,OpenResty 新的接口一般都会放到这里。
跟其他 lua-resty 开头的库一样,lua-resty-core 也是用 Lua 实现的。说到这有人可能会问,既然 lua-nginx-module 已经有了一套 API,为什么还要在 lua-resty-core 里面重新实现一次,而且还是用 Lua?

需要澄清下,lua-nginx-module 提供的 API,并不是完全意义上的用 C 实现的。准确来说,是通过 C 实现,并通过 Lua CFunction 暴露出来的。而 lua-resty-core 提供的 API,也不是表面看上去那样用 Lua 实现的。准确来说,是通过在 lua-nginx-module 里面的以 *_lua_ffi_* 形式命名的 C 函数实现的,并在 lua-resty-core 里面通过 LuaJIT FFI 暴露出来的。所以其实两者都是 C 实现。两者的比较,应该是 Lua CFunction 和 LuaJIT FFI 的比较。

那 LuaJIT FFI 有着怎样的优点,值得在已有一套的基于 Lua CFunction 的接口的前提下,去费大力气重新实现一遍?

FFI + JIT

LuaJIT FFI 的实现深深地根植于解释器自身。如果当前 LuaJIT 正处于 JIT 模式,它会在 FFI 调用时优化 Lua 领域和 C 领域间传参和返回的过程,因此采用 FFI 要比直接调用 Lua CFunction 要快。至于能快多少,则取决于调用时两个领域间数据交换频繁情况。

举个例子,

init_by_lua_block {
    -- 注释下面一行来禁用 lua-resty-core
    require 'resty.core'
}

location /foo {
    content_by_lua_block {
        local s = ("test"):rep(256)
        local start = ngx.now()
        for _ = 1, 1e6 do
            ngx.md5(s)
        end
        ngx.update_time()
        ngx.say(ngx.now() - start)
    }
}

在启用了 lua-resty-core 的情况下(走 FFI 路径),用时是

¥ curl localhost:8888/foo
2.6159999370575

禁用 lua-resty-core 后(走 CFunction 路径),用时是

¥ curl localhost:8888/foo
2.664999961853

两者并无明显区别。

不过换个需要跟 C 领域频繁交互的调用,

local s = ("test"):rep(256)
local start = ngx.now()
for _ = 1, 1e8 do
    ngx.ctx.test = s
    local r = ngx.ctx.test
end
ngx.update_time()
ngx.say(ngx.now() - start)

启用了 lua-resty-core,用时

¥ curl localhost:8888/foo
1.800999879837

禁用后用时

¥ curl localhost:8888/foo
38.345999956131

两者便有天壤之别。

ngx.ctx 一样,会收益于 FFI + JIT 的接口,还有 ngx.shared.dictngx.re 这样两类。(当然对它们的加成相对没有那么显著)

前面在提到 FFI 优化的时候,我特意强调了“当前 LuaJIT 正处于 JIT 模式”。如果当前 LuaJIT 处于解析器模式,很不幸,FFI 调用会比 CFunction 的形式慢。

在继续之前,先跳出 FFI 的话题,介绍下 LuaJIT 的 JIT 原理。

LuaJIT 是 tracing JIT Compiler。它的 JIT 是基于分支(循环或者函数)的。对于每个 tracing 的分支,它会维护一个计数器。一旦某个分支足够热,LuaJIT 会把该分支编译掉,并用编译掉的结果替换原来的代码。这要求一点:整个分支都需要是可被编译的。如果分支中有不能编译的语句,LuaJIT 会中断 tracing,该分支也就一直没法被 JIT 掉。这种不能被编译的语句,在 LuaJIT 里面叫 NYI。可以在 http://wiki.luajit.org/NYI 查看当前的 NYI 列表。

查看 JIT trace 结果很容易,仅需在 init_by_lua_block 里添加下面两行:

local v = require "jit.v"
v.on("/tmp/dump")
require "resty.core" -- 确保 lua-resty-core 是启用的

运行之后就能在 /tmp/dump 里查看 trace 情况了。在我们的例子里,结果只有一行:

[TRACE   1 content_by_lua(nginx.conf:21):4 loop]

它表示 content_by_lua 第 4 行有一个循环,能够被完整地 trace 掉。

如果想了解更详细的情况,可以改用下面两行:

local dump = require "jit.dump"
dump.on(nil, "/tmp/dump")

这时候它会记录更详细的内容,包括 trace 的过程、IR 和 mcode 的生成情况。当 LuaJIT 中断 tracing 时,你可以凭 dump 下来的内容找出它是在哪里中断的。

回归正题。让我们找个 NYI 语句,插入到循环中,比如下面这样:

local t = {}
for _ = 1, 1e6 do
    ngx.md5(s)
    next(t)
end

重新跑下,用时

¥ curl localhost:8888/foo
2.9719998836517

比调用 Lua CFunction 时要慢一些。

欲抑先扬,欲扬先抑。即使解释器模式下 FFI 会明显地慢,但有些时候还是比 CFunction 快一些。比如前面的 ngx.ctx 这个例子,在解释器模式下,它的用时是:

¥ curl localhost:8888/foo
19.00200009346

慢得要命,但还是 CFunction 版本的两倍。

如果担心项目支持的 NYI 语句太多,启用 lua-resty-core 会导致性能不升反降,那么我插一句:Lua CFunction 调用就是一种 NYI 语句,而 FFI 调用是可以 JIT 的。也即是说,启用 lua-resty-core 会减少项目中一类 NYI 语句的存在。这算是切换到 lua-resty-core 的另一个理由了。

lua-resty-core 寄托着 OpenResty 的未来

你可能会觉得,JIT 啊、NYI 啊什么的离我太远了,我们的项目不需要什么性能上的优化,所以也无需引入 lua-resty-core。

OK,即使不考虑性能,你也应该引入 lua-resty-core。春哥(OpenResty 的作者)曾经公开说过,有计划淘汰掉现有的一套 Lua CFunction 接口。所以迟早你也会用上 lua-resty-core 所暴露的接口。这是其一。

其二,OpenResty 目前新的功能开发,都是放到 lua-resty-core 上的。毕竟旧的接口要淘汰了嘛。对 FFI 的偏好并不仅仅体现在新功能开发上。如果改用 FFI 能解决 CFunction 接口的 bug,OpenResty 开发者会认为这个问题已经解决了。(参见 BUG Report 严重(特别是使用了lua-resty-lock库的服务,有一定概率workers死锁,可重现)

最后,同样的方法,来自 lua-resty-core 的版本除了性能外,还会有其他优势。举个例子,因为内部实现上的差异,lua-resty-core 中的 ngx.re 可以用在 init_by_lua* 阶段,而原来的 Lua CFunction 版本不支持这么用。

阅读 5.1k

推荐阅读
spacewander
用户专栏

这个专栏什么都有,大部分都是关于Linux或后端开发的

847 人关注
98 篇文章
专栏主页