本文内容,由我在 OpenResty Con 2018 上的同名演讲的演讲稿整理而来。

PPT 可以在 这里 下载,因为内容比较多,我就不在这里一张张贴出来了。有些内容需要结合 PPT 才能理解,请多包涵。

编写正确且高效的应用,最为关键是一系列软件工程上的实践,像测试、code review、灰度、监控、压测等等。不过由于这是 OpenResty 大会上的演讲,我会专注于讲讲 OpenResty 和 LuaJIT 的一些小细节,帮助各位听众避免线上踩坑。

自我介绍

按惯例,得先自我介绍下。spacewander,这个是我的 GitHub 昵称。我目前在 OpenResty Inc. 公司工作。

第一部分

先从 OpenResty 开始讲吧。

init_by_lua*

init_by_lua* 是 OpenResty 目前唯一运行在 master 进程里的阶段。它运行的时机非常靠前,就在 Nginx 刚解析完配置之后。

这就意味着,只要运行 Nginx 可执行文件,init_by_lua* 里面的代码就会被调用。有些时候,我们运行 Nginx 可执行文件,并不是想启动它的服务。比如在调用 nginx -t 检查配置文件是否正确,或者在调用 nginx -s 控制当前的 master 进程的时候。如果你的代码里包含对本进程以外的资源的修改,这种意料之外的执行是不受欢迎的。那怎么避免呢?对于 -s,可以通过直接用 kill 发送信号的方式代替。对于 -t,略微复杂一点,可以通过 FFI 的方式获取当前 Nginx 进程的命令行参数,判断其中是否包含 -t 选项。PPT 上面包含了这么做的代码。

如果 init_by_lua* 里面的代码执行时间过长,比如启动时会先从远程服务器加载数据,可能会带来另一个问题。大多数部署脚本里面,检测 Nginx 进程是否顺利启动,是通过查看 nginx.pid 这个文件实现的。由于 Nginx 是在执行完这一部分 Lua 代码后才会创建 nginx.pid 文件,如果执行时间过长,可能会在部署时造成误判。这时候可能需要恰当地增加查看 nginx.pid 的时间间隔。

ngx.worker.id()

要想区分不同 worker 进程,通常的做法是用 ngx.worker.id()。需要注意的是,有些时候多个 worker 进程可能会有同样的 id,比如在 reload 或者 binary upgrade 的时候。

Nginx 在 reload 的时候,会有两组 worker 进程。新的 worker 们会接替老的 worker,但直到老 worker 退出之前,这两组 worker 是同时运行的。如果 shdict 的配置不变,这两组进程甚至有同样的共享内存空间,所以在用 worker id 作为 shdict key 的时候,这种边界情况需要考虑下。

Nginx 在 binary upgrade 的时候,则会有两组 master+workers。这两组进程并不共享内存空间,所以用 worker id 作为 shdict key 时,不用关心这种情况。但是当你用 worker id 作为外部服务或者文件系统的 key 时,还是要注意下的。一种可能的解决方案是引入 parent pid 作为前缀,然后处理好 key 变化时,数据迁移的逻辑。

shdict is a LRU cache

如果 shdict 里面的数据超过了事先分配好的内存大小限制,OpenResty 会根据 LRU 算法,清除现有的数据。我发现许多情况下,我们会忽视这一事实,认为 shdict 里面的空间一定足以容纳所有数据。不过假若你是一个厌恶风险的人,可以考虑只用 safe_ 开头的一系列 API 来操作 shdict。

这一类 API 在 shdict 不够空间时,会失败,而不是默默地挤掉当前的数据。

update time

Nginx 对时间的缓存非常激进,只有在开始新的一个事件循环时才会更新缓存的时间。对于服务器,这似乎足够了;但对于应用代码,一不小心可能就踩坑了。

通俗易懂的说法,如果你的 Lua 代码不 yield,那么从头到尾获取到的时间都是缓存的结果。另外,如果多个请求同处一个事件循环里面,而其中一个请求产生了阻塞操作,那么执行剩下的请求时,缓存的时间跟真正的时间就会有明显的差别。所以,在进行了耗时操作之后,可能需要调用下 ngx.update_time。如果需要准确的时间戳,不应直接调用 ngx.now()

接下来让我们比较下几种 ngx.now() 的替代品。

在上图中,我们以 os.time() 作为基准,比较下各方的性能。我们看到,ngx.update_time() + ngx.now() 的组合是最耗时的,因为 ngx.update_time() 除了要获取当前的时间外,还要更新一系列时间字符串。值得一提的是,我们自己实现 current_ms_time() 无论是性能还是时间的精准度都比 os.time() 要好,可见在 JIT 下,FFI 实现可以击败无法被 JIT 的内置函数。另外我们也会看到,resty.core版本的 ngx.now()ngx.update_time() 相当地快。在本演讲的最后,我会解释为什么会这样。

