OpenResty® 使用 LuaJIT 作为主要的计算引擎,用户也主要使用 Lua 语言来编写应用,即使是非常复杂的应用。在 64 位系统(包括 x86_64)上,LuaJIT 垃圾回收器能管理的内存最大只有 2GB 一直为社区所诟病。所幸 LuaJIT[1] 官方在 2016 年引入了 “GC64” 模式,这使得这个上限可以达到 128 TB(也就是低 47 位的地址空间),这也就意味着可以不受限制的跑在当今主流的个人电脑和服务器之上了。在过去的两年里,GC64 模式已经足够成熟,所以我们决定在 x84_64 体系结构上也默认开启 GC64 模式,就像在 ARM64(或者 AArch64)体系结构上一样。这篇文章将简要介绍过去老的内存限制原因,以及新的 GC64 模式。

老的内存限制

官方的 LuaJIT 在 x86_64 体系结构上默认使用 “X64” 模式,OpenResty 1.13.6.2 之前在 x86_64 体系上也默认使用这个模式。这个模式下 LuaJIT 垃圾回收器只能使用低 31 位的地址空间,这也就意味着最多能管理 2 GB 内存。

何时会碰到这个内存限制

那么什么时候会碰到这个 2 GB 的内存限制呢,我们很容易用一个小的 Lua 脚本来复现

1
2
3
4
5
6
7
8
9
10
-- File grow.lua

local tb = {}
local i = 0
local s = string.rep("a", 1024 * 1024)
while true do
i = i + 1
tb[i] = s .. i
print(collectgarbage("count"), " KB")
end

这个脚本里有一个无限的 while 循环,不断的创建新的 Lua 字符串,并且插入到一个 Lua table 里(为了防止 Lua 垃圾回收器回收他们)。每一个循环里,会创建一个 1MB 的 Lua 字符串,并且用 Lua 标准的 API collectgabarge 来输出当前被 Lua GC 回收器所管理的内存大小。另外,值得一提的是,顶层的 Lua table 变量 tb 也是会持续变大的,这也会不断的消耗内存,尽管比 Lua 字符串消耗的内存要小很多。

我们可以简单的使用 OpenResty 提供的 resty 命令来跑这个脚本,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ resty grow.lua
4181.08984375 KB
5205.6767578125 KB
6229.869140625 KB
6229.66796875 KB
8277.4013671875 KB
9301.5546875 KB
10325.741210938 KB
...
2003241.1367188 KB
2004265.3320313 KB
2005289.5273438 KB
2006313.7226563 KB
2007337.9179688 KB

$

这次我们使用 “X64” 模式编译的 OpenResty,结果也很显然,在 Lua 垃圾回收器管理的内存快接近 2GB 的时候,resty 工具退出了,实际上是进程崩溃了,我们可以看 shell 的返回值

1
2
$ echo $?
134

使用 luajit 命令来跑这个脚本,我们可以看到更详细的崩溃错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ /usr/local/openresty/luajit/bin/luajit grow.lua
4181.08984375 KB
5205.6767578125 KB
6229.869140625 KB
6229.66796875 KB
8277.4013671875 KB
...
2053220.5429688 KB
2054244.5634766 KB
2055268.5839844 KB
2056292.6044922 KB
2057316.625 KB
PANIC: unprotected error in call to Lua API (not enough memory)

这证明我们确实是碰到了内存限制。

内存限制是每进程的

OpenResty 继承了 NGINX 的多进程模式来充分利用单机的多个 CPU 核心,所以每个 NGINX worker 进程有它独立的地址空间。因此,这个 2 GB 的限制也只是每一个独立的 NGINX worker 进程级别的。假如一个 OpenResty/NGINX 服务有 12 个 worker 进程的话,那么这个总的内存限制将是 2 * 12 = 24 GB。这也是为什么这么多年来,这个限制并没有给大型的 OpenResty 应用带来太多的问题,甚至大部分的 OpenResty 用户还不知道有这个限制。

