函数局部静态常量对象的线程安全初始化

Thread-safe initialization of function-local static const objects

提问人:sbi 提问时间:6/2/2010 最后编辑:Communitysbi 更新时间:6/13/2018 访问量:7156

问:

这个问题让我质疑了我多年来一直遵循的做法。

对于函数局部静态常量对象的线程安全初始化,我保护对象的实际构造,但不保护引用它的函数局部引用的初始化。像这样的东西:

namespace {
  const some_type& create_const_thingy()
  {
     lock my_lock(some_mutex);
     static const some_type the_const_thingy;
     return the_const_thingy;
  }
}

void use_const_thingy()
{
  static const some_type& the_const_thingy = create_const_thingy();

  // use the_const_thingy

}

这个想法是锁定需要时间,如果引用被多个线程覆盖,那就无关紧要了。

如果这是,我会很感兴趣

  1. 在实践中足够安全?
  2. 根据规则安全吗?(我知道,当前的标准甚至不知道什么是“并发”,但是践踏已经初始化的引用呢?其他标准,如POSIX,是否与此相关?

我想知道这一点的原因是我想知道我是否可以保持代码不变,或者我是否需要返回并修复此问题。


对于好奇的人:

我使用的许多这样的函数局部静态 const 对象都是映射,它们在第一次使用时从 const 数组初始化并用于查找。例如,我有几个 XML 解析器,其中标记名称字符串映射到值,因此我稍后可以覆盖标记的值。enumswitchenum


由于我得到了一些关于该怎么做的答案,但还没有得到我的实际问题的答案(见上面的 1 和 2),我将开始赏金。再说一遍:
我对我能做什么不感兴趣,我真的很想知道这件事

C++ 并发 多线程静态 初始化

评论

0赞 6/2/2010
我看不出你的问题与你提到的问题有何不同。以前不是以一种或另一种形式问过很多次吗?例如,stackoverflow.com/questions/1270927/...
0赞 sbi 6/2/2010
@Neil:我不是在问一般的双重检查锁定等,而是具体问不保护简单地址的分配。我没有找到任何关于这个的东西,但如果它存在在这里,我很乐意被提及。

答:

5赞 Nikko 6/2/2010 #1

这是我的看法(如果真的不能在线程启动之前初始化它):

我已经看到(并使用)这样的东西来保护静态初始化,使用 boost::once

#include <boost/thread/once.hpp>

boost::once_flag flag;

// get thingy
const Thingy & get()
{
    static Thingy thingy;

    return thingy;
}

// create function
void create()
{
     get();
}

void use()
{
    // Ensure only one thread get to create first before all other
    boost::call_once( &create, flag );

    // get a constructed thingy
    const Thingy & thingy = get(); 

    // use it
    thingy.etc..()          
}

在我的理解中,这样所有线程都会等待 boost::call_once,除了一个将创建静态变量的线程。它只会创建一次,然后永远不会再次调用。然后你就没有锁了。

评论

0赞 Matthieu M. 6/2/2010
我有同样的理解,看起来非常好,尽管我想知道是否有可能以某种方式包装它,这样就不会暴露并确保它不会被遗忘。call_once
0赞 sbi 6/8/2010
我知道有一些方法可以正确地做到这一点,也许是一种廉价的方法。然而,这并不能回答我的问题,即我过去使用的做法是否足够好,让我不回到旧代码并修复所有这些问题。boost::once
0赞 Nikko 6/8/2010
对我来说没关系,它有效,这只是避免锁定开销的解决方案
0赞 Matthieu M. 6/2/2010 #2

我不是标准主义者......

但是对于您提到的用途,为什么不在创建任何线程之前简单地初始化它们呢?许多单例问题之所以引起,是因为人们使用惯用的“单线程”延迟初始化,而他们可以在加载库时简单地实例化值(就像典型的全局初始化一样)。

懒惰时尚只有在使用另一个“全局”的这个值时才有意义。

另一方面,我见过的另一种方法是使用某种协调:

  • “Singleton”在库加载期间在“GlobalInitializer”对象中注册其初始化方法
  • “GlobalInitializer”在启动任何线程之前在“main”中调用

虽然我可能没有准确地描述它。

评论

0赞 Nikko 6/2/2010
我也同意,如果你不需要 lazy-init,只需在任何线程启动之前初始化它
0赞 sbi 6/8/2010
Matthieu:这是一个几个MLoC代码库,大部分是开发的,没有线程感知,需要带到MT中。我按照我描述的方式浏览并修复了数十个函数局部静态对象,因为有些代码已经以这种方式编写,我更喜欢代码保持一致。它似乎有效,但我担心它可能会在将来的某个时候坏掉,这就是我问的原因。在应用程序启动时初始化数十个这样的函数局部静态可能会起作用(如果我能确定所有函数都被捕获),但不需要。(对于大多数运行,甚至没有一半被初始化。
3赞 R Samuel Klatchko 6/21/2010 #3

因此,规范的相关部分是 6.7/4:

允许实现在相同条件下执行具有静态存储持续时间的其他本地对象的早期初始化,就像允许实现在命名空间范围 (3.6.2) 中静态初始化具有静态存储持续时间的对象一样。否则,此类对象在控件第一次通过其声明时初始化;此类对象在初始化完成后被视为已初始化。

假设第二部分为 (),则您的代码可以被视为线程安全。object is initialized the first time control passes through its declaration

通读 3.6.2,似乎允许的早期初始化是将动态初始化转换为静态初始化。由于静态初始化必须在任何动态初始化之前发生,并且由于在进行动态初始化之前我想不出任何创建线程的方法,因此这种早期初始化也将保证构造函数将被调用一次。

更新

因此,在调用构造函数方面,您的代码根据规则是正确的。some_typethe_const_thingy

这就留下了关于覆盖引用的问题,而规范绝对没有涵盖该问题。也就是说,如果你愿意假设引用是通过指针实现的(我认为这是最常见的方法),那么你要做的就是用它已经持有的值覆盖指针。所以我的看法是,这在实践中应该是安全的。

评论

1赞 sbi 6/21/2010
@Christopher:我不这么认为。当前的标准甚至不承认线程的存在,所以我认为这句话不能这样解释。它特别没有提到践踏已经初始化的引用。
0赞 bdonlan 6/21/2010
如果标准不承认线程的存在,你就根本无法对安全性做出任何假设......
0赞 sbi 6/22/2010
@bdonlan:我在问题中写过。你读对了吗?
0赞 rjnilsson 6/21/2010 #4

简而言之,我认为:

  • 对象初始化是线程安全的,假设在输入“create_const_thingy”时“some_mutex”是完全构造的。

  • “use_const_thingy”内部对象引用的初始化不保证是线程安全的;它可能(正如你所说)被多次初始化(这不是一个问题),但它也可能受到单词撕裂的影响,这可能导致未定义的行为。

[我假设 C++ 引用是使用指针值实现为对实际对象的引用,理论上可以在部分写入时读取]。

所以,试着回答你的问题:

  1. 在实践中足够安全:很有可能,但最终取决于指针大小、处理器架构和编译器生成的代码。这里的关键可能是指针大小的写/读是否是原子的。

  2. 根据规则安全:好吧,C++98 中没有这样的规则,对不起(但你已经知道了)。


更新:发布这个答案后,我意识到它只关注真正问题的一小部分深奥的部分,因此决定发布另一个答案而不是编辑内容。我将内容“按原样”保留,因为它与问题有一定的相关性(也为了谦虚自己,提醒我在回答之前多想一想)。

-1赞 Puppy 6/21/2010 #5

只需在开始创建线程之前调用该函数,从而保证引用和对象。或者,不要使用这种真正糟糕的设计模式。我的意思是,为什么对静态对象有静态引用?为什么还要有静态对象?这没有任何好处。单身是一个糟糕的主意。

评论

0赞 sbi 6/21/2010
<sigh>“我对我能做什么不感兴趣,我真的很想知道这件事”有什么难理解的?我什至把它加粗了,我还给出了我想知道这个的确切原因......
0赞 Puppy 6/21/2010
@sbi:哎呀。没想到开场海报居然还在看回复,因为这个话题已经两周了。
1赞 sbi 6/21/2010
继续仔细阅读问题。它说海报开始了赏金。(此外,无论谁发布问题,都会被告知任何新答案。
14赞 rjnilsson 6/22/2010 #6

这是我第二次尝试回答。我只会回答你的第一个问题:

  1. 在实践中足够安全?

不。正如你自己所说,你只是确保对象创建受到保护,而不是初始化对对象的引用。

在没有 C++ 内存模型且编译器供应商没有显式语句的情况下,无法保证写入表示实际引用的内存和写入包含引用的初始化标志值的内存(如果这是实现方式)的顺序在多个线程中以相同的顺序显示。

正如您所说,使用相同的值多次覆盖引用应该不会产生语义差异(即使在存在单词撕裂的情况下,这在处理器体系结构上通常不太可能,甚至可能是不可能的),但有一种情况很重要:当多个线程在程序执行期间首次竞相调用函数时.在这种情况下,这些线程中的一个或多个线程可能会在初始化实际引用之前看到正在设置的初始化标志。

您的程序中有一个潜在的错误,您需要修复它。至于优化,我敢肯定,除了使用双重检查的锁定模式之外,还有很多优化。

评论

2赞 sbi 6/22/2010
你真的建议可能有一些实现首先设置初始化标志,然后分配指针吗?编辑:哦等等,对。如果像它一样下降,底层平台可以重新排序写入!这确实是对的。我根本没有想到这一点,但这确实令人震惊。如果没有人出现并显示这个论点中的错误,我会接受它。
0赞 rjnilsson 6/22/2010
@sbi:我什至不认为写入必须由硬件重新排序;引用和标志可能在内存中分离,足以驻留在不同的缓存行中。
0赞 sbi 6/23/2010
是的,这也可能是一个问题。感谢您回答我的问题!
2赞 Puppy 1/25/2012
双重检查锁定实际上并不安全。
-1赞 Chappelle 6/13/2012 #7

这似乎是我能想到的最简单/最干净的方法,而不需要所有的互斥锁:

static My_object My_object_instance()
{
    static My_object  object;
    return object;
}

// Ensures that the instance is created before main starts and creates any threads
// thereby guaranteeing serialization of static instance creation.
__attribute__((constructor))
void construct_my_object()
{
    My_object_instance();
}

评论

1赞 sbi 6/13/2012
但那是 C++11,当我写这个问题时还没有发布。
0赞 Chappelle 6/13/2012
无论如何,如果我今天阅读这个线程,我会想知道该问题的最佳/当前解决方案。
0赞 sbi 1/1/2013
但是,我特别没有要求“解决方案”。我问我的旧代码是否安全或需要修复。
0赞 user2356685 6/13/2018 #8

我已经编写了足够多的进程间套接字来做噩梦。为了在具有 DDR RAM 的 CPU 上实现线程安全,您必须对数据结构进行缓存行对齐,并将所有全局变量连续打包到尽可能少的缓存行中。

未对齐的进程间数据和松散打包的全局变量的问题在于,它会导致缓存未命中导致的别名。在使用 DDR RAM 的 CPU 中,(通常)有一堆 64 字节的缓存行。当您加载缓存行时,DDR RAM 会自动加载更多缓存行,但第一个缓存行始终是最热的。高速发生的中断是缓存页面将充当低通滤波器,就像在模拟信号中一样,如果您不知道发生了什么,它将过滤掉中断数据,从而导致完全令人困惑的错误。同样的事情也适用于没有紧密包装的全局变量;如果它占用了多个缓存行,它将不同步,除非您拍摄关键进程间变量的快照并将它们传递到堆栈和寄存器上,以确保数据正确同步。

.bss 部分(即存储全局变量的位置)将初始化为全零,但编译器不会为您缓存行对齐数据,您必须自己执行此操作,这也可能是使用 C++ 构造的好地方。要了解对齐指针的最快方法背后的数学原理,请阅读本文;我试图弄清楚我是否想出了那个技巧。代码如下所示:

inline char* AlignCacheLine (char* buffer) {
  uintptr_t offset = ((~reinterpret_cast<uintptr_t> (buffer)) + 1) & (63);
  return buffer + offset;
}

char SomeTypeInit (char* buffer, int param_1, int param_2, int param_3) {
  SomeType type = SomeType<AlignCacheLine (buffer)> (1, 2, 3);
  return 0xff;
}

const SomeType* create_const_thingy () {
  static char interprocess_socket[sizeof (SomeType) + 63],
              dead_byte = SomeTypeInit (interprocess_socket, 1, 2, 3);
  return reinterpret_cast<SomeType*> (AlignCacheLine (interprocess_socket));
}

根据我的经验,您必须使用指针,而不是引用。