有限的 timer

在 OpenResty 里面,timer 的个数是有限的。

首先每个 timer 都是一个 fake request,在 Nginx 这边看来,每个 timer 其实都是一个请求。跟真实的 request 一样,它也会占据一个连接。所以你的 worker_connections 要足够大,即使 timer 并不会真的建立一个网络连接。另外,OpenResty 还有两个参数限制了 timer 的总数,lua_max_pending_timerslua_max_running_timers,需要保证它们够大。另外如果启动 timer 时没有足够的内存,也是会失败的。如果可以的话,尽可能用 ngx.timer.every 来启动定期的 timer。用 ngx.timer.at 反复启动 timer 的话,一旦每次启动失败,那就真的失败了。

既然每个 timer 都是一个请求,那么如果你每个网络请求都会启动一个甚至多个 timer,性能自然好不到哪儿去。最简单的优化办法是引入批处理,避免不断创建 timer,你也可以考虑下队列,甚至更为复杂的时间轮。不过要想复用 timer,还要面对额外的挑战……

第一个挑战来自于 Nginx 的每请求内存池。只有在请求结束时,Nginx 才会释放这一内存池内所有的内存。而前面已经说了,每个 timer 在 Nginx 看来都是一个请求,所以某种意义上,一个 timer 就像是一个长连接,尤其当这个 timer 会一直运行到进程结束时。长时间运行的 timer 自然会带来内存的持续上涨,但其上涨的速度一般而言并不显著。原因有二:

  1. 许多 OpenResty API 实际上只会分配 Lua 层面上的内存。而这部分内存是在 LuaJIT GC 管理下的。在引入了 lua-resty-core 后更是如此。
  2. cosocket 的 send/receive 操作,会有 buffer 复用机制,使得其内存占用不会无限制增加。

另一个挑战来自于较为隐晦的地方。当前 entry thread 会把它所创建的每个协程,记录到一个链表里。而各种协程 API,大都需要访问这个链表。如果 timer 或者长连接持续大量地创建协程,会导致协程 API 变得越来越慢。就目前的情况,要想解决这个问题,需要对协程进行复用,避免无限制地创建协程。

第二部分

讲完 OpenResty,让我们看看 LuaJIT。

不可变的 string

Lua 跟其他大部分语言有一点不一样,就是它的字符串是不可变的。不变字符串自然有些优点,比如减低内存占用、比较字符串时可以直接比较它的内存地址等等。但是缺点也不少。在其他语言里面,当我们想修改一个字符串部分内容,比如大小写转换,我们可以直接改变对应的位置上的 byte。毕竟字符串通常就是一个字节数组(byte array)。但是这事要在 Lua 里面做,非得拷贝一个新字符串不可。而且由于要保证每种字符串都只有一个实例,lj_str_new 需要对实际的字符串内容做 hash,然后用它查找该内容是否已经创建了对应的实例。

既然说到做 hash,那么自然得提到 hash 碰撞。对于那些 hash 值一样的字符串,LuaJIT 把它们存储在链表里。如果许多字符串有着一样的 hash 值,那么这个链表就会很长,原来 O(1) 的开销会退化为 O(n)。这就是所谓的 hash 碰撞。不幸的是,LuaJIT 的默认的字符串 hash 函数就有这样的问题。在网上你能找到一些相关的报告。

OpenResty 自带的 LuaJIT 用硬件加速的 CRC32 函数替换了默认的字符串 hash 函数,降低了发生 hash 碰撞的风险。需要说明的是,只有在支持 SSE 4.2 指令集的 x64 平台上才会启用这一函数。

即使 hash 碰撞的问题可以避免,lj_str_new 依然是一个既频繁又耗时的函数。
最好的优化就是不做。比如如果只是想查看字符串里面的字符,可以用 string.byte 代替 string.sub
OpenResty 里面,也有许多 API 支持在 C 层面上完成字符串的拼接,无需调用 lj_str_new,比如 cosocket 的 send、ngx.sayngx.log
它们接受多个参数,或者数组 table,在 C 层面上拼接成字符串。这里的数组 table 甚至可以是嵌套的。

谁可以代替字节数组

LuaJIT 缺乏字节数组,这是个痛点,尤其是在做协议转换的时候。一个通常的代替品是用数组 table。另一个是借助 FFI,申请一块名符其实的字节数组。

这里有些操作数组 table 的方法。有两个需要解释下:

