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

spacewander

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 版本不支持这么用。

阅读 6.1k

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

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.

5.4k 声望
1.4k 粉丝
0 条评论
你知道吗?

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.

5.4k 声望
1.4k 粉丝
宣传栏