以下文章来源于OpenResty 软件 ,作者章亦春
OpenResty® 开源 Web 平台以高性能 和低内存占用著称。我们有一些用户甚至在嵌入式系统中运行复杂的 OpenResty 应用,比如机器人。也有一些用户在把他们的应用从其他技术栈(比如 Java,NodeJS 和 PHP)迁移到 OpenResty 之后,观察到内存使用量上的显著下降。
然而,有时候我们还是需要优化某些 OpenResty 应用的内存使用。这些应用中的 Lua 代码、NGINX 配置、第三方 Lua 库或第三方 NGINX 模块都可能会有 BUG 或者性能问题,从而导致应用占用过多的内存,甚至存在内存泄露。
为了有效地调试和优化内存的过度使用或者内存泄漏问题,我们需要了解 OpenResty、Nginx 和 LuaJIT 在内部是如何分配和管理内存的。
我们的 OpenResty XRay 商业产品,能够在不修改目标应用的情况下,自动分析和诊断几乎所有的内存使用问题,即使是线上的生产应用。
我们将撰写一个系列的文章(本文是第一篇),使用 OpenResty XRay 在真实案例里获取到的数据和图表,来详细阐述 OpenResty、Nginx 和 LuaJIT 的内存分配和管理机制。
下面我们首先介绍 Nginx 进程在系统层面的内存占用分布,然后再逐个介绍应用层面的各种内存分配器。
系统层面
在现代操作系统中,进程在最高层面上申请和使用的内存都是虚拟内存。操作系统为每个进程分配和管理虚拟内存,并将实际使用的虚拟内存页,映射到物理内存页上去(比如 DDR4 内存条等设备里的)。
一个很重要的概念是,进程可能会申请很多的虚拟内存空间,而实际只使用其中很小一部分。比如,一个进程可以向操作系统申请 2TB 的虚拟内存空间,即使当前系统只有 8GB 的物理内存(RAM)。只要这个进程没有在这个巨大的虚拟内存空间中读写很多内存页,就不会有任何问题。
这部分实际映射到物理内存设备上的虚拟内存空间,才是我们真正需要关注的。所以不要因为看到 ps
或者 top
里显示占用了很大的虚拟内存空间(通常叫做 VIRT
)而感到惊慌。
实际使用的那一小部分虚拟内存(即读写了数据的),通常被叫做 RSS
,即 常驻内存
(resident memory)。当系统的物理内存快耗尽的时候,一部分常驻内存页里的数据会被 交换 到硬盘上^1。这部分被交换出去的内存空间不再是常驻内存的一部分,而是成为 “交换出去的内存”(简称 "swap")。
[^1]: 现代安卓(Android)操作系统支持将内存页交换到内存,但那些内存页是经过压缩的,同时可以节约物理内存空间。
有很多工具可以提供任意进程(包括 OpenResty 应用的 nginx
工作进程)的虚拟内存占用、常驻内存占用和交换出去的内存空间大小。OpenResty XRay 可以自动分析任意一个正在运行中的 nginx 工作进程,并绘制出很漂亮的内存使用量的分解饼图:
一个 Nginx 工作进程的内存使用分解图
在这张图里,整块饼代表 Nginx 进程从操作系统申请的全部虚拟内存空间。饼中的 Resident Memory
那一片则代表常驻内存的使用量,即实际使用的内存量。最后,Swap
块则代表被交换出去的内存(此图中并没有出现,这是因为这个进程并没有任何被交换出去的内存页)。
如上所述,我们通常最关心的是 Resident Memory
这一部分。不过如果饼图中出现了 Swap
组分,也是非常值得注意的,因为这意味着系统的物理内存已经不足了,可能会因频繁换入换出内存页而过载。
另外,我们也需要注意一下图中 未使用 的虚拟内存空间。这部分可能是因为应用申请了过多过大的 Nginx 共享内存区域。这些尚未使用的共享内存空间可能在未来某一天被写满数据(即它们将转变成为 Resident Memory
组分的一部分),从而导致物理内存枯竭。
我们将在后续专门的一篇文章里展开介绍常驻内存相关的更多有趣问题。下面先让我们一起看看应用层面的内存使用分解。
应用层面
在应用层面分析内存使用细节往往会更有帮助。我们更关心当前使用的内存空间里有多少是由 LuaJIT 内存分配器分配的,多少是 Nginx 核心和模块分配的、而多少又是为 Nginx 的共享内存区域所占用的,诸如此类。
比如下面这个新类型的饼图,是 OpenResty XRay 自动分析一个 OpenResty 应用的 Nginx 工作进程时得到的:
一个 Nginx 工作进程的应用层内存使用分解图
Glibc Allocator
饼图里的 Glibc Allocator
(Glibc 的分配器)部分是通过 Glibc 库分配的总内存(Glibc 是 GNU 实现的标准 C 运行时库)。通常我们在 C 代码里调用 malloc()
、realloc()
、calloc()
等函数就在使用这个内存分配器。它通常也被称为系统分配器。
Nginx 核心及其模块也通过这个系统分配器分配内存(有一个例外是 Nginx 的共享内存区域,我们后面会讲到)。一些包含 C 组件或者 FFI 调用的 Lua 库有时也会直接调用这个系统分配器,不过它们更常用的还是 LuaJIT 的内建分配器。
当然,有些用户也会选择使用其他的标准 C 运行时库实现,来编译和构建 OpenResty 或 Nginx ,比如 musl libc。
我们也会在后续专门的文章中展开讨论系统分配器和 Nginx 的分配器。
Nginx Shm Loaded
饼图中的 Nginx Shm Loaded
组分是 Nginx 核心及其模块分配的共享内存(即 "shm")区域中 实际使用 的那部分空间。这些共享内存是通过 UNIX 系统调用 mmap()
直接分配的,因此完全绕过了标准 C 运行时库的分配器。
Nginx 共享内存是所有 Nginx 工作进程之间共享的。这些共享内存区域通常是通过标准 Nginx 配置指令来创建的,比如 ssl_session_cache、 proxy_cache_path、 limit_req_zone、 limit_conn_zone、 和 upstream 的 zone 指令。
Nginx 的第三方模块也可能会创建自己的共享内存区域,比如 OpenResty 的核心组件 ngx_http_lua_module。OpenResty 应用通常在 Nginx 配置文件中使用 lua_shared_dict 指令来创建自己的共享内存区域。我们近期也会有专门文章更详细地阐述 Nginx 的共享内存相关的细节。
LuaJIT Allocator
饼图中的 HTTP/Stream LuaJIT Allocator
这两个组分则代表 LuaJIT 的内建分配器分配和管理的内存大小。其中一个表示 Nginx 的 HTTP 子系统中的 LuaJIT 虚拟机(VM)实例,另外一个代表 Nginx 的 Stream 子系统中的 LuaJIT VM 实例。
LuaJIT 有一个编译选项可以强制使用系统分配器[^2],不过这个选项通常只用于特殊的调试和测试工具(比如 Valgrind 和 AddressSanitizer)。
[^2]: 这个编译选项叫做 -DLUAJIT_USE_SYSMALLOC,但千万别在生产中使用!
Lua 字符串、表(table)、函数、cdata、userdata、upvalue 等等,都是通过这个分配器来分配的。与之相反,原初类型的 Lua 值,比如整数[^3]、浮点数、light userdata 以及布尔值等等,则不需要任何动态内存分配。
[^3]: 通常 LuaJIT 运行时在底层只使用一种数值类型表示,即双精度浮点数(double),但用户仍然可以通过传入编译选项 -DLUAJIT_NUMMODE=2 来同时启用 32 位整数的底层表示。
此外,在 Lua 代码里调用 ffi.new()
所分配的 C 级别的内存块,也是通过 LuaJIT 自己的分配器来分配的。由这个分配器分配的所有内存块,都由 LuaJIT 的垃圾回收器(GC)来统一管理,因此我们无需主动释放不再需要的内存块[^4]。这些内存对象也被叫做“GC 对象”。我们将在其他文章里阐述这个课题。
[^4]: 但是我们仍然有责任确保所有指向那些无用对象的引用都被正确去除。
Text Segments
饼图里的 Text Segments
组分则对应所有可执行文件和动态链接库的 .text
段,映射到虚拟内存空间之后的总大小。这些 .text
段通常包含可执行的二进制机器代码。
System Stacks
最后,图中的 System Stacks
组分指的是目标进程里所有系统栈(或者说 “C 栈”)占用的总大小。每个操作系统(OS)线程都有自己的系统栈。只有当使用了多线程的时候才会出现多个系统栈(请注意 OpenResty 中使用 ngx.thread.spawn 创建的 “轻线程” 跟这种系统级别的线程,是完全不同的两种东西)。
Nginx 工作进程通常只有一个系统线程,除非配置了 OS 线程池(通过 aio threads
配置指令)。
有些用户可能会选择在自己编译的 OpenResty 或者 Nginx 中使用第三方内存分配器。常见的例子是 tcmalloc 和 jemalloc,因为它们可以加速系统分配器(比如 malloc
)。
对于一些 Nginx 第三方模块、Lua C 模块或 C 库(包括 OpenSSL!)中直接调用 malloc()
申请小内存块的场景,它们确实可以提供比较明显的加速效果。便是对于那些已经使用了设计良好的分配器(比如 Nginx 的内存池和 LuaJIT 的内建分配器)的部分,使用它们则没有太多好处。反之,使用这样的“外挂”分配器的软件库,会引入新的复杂性和问题。我们将会在后续文章中更加详细地阐述。
已用或未用
使用上面介绍的应用级别的内存分解图,并不太好直接分析哪些虚拟内存页被实际使用,而哪些并没有。只有饼图中的 Nginx Shm Loaded
组分是实际使用的虚拟内存空间,而其他组分则同时包含了使用了的和尚未使用的虚拟内存页。
幸运的是,Glibc 的分配器和 LuaJIT 的分配器分配的内存,经常都会被立即实际使用的,所以绝大多数时候,二者并没有多少差别。
传统的 Nginx 服务器
传统的 Nginx 服务器软件只是 OpenResty 应用的严格子集。这些用户仍会看到系统分配器的内存用量和 Nginx 共享内存区域的使用量,偶尔也会涉及一些其他内存分配器。
OpenResty XRay 仍然可以用于直接检查和分析这些服务器进程,甚至在生产环境。当然,如果你没有编译 Lua 模块进你的 Nginx,那就不会看到任何与 Lua 相关的内存使用。
结论
本文是一个系列文章中的第一篇。这个系列会详细介绍 OpenResty 和 Nginx 分配和管理内存的细节,以便帮助那些基于这些技术构建的应用能够有效地优化其内存使用。后续的文章会展开介绍每一个细分的主题,覆盖各个不同的内存分配器和内存管理机制。敬请期待!
关于 OpenResty XRay
OpenResty XRay 是由 OpenResty Inc. 公司提供的商业产品。我们使用此产品为我们的文章(比如本文)提供直观的图表演示和真实系统内部的统计数据。
OpenResty XRay 可以在无需目标程序任何配合的情况下,帮助用户深入洞察其线上或者线下的各种软件系统的行为细节,有效地分析和定位各种性能问题、可靠性问题和安全问题。
OpenResty XRay
关于作者
章亦春是开源项目 OpenResty® 的创始人,同时也是 OpenResty Inc. 公司的创始人和 CEO。他贡献了许多 Nginx 的第三方模块,相当多 Nginx 和 LuaJIT 核心补丁,并且创建了 OpenResty XRay 等产品。
翻译
我们提供了英文版原文和中译版(本文) 。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!