C++静态初始化

5
以下情况下应该采取何种行为:

class C {
    boost::mutex mutex_;
    std::map<...> data_;
};

C& get() {
    static C c;
    return c;
}

int main() {
    get(); // is compiler free to optimize out the call? 
    ....
}

编译器是否可以优化掉对get()的调用?

想法是在多线程操作需要使用静态变量之前触发它以初始化它。

这是更好的选择吗?

C& get() {
    static C *c = new C();
    return *c;
}

1
C 的构造函数和析构函数是否是平凡的? - James McNellis
即使构造函数不是平凡的,::get::c是静态的,无论对::get()的调用如何,它们都以相同的方式被调用,编译器可以正确地优化该调用而不会损害功能。 - SingleNegationElimination
@TokenMacGuy:编译器(以及链接器,很可能)必须确保main()中的get()是对get()的第一个调用;如果在命名空间作用域的静态对象的动态初始化期间调用了get(),那么这种情况就不成立了。 - James McNellis
@Tok,嗯,这个想法是为了在多线程操作需要它之前触碰静态变量以初始化它。 - Anycorn
4
@Tok 我认为对于静态变量来说这是不必要的,它们只需要被初始化一次就可以了。 - Anycorn
显示剩余6条评论
4个回答

4

C和C++标准遵循一个简单的原则,通常被称为“as-if规则”——基本上,只要符合标准的代码不能区分编译器实际执行的操作和官方要求的操作之间的差异,编译器就可以自由地执行几乎任何操作。

我认为符合标准的代码无法区分在这种情况下是否实际调用了get,所以看起来编译器可以将其优化掉。


1
@Matthieu:很难说——从标准的角度来看,LTO和其他优化之间没有真正的区别。话虽如此,我的猜测是通常所谓的LTO不会典型地做到这一点,但所谓的LTCG可能会。 - Jerry Coffin
@PeterCordes:根据当前的标准,我已经更新了答案,并且注明了当前标准与我最初回答时的差异。 - undefined
@PeterCordes:这几乎肯定与示例代码无关,但对于标题问题可能有重要性(这个问题要更加普遍)。如果你期望构造函数在特定时间产生副作用,那么这可能会有所不同。例如,如果a()b()分别构造具有副作用的局部静态变量,在当前规则下,a(); b();应该按顺序产生副作用,但在旧规则下可能不会。 - undefined
1
实际上,我的理解是,C++20及之前的版本明确允许可见的副作用尽早发生(但不是晚发生)。或者说,任何可能间接导致I/O或其他操作的副作用,比如写入非易失性全局变量。我猜,甚至在早期执行时读取可能尚未初始化的全局变量也是可以的?如果有的话,C++23要求所有这些操作都在第一次调用时按照as-if规则执行。其他一切仍然取决于as-if规则。 - undefined
@PeterCordes:明确规定,任何具有强制静态初始化的内容在其他任何操作发生之前都将被零初始化。因此,你读取的任何全局变量在你读取它之前都会被初始化(但不一定按照程序员的意图进行初始化)。 - undefined
显示剩余7条评论

3
根据您的编辑,这是一个改进版,具有相同的结果。
输入:
struct C { 
    int myfrob;
    int frob();
    C(int f);
 };
C::C(int f) : myfrob(f) {}
int C::frob() { return myfrob; }

C& get() {
    static C *c = new C(5);
    return *c;
}

int main() {
    return get().frob(); // is compiler free to optimize out the call? 

}

输出:

; ModuleID = '/tmp/webcompile/_28088_0.bc'
target datalayout = "e-p:64:64:64-i1:8:8-i8:8:8-i16:16:16-i32:32:32-i64:64:64-f32:32:32-f64:64:64-v64:64:64-v128:128:128-a0:0:64-s0:64:64-f80:128:128-n8:16:32:64"
target triple = "x86_64-linux-gnu"

%struct.C = type { i32 }

