OpenResty 和 Nginx 服务器通常会配置共享内存区,用于储存在所有工作进程之间共享的数据。 例如,Nginx 标准模块 ngx_http_limit_reqngx_http_limit_conn 使用共享内存区储存状态数据,以限制所有工作进程中的用户请求速率和用户请求的并发度。 OpenResty 的 ngx_lua 模块通过 lua_shared_dict,向用户 Lua 代码提供基于共享内存的数据字典存储。

本文通过几个简单和独立的例子,探讨这些共享内存区如何使用物理内存资源(或 RAM)。 我们还会探讨共享内存的使用率对系统层面的进程内存指标的影响,例如在 ps 等系统工具的结果中的 VSZRSS 等指标。

与本博客网站 中的几乎所有技术类文章类似, 我们使用 OpenResty XRay 这款动态追踪产品对未经修改的 OpenResty 或 Nginx 服务器和应用的内部进行深度分析和可视化呈现。 因为 OpenResty XRay 是一个非侵入性的分析平台,所以我们不需要对 OpenResty 或 Nginx 的目标进程做任何修改 – 不需要代码注入, 也不需要在目标进程中加载特殊插件或模块。 这样可以保证我们通过 OpenResty XRay 分析工具所看到的目标进程内部状态,与没有观察者时的状态是完全一致的。

我们将在多数示例中使用 ngx_lua 模块的 lua_shared_dict,因为该模块可以使用自定义的 Lua 代码进行编程。 我们在这些示例中展示的行为和问题,也同样适用于所有标准 Nginx 模块和第三方模块中的其他共享内存区。

Slab 与内存页

Nginx 及其模块通常使用 Nginx 核心里的 slab 分配器 来管理共享内存区内的空间。这个 slab 分配器专门用于在固定大小的内存区内分配和释放较小的内存块。

在 slab 的基础之上,共享内存区会引入更高层面的数据结构,例如红黑树和链表等等。

slab 可能小至几个字节,也可能大至跨越多个内存页。

操作系统以内存页为单位来管理进程的共享内存(或其他种类的内存)。 在 x86_64 Linux 系统中,默认的内存页大小通常是 4 KB,但具体大小取决于体系结构和 Linux 内核的配置。例如,某些 Aarch64 Linux 系统的内存页大小高达 64 KB。

我们将会看到 OpenResty 和 Nginx 进程的共享内存区,分别在内存页层面和 slab 层面上的细节信息。

分配的内存不一定有消耗

与硬盘这样的资源不同,物理内存(或 RAM)总是一种非常宝贵的资源。 大部分现代操作系统都实现了一种优化技术,叫做按需分页(demand-paging),用于减少用户应用对 RAM 资源的压力。 具体来说,就是当你分配大块的内存时,操作系统核心会将 RAM 资源(或物理内存页)的实际分配推迟到内存页里的数据被实际使用的时候。 例如,如果用户进程分配了 10 个内存页,但却只使用了 3 个内存页,则操作系统可能只把这 3 个内存页映射到了 RAM 设备。这种行为同样适用于 Nginx 或 OpenResty 应用中分配的共享内存区。用户可以在 nginx.conf 文件中配置庞大的共享内存区,但他可能会注意到在服务器启动之后,几乎没有额外占用多少内存,毕竟通常在刚启动的时候,几乎没有共享内存页被实际使用到。

空的共享内存区

我们以下面这个 nginx.conf 文件为例。该文件分配了一个空的共享内存区,并且从没有使用过它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
master_process on;
worker_processes 2;

events {
worker_connections 1024;
}

http {
lua_shared_dict dogs 100m;

server {
listen 8080;

location = /t {
return 200 "hello world\n";
}
}
}

我们通过 lua_shared_dict 指令配置了一个 100 MB 的共享内存区,名为 dogs。 并且我们为这个服务器配置了 2 个工作进程。请注意,我们在配置里从没有触及这个 dogs 区,所以这个区是空的。

可以通过下列命令启动这个服务器:

1
2
3
4
5
mkdir ~/work/
cd ~/work/
mkdir logs/ conf/
vim conf/nginx.conf # paste the nginx.conf sample above here
/usr/local/openresty/nginx/sbin/nginx -p $PWD/

然后用下列命令查看 nginx 进程是否已在运行:

1
2
3
4
5
$ ps aux|head -n1; ps aux|grep nginx
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
agentzh 9359 0.0 0.0 137508 1576 ? Ss 09:10 0:00 nginx: master process /usr/local/openresty/nginx/sbin/nginx -p /home/agentzh/work/
agentzh 9360 0.0 0.0 137968 1924 ? S 09:10 0:00 nginx: worker process
agentzh 9361 0.0 0.0 137968 1920 ? S 09:10 0:00 nginx: worker process

这两个工作进程占用的内存大小很接近。下面我们重点研究 PID 为 9360 的这个工作进程。 在 OpenResty XRay 控制台的 Web 图形界面中,我们可以看到这个进程一共占用了 134.73 MB 的虚拟内存(virtual memory)和 1.88 MB 的常驻内存(resident memory),这与上文中的 ps 命令输出的结果完全相同:

空的共享内存区的虚拟内存使用量明细

正如我们的另一篇文章《OpenResty 和 Nginx 如何分配和管理内存》中所介绍的,我们最关心的就是常驻内存的使用量。常驻内存将硬件资源实际映射到相应的内存页(如 RAM [1])。 所以我们从图中看到,实际映射到硬件资源的内存量很少,总计只有 1.88MB。 上文配置的 100 MB 的共享内存区在这个常驻内存当中只占很小的一部分(详情请见后续的讨论)。

当然,共享内存区的这 100 MB 还是全部贡献到了该进程的虚拟内存总量中去了。 操作系统会为这个共享内存区预留出虚拟内存的地址空间,不过,这只是一种簿记记录,此时并不占用任何的 RAM 资源或其他硬件资源。

不是空无一物

我们可以通过该进程的“应用层面的内存使用量的分类明细”图,来检查空的共享内存区是否占用了常驻(或物理)内存。

应用层面内存使用量明细

有趣的是,我们在这个图中看到了一个非零的 Nginx Shm Loaded (已加载的 Nginx 共享内存)组分。 这部分很小,只有 612 KB,但还是出现了。所以空的共享内存区也并非空无一物。 这是因为 Nginx 已经在新初始化的共享内存区域中放置了一些元数据,用于簿记目的。 这些元数据为 Nginx 的 slab 分配器所使用。

已加载和未加载内存页

我们可以通过 OpenResty XRay 自动生成的下列图表,查看共享内存区内被实际使用(或加载)的内存页数量。

共享内存区域内已加载和未加载的内存页

我们发现在dogs区域中已经加载(或实际使用)的内存大小为 608 KB,同时有一个特殊的 ngx_accept_mutex_ptr 被 Nginx 核心自动分配用于 accept_mutex 功能。

这两部分内存的大小相加为 612 KB,正是上文的饼状图中显示的 Nginx Shm Loaded 的大小。

如前文所述,dogs 区使用的 608 KB 内存实际上是 slab 分配器 使用的元数据。

未加载的内存页只是被保留的虚拟内存地址空间,并没有被使用过。

关于进程的页表

我们没有提及的一种复杂性是,每一个 nginx 工作进程其实都有各自的页表。CPU 硬件或操作系统内核正是通过查询这些页表来查找虚拟内存页所对应的存储。因此每个进程在不同共享内存区内可能有不同的已加载页集合,因为每个进程在运行过程中可能访问过不同的内存页集合。为了简化这里的分析,OpenResty XRay 会显示所有的为任意一个工作进程加载过的内存页,即使当前的目标工作进程从未碰触过这些内存页。也正因为这个原因,已加载内存页的总大小可能(略微)高于目标进程的常驻内存的大小。

空闲的和已使用的 slab

如上文所述,Nginx 通常使用 slabs 而不是内存页来管理共享内存区内的空间。 我们可以通过 OpenResty XRay 直接查看某一个共享内存区内已使用的和空闲的(或未使用的)slabs 的统计信息:

dogs区域中空的和已使用的slab

如我们所预期的,我们这个例子里的大部分 slabs 是空闲的未被使用的。注意,这里的内存大小的数字远小于上一节中所示的内存页层面的统计数字。这是因为 slabs 层面的抽象层次更高,并不包含 slab 分配器针对内存页的大小补齐和地址对齐的内存消耗。

我们可以通过OpenResty XRay进一步观察在这个 dogs 区域中各个 slab 的大小分布情况:

空白区域的已使用 slab 大小分布

空的 slab 大小分布

我们可以看到这个空的共享内存区里,仍然有 3 个已使用的 slab 和 157 个空闲的 slab。 这些 slab 的总个数为:3 + 157 = 160个。请记住这个数字,我们会在下文中跟写入了一些用户数据的 dogs 区里的情况进行对比。

写入了用户数据的共享内存区

下面我们会修改之前的配置示例,在 Nginx 服务器启动时主动写入一些数据。 具体做法是,我们在 nginx.conf 文件的 http {} 配置分程序块中增加下面这条 init_by_lua_block 配置指令:

1
2
3
4
5
init_by_lua_block {
for i = 1, 300000 do
ngx.shared.dogs:set("key" .. i, i)
end
}

这里在服务器启动的时候,主动对 dogs 共享内存区进行了初始化,写入了 300,000 个键值对。

然后运行下列的 shell 命令以重新启动服务器进程:

1
2
kill -QUIT `cat logs/nginx.pid`
/usr/local/openresty/nginx/sbin/nginx -p $PWD/

新启动的 Nginx 进程如下所示:

1
2
3
4
5
$ ps aux|head -n1; ps aux|grep nginx
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
agentzh 29733 0.0 0.0 137508 1420 ? Ss 13:50 0:00 nginx: master process /usr/local/openresty/nginx/sbin/nginx -p /home/agentzh/work/
agentzh 29734 32.0 0.5 138544 41168 ? S 13:50 0:00 nginx: worker process
agentzh 29735 32.0 0.5 138544 41044 ? S 13:50 0:00 nginx: worker process

虚拟内存与常驻内存

针对 Nginx 工作进程 29735,OpenResty XRay 生成了下面这张饼图:

非空白区域的虚拟内存使用量明细

显然,常驻内存的大小远高于之前那个空的共享区的例子,而且在总的虚拟内存大小中所占的比例也更大(29.6%)。

虚拟内存的使用量也略有增加(从 134.73 MB 增加到了 135.30 MB)。 因为共享内存区本身的大小没有变化,所以共享内存区对于虚拟内存使用量的增加其实并没有影响。这里略微增大的原因是我们通过 init_by_lua_block 指令新引入了一些 Lua 代码(这部分微小的内存也同时贡献到了常驻内存中去了)。

应用层面的内存使用量明细显示,Nginx 共享内存区域的已加载内存占用了最多常驻内存:

dogs 区域内已加载和未加载的内存页

已加载和未加载内存页

现在在这个 dogs 共享内存区里,已加载的内存页多了很多,而未加载的内存页也有了相应的显著减少:

dogs区域中的已加载和未加载内存页

空的和已使用的 slab

现在 dogs 共享内存区增加了 300,000 个已使用的 slab(除了空的共享内存区中那 3 个总是会预分配的 slab 以外):

dogs非空白区域中的已使用slab

显然,lua_shared_dict 区中的每一个键值对,其实都直接对应一个 slab。

空闲 slab 的数量与先前在空的共享内存区中的数量是完全相同的,即 157 个 slab:

dogs非空白区域的空slab

虚假的内存泄漏

正如我们上面所演示的,共享内存区在应用实际访问其内部的内存页之前,都不会实际耗费物理内存资源。因为这个原因,用户可能会观察到 Nginx 工作进程的常驻内存大小似乎会持续地增长,特别是在进程刚启动之后。这会让用户误以为存在内存泄漏。下面这张图展示了这样的一个例子:

process memory growing

通过查看 OpenResty XRay 生成的应用级别的内存使用明细图,我们可以清楚地看到 Nginx 的共享内存区域其实占用了绝大部分的常驻内存空间:

Memory usage breakdown for huge shm zones

这种内存增长是暂时的,会在共享内存区被填满时停止。但是当用户把共享内存区配置得特别大,大到超出当前系统中可用的物理内存的时候,仍然是有潜在风险的。正因为如此,我们应该注意观察如下所示的内存页级别的内存使用量的柱状图:

Loaded and unloaded memory pages in shared memory zones

图中蓝色的部分可能最终会被进程用尽(即变为红色),而对当前系统产生冲击。

HUP 重新加载

Nginx 支持通过 HUP 信号来重新加载服务器的配置而不用退出它的 master 进程(worker 进程仍然会优雅退出并重启)。通常 Nginx 共享内存区会在 HUP 重新加载(HUP reload)之后自动继承原有的数据。所以原先为已访问过的共享内存页分配的那些物理内存页也会保留下来。于是想通过 HUP 重新加载来释放共享内存区内的常驻内存空间的尝试是会失败的。用户应改用 Nginx 的重启或二进制升级操作。

值得提醒的是,某一个 Nginx 模块还是有权决定是否在 HUP 重新加载后保持原有的数据。所以可能会有例外。

结论

我们在上文中已经解释了 Nginx 的共享内存区所占用的物理内存资源,可能远少于 nginx.conf 文件中配置的大小。这要归功于现代操作系统中的按需分页特性。我们演示了空的共享内存区内依然会使用到一些内存页和 slab,以用于存储 slab 分配器本身需要的元数据。通过 OpenResty XRay 的高级分析器,我们可以实时检查运行中的 nginx 工作进程,查看其中的共享内存区实际使用或加载的内存,包括内存页和 slab 这两个不同层面。

另一方面,按需分页的优化也会产生内存在某段时间内持续增长的现象。这其实并不是内存泄漏,但仍然具有一定的风险。我们也解释了 Nginx 的 HUP 重新加载操作通常并不会清空共享内存区里已有的数据。

我们将在本博客网站后续的文章中,继续探讨共享内存区中使用的高级数据结构,例如红黑树和队列,以及如何分析和缓解共享内存区内的内存碎片的问题。

延伸阅读

关于作者

章亦春是开源项目 OpenResty® 的创始人,同时也是 OpenResty Inc. 公司的创始人和 CEO。他贡献了许多 Nginx 的第三方模块,相当多 Nginx 和 LuaJIT 核心补丁,并且设计了 OpenResty XRay 等产品。

关注我们

如果您觉得本文有价值,非常欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文) 。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!


  1. 当发生交换(swapping)时,一些常驻内存会被保存和映射到硬盘设备上去。