这个单例实现中,两个存储位置可以重新排序吗?

3
在下面的单例模式的'get'函数中,其他线程是否可以看到instance不为null,但是almost_done仍然为false?(假设almost_done最初为false。)
Singleton *Singleton::Get() {
    auto tmp = instance.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> guard(lock);
        tmp = instance.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new Singleton();
            almost_done.store(true, std::memory_order_relaxed); // 1
            std::atomic_thread_fence(std::memory_order_release);
            instance.store(tmp, std::memory_order_relaxed); // 2
        }
    }
    return tmp;
}

如果可以,为什么?是什么原理?
我知道没有任何东西可以“离开”acquire-release部分,但是2不能进入并与1重排序吗?
我知道在C ++中实现线程安全的单例不需要使用这些复杂的技术,是的,almost_done 没有太多意义,这纯粹是为了学习。

为什么你不把这行代码 std::lock_guardstd::mutex guard(lock); 移到函数的第一行呢?这样做不就解决了所有同步问题吗? - Omid CompSCI
1
@iMajuscule 已修复 - ledonter
我认为应该在“if (tmp == nullptr) {”之后编写“std::atomic_thread_fence(std::memory_order_acquire);”,因为当tmp不为空时,我们不希望执行该操作。我猜想这么做只是为了学习,但我认为这样会更有意义。 - Olivier Sohn
@ledonter 这只是避免不必要的指令的问题,但并不改变逻辑。 - Olivier Sohn
@ledonter,因为一个store-release操作不能与一个load-relaxed操作同步。因此,从第一个load-relaxed操作获取值的线程确实看到了有效的指针值,但它所指向的内存没有被同步。这是一个问题(例如,它可能指向一个仍在构建中的“Singleton”实例)。 - LWimsey
显示剩余4条评论
1个回答

2
您的代码展示了Double-Checked-Locking pattern (DCLP) 的有效实现。根据线程进入代码的顺序,同步由std::mutex或std::atomic::instance处理。
其他线程能否看到instance不为null但almost_done仍然为false? 不可能。
DCLP模式保证,在开始执行load-acquire(返回非null值)的所有线程都将看到instance指向有效内存且almost_done==true, 因为加载已与存储-release同步。
一个人可能认为它是可能的原因,在第一个线程(#1)持有std::mutex的小窗口期间,第二个线程(#2)正在进入第一个if语句。 在#2锁定std::mutex之前,它可以观察到instance的值(仍指向未同步的内存,因为互斥体负责),但尚未同步。 但即使发生这种情况(这个模式中的有效场景),#2也会看到almost_done==true,因为release fence(由#1调用)将store-relaxed排序到almost_done 在store-relaxed到instance之前,并且其他线程观察到相同的顺序。

我不是在问其他线程调用相同函数的情况,我是在问线程#2是否只对almost_doneinstance进行了轻松加载,而没有进行任何获取操作。另外,为什么释放栅栏可以防止存储器重排序?我在标准中(或者说任何地方)都没有找到类似的内容。如果您会说“根据定义”,请提供相关信息,因为显然标准中并没有这样的内容 :( - ledonter
@ledonter 发布栅栏可以防止所有前面的内存操作在后续写操作之前被重新排序。请查看Jeff Preshing关于该主题的文章(或者此篇文章)。 - LWimsey
这正是我所询问的!我读了那篇文章,确实如你引用的那样说,还有其他几篇文章也是这样说的。但很多人都说实际上任何(both 读和写)都可以进入获取-释放部分,例如从底部。这是我困惑的根源。C++ 如何定义它?或者它不是由 C++ 定义的,而只是由架构定义的?更重要的是,允许任何东西进入并仅允许从顶部存储/从底部加载的原理是什么?是这样的模式吗? - ledonter
1
@ledonter 发布栅栏指示存储器在线程#1中将存储器存储到almost_done之前存储到instance。这意味着线程#2以相同的顺序_观察_这两个存储器。它_不_意味着线程#2可以在没有设置获取栅栏的情况下使用该排序。原因是内存排序是双向的;即线程#2执行的任何操作也可能被线程#1无序地观察到。为了保证有效的跨线程发生之前关系,必须使用由C++标准定义的同步关系。 - LWimsey
1
@ledonter,你的问题是关于其他线程如何“看到”变量,而不是如何使用它。 这是一个非常微妙的区别,你在发布问题时可能没有意识到。 C++标准定义了同步关系(包括获取和释放),因为这是C++程序可靠地访问在线程之间共享数据所需的。 - LWimsey

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