table.new 是 LuaJIT 独有的方法,允许在创建 table 时指定大小,减少后面 resize 的成本。

table.clone 是 OpenResty 自带的 LuaJIT 的方法,允许对一个 table 做浅复制。它内部调用了 lj_tab_dup 这个 LuaJIT 内部函数。

buffer 复用

前面讲到,我们可以给某些 API 传递 table 而不是 concat 之后的字符串。可能有人会怀疑,创建 table 开销不会比 concat 字符串大吗?
其实这里的 table,是可以复用的,无需每次都创建。

如果你的函数里面没有 yield,你可以直接拿个 local 变量,每次都复用这个变量。为了避免影响到其他函数,我们这里用了个 do block 把相关的变量都包起来。
如果你的函数里面有 yield,你可以通过 lua-tablepool 这个库实现 table 的回收复用。

避免在 table 中间存储 nil

一个众所周知的事实:如果数组 table 中间有 nil,获取到的长度可能会不准。Lua 可能会把某个 nil 的位置作为这个 table 的结尾。

不过较少为人所知的是,nil 也会影响 unpack 的结果。由于 unpack 返回的结果个数取决于 table 的长度,所以如果获取的长度不准,unpack 返回的结果数也会不准。如果我们 unpack 前面的 table,就只会返回第一个数 0. 另外,Lua 里常用的两种迭代数组的方式,for i in ipairs()for i = 1, #table,在处理数组中的 nil 的方式上有所不同。前者每次迭代时都会检查当前元素是不是 nil,如果是的话结束迭代。

尽可能不要把数组 table 中的某个元素置为 nil,应该用 ngx.null 作为占位符。

有上限的 unpack

既然提到了 unpack,顺便提下 unpack 也是有大小限制的。如果 unpack 的数组大小超过 8000,unpack 会抛异常。

FFI buffer 作为 byte[]

除了用 table,也可以考虑下用 FFI buffer 作为字节数组。FFI buffer 的好处在于内存占用少。坏处呢,一个是周边的 API 支持少,用起来不像 table 那么方便;另一个是,如果不能被 JIT 编译的话,FFI 操作很昂贵。
当然 FFI buffer 也是可以复用的,复用方法跟 table 差不多。有兴趣的听众可以看看 lua-resty-core 的 get_string_buf 这个方法。

LUAJIT_NUMMODE

LuaJIT 有一个编译选项 LUAJIT_NUMMODE,控制对 number 类型的处理方式。它的默认值为 1。当我们把它在编译时设置为 2 时,对于能够用 32 位整数表示的 number,LuaJIT 会用 int32 表示,而不是一概用 double 来表示。通常来说,设置 LUAJIT_NUMMODE=2 会让程序快一点,因为 CPU 更擅长对整数进行计算。但是也不一定,因为影响性能的因素非常复杂,具体问题需要具体分析。后面我会给大家看一个例子,LUAJIT_NUMMODE=2 会让程序更慢。

JIT

终于讲到重头戏,LuaJIT 的 JIT 编译。LuaJIT 采用 Tracing JIT 来记录并实时编译字节码。当某个循环或者函数调用足够热时,LuaJIT 会开始记录执行的字节码,进行优化后生成 IR,然后把 IR 编译成 mcode。你可以在上面两个文档中找到对 字节码 和 IR 的一些说明。

你可以在 LuaJIT 代码中添加下面两行代码,把这一过程 dump 到指定文件中:

local dump = require "jit.dump"
dump.on("abimsrtx", filepath)

让我们看一个实际的例子。

这个例子是为了展示 JIT 过程而设计的,我们可以从 dump 输出中看到不少信息。

从 Trace 2 的 bytecode 部分可以看到,Tracing JIT 在 tracing 的时候是跨函数的。
从 Trace 2 的 IR 部分可以看到,string.rep 等操作被移到了 LOOP 以外,因为它的结果在整个循环中是不变的。

在 IR 里面有一个有意思的输出:

CALLXS [0x7f248ac41180]

从对应的 base_encoding.lua 代码可以看出,这里其实是通过 FFI 调用了某个 so 里面的函数。
在最终生成的 mcode 里面,我们也能找到对应的 call 0x7f248ac41180

为什么 FFI 在 JIT 下性能会比解释器模式下快很多呢?原因在于解释器模式下,LuaJIT FFI 需要实现 Lua 和 C 数据间的 marshal 和 unmarshal。而在 JIT 模式下,两者的交互都是汇编层面上的。