然而,这个内存限制并不是每 LuaJIT 虚拟机(VM)级别的。比如,同一个 NGINX 进程内,ngx_stream_lua_modulengx_http_lua_module 都创建了他们自己的 LuaJIT VM 实例,但是并不意味着这两个 LuaJIT VM 分别可以最多管理 2GB 内存,而是这两个 LuaJIT VM 加起来最多管理 2GB 的内存。因为这个内存限制是受限于地址空间,LuaJIT 的 GC 管理器只能使用低 31 位的地址空间,这个地址空间是进程级别的。

GC 管理的内存

大多数的 Lua 层面的对象(比如,string, table, fucntion, userdata, cdata, thread, trace, upvalue 和 proto)都是被 GC 管理的。其中,upvalue 和 proto 是被 function 所关联引用的。这些都被称作为 “GC 对象”。

其他原始值,比如 number,boolean 和 light userdata 并不是被 GC 管理的。他们直接使用真实的值作来编码,在 LuaJIT 内部被称作 “TValue”(或者 tagged values)。在 LuaJIT 内部,TValue 总是 64 位的,即使是单精度浮点数也是 64 位的(LuaJIT 使用了 “NAN tagging” 的技术来实现如此的高效的)。这也是为什么通常情况下,同一个应用使用 LuaJIT 来运行,会比使用标准的 Lua 5.1 解释器来运行所占用的内存更少。

GC 管理之外的内存

LuaJIT 的 cdata 数据类型比较特殊。如果是通过标准的 LuaJIT API 函数 ffi.new() 来创建的 cdata 对象,他是由 GC 来管理的。但是如果是通过 C 层面函数,比如 malloc()mmap(),或者其他的 C 库函数来申请的内存,那么这些内存 不是 由 GC 管理的,也就不会受到这个 2 GB 的限制。我们可以用如下的 Lua 脚本来测试:

1
2
3
4
5
6
7
8
-- File big-malloc.lua

local ffi = require "ffi"
ffi.cdef[[
void *malloc(size_t size);
]]
local ptr = ffi.C.malloc(5 * 1024 * 1024 * 1024)
print(collectgarbage("count"), " KB")

这里我们使用 ffi 调用了标准的 C 库函数 malloc() 来申请 5 GB 的内存块,使用 "X64” 模式的 OpenResty 或者 LuaJIT 来运行这个脚本并不会崩溃。

1
2
$ resty big-malloc.lua
73.1298828125 KB

GC 管理的内存大小也只有 73 KB,很明显没有包括直接从系统申请的 5 GB 内存块。

然而,不被 GC 管理的内存也可能对 LuaJIT 的内存限制产生不利影响。这是为什么呢?因为这些内存也可能会在低 31 位的空间内。

在 Linux X86_64 系统上,没有任何指定地址的参数(或者其他会影响到内存分配地址的参数)执行 mmap() 系统调用的时候,一般不会使用到低 31 的地址空间。然而使用像 sbrk() 这类的调用则几乎总是优先使用低地址空间,这种就会使得 LuaJIT GC 内存分配器所能实际使用的内存空间变小了。这是由于 Linux 等操作系统的内存布局特性导致的,“堆” 总是从低位到高位向上生成的。 类似的,由于在 Linux 等系统上,程序的数据段总是使用低地址空间段的开始位置,所以数据段内的静态常量(比如常量 c 字符串)也会挤压可用的低地址空间。

由于以上的原因,在 x86_64 体系下,实际上可供 LuaJIT 使用的地址空间可能远小于 2 GB,这取决于应用程序另外在哪个申请了内存,以及申请了多少内存。社区的用户也给我们反馈这样的问题:比如在 FreeBSD 上, NGINX 的申请共享内存(实际上是通过 libc 的内存分配器申请的)也会挤压 LuaJIT 能使用的低支持空间;比如使用了类似 ngx_http_slice_module 这样的第三方模块的场景,更容易导致 LuaJIT 抱怨内存空间不足。

提升 X64 模式的限制到 4 GB

理论上来说,LuaJIT 在 X64 模式上的上限应该是 4 GB(也就是低 32 位地址空间)而不是 2 GB,而且在 i386 系统上 LuaJIT 也确实能充分利用低地址的 4GB 空间。因为 LuaJIT 内部的手写汇编代码必须考虑到 sign extension,从 32 位地址指针值到 64 位地址指针值都需要考虑(i386 体系则不需要考虑,因为始终是 32 位的),也就导致了这个上限只有 2 GB。

