Adventures in Systems Programming C++ Local Statics
翻译自:https://manishearth.github.io/blog/2015/06/26/adventures-in-systems-programming-c-plus-plus-local-statics/
文章主要内容:这篇⽂章讲述为什么在局部静态变量时使⽤了递归锁。
系统编程的冒险c++本地静态
一段时间以来,我一直对编译器和系统编程非常感兴趣;我觉得系统编程的一个重要特性是,在操作系统或硬件级别上比较容易判断一行代码做了什么(模块化优化)。相反,了解您的工具在系统编程中是如何工作的比以往任何时候都更重要。所以当我看到一个我不熟悉的语言特性时,我很想知道它的底层是如何工作的。
我不是c++专家。我可以在c++代码库上工作,但我还远远不了解c++的所有特性和细微差别。但是,我非常擅长于Rust,并且了解编译器内部的相当一部分内容。这为我提供了一个很好的视角——我还没有将大多数c++特性内在化,并认为它们是理所当然的,我已经做好了研究这些特性的准备。
今天,我遇到了一些类似以下的c++代码:
void foo(){
static SomeType bar = Env()->someMethod();
static OtherType baz = Env()->otherMetgod(bar);
}
这段代码引起了我的兴趣。具体来说,就是 静态局部变量local static
, 我知道当你有了一个 static
如下:
static int FOO = 1;
1
存储在程序的.data部分的某个地方。这是很容易用 gdb
工具来验证:
static int THING = 0xAAAA;
int main() {
return 1;
}
$ g++ test.cpp -g
$ gdb a.out
(gdb) info addr THING
Symbol "THING" is static
(gdb) info symbol 0x601038
THING in section .data
这基本上是编译程序加载到内存中的一部分。类似地,当您有一个用函数初始化的静态文件时,它将存储在.bss
部分中,并在main()
之前初始化。很容易验证:
#include<iostream>
using namespace std;
int bar() {
cout<<"bar called\n";
return 0xFAFAFA;
}
static int THING = bar();
int main() {
cout<<"main called\n";
return 0;
}
$ ./a.out
bar called
main called
$ gdb a.out
(gdb) info addr THING
Symbol "THING" is static storage at address 0x601198.
(gdb) info symbol 0x601198
THING in section .bss
我们也可以让statics
未初始化(static int THING;
) 它们将被放置在.bss
。
到目前为止一切顺利。
现在回到原来的片段:
void foo(){
static SomeType bar = Env()->someMethod();
static OtherType baz = Env()->otherMetgod(bar);
}
有些人可能会天真地说,这些是局部作用域的静态,以避免名称冲突。除了它不是全局标识符之外,它与 static THING = bar()
没有太大区别。
然而,事实并非如此。提示我的是这叫做Env()
,我不确定在调用 main()
之前环境是否被正确初始化并可用。
相反,它们是静态的,在第一次调用函数时初始化。
#include<iostream>
using namespace std;
int bar() {
cout<<"bar called\n";
return 0xFAFAFA; //此处的作用是什么
}
void foo() {
cout<<"foo called\n";
static int i = bar();
cout<<"Static is:"<< i<<"\n";
}
int main() {
cout<<"main called\n";
foo();
foo();
foo();
return 0;
}
$ g++ test.cpp
$ ./a.out
main called
foo called
bar called
Static is:16448250
foo called
Static is:16448250
foo called
Static is:16448250
等等,“第一次调用函数” ? 警铃响了… … 肯定会有一些代价! 让我们来研究一下。
$ gdb a.out
(gdb) disas bar
// snip
0x0000000000400c72 <+15>: test %al,%al
0x0000000000400c74 <+17>: jne 0x400ca4 <_Z3foov+65>
0x0000000000400c76 <+19>: mov $0x6021f8,%edi
0x0000000000400c7b <+24>: callq 0x400a00 <__cxa_guard_acquire@plt>
0x0000000000400c80 <+29>: test %eax,%eax
0x0000000000400c82 <+31>: setne %al
0x0000000000400c85 <+34>: test %al,%al
0x0000000000400c87 <+36>: je 0x400ca4 <_Z3foov+65>
0x0000000000400c89 <+38>: mov $0x0,%r12d
0x0000000000400c8f <+44>: callq 0x400c06 <_Z3barv>
0x0000000000400c94 <+49>: mov %eax,0x201566(%rip) # 0x602200 <_ZZ3foovE1i>
0x0000000000400c9a <+55>: mov $0x6021f8,%edi
0x0000000000400c9f <+60>: callq 0x400a80 <__cxa_guard_release@plt>
0x0000000000400ca4 <+65>: mov 0x201556(%rip),%eax # 0x602200 <_ZZ3foovE1i>
0x0000000000400caa <+71>: mov %eax,%esi
0x0000000000400cac <+73>: mov $0x6020c0,%edi
// snip
位于<+44>
的指令调用了bar()
,它似乎被对其他cxa_guard
函数的调用所包围。
我们可以天真地猜测它的作用:它可能只是在初始化时设置了一个隐藏的静态标志,以确保它只运行一次。
当然,实际的解决方案并不那么简单。它需要避免数据竞争、处理错误并以某种方式处理递归初始化。
让我们看看规范和一个实现,它是通过搜索剩余的cxa_guard
找到的。
它们都向我们展示了用于初始化诸如本地静态之类的东西的生成代码:
if (obj_guard.first_byte == 0) {
if ( __cxa_guard_acquire (&obj_guard) ) {
try {
// ... initialize the object ...;
} catch (...) {
__cxa_guard_abort (&obj_guard);
throw;
}
// ... queue object destructor with __cxa_atexit() ...;
__cxa_guard_release (&obj_guard);
}
}
这里,obj_guard
是我们的“隐藏静态标志”,还有一些其他的额外数据。
__cxa_guard_acquire
与 __cxa_guard_release
的获取及释放锁以防止递归初始化。所以这个程序会崩溃:
#include<iostream>
using namespace std;
void foo(bool recur);
int bar(bool recur) {
cout<<"bar called\n";
if(recur) {
foo(false);
}
return 0xFAFAFA;
}
void foo(bool recur) {
cout<<"foo called\n";
static int i = bar(recur);
cout<<"Static is:"<< i<<"\n";
}
int main() {
foo(true);
return 0;
}
$ g++ test.cpp
$ ./a.out
foo called
bar called
foo called
terminate called after throwing an instance of '__gnu_cxx::recursive_init_error'
what(): std::exception
Aborted (core dumped)
在这里,为了初始化i
,需要调用bar()
,但是bar()
调用了 foo()
,而foo()
需要对i
进行初始化,而 foo()
又会调用bar()
(尽管这次不会递归)。如果我不是静态的,那就好了,但是现在我们有两个尝试初始化i的调用,不清楚应该使用哪个值。
这个实现非常有趣。 在查看这些代码之前,我快速猜测当地的静态数据会发生以下情况:
-
obj_guard
是一个包含互斥对象和三个状态的标志的结构体: “ uninitialized”、“ initializing” 和 “ initialized”。 或者,使用原子状态指示器 -
当我们第一次尝试初始化时,互斥对象被锁定,标志被设置为“初始化” ,互斥对象被释放,值被初始化,标志被设置为“初始化”
-
如果在获取互斥对象时,该值为“ initialized” ,则不要再次初始化
-
如果在获取互斥对象时,该值为“ initializing” ,则抛出一些异常
(我们需要三态标志,因为没有它递归会导致死锁)
我认为这个实现可以工作,尽管它不是正在使用的实现。在bionic (C stdlib的Android版本) 中的实现类似; 它使用每个静态原子来表示各种状态。但是,当我们进行递归初始化时,它并不抛出异常,相反,它似乎是死锁。这是可以的,因为c++规范说(第6.7.4节).
如果控件在初始化对象时(递归地)重新进入声明,则行为是未定义的。
然而,gcc / libstdc++
中的实现(还有来自 Apple 的libcppabi
的这个版本,它的可读性更强一些)做了一些不同的事情。 它们使用全局递归互斥对象来处理可重入性。 递归互斥锁基本上可以被一个线程锁定多次,但不能被另一个线程锁定,直到锁定线程解锁相同次数。 这意味着递归 / 可重入不会导致死锁,但是我们仍然可以一次访问一个线程。 这些实现所做的是:
guard_object
是一组两个标志,一个标志表示静态是否已初始化,另一个标志表示静态正在初始化(“ in use”)- 如果对象被初始化,那么什么也不做(这不使用互斥锁,而且很便宜)。 这并不是库中实现的一部分,而是生成的代码的一部分
- 如果未初始化,请获取全局递归锁
- 如果对象在获取锁的时候被初始化,那么解锁并返回
- 如果没有,检查静态是否正在从第二个
guard_object
标志初始化。如果它是“在使用中”,抛出异常。 - 如果不是,则将静态的守卫对象的第二个标志标记为“正在使用”
- 调用初始化函数,气泡错误(bubble errors)
- 解锁全局互斥锁
- 将第二个标志标记为“未使用”
在任何时候,由于全局递归互斥,在运行初始化例程的过程中只有一个线程。 由于互斥量是递归的,用于初始化局部静校正的函数(如 bar ())本身可能使用(不同的)局部静校正。 由于“正在使用”标志,局部静态的初始化可能不会递归调用其父函数而不会导致错误。
这不需要每个静态原子,也不会出现死锁,但是它有一个全局互斥体的成本,每个局部静态最多调用一次。 在有大量这样的静态数据的高度线程化的情况下,可能需要直接使用本地静态数据重新评估。
LLVM’s libcxxabi
类似于 libstdc++
实现,但是它使用的不是递归互斥体,而是一个常规互斥体(在非 arm Apple 系统上) ,在通过在保护对象中注明线程 ID (而不是“ in use”标志)来获取出口和测试可重入性之前解锁该互斥体。 Condvars
用于等待线程停止使用对象。 在其他平台上,似乎出现了僵局,尽管我不确定。
因此,这里我们有一个看起来相当单纯的特性,它有一些隐藏的成本和缺陷。 但是现在我可以查看正在使用这个特性的一行代码,并对其中发生的情况有一个很好的了解。 离成为一个更好的系统程序员又近了一步!