背景
在代码里看到发现某对象有个 buffer 每次会分配较大内存(定义一个大的 vector
),担心如果对象太多会导致 vector
内存分配失败,于是自己写了个简单测试看能不能捕获 bad_alloc
。
1 |
|
比较遗憾的是,运行直接显示 Killed 崩溃,在 Linux 上也就是收到了 SIGKILL
信号。这让我想起来当初压测某 C++ 客户端时,对于 500 个分区的 topic,在限制内存为 1 GB 的容器中运行 C++ 客户端,很多都会直接 Killed,日志里也没异常信息。
内存分配
C 标准库的内存分配是使用 malloc
(以及 calloc
/realloc
,这三者类似,这里不讲区别),它的使用很简单,分配失败就返回 NULL
,见 man page:
1 | The malloc() function allocates size bytes and returns a pointer to |
C++ 的 new
则是分为两步,首先是调用 operator new
分配内存,然后就地构造(placement new),对于基本类型就是内存拷贝,对于类而言调用构造函数。
一般而言分配内存是前一步,大概是直接包的 malloc
,相比而言,C++ 可以重写 operator new
来执行自定义内存分配策略,虽然 C 替代 malloc
也可以,但不一定通用。C++ new
失败不会返回空指针,而是会抛出 std::bad_alloc
异常。
STL 内存分配则又是一回事,为了能让用户能更精确掌控内存分配(而不是受到 new
的制约),它提供了一个 std::allocator<T>
类模板进行默认的内存分配,而用户可以自行实现接口作为自己的内存分配策略。当然,是不是包的 operator new
我也没去细看。
怀疑是 STL allocator 的问题,于是这里用 new
来替代 STL allocator 看看,由于 vector
一般实现是 3 个指针,所以这里也模拟了实现:
1 |
|
结果还是收到 SIGKILL
了。把 new
改成 malloc
并检查返回值是否为空,结果也是一样。
malloc 真的会返回 NULL 吗?
虽然答案显然是会,毕竟如果不会,那肯定不会是我第一个发现。所以我简单试了下:
1 |
|
还是收到 SIGKILL
了。这里我想起了之前看《STL 源码剖析》时,SGI STL 内存分配器对于小内存分配会进行优化,我想 malloc
应该也会,可能是我单次分配的内存(sizeof(int)
,4 字节)太小了,于是我开始尝试增加单次分配的字节数来观察现象。
4 B:Killed
4 KiB:Killed
4 MiB:Killed
20 MiB:Killed
80 MiB:Killed
1 GiB:没有被 Killed,运行多次的结果如下:
1
2
3
4
5[131070] malloc failed: Cannot allocate memory
[131071] malloc failed: Cannot allocate memory
[131070] malloc failed: Cannot allocate memory
[131071] malloc failed: Cannot allocate memory
[131071] malloc failed: Cannot allocate memory
用 strace
重新运行查看系统调用:
1 | brk(0x27a60149a000) = 0x27a5c149a000 |
在此之前是大量的 brk
,也就是说每次运行 malloc
其实都是调用 brk
。而导致 malloc
出错的是 mmap
,它设置了错误码 ENOMEM
,也就是 malloc
的 man page 里提到的错误码,表示内存不足。
将单词内存分配大小改回 128 MiB 试试(512 MiB 仍然能正常终止),最后几次系统调用:
1 | brk(0x181922339000) = 0x181922339000 |
发现并没有调用 mmap
,而是持续调用 brk
,直到收到 SIGKILL
。
brk 和 mmap 系统调用
从前一节可知,这里出问题的并不是 STL allocator,而是 malloc
本身。而之前一直在调用 brk
。还是参考 man page,这里首先得弄清楚 program break 的概念,首先看下进程虚拟地址空间分布(最简单的模型):
1 | high address +--------------------+ |
program break 就是 bss 段上面的第一个地址。brk
的函数签名:
1 |
|
brk
的作用是改变 program break 的位置为 addr
。增加 addr
相当于申请空间。至于 sbrk
则是增加而非设置。
这里以一个最简单的内存申请为例:
1 | int* p = (int*)malloc(1024 * 1024 * 10); |
对应系统调用:
1 | brk(0) = 0x2112000 |
第一次调用是 brk(0)
,返回的不是 0 而是 0x2112000,也就是最初状态下 bss 段上面的第一个地址。后面移动到了新位置,增加了 program break,0x2144000 - 0x2112000 = 0x0032000,也就是 204800 字节,即 197 KiB,显然是不足 10 MiB。
然而接下来的 mmap
第二个参数,10489856 刚好是 10 MiB。brk
只是调整 program break,这只是虚拟地址空间的一个标记,如果要使用这块内存,需要用 mmap
进行映射。
1 |
|
addr
:为NULL
则由内核选择首地址(满足页对齐),即使不为NULL
,也只是内核根据这个值作为提示信息来选择。length
:映射的字节数,实际上会被提升至分页大小(sysconf(_SC_PAGE_SIZE)
)的整数倍。prot
:保护等级,为PROT_NONE
(无法访问)或者PROT_READ
(可读)/PROT_WRITE
(可写)/PROT_EXEC
(可执行)的组合。如果对这部分内存映射违反了保护等级,会产生SIGSEGV
信号,也就是常见的段错误(segmentation fault)。flags
:MAP_PRIVATE
(私有映射)或者MAP_SHARED
(共享映射),前者是对其他进程不可见,后者是可见的,也就是共享内存。
最终内存会映射到从文件描述符 fd
对应的文件的偏移量 offset
处开始,注意 offset
也必须是分页大小的倍数。如果 fd
为 -1,则不映射到文件。
回顾之前的调用:
1 | mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fb6338d2000 |
也就是将物理内存的 10 MiB 给映射到堆的虚拟内存空间。
至于 munmap
即删除映射,addr
为对应 mmap
的返回值。
malloc 小内存为何不返回 NULL?
刚才了解了 brk
和 mmap
结合前文内容,我们可知只有 mmap
进行内存映射失败(比如物理内存不够)malloc
才会返回 NULL
并将 errno
置为 ENOMEM
。但是对于频繁申请小内存的情况,则是无限调用 brk
导致内存崩溃,并没有出现 mmap
。回到第一节的代码(无限创建 1 MiB 的 vector
),用 strace
观察的结果是无限调用 mmap
导致内存崩溃:
1 | mmap(NULL, 4198400, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3fdead1000 |
可见每次 mmap
长度上限也就 4198400(差不多 4 MiB)。
而假如 vector
大小改成 256 MiB,那么对应 strace
信息为:
1 | brk(0) = 0xcbb000 |
每次 mmap
长度是 1073745920,差不多 1 GiB。这么看,其实根本原因在于 malloc
实现(调用 brk
和 mmap
),因为 malloc
允许较为高效的小内存分配(比如它不会因为 N 次 int
的分配就调用 N 次 brk
)。
其实在 malloc
的 man page 上面就可以看到原因,以下内容摘自 NOTE 部分。
默认情况下,Linux 遵循乐观的内存分配策略,也就是
malloc
返回非NULL
时,无法保证内存确实可用,如果系统 OOM 了,进程会被 OOM killer 干掉,也就是 OOM killer 发送SIGKILL
信号。通常
malloc()
从堆上分配内存,并使用sbrk()
调整堆的大小。当分配的内存块字节数大于MMAP_THRESHOLD
时,glibc 的malloc()
实现使用mmap()
进行私有匿名映射来分配内存。MMAP_THRESHOLD 默认 128 kB,但是可以通过mallopt()
进行调整。
这也说明了,为啥分配大容量 vector
时是一直调用 mmap
,而分配 int
时则是一直调用 brk
。
mmap
的大小也有区别,每次映射 4198400 字节时,最后引向的是 SIGKILL
,而每次映射 1073745920 字节时,最后得到的是错误码 ENOMEM
。从上述文档可知,前者应该是触发 OOM killer 了,也就是映射了较小的内存,但不一定保证可用就返回了。也就是所谓的 乐观内存分配策略 导致的。
在 sigkill while allocating memory in c 这个讨论帖有讲,简单说就是:
new
或malloc
可能从内核拿到了一个不合法的地址,即使没有足够的内存,因为:- 内核直到第一次访问时才分配地址
- 如果所有的 overcommited 内存都被使用,操作系统只能杀死其中的一个进程(也就是 OOM killer)。
OOM killer 可参考: https://linux-mm.org/OOM_Killer
简单说就是牺牲一个或多个进程,以便在其他进程故障时为系统释放进程。
那 overcommited 内存呢?它是一个内存分配策略,其值记录在 /proc/sys/vm/overcommit_memory
文件。所谓 overcommit,就是过度提交,其实也就是刚才说的,内核直到第一次访问时才分配地址,否则即使请求的内存超过限制,只要不去访问,内核仍然当作可用。
how to check if malloc overcommits memory 给出了几种解决方法,方法 2 我是开启了 --oom-kill-disable
选项启动 docker,但还是一样。可能是哪里操作不对。方法 3(在 malloc
后进行写入)可能会花很长时间来分配较大内存,但是仍然可能被 kill。
总结
Linux 上,在某些情况下 malloc
即使返回非 NULL
值,由于内核的 overcommit 内存分配策略,也有可能导致这片内存不是可用的,这种内存泄漏累积的结果就是导致进程被 OOM killer 干掉(发送 SIGKILL
信号,无法被捕获)。
单次 malloc
申请较大的内存可以规避这点,因为即使有 overcommit 策略,也不可能超过可用太多。
总之,如果是在限制内存(比如 docker)的环境下运行 C++ 程序,内存分配失败不一定会以 std::bad_alloc
结束,此时会拿不到异常栈信息。虽然用 dmesg
能够找到 OOM 记录,但对调试的帮助仍然有限。这种时候,根本性的解决方案还是自定义内存分配器。