尽管 4 GB 比 2 GB 大了一倍,不过还是可能碰到上述的一些问题,所以 LuaJIT 的开发者决定开发一个新的 VM,使得可以使用 更大 的地址空间,因此也就诞生了 GC64 模式。而且,这个 GC64 模式也是 ARM64 系统上的唯一选择,因为在 ARM64 上低地址段空间并不容易申请到。

新的 GC64 模式

GC64 模式始于 2016 年,最先由 Peter Cawley 实现,然后由 Mike Pall 来整合。在过去的两年多里,已经修复了很多 bug,并且经过广泛的测试,证明它已经足够稳定用于生产环境了,所以 OpenResty 也将在 x86_64 体系上切换到这个新的 GC64 模式 (ARM64 上已经强制使用 GC64 模式了)。

在 GC64 模式下,原始的 Lua 变量(上面提到的 TValue)还是继续保持 64 位大小,我们不用担心新的模式下内存使用量会有明显的涨幅。但是,还是有一些数据类型会变大(从 32 位变成 64 位),比如 MRefGCRef 这样的在 LuaJIT 里很常见 C 数据类型。所以,GC64 模式下,尽管内存占用不会多很多,但是肯定会变得更大一些的。

在 GC64 模式下,垃圾回收管理器已经能使用低的 47 位地址空间了,也就是 128 TB,这已经超过了当今高端机的整个机器物理内存(通常 64GB 就可以算大内存的机器,AWS EC2 实例最大的内存也只有 12 TB)。这也就意味着,GC64 模式在当今的现实世界里是没有 GC 管理内存限制的。

如何开启 GC64 模式

如果从 LuaJIT 源码编译,可以这样开启

1
make XCFLAGS='-DLUAJIT_ENABLE_GC64'

如果从 1.13.6.2 版本 之前 的 OpenResty 源码安装,可以在 ./configure 脚本加上如下选项来开启:

1
-with-luajit-xcflags='-DLUAJIT_ENABLE_GC64'

OpenResty 1.15.8.1 开始已经默认在 x86_64 系统上开启 GC64,包括 OpenResty 官方提供的二进制包 也默认开启了。

性能影响

新的 GC64 模式将产生多大的影响呢,下面用我们的一些大 Lua 程序来测试一下

Edge 语言编译器

首先,我们使用 Edge 语言(也叫 “edgelang”)编译器来编译一些大的 WAF 模块,在 X64 模式下:

1
2
3
4
$ PATH=/opt/openresty-x64/bin:$PATH /bin/time ./bin/edgelang waf.edge >
/dev/null
0.73user 0.03system 0:00.77elapsed 99%CPU (0avgtext+0avgdata 119660maxresident)k
0inputs+0outputs (0major+33465minor)pagefaults 0swaps

edgelang 编译器把 waf.edge 编译为 Lua 代码,花费了 0.73s 的用户态时间,最大内存占用是 119660 KB,也就是 116.9MB。下面我们用 GC64 模式:

1
2
3
4
$ PATH=/opt/openresty-plus-gc64/bin:$PATH /bin/time ./bin/edgelang waf.edge
> /dev/null
0.70user 0.03system 0:00.74elapsed 99%CPU (0avgtext+0avgdata 133748maxresident)k
0inputs+0outputs (0major+35396minor)pagefaults 0swaps

最大内存占用是 133748 KB,也就是 130.6MB,只大了 11.1%。CPU 使用时间几乎是一样的,这一点区别可以当做测试误差。

Edge 语言编译器是基于 OpenResty 上纯 Lua 的实现,包括空行和注释一共有 83,315 行代码,两者模式下对应的 LuaJIT 字节码都是 1.8MB(尽管 X64 和 GC64 的字节码不兼容)。

Y 语言编译器

我们再试一下 Y 语言(也叫做 ylang)编译器,这也是基于 OpenResty 的纯 Lua 命令行程序。

