是所有对象的静态初始化原子



C++11保证静态局部变量的初始化在函数的第一次调用时是原子的。尽管该标准没有强制要求任何实现,但有效处理此问题的唯一方法是双重检查锁定
我问自己,是否所有初始化的对象都是在同一个互斥体上初始化的(可能),或者每个静态对象初始化是否都作用于自己的互斥体上(不可能)。因此,我编写了这个litlte C++20程序,它使用了一些可变和折叠表达式技巧来拥有许多不同的函数,每个函数都初始化自己的静态对象:

#include <iostream>
#include <utility>
#include <latch>
#include <atomic>
#include <chrono>
#include <thread>
using namespace std;
using namespace chrono;
atomic_uint globalAtomic;
struct non_trivial_t
{
non_trivial_t() { ::globalAtomic = ~::globalAtomic; }
non_trivial_t( non_trivial_t const & ) {}
~non_trivial_t() { ::globalAtomic = ~::globalAtomic; }
};
int main()
{
auto createNThreads = []<size_t ... Indices>( index_sequence<Indices ...> ) -> double
{
constexpr size_t N = sizeof ...(Indices);
latch latRun( N );
atomic_uint synch( N );
atomic_int64_t nsSum( 0 );
auto theThread = [&]<size_t I>( integral_constant<size_t, I> )
{
latRun.arrive_and_wait();
if( synch.fetch_sub( 1, memory_order_relaxed ) > 1 )
while( synch.load( memory_order_relaxed ) );
auto start = high_resolution_clock::now();
static non_trivial_t nonTrivial;
nsSum.fetch_add( duration_cast<nanoseconds>( high_resolution_clock::now() - start ).count(), memory_order_relaxed );
};
(jthread( theThread, integral_constant<size_t, Indices>() ), ...);
return (double)nsSum / N;
};
constexpr unsigned N_THREADS = 64;
cout << createNThreads( make_index_sequence<N_THREADS>() ) << endl;
}

我用上面的代码创建了64个线程,因为我的系统在一个处理器组中最多有64个CPU(Ryzen Threadipper 3990X,Windows 11)。结果达到了我的预期,据报道,每次初始化大约需要7.000ns。如果每次初始化都作用于自己的互斥锁,那么互斥锁将走短路径,这样就不会有内核争用,时间也会大大缩短。那么还有什么问题吗?

之后我问自己的问题是:如果静态对象的构造函数有自己的静态对象,会发生什么?标准是否明确规定这应该起作用,迫使实现考虑互斥必须是递归的?

否,静态初始化不是所有对象的原子初始化。不同的静态对象可能同时被不同的线程初始化。

事实上,GCC和Clang确实使用了一个全局递归互斥体(用于处理您所描述的递归情况,这是工作所必需的),但其他编译器对每个静态函数本地对象都使用互斥体(即苹果的编译器)。因此,您不能依赖于一次只进行一个对象的静态初始化,因为它不是这样,这取决于您的编译器(以及该编译器的版本)。

标准第6.7.4节:

POD类型(basic.types)的本地对象用常量表达式初始化的存储持续时间为在第一次进入其块之前初始化。实现是允许执行其他本地对象的早期初始化在与允许实现使用静态初始化对象命名空间作用域中的静态存储持续时间(basic.start.init)。
否则,在控件第一次通过时初始化此类对象通过其声明;这样的对象被认为是在完成其初始化。如果初始化通过退出抛出异常,初始化未完成,因此它将下次控件进入声明时重试如果控件在初始化对象时(递归)重新输入声明,则行为未定义

标准仅禁止递归初始化相同静态对象的;它并不禁止一个静态对象的初始化需要初始化另一个静态对象。由于该标准明确规定,在首次执行包含静态对象的块时,必须初始化所有不属于此禁止类别的静态对象,因此允许您询问的情况。

int getInt1();
int getInt2() { //This could be a constructor, too, and nothing would change
static int result = getInt1();
return result;
}
int getInt3() {
static int result = getInt2(); //Allowed!
return result;
}

这也适用于函数本地静态对象的构造函数本身包含此类静态对象的情况。构造函数实际上也只是一个函数,这意味着这种情况与上面的例子相同。

另请参阅:https://manishearth.github.io/blog/2015/06/26/adventures-in-systems-programming-c-plus-plus-local-statics/

每个静态局部变量都必须是原子变量。如果它们中的每一个都有自己的互斥锁或双重检查锁定,那么这将是真的。

还可以有一个全局递归互斥,它允许一个线程和一个线程一次只初始化静态局部变量。这也行得通。但是,如果有许多静态局部变量和多个线程第一次访问它们,那么速度可能会非常慢。

但是,让我们考虑一下静态局部变量具有静态局部变量的情况:

class A {
static int x = foo();
};
void bla() {
static A a;
};

初始化a需要初始化x。但没有任何东西表明不可能有其他线程也有A c;,并且将同时初始化x。因此x仍然需要保护,即使在bla()的情况下它处于已经静态的初始化中。

另一个例子(希望编译,没有检查):

void foo() {
static auto fn = []() {
static int x = bla();
};
}

这里,只有当fn被初始化时,x才能被初始化。因此编译器可能会跳过对x的保护。这将是一个遵循的优化,就好像主体一样。除了定时之外,CCD_ 10是否受到保护没有区别。另一方面,CCD_ 11的锁定总是成功的,并且其成本非常小。编译器可能不会对其进行优化,因为没有人投入时间来检测和优化此类情况。

最新更新