@guard variable for get()::c = internal global i64 0            ; <i64*> [#uses=4]

declare i32 @__cxa_guard_acquire(i64*) nounwind

declare i8* @operator new(unsigned long)(i64)

declare void @__cxa_guard_release(i64*) nounwind

declare i8* @llvm.eh.exception() nounwind readonly

declare i32 @llvm.eh.selector(i8*, i8*, ...) nounwind

declare void @__cxa_guard_abort(i64*) nounwind

declare i32 @__gxx_personality_v0(...)

declare void @_Unwind_Resume_or_Rethrow(i8*)

define i32 @main() {
entry:
  %0 = load i8* bitcast (i64* @guard variable for get()::c to i8*), align 8 ; <i8> [#uses=1]
  %1 = icmp eq i8 %0, 0                           ; <i1> [#uses=1]
  br i1 %1, label %bb.i, label %_Z3getv.exit

bb.i:                                             ; preds = %entry
  %2 = tail call i32 @__cxa_guard_acquire(i64* @guard variable for get()::c) nounwind ; <i32> [#uses=1]
  %3 = icmp eq i32 %2, 0                          ; <i1> [#uses=1]
  br i1 %3, label %_Z3getv.exit, label %bb1.i

bb1.i:                                            ; preds = %bb.i
  %4 = invoke i8* @operator new(unsigned long)(i64 4)
          to label %invcont.i unwind label %lpad.i ; <i8*> [#uses=2]

invcont.i:                                        ; preds = %bb1.i
  %5 = bitcast i8* %4 to %struct.C*               ; <%struct.C*> [#uses=1]
  %6 = bitcast i8* %4 to i32*                     ; <i32*> [#uses=1]
  store i32 5, i32* %6, align 4
  tail call void @__cxa_guard_release(i64* @guard variable for get()::c) nounwind
  br label %_Z3getv.exit

lpad.i:                                           ; preds = %bb1.i
  %eh_ptr.i = tail call i8* @llvm.eh.exception()  ; <i8*> [#uses=2]
  %eh_select12.i = tail call i32 (i8*, i8*, ...)* @llvm.eh.selector(i8* %eh_ptr.i, i8* bitcast (i32 (...)* @__gxx_personality_v0 to i8*), i8* null) ; <i32> [#uses=0]
  tail call void @__cxa_guard_abort(i64* @guard variable for get()::c) nounwind
  tail call void @_Unwind_Resume_or_Rethrow(i8* %eh_ptr.i)
  unreachable

_Z3getv.exit:                                     ; preds = %invcont.i, %bb.i, %entry
  %_ZZ3getvE1c.0 = phi %struct.C* [ null, %bb.i ], [ %5, %invcont.i ], [ null, %entry ] ; <%struct.C*> [#uses=1]
  %7 = getelementptr inbounds %struct.C* %_ZZ3getvE1c.0, i64 0, i32 0 ; <i32*> [#uses=1]
  %8 = load i32* %7, align 4                      ; <i32> [#uses=1]
  ret i32 %8
}

值得注意的是,对于::get没有生成任何代码,但是主函数仍然根据需要(在invcont.i和lpad.i的末尾)分配具有守护变量的::get::c(在%4处)。LLVM在这里内联了所有这些内容。

简而言之:不用担心,优化器通常会正确处理此类问题。您是否看到了错误信息?


main()函数中,初始化后没有使用C结构体是没有意义的。调用get()除了初始化和返回对c的引用之外,没有其他副作用。因此,在优化该行代码使其不起作用的情况下,代码的行为不会有任何不同...很难责怪编译器。这与原始问题类似,只是我们不知道调用之后的代码做了什么。 - stefanB
暂时没有错误,但不想以困难的方式发现。谢谢。 - Anycorn
1
如果优化更改了您的代码行为,那么要么是因为您正在执行未定义的操作(在这种情况下不是),要么是因为优化器出现了故障。 - SingleNegationElimination
这样做并没有改善情况,反而给类/结构的所有未来使用增加了不必要的间接性。 - undefined

1
你的原始代码是安全的。 不要引入额外的间接层(一个指针变量,在std::map的地址可用之前必须加载)。
正如Jerry Coffin所说,你的代码必须按照源代码顺序运行。这包括在主函数中运行,就好像已经构造了boost或std::mutexstd::map,然后才运行后续的线程。
在C++11之前,语言标准和内存模型并没有正式支持多线程,但是像这样的东西(线程安全的static局部初始化)仍然可以工作,因为编译器的作者希望他们的编译器有用。例如,2006年的GCC 4.1(https://godbolt.org/z/P3sjo4Tjd)仍然使用了一个保护变量,以确保在多个对get()的调用同时发生时,只有一个线程进行构造。
现在,使用C++11及更高版本,ISO标准确实包括了线程,并且官方要求确保安全性。
由于您的程序无法观察到这种差异,理论上编译器可以选择跳过构造函数,而让第一个调用get()的线程来实际发生构造。这没问题,static局部变量的构造是线程安全的,像GCC和Clang这样的编译器会在函数开始时使用一个“守卫变量”进行检查(只读,使用acquire加载)。
文件作用域的static变量将避免每次调用时守卫变量的加载+测试/分支开销,并且只要在main()开始之前没有调用get(),它就是安全的。守卫变量在像x86、AArch64和32位ARMv8这样的ISA上是相当廉价的,因为它们具有廉价的acquire加载,但在ARMv7等地方则更昂贵,因为acquire加载使用了dmb ish全屏障。
如果有一个假设的编译器真的进行了你担心的优化,那么差异可能在于保存静态变量 static C c 的 .bss 页的 NUMA 放置,如果该页中的其他内容没有被首先访问。如果在第二个线程调用 get() 时构造函数还没有完成,可能会短暂地阻塞其他线程的第一次调用 get()
当前的GCC和clang在实践中并没有进行这种优化。
Clang 17与libc++在x86-64上使用-O3选项生成以下汇编代码(由Godbolt解析)。get()的汇编代码也被内联到main中。GCC与libstdc++非常相似,只有在std::map内部有所不同。
get():
        movzx   eax, byte ptr [rip + guard variable for get()::c]  # all x86 loads are acquire loads
        test    al, al                       # check the guard variable
        je      .LBB0_1
        lea     rax, [rip + get()::c]        # retval = address of the static variable
   # end of the fast path through the function.
   # after the first call, all callers go through this path.
        ret

 # slow path, only reached if the guard variable is zero
.LBB0_1:
        push    rax
        lea     rdi, [rip + guard variable for get()::c]
        call    __cxa_guard_acquire@PLT
        test    eax, eax   # check if we won the race to construct c,
        je      .LBB0_3    # or if we waited until another thread finished doing it.

        xorps   xmm0, xmm0
        movups  xmmword ptr [rip + get()::c+16], xmm0     # first 16 bytes of std::map<int,int> = NULL pointers
        movups  xmmword ptr [rip + get()::c], xmm0        # std::mutex = 16 bytes of zeros
        mov     qword ptr [rip + get()::c+32], 0          # another NULL
        lea     rsi, [rip + get()::c]                     # arg for __cxa_atexit
        movups  xmmword ptr [rip + get()::c+48], xmm0     # more zeros, maybe a root node?
        lea     rax, [rip + get()::c+48]                  
        mov     qword ptr [rip + get()::c+40], rax        # pointer to another part of the map object

        lea     rdi, [rip + C::~C() [base object destructor]]  # more args for atexit
        lea     rdx, [rip + __dso_handle]
        call    __cxa_atexit@PLT                 # register the destructor function-pointer with a "this" pointer

        lea     rdi, [rip + guard variable for get()::c]
        call    __cxa_guard_release@PLT          # "unlock" the guard variable, setting it to 1 for future calls
             # and letting any other threads return from __cxa_guard_acquire and see a fully-constructed object

.LBB0_3:                                     # epilogue
        add     rsp, 8
        lea     rax, [rip + get()::c]        # return value, same as in the fast path.
        ret

即使std::map没有被使用,构造它仍然需要调用__cxa_atexit(C++内部版本的atexit)来注册析构函数,以便在程序退出时释放红黑树。我怀疑这就是优化器无法理解的部分,也是它不能像static int x = 123;static void *foo = &bar;那样在.data中预初始化空间而不进行运行时构造(也没有保护变量)的主要原因。
如果struct C只包含std::mutex,那么常量传播可以避免任何运行时初始化的需要。至少在GNU/Linux中,std::mutex没有析构函数并且实际上是零初始化的。(在C++23之前的C++中,即使包含了可见的副作用,也允许提前初始化。这里没有副作用;编译器仍然可以将static int local_foo = an_inline_function(123);常量传播到.data中的某些字节,而不进行运行时调用。)
GCC和Clang也不会优化掉保护变量(如果有任何运行时工作要做的话),尽管main根本没有启动任何线程,更不用说在调用get()之前了。在另一个编译单元(包括共享库)中的构造函数可能已经启动了另一个线程,同时调用了get(),就像main一样。(使用gcc -fwhole-program时,这可能是一个被忽视的优化。)
如果构造函数有任何(潜在的)可见副作用,可能包括对`new`的调用,因为`new`是可替换的,编译器不能推迟它,因为C++语言规则规定了构造函数在抽象机器中的调用时机。(编译器对`new`可以做一些假设,例如,带有libc++的clang可以优化未使用的`std::vector`的`new`/`delete`。)
像`std::unordered_map`这样的类(使用哈希表而不是红黑树)在其构造函数中确实使用了`new`。
我正在测试使用`std::map`,所以单个对象没有可见的副作用析构函数。如果`Foo::~Foo`打印一些东西的`std::map`在静态局部初始化器运行时会有影响,因为那时我们调用`__cxa_atexit`。假设析构顺序与构造相反,等到稍后再调用`__cxa_atexit`可能会导致它被提前析构,从而导致`Foo::~Foo()`调用过早,可能在其他可见副作用之前而不是之后发生。
或者其他全局数据结构可能会引用`std::map`中的`int`对象,并在其析构函数中使用它们。如果我们过早地析构`std::map`,那将不安全。
(我不确定ISO C++或GNU C++是否对析构函数的顺序提供了这样的保证。但如果确实有这样的保证,编译器通常无法推迟涉及注册析构函数的构造。在简单的程序中寻找这种优化并不值得付出编译时间的代价。)
使用文件范围的static来避免使用守卫变量。
请注意缺少守卫变量,使得快速路径更快,特别是对于像ARMv7这样没有良好方法来执行仅获取屏障的ISA。https://godbolt.org/z/4bGx3Tasj -
static C global_c;     // It's not actually global, just file-scoped static

C& get2() {
    return global_c;
}

# clang -O3 for x86-64
get2():
      # note the lack of a load + branch on a guard variable
        lea     rax, [rip + global_c]
        ret

main:
      # construction already happened before main started, and we don't do anything with the address
        xor     eax, eax
        ret

# GCC -O3 -mcpu=cortex-a15     // a random ARMv7 CPU
get2():
        ldr     r0, .L81          @ PC-relative load
        bx      lr

@ somewhere nearby, between functions
.L81:
        .word   .LANCHOR0+52      @ pointer to struct C global_c

main:
        mov     r0, #0
        bx      lr
构造函数代码仍然存在,它执行存储并调用__cxa_atexit,只是在一个名为_GLOBAL__sub_I_example.cpp:(clang)或_GLOBAL__sub_I_get():(GCC)的单独函数中,编译器将其添加到在main之前调用的初始化函数列表中。

函数范围的局部变量通常都没问题,开销非常小,尤其是在x86-64和ARMv8上。但是,由于您担心像std::map何时构造的微观优化问题,我认为值得一提。同时,这也展示了编译器在幕后使用的机制,使这些功能正常工作。


0

根据标准,编译器是否优化函数调用基本上是未指定的行为。未指定的行为基本上是从有限可能性的集合中选择的行为,但每次选择可能不一致。在这种情况下,选择是“优化”或“不优化”,标准没有指定,实现也不应该记录,因为这是一个可能不被给定实现一致采取的选择。

如果想要“触发”,我们只需添加一个虚拟易失变量并在每次调用时虚拟增加它,这样做是否有帮助?

e.g

C& getC(){
   volatile int dummy;
   dummy++;
   // rest of the code
}

你如何定义“第一次调用”?无论如何,这个函数非常简单,可以完全优化掉。 - casablanca
遗憾的是,Sutter在其中一次演讲中提到,一个聪明的编译器可以丢弃dummy中的volatile限定符。其理由是,它可以确定该变量在堆栈上,不是指向特殊硬件的变量。此外,没有将指向该变量的指针传递给任何其他函数,因此编译器可以确定对dummy的更改仅在getC内可见,因此可以删除volatile。之后,如果编译器注意到该值从未被使用,则可以完全删除该变量。我不知道有任何编译器会这样做。 - David Rodríguez - dribeas
@dribeas - David Rodríguez:好的,我刚在LLVM中尝试了所有这些。在一个案例中,volatile变量被删除了(只剩下一个main {return 0}),但在另一个案例中,带有增量的volatile被内联到了main函数中与getC的其余部分一起。我认为这证明了你的观点,即你永远无法知道会发生什么! - SingleNegationElimination
在内联getC之后,对static C c;的初始化与std::map成员是分开的,这个初始化是一个volatile存储或增量。如果编译器在启动线程之前要优化掉对构造函数的调用,那么在此之前(或之后)是否存在volatile访问是无关紧要的。如果你的推理是基于执行全部或不执行函数的话,那就不是这样的。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接