我们可以看到,不少 IR 左边有个 >,这表示这个 IR 是作为 guard 存在的。Trace 是没有分支的,一旦发生 guard 不能满足的情况,会退出当前 trace 进入解释器模式。
看下 LOOP 里面这个 NE 0069 这个 IR。结合上一个 IR,可以知道它的意思是判断 % 5 != 4。我们可以找到对应的 mcode:

7f24b63bfeca  mov ebx, eax
7f24b63bfecc  mov esi, 0x5
7f24b63bfed1  mov edi, ebp
7f24b63bfed3  call 0x7f248c2e68a0    ->lj_vm_modi
7f24b63bfed8  mov rdi, [rsp+0x8]
7f24b63bfedd  cmp eax, +0x04
7f24b63bfee0  jz 0x7f24b63b002c    ->7

我们可以看到,这里面插了个 jz 0x7f24b63b002c 的判断。也就是如果不符合 != 4 的条件,就会跳到 0x7f24b63b002c 这个地址,而不是继续执行下去。旁边有一个 ->7 的标记,表示退出时用 snapshot 7 里面的数据恢复解释器模式。snapshot 7 就在 NE 0069 的上面。需要解释下,snapshot 的输出和 IR 的输出是并行的,只是恰好在 NE 0069 上面,两者输出的位置并无因果性。

再往下拉,我们会看到 TRACE 2 多次 exit 7。当另一个分支足够热时,会从原来的 TRACE 里面生成一个 side trace,也就是这里的 TRACE 3. 然后 TRACE 3 追踪到 unpack 这里的没了。因为 unpack 是 NYI 的,JIT 没法 tracing 下去。不过好在 LuaJIT 支持 stitch,可以绕过 NYI 语句,生成新的 TRACE 4.有点像下了高速,开了段路后又重上高速。

side trace 有一个问题,就是它们在结束后,会跳回到 root trace 的开头。像 TRACE 4 的最后一个指令,就是跳到 TRACE 2 的开头。我们知道,TRACE 4 是从 LOOP 里面长出来的,然而 TRACE 4 结束后会跳到 TRACE 2 开头,也就是像 string.rep 这样的操作,每次在 TRACE 4 执行完之后都会再走一遍,哪怕它的结果在整个循环里是不变的。

让我们看下第二个例子。这是段在 Lua 里面算 CRC32 的程序。然后改动了两行代码,用 FFI buffer 替换了 table,它的性能是原来的 2.5 倍。我会从 jit.dump 输出的角度解释为什么前后差别那么大。

why_byte_level_slow 是 table 版本的 dump,而 crc32_ffi 是 FFI 版本的 dump。这两个 dump 的 TRACE 1,都是一样的字节码,但是两者 IR 的 LOOP 中间部分不一样。抛去相似的部分不谈,可以看出 table 版本多了个 ABC,也就是 array boundary check。然后比较下 mcode 对应部分,table 版本有 23 个指令,而 FFI 版本只有 17 个指令。

但是 LOOP 部分从 23 个指令减少到 17 个跟 2.5 倍提升对不上。显然还有第二个因素在起作用。

看下 table 版本的 dump,你会发现它的 TRACE 数量很多,而且相似。仔细看,你会发现,有些地方从 table 中加载的数据类型是 num,而有些地方是 int。比如 TRACE 1 的 ALOAD 是 num,而 TRACE 2 的 ALOAD 是 int。这个 dump 是在 LUAJIT_NUMMODE=2 的情况下生成的。前面提到,这种模式下,LuaJIT 会尽可能把数值当作 int32 处理。但是 CRC32 表里面,有些数字超过了 int32,只能作为 double 处理。由于这两种类型需要生成不同的 mcode,导致大量 side trace 的生成。在 FFI 版本里,由于我们指定 CRC32 表的类型为 unsigned int,就没有这个问题。

why lua-resty-core is faster

最后我们来看下为什么同样的函数, lua-resty-core 里面的版本会更快。这是同样一段使用了 ngx.re.find 的代码,在 CFunction 和 FFI+JIT 两个版本下生成的火焰图。我们可以看到,CFunction 版本的火焰图里面有大量 lua_xxxx 这样的函数的开销,而 FFI+JIT 版本里面,就没有这些函数。

由于 JIT 时可以优化掉 FFI 调用的数据交换过程,所以当一个 API 在数据交换上耗费的比重越多,改写成 FFI 时带来的性能提升越大。
比如 ngx.re.find (数据交换复杂)
比如 ngx.time (C 部分的逻辑简单,大部分耗时在数据交换上)
反之,如果一个 API 耗费在数据交换的比重小,则 FFI 化带来的提升就小,比如 ngx.md5。
FFI 改造还能减少 stitch,这方面的提升需要结合具体上下文分析。

12-29 16:40