这些内存屏障是否必要?

4
我遇到了以下的Singleton模式get_instance函数实现:
template<typename T>
T* Singleton<T>::get_instance()
{
    static std::unique_ptr<T> destroyer;

    T* temp = s_instance.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);

    if (temp == nullptr) 
    {
        std::lock_guard<std::mutex> lock(s_mutex);
        temp = s_instance.load(std::memory_order_relaxed);/* read current status of s_instance */
        if (temp == nullptr) 
        {
            temp = new T;
            
            destroyer.reset(temp);
            std::atomic_thread_fence(std::memory_order_release);
            s_instance.store(temp, std::memory_order_relaxed);
        }
    }
    
    return temp;
}

我在想 - 在获取和释放内存屏障中是否有任何价值?据我所知 - 内存屏障旨在防止在两个不同变量之间重新排列内存操作。让我们拿经典的例子来说:

(这都是伪代码 - 不要被语法卡住)

# Thread 1
while(f == 0);
print(x)

# Thread 2
x = 42;
f = 1;

在这种情况下,我们希望防止线程2中2个存储操作的重排序以及线程1中2个加载操作的重排序。因此,我们插入屏障:

# Thread 1
while(f == 0)
acquire_fence
print(x)

# Thread 2
x = 42;
release_fence
f = 1;

在上述代码中,栅栏的好处是什么?
编辑
据我所见,这两种情况的主要区别在于,在经典示例中,我们使用内存屏障,因为我们处理了2个变量 - 因此我们面临Thread 2在存储x之前存储f或在Thread 1加载f之前加载x的“危险”。
但在我的Singleton代码中,内存屏障旨在防止可能的内存重排序是什么?
注意
我知道有其他方法(也许更好)来实现这一点,我的问题是出于教育目的 - 我正在学习关于内存屏障,并好奇地知道在这种特定情况下它们是否有用。因此,请忽略与此问题无关的所有其他事项。

为什么不直接使用 static T inst; return &inst;?这样可以完全线程安全,不需要动态分配内存,并且在性能方面很难被超越。 - Ben
temp 是一个指针,指向的内存由构造函数写入。将指针释放存储到 s_instance 上确保加载指针并解引用它的事物将看到有效数据。在某些机器上,s_instance.store(temp, mo_release) 更有效,并且更容易输入,因此您实际上不需要分离栅栏,除非您坚持使用 mo_relaxed。(至少我没有看到使用单独栅栏的理由。) - Peter Cordes
@PeterCordes 您的意思是将 temp 分配给 s_instance 的赋值操作发生在 temp 被分配之后? - YoavKlein
@PeterCordes - 我看不出这可能发生!temp = new Ts_instance.store(temp, mo_relaxed)的顺序不能被重新排序,因为这些操作之间存在明显的因果关系。如果发生了这种情况 - 那么NULL将被存储在s_instance中,并且它不会改变!与最终两个都将对其他线程可见的xf不同... - YoavKlein
它必须与构造函数中的发布存储同步,以确保读取器在取消引用返回值时在指向的内存中看到有效的内容。 - Peter Cordes
显示剩余6条评论
1个回答

3
这个模式(称为双重检查锁定或DCLP)的复杂性在于数据同步可以以2种不同的方式发生(取决于读者何时访问单例),并且它们有点重叠。但既然你在问关于栅栏的事情,我们跳过互斥部分。

但在我的Singleton代码中,内存屏障旨在防止可能的内存重排序是什么?

这与您的伪代码没有太大区别,在其中您已经注意到获取和释放栅栏是必要的,以保证42的结果。f用作信号变量,最好不要与x重排序。
在DCL模式中,第一个线程可以分配内存:temp = new T;
其他线程将访问temp指向的内存,因此必须同步(即对其他线程可见)。
释放栅栏后跟随松散存储保证new操作在存储之前排序,以便其他线程观察到相同的顺序。 因此,一旦指针写入原子s_instance并且其他线程从s_instance读取地址,它们也将具有指针指向的内存的可见性。

获取栅栏以相反的顺序执行相同的操作; 它保证在松散加载和栅栏之后排序的所有内容(即访问内存)对分配此内存的线程不可见。 这样,在一个线程中分配内存并在另一个线程中使用它将不会重叠。

另一个答案中,我尝试用图表来说明这一点。

注意,这些栅栏始终成对出现,没有获取操作的释放栅栏是没有意义的,尽管您也可以使用(和混合)带有释放/获取操作的栅栏。
s_instance.store(temp, std::memory_order_release); // no standalone fence necessary

DCLP的成本在于每次使用(在每个线程中)都涉及到一个load/acquire,这至少需要一个未优化的load(即从L1缓存加载)。这就是为什么C++11中的静态对象(可能使用DCLP实现)可能比C++98中的更慢(没有内存模型)的原因。
有关DCLP的更多信息,请查看Jeff Preshing的this article

谢谢。我可以清楚地理解释放栅栏的必要性,但是不理解获取栅栏的必要性。如果释放栅栏保证s_instance的值只有在实例初始化之后才可见 - 那么在读取线程能够读取s_instance的值的时刻 - 只有那时它才会知道实例的位置,而此时实例本身已经被初始化了。有什么问题吗? - YoavKlein
@YoavKlein 拿到订购通常更难理解,但实际上它是完全相同的;内存排序是双向街。 您不希望将写入与发布重新排列,但也不希望将读取与获取重新排列(方向相反)。 没有获取排序,读取可能会返回在第一个线程执行释放之前的值。 (我正在使用读写重排序作为示例,但适用于两者)。 - LWimsey
我理解为什么在上述经典示例中需要使用获取屏障,但两种情况之间存在本质差异——在经典示例中,由于加载线程具有xf的内存地址,因此加载可以被重新排序,而在DCL案例中,则不是这种情况——只有在读取线程分配s_instance时才能访问指向它的内存!您能够准确地解释可能发生的事情吗? - YoavKlein
我觉得我对"它保证在轻松加载和栅栏(即访问内存)之后顺序排列的所有内容对分配此内存的线程不可见。这样,在一个线程中分配内存并在另一个线程中使用它将不会重叠"有些迷惑。你能更好地解释一下这段话吗? - YoavKlein
关于重叠部分...我希望我在答案中提到的图表可以澄清这一点。这种获取/释放排序的全部目的是让线程以严格有序的方式访问内存。 标准将其称为“先于发生”。如果你遵循规则,释放线程对该内存执行的所有操作都会先于获取线程对其执行的任何操作。 - LWimsey
显示剩余2条评论

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