Linux C++ 内存分配 SIGKILL 问题

背景

在代码里看到发现某对象有个 buffer 每次会分配较大内存(定义一个大的 vector),担心如果对象太多会导致 vector 内存分配失败,于是自己写了个简单测试看能不能捕获 bad_alloc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <limits.h>
#include <iostream>
#include <vector>
using namespace std;

constexpr int size = 1024 * 1024; // 1 MiB

int main() {
int i = 0;
try {
for (; i < INT_MAX - 1; i++) {
new vector<int>(size);
}
cout << "Success" << endl;
} catch (const std::bad_alloc& e) {
cout << "Failed at " << i << "th allocate" << endl;
}
return 0;
}

比较遗憾的是,运行直接显示 Killed 崩溃,在 Linux 上也就是收到了 SIGKILL 信号。这让我想起来当初压测某 C++ 客户端时,对于 500 个分区的 topic,在限制内存为 1 GB 的容器中运行 C++ 客户端,很多都会直接 Killed,日志里也没异常信息。

内存分配

C 标准库的内存分配是使用 malloc(以及 calloc/realloc,这三者类似,这里不讲区别),它的使用很简单,分配失败就返回 NULL,见 man page:

1
2
3
4
The malloc() function allocates size bytes and returns a pointer to
the allocated memory. The memory is not initialized. If size is 0,
then malloc() returns either NULL, or a unique pointer value that can
later be successfully passed to free().

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <limits.h>
#include <iostream>
using namespace std;

constexpr int size = 1024 * 1024; // 1 MiB

struct VectorInt {
int* start;
int* finish;
int* end;
};

int main() {
int i = 0;
try {
for (; ; i++) {
auto p = new VectorInt;
p->start = new int[size];
}
cout << "Success" << endl;
} catch (const std::bad_alloc& e) {
cout << "Failed at " << i << "th allocate" << endl;
}
return 0;
}

结果还是收到 SIGKILL 了。把 new 改成 malloc 并检查返回值是否为空,结果也是一样。

malloc 真的会返回 NULL 吗?

虽然答案显然是会,毕竟如果不会,那肯定不会是我第一个发现。所以我简单试了下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char* argv[]) {
for (int i = 0; i < 1024 * 1024 * 1024; i++) {
int* p = (int*)malloc(sizeof(int));
if (!p) {
printf("[%d] malloc failed: %s\n", i, strerror(errno));
return 1;
}
}
return 0;
}

