initializer_list 引发的栈溢出问题

  1. braced-init-list
  2. initializer_list的实现
  3. 参考

最近项目要进行最后一次线上测试,对一些新增的server进行压测,在对同事用我写的协程框架搭建的ranksvr的时候进行压测的时候,ranksvr挂掉了,由于是压了一段时间才挂掉,所以有特定的业务逻辑触发了栈溢出,由于涉及到调用公司第三方API所以,查起来还是比较麻烦,一开始经过加日志,查看栈使用情况,但是发现在栈溢出前业务逻辑的栈使用情况正常。

这里我查问题的时候犯了一个错误,就是栈的使用情况不能简单的通过栈上变量的地址变化来对比查看。因为栈上的变量不一定是按照定义顺序从栈顶向下分配地址空间。特别是在开启编译优化的时候。

虽然说栈的使用是遵循LIFO,其实编译器只要保证函数之间调用时栈帧的使用符合LIFO就可以了,在进入函数时,压入caller的RBP和RIP到栈顶,然后构建当前函数的栈帧,退出函数时,弹出caller的RBP和RIP就恢复到了caller的栈帧。对于函数内部栈帧的使用就看编译器本身的优化了。有兴趣的可以试试在开启优化时下面输出结果和定义的顺序:

1
2
3
4
5
6
7
8
int main()
{
int a;
char b;
long c;
char d;
printf("%x,%x,%x,%x", &a,&b,&c,&d);
}

还需要注意的是:栈空间的分配是在编译后都确定好的,以及一些临时变量都是占用栈空间的。于是可以查看coredump业务逻辑代码的栈使用情况,如下:

可以看到该函数使用了0x1e178 = 120KB的栈空间本身协程栈只设置了132KB,所以问题定位到UpdateRankCoro的函数使用了大的栈上变量,于是我查看同事的代码,结果看了半天没有发现有大的栈空间的使用,凡是大的结构都是new出来的,pb结构的数据也都是堆上的。没办法,只能原始带比较相对比较高效的方法:折半注释代码,然后查看更改后函数的反汇编代码,查看栈空间大小的变化,最终发现占用大空间的是如下代码:

1
2
3
shared_ptr<TB_RANK_EXTRA_DATA> tmp_extra_data_buffer(new(std::nothrow) TB_RANK_EXTRA_DATA);
...
vector<TB_RANK_EXTRA_DATA> to_deleted_list { *tmp_extra_data_buffer };

TB_RANK_EXTRA_DATA是一个120K的POD数据,正是{ *tmp_extra_data_buffer }占用了120K的栈上空间。我们知道c++11中引入的新功能:list-initialization(列表初始化,注意不是成员初始化列表),通过braced-init-list(大括号列表)对容器进行初始化,为了提供这个新特性,c++11中引入了initializer_list :

1
2
template< class T >
class initializer_list;

为了分析问题,我们来探究一下braced-init-list和initializer_list两个的实现

braced-init-list

大括号初始化列表的底层实现是数组,其中每个元素都从列表的对应元素复制初始化的, 但是其存储为未指定的,引用

底层数组不保证在原始 initializer_list 对象的生存期结束后继续存在。其中每个元素都从原始初始化器列表的对应元素复制初始化std::initializer_list 的存储是未指定的(即它可以是自动、临时或静态只读内存,依赖场合)

1
2
3
4
int main()
{
std::initializer_list<int> a = {1,2,3,4,5};
}

我们看一下反汇编的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 通过命令objdump -S a.out查看elf的代码段的反汇编信息
int main()
{
4006b6: 55 push %rbp
4006b7: 48 89 e5 mov %rsp,%rbp
std::initializer_list<int> a = {1,2,3,4,5};
# 可以看到{1,2,3,4,5}是存放在rodata段的
# 这条指令是将数组的起始地址赋值给a的成员,后面分析initializer_list的实现你就会明白原因
4006ba: 48 c7 45 f0 d0 07 40 movq $0x4007d0,-0x10(%rbp)
4006c1: 00
# 这条指令是将数组的长度赋值给a的成员
4006c2: 48 c7 45 f8 05 00 00 movq $0x5,-0x8(%rbp)
4006c9: 00
}
4006ca: b8 00 00 00 00 mov $0x0,%eax
4006cf: 5d pop %rbp
4006d0: c3 retq

# 通过objdum -s a.out查看elf各个段的数据
Contents of section .rodata:
4007b0 01000200 00000000 00000000 00000000 ................
4007c0 00000000 00000000 00000000 00000000 ................
4007d0 01000000 02000000 03000000 04000000 ................
4007e0 05000000

上面的示例我们可以看到{1,2,3,4,5}的底层数组是存放在只读数据段的,那么我们的业务代码中都是变量而不是常量,所以**{ *tmp_extra_data_buffer }是存放在栈空间上的**。

initializer_list的实现

std::initializer_list<T>的引入是为了能够访问 const T 类型对象数组,是一个代理类,std::initializer_list 对象在很多时候会自动构造:

  • 用花括号初始化器列表列表初始化一个对象,其中对应构造函数接受一个 std::initializer_list 参数
  • 以花括号初始化器列表为赋值的右运算数,或函数调用参数,而对应的赋值运算符/函数接受 std::initializer_list 参数
  • 绑定花括号初始化器列表到 auto ,包括在范围 for 循环中

initializer_list 可由一对指针或指针与其长度实现。复制一个 std::initializer_list 不会复制其底层对象

我们看看gcc 5.4.0中initializer_list的实现

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
26
27
28
29
30
31
32
33
34
35
template<class _E>
class initializer_list
{
public:
typedef _E value_type;
typedef const _E& reference;
typedef const _E& const_reference;
typedef size_t size_type;
typedef const _E* iterator;
typedef const _E* const_iterator;

private:
iterator _M_array;
size_type _M_len;

// The compiler can call a private constructor.
constexpr initializer_list(const_iterator __a, size_type __l)
: _M_array(__a), _M_len(__l) { }

public:
constexpr initializer_list() noexcept
: _M_array(0), _M_len(0) { }

// Number of elements.
constexpr size_type
size() const noexcept { return _M_len; }

// First element.
constexpr const_iterator
begin() const noexcept { return _M_array; }

// One past the last element.
constexpr const_iterator
end() const noexcept { return begin() + size(); }
};

我们可以看到initializer_list中只有两个成员:_M_array和_M_len

  • _M_array:指向底层数组的起始地址
  • _M_len:表示底层数组的长度

所以我们看到上一小节中的反汇编代码中拷贝了这两个数值到initializer_list的这个成员中。

参考

https://zh.wikibooks.org/zh-hans/C%2B%2B/Initializer_list

https://zh.cppreference.com/w/cpp/utility/initializer_list