双重检查锁定模式

12

C++和双重检查锁定的危险中,作者建议使用正确的模式来实现伪代码。请参见下面的内容:

Singleton* Singleton::instance () {
    Singleton* tmp = pInstance;
    ... // insert memory barrier (1)
    if (tmp == 0) {
        Lock lock;
        tmp = pInstance;
        if (tmp == 0) {
            tmp = new Singleton;
            ... // insert memory barrier (2)
            pInstance = tmp;
        }
    }
    return tmp;
}

我想知道第一个内存屏障是否可以移动到return语句的上方?
编辑:另一个问题:在链接的文章中,如vidstige所引用,
“技术上来说,你不需要完整的双向屏障。第一个屏障必须防止Singleton的构造(由另一个线程)向下迁移;第二个屏障必须防止pInstance的初始化向上迁移。这些被称为“获取”和“释放”操作,在硬件上(如Itainum)可能比全屏障具有更好的性能。”
它说第二个屏障不需要是双向的,那么它如何防止对pInstance的赋值在该屏障之前被移动?尽管第一个屏障可以防止向上迁移,但另一个线程仍然有机会看到未初始化的成员。
编辑:我认为我几乎理解了第一个屏障的目的。正如sonicoder所指出的,分支预测可能导致if返回true时tmp为NULL。为了避免这个问题,在return之前必须有一个获取屏障来防止在if中读取tmp之前读取它。 第一个屏障与第二个屏障配对以实现同步关系,因此它可以向下移动。
编辑:对于那些对这个问题感兴趣的人,我强烈推荐阅读memory-barriers.txt

6
您需要使用mem-barrier指令来确保内存访问不会乱序执行。请考虑分支预测等问题,以及它如何可能破坏内部if语句的执行顺序。 - Matthieu N.
1
你想要移除内存屏障。你提供的链接文章没有解释为什么需要它吗? - David Heffernan
@David:我在另一本书《Windows并发编程》中看到作者在return语句之前放置了一个屏障,所以我有点困惑。第一个屏障是为了防止线程看到未初始化的成员变量,对吗? - ashen
1
@David:他不想删除它,他想把它移动到更下面。如果唯一的危险是没有屏障,函数可能会返回一个(在此线程中)未初始化/看起来未初始化的对象指针,那么在return之前放置一个屏障就可以了。所以问题是,是否存在其他危险,这意味着屏障的特定位置很重要? - Steve Jessop
1
@Alex:一个可能性是,在Windows上,您可以对内存/线程模型做出比第一篇论文的作者更强的假设。例如,Windows使用基于Intel的体系结构和一致的内存缓存,但其他某些操作系统在其他体系结构上不使用。不过,我不知道这在这种情况下是否有所不同。请考虑,此代码假定写入指针是原子性的,这是一个合理的约束条件,但同样可能不适用于所有编译器和所有硬件。 - Steve Jessop
显示剩余2条评论
2个回答

5

我没有看到任何与您的问题相关的正确答案,所以即使过了三年多,我决定发布一个答案;)

我只是想知道第一个内存屏障是否可以移到return语句的上面?

是的,可以。

这是为了那些不会进入if语句的线程而设计的,即pInstance已经被正确构建和初始化,并且可见。

第二个屏障(紧挨着pInstance = tmp;之前的那个)确保了单例模式成员字段的初始化被提交到内存中,然后才提交pInstance = tmp;。但这并不一定意味着其他线程(在其他核心上)会以相同的顺序看到这些内存效果(直观上有点反常吧?)。第二个线程可能已经在缓存中看到指针的新值,但是还没有看到那些成员字段。当它通过解引用指针(例如p->data)访问成员时,该成员的地址可能已经在缓存中,但是所需的那个成员可能还没有。糟糕!读取了错误数据。请注意,这不仅仅是理论上的问题。有些系统需要执行缓存一致性指令(例如内存屏障)才能从内存中拉取新数据。
这就是为什么第一个屏障存在的原因。这也解释了为什么将其放置在return语句之前是可以的(但必须在Singleton* tmp = pInstance;之后)。
它说第二个屏障不需要是双向的,那么如何防止对pInstance的分配在该屏障之前被移动?写障碍保证在它之前的每次写入都将有效地发生在它之后的每次写入之前。这是一个停止标志,任何写入都不能越过它到达另一侧。有关详细说明,请参见此处

你说得对,写屏障是一个完整的栅栏,我可能误解了它,并将其与单向栅栏混淆,即释放栅栏。 - ashen
写屏障不是完全屏障。当人们谈论“完全屏障”时,他们真正意思的是防止读取和写入都越过该线的屏障。写屏障(或释放屏障)仅限制写入。顺便说一下,在C++11中还有一种较弱的关系叫做“写-释放”。但它不一定是一个屏障。您可以将它们视为完全屏障>写屏障>=写-释放,其中>表示比...更强。这里有一篇关于写-释放与写屏障的优秀文章。 - Eric Z
值得一提的是,C++中的独立释放栅栏std::atomic_thread_fence(std::memory_order_release)比通常只限制写入的写屏障更强。这是因为释放栅栏还可以防止先前的读取被重新排序。释放栅栏=写/写屏障+读/写屏障。因此,我应该指出“释放栅栏”和“写屏障”是不同的。 - Eric Z

4
不,内存屏障不能移到赋值语句下面,因为内存屏障保护赋值语句免受向上迁移的影响。引用文章中的描述:
第一个屏障必须防止Singleton的构造函数向下迁移(被另一个线程调用);第二个屏障必须防止pInstance的初始化向上迁移。
另外提一句,只有在对性能要求极高时,使用双重检查锁定单例模式才是有用的。
你是否对二进制文件进行了分析,并观察到单例访问成为瓶颈?如果没有,那么很可能根本不需要使用双重检查锁定模式。
我建议使用简单锁。

3
您提到了向上迁移,但引用却说第一个内存屏障是用于向下迁移。 - Thomas Edleson
@Thomas Edleson 是的。这个引用也提到了第一个障碍,但问题和答案是针对第二个障碍的。 - vidstige
那么为什么问题中说“我只是想知道第一个内存屏障是否可以移动...”呢? - Thomas Edleson
@Thomas Edleson,你是对的。我完全误解了问题。 - vidstige

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