还是收到 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
2
3
4
5
6
7
8
9
10
brk(0x27a60149a000)                     = 0x27a5c149a000
mmap(NULL, 1073876992, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
mmap(NULL, 134217728, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x3fe58dc4e000
munmap(0x3fe58dc4e000, 37429248) = 0
munmap(0x3fe594000000, 29679616) = 0
mprotect(0x3fe590000000, 135168, PROT_READ|PROT_WRITE) = 0
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 3), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fe5a6a28000
write(1, "[106135] malloc failed: Cannot a"..., 47) = 47

在此之前是大量的 brk,也就是说每次运行 malloc 其实都是调用 brk。而导致 malloc 出错的是 mmap,它设置了错误码 ENOMEM,也就是 malloc 的 man page 里提到的错误码,表示内存不足。

将单词内存分配大小改回 128 MiB 试试(512 MiB 仍然能正常终止),最后几次系统调用:

1
2
3
brk(0x181922339000)                     = 0x181922339000
brk(0x18192a339000 <ptrace(SYSCALL):No such process>
+++ killed by SIGKILL +++

发现并没有调用 mmap,而是持续调用 brk,直到收到 SIGKILL

brk 和 mmap 系统调用

从前一节可知,这里出问题的并不是 STL allocator,而是 malloc 本身。而之前一直在调用 brk。还是参考 man page,这里首先得弄清楚 program break 的概念,首先看下进程虚拟地址空间分布(最简单的模型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
high address +--------------------+
| |
| |
+--------------------+
| stack |
+---------+----------+
| | |
| | |
| v |
| |
| |
| |
| ^ |
| | |
| | |
+---------+----------+
| heap |
+--------------------+
| uninitialized data |
| (bss) |
+--------------------+
| initialized data |
+--------------------+
| text |
low address +--------------------+

program break 就是 bss 段上面的第一个地址。brk 的函数签名:

1
2
3
4
#include <unistd.h>

int brk(void *addr);
void *sbrk(intptr_t increment);

brk 的作用是改变 program break 的位置为 addr。增加 addr 相当于申请空间。至于 sbrk 则是增加而非设置。

这里以一个最简单的内存申请为例:

1
2
int* p = (int*)malloc(1024 * 1024 * 10);
free(p);

对应系统调用:

1
2
3
brk(0)                                  = 0x2112000
brk(0x2144000) = 0x2144000
mmap(NULL, 10489856, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fb6338d2000

第一次调用是 brk(0),返回的不是 0 而是 0x2112000,也就是最初状态下 bss 段上面的第一个地址。后面移动到了新位置,增加了 program break,0x2144000 - 0x2112000 = 0x0032000,也就是 204800 字节,即 197 KiB,显然是不足 10 MiB。

然而接下来的 mmap 第二个参数,10489856 刚好是 10 MiB。brk 只是调整 program break,这只是虚拟地址空间的一个标记,如果要使用这块内存,需要用 mmap 进行映射。

1
2
3
4
5
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void *addr, size_t length);
  • addr:为 NULL 则由内核选择首地址(满足页对齐),即使不为 NULL,也只是内核根据这个值作为提示信息来选择。
  • length:映射的字节数,实际上会被提升至分页大小(sysconf(_SC_PAGE_SIZE))的整数倍。
  • prot:保护等级,为 PROT_NONE(无法访问)或者 PROT_READ(可读)/PROT_WRITE(可写)/PROT_EXEC(可执行)的组合。如果对这部分内存映射违反了保护等级,会产生 SIGSEGV 信号,也就是常见的段错误(segmentation fault)。
  • flagsMAP_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?

刚才了解了 brkmmap 结合前文内容,我们可知只有 mmap 进行内存映射失败(比如物理内存不够)malloc 才会返回 NULL 并将 errno 置为 ENOMEM。但是对于频繁申请小内存的情况,则是无限调用 brk 导致内存崩溃,并没有出现 mmap。回到第一节的代码(无限创建 1 MiB 的 vector),用 strace 观察的结果是无限调用 mmap 导致内存崩溃:

1
2
3
4
mmap(NULL, 4198400, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3fdead1000
/* ... */
mmap(NULL, 4198400, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3e9ddcf000
+++ killed by SIGKILL +++

可见每次 mmap 长度上限也就 4198400(差不多 4 MiB)。

而假如 vector 大小改成 256 MiB,那么对应 strace 信息为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
brk(0)                                  = 0xcbb000
brk(0xced000) = 0xced000
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fdda8485000
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fdd68484000
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fdd28483000
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fdce8482000
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fdca8481000
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
brk(0x40ced000) = 0xced000
mmap(NULL, 1073876992, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
mmap(NULL, 134217728, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7fdca0481000
munmap(0x7fdca0481000, 62386176) = 0
munmap(0x7fdca8000000, 4722688) = 0
mprotect(0x7fdca4000000, 135168, PROT_READ|PROT_WRITE) = 0
mmap(NULL, 1073745920, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)

每次 mmap 长度是 1073745920,差不多 1 GiB。这么看,其实根本原因在于 malloc 实现(调用 brkmmap),因为 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 这个讨论帖有讲,简单说就是:

  • newmalloc 可能从内核拿到了一个不合法的地址,即使没有足够的内存,因为:
    1. 内核直到第一次访问时才分配地址
    2. 如果所有的 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 记录,但对调试的帮助仍然有限。这种时候,根本性的解决方案还是自定义内存分配器。