ylang 编译器比 edgelang 编译器要更大一些,对应的 LuaJIT 字节码有 2.1 MB(两个模式都是)。 我们先用 X64 模式,把 ljfrace.y 工具编译为 systemtap 脚本:

1
2
3
4
$ PATH=/opt/openresty-x64/bin:$PATH /bin/time ./bin/ylang --stap --symtab
luajit.jl lftrace.y > /dev/null
1.30user 0.12system 0:01.42elapsed 99%CPU (0avgtext+0avgdata 401184maxresident)k
0inputs+240outputs (0major+116438minor)pagefaults 0swaps

花了 1.3s 的用户态时间,最多占用了 401184 KB 内存,下面试一下 GC64 模式:

1
2
3
4
$ PATH=/opt/openresty-gc64/bin:$PATH /bin/time ./bin/ylang --stap --symtab
luajit.jl lftrace.y > /dev/null
1.30user 0.11system 0:01.42elapsed 99%CPU (0avgtext+0avgdata 433948maxresident)k
0inputs+240outputs (0major+125591minor)pagefaults 0swaps

还是花了 1.3s 时间,以及 433948 KB。这次时间没有区别,内存也只多占用了 8.2%。

调试分析工具链

目前开源的动态分析工具,包括 openresty-systemtap-toolkitstap++openresty-gdb-toolkit 几乎都不支持新的 GC64 模式,我们希望社区能够更新这些工具来支持 GC64 模式(原有的 X64 模式还是希望保留)。

我们的精力将会放在 ylang 编译器上,我们用标准 C 语言(称为ylang)的超集编写的工具,ylang 编译器可以将其编译为 python 代码跑在gdb 里,也可以编译为 stap 脚本用 systemtap 来运行(未来也会支持更多的后端,可以运行在调试分析工具里)。我们几乎不用改动我们的工具,就可以支持 GC64,这个得益于智能化的调试信息,以及 ylang 使用的是 C 语言层面的表达方式。

如下是一个 GC64 模式下的 Openresty Lua 层面的火焰图,使用的是我们的 ylang 工具和 systemtap。

Lua-land CPU Flame Graph for GC64 LuaJIT

并且 ylang 编译器生成的 gdb 工具也可以用于分析 core dump 文件,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) lbt 0 full
[builtin#128]
exit
test.lua:16
c = 3
d = 3.140000
e = true
k = nil
null = light userdata (void *) 0x0
test.lua:baz
test.lua:19
ffi = table: (GCtab *)\E0x[0-9a-fA-F]+\Q
cjson = table: (GCtab *)\E0x[0-9a-fA-F]+\Q
test.lua:0
ffi = table: (GCtab *)\E0x[0-9a-fA-F]+\Q
cjson = table: (GCtab *)\E0x[0-9a-fA-F]+
C:pmain

(gdb)

火焰图里的函数帧包含了 Lua 函数帧和 C 函数帧。

我们在 OpenResty Trace 里不仅提供了我们已经写好了的,基于 ylang 编译的追踪分析工具,也提供了在线编译器,我们可以使用 y 语言很轻松的实现一个新的分析工具。

LuaJIT 内置的分析器

从 2.1 版本开始,LuaJIT 官方就内置了虚拟机层面的分析器,当然这个可以继续在 GC64 模式下使用。而然,不像比如 systemtap 这样的系统层面追踪工具,它必须清除所有已经存在的已经动态编译了的 Lua 代码(在 LuaJIT 的术语里叫 “traces”),并且需要用特定的分析模式重新编译。每次打开和关闭分析器,都会触发重新编译,这必然也需要在当前进程里修改很多的状态(很容易有意外的副作用,或者极端 bug 的出现),并且在分析采样期间也会有更多的性能消耗。另外,目标 Lua 程序也需要提供一个特殊的 API 或者 钩子 来触发采样分析,需要应用程序配合才能让内置分析器工作。然而,基于动态追踪工具的分析则不需要 Lua 应用程序的配合,甚至不需要特殊的编译构建选项。


  1. OpenResty 维护了我们自己的分支,这个分支里包含了一些高级特性以及针对 OpenResty 的特殊优化,这个分支会定期的从 官方 LuaJIT 同步。