C++内存模型介绍

本文参考链接:https://gcc.gnu.org/wiki/Atomic/GCCMM/AtomicSync。

一、内存模型同步模式

原子变量主要用于不同线程之间同步共享内存的访问。典型的是一个线程创建了数据,然后存储到原子变量中。另一个线程读取这个原子变量,将看到另一个线程创建的数据。不同的内存模型模式用于标识,在不同线程之间数据共享的强度。不过厉害的程序员可以使用较弱的模型来制作高效的软件。

每一个原子变量都有 load()store 操作来执行操作。

atomic_var1.store (atomic_var2.load()); // atomic variables
    vs
var1 = var2;   // regular variables

这些操作函数都有第二个可选参数用来指定用于同步的内存模型。这里有三种模式允许程序员指定线程间同步的类型。

二、Sequentially Consistent 模式(顺序一致性)

第一个模式是"顺序一致性"。是没有任何指定时的默认模式,也是最严格的模式。也可以通过 std::memory_order_seq_cst 来显式指定。

-Thread 1-       -Thread 2-
y = 1            if (x.load() == 2)
x.store (2);        assert (y == 1)

尽管 x 和 y 是不相关的变量,这个内存模型下断言不会失败。在线程1中,y 的存储 happens-before x 的存储。线程2中,如果获取 x 的结果是在线程1存储之后,它肯定会看到线程1中存储 x 之前(happened before)的所有操作 ,即使是不相关的变量。这意味着优化器不能随机重排线程1中的两个存储,因为线程2也必须看到 y 的存储。

这也适用于 load 函数:

            a = 0
            y = 0
            b = 1
-Thread 1-              -Thread 2-
x = a.load()            while (y.load() != b)
y.store (b)                ;
while (a.load() == x)   a.store(1)
   ;

线程2循环直到 y 的值改变,然后继续修改 a。线程1等待着 a 的修改。

当正常编译这个代码时,线程1的 while (a.load() == x) 代码看起来是个无限循环(不管线程2)。并且每次循环都要存储 a 和比较 x,这样线程1和线程2才能如期运行。

从实际的角度看,相当于所有的原子操作都扮演了优化屏障的角色。可以想象原子操作的 loadstore 是具有未知副作用的函数,它其实会影响优化器。在原子操作之间进行指令重新排序是可以的,但是不能跨原子操作重新排序。线程本地内容不受影响,因为这些内容对其他线程不可见。

这个模式还提供了跨所有线程的一致性。下面示例中所有的断言都不会失败(x 和 y 初始化为0):

-Thread 1-       -Thread 2-                   -Thread 3-
y.store (20);    if (x.load() == 10) {        if (y.load() == 10)
x.store (10);      assert (y.load() == 20)      assert (x.load() == 10)
                   y.store (10)
                 }

这种行为预期是合理的,但是跨线程的实现需要系统总线来同步,因此线程3观察到的结果和线程2的相同。这可能涉及一些昂贵的硬件同步。

这个模式是默认的是有充分理由的,当使用这个模式时,程序员不太可能得到异常的结果。

三、Relaxed 模式

相反的模式是 std::memory_order_relaxed。这个模式通过移除 happens-before 的限制来减少同步。这些类型的原子操作还可以执行各种优化,比如 dead store 的移除和共享。

因此,在前面的例子中:

-Thread 1-
y.store (20, memory_order_relaxed)
x.store (10, memory_order_relaxed)

-Thread 2-
if (x.load (memory_order_relaxed) == 10)
{
  assert (y.load(memory_order_relaxed) == 20) /* assert A */
  y.store (10, memory_order_relaxed)
}

-Thread 3-
if (y.load (memory_order_relaxed) == 10)
  assert (x.load(memory_order_relaxed) == 10) /* assert B */

由于线程之间不需要跨系统进行同步,所以所有的断言都有可能失败。

没有了 happens-before 的限制,没有线程可以获取其他线程中指定的顺序。一不小心就会导致意想不到的结果。唯一的规则就是,一旦线程2看到线程1中的某个变量值,线程2无法看到线程1中该变量更早之前的值。例如,x 初始化为 0:

-Thread 1-
x.store (1, memory_order_relaxed)
x.store (2, memory_order_relaxed)

-Thread 2-
y = x.load (memory_order_relaxed)
z = x.load (memory_order_relaxed)
assert (y <= z)

断言不会失败。一旦线程2看到了 x 为 2,它就不会再看到 x 为 1 了。

可以推断,一个线程中 relaxed 模式的存储会在合理的时间内被另一个线程的 relaxed 模式的看到。这意味着,在非缓存一致性架构上,relaxed 操作需要刷新缓存(尽管这些刷新可以 merge 几个 relaxed 操作)。

当程序员只想要一个本质上是原子的变量,而不是用来同步线程做内存共享,这个模式是最常用的。

四、Acquire/Release 模式

第三种模式是前面两种模式的混合。acquire/release 模式有点类似于 sequentially consistent 模式,除了它只在有相关关系的变量上执行 happens-before。模式允许在独立读和独立写之间进行 relaxed 的同步。

例如,假定 x 和 y 初始化为 0:

-Thread 1-
y.store (20, memory_order_release);

-Thread 2-
x.store (10, memory_order_release);

-Thread 3-
assert (y.load (memory_order_acquire) == 20 && x.load (memory_order_acquire) == 0)

-Thread 4-
assert (y.load (memory_order_acquire) == 0 && x.load (memory_order_acquire) == 10)

这些断言都可以通过,因为线程1和线程2之间顺序限制。

如果这个例子使用 sequentially consistent 模式,那么其中一个存储肯定 happen-before 另一个(尽管顺序直到运行时才会确定),这些值在线程之间同步,如果一个断言通过了,那么另一个断言肯定会失败

让问题稍微复杂一点,非原子变量之间的相互作用也是一样的。任何原子变量之前的存储操作,在其他线程都必须要看到。例如:

-Thread 1-
y = 20;
x.store (10, memory_order_release);

-Thread 2-
if (x.load(memory_order_acquire) == 10)
   assert (y == 20);

即使 y 不是原子变量,因为存储 y happens-before 存储 x,断言也不会失败。优化器需要限制在原子变量周围的共享内存的优化操作。

五、Consume 模式

std:memory_order_consumerelease/acquire 模式的进一步改进,通过删除非依赖共享变量上的 happen-before,来放松一些要求。

假设 n 和 m 都是普通变量并初始化为 0,每个线程都获取线程1中 p 的存储值。

-Thread 1-
n = 1
m = 1
p.store (&n, memory_order_release)

-Thread 2-
t = p.load (memory_order_acquire);
assert( *t == 1 && m == 1 );

-Thread 3-
t = p.load (memory_order_consume);
assert( *t == 1 && m == 1 );

线程2的断言肯定会通过,因为线程1中存储 m happens-before 存储 p。

线程3的断言可能会失败,因为存储 m 和存储 p 没有依赖关系,因此这些值不需要同步。这个模式是 PowerPC 和 ARM’s 加载指针时的默认内存模型。

所有的线程都会看到 n==1,因为它在线程1的存储 p 的表达式中使用了。

最主要的区别是硬件需要刷新多少状态来同步。由于 Consume 模式执行更快,因此了解这个模式的人可以在性能要求高的程序上用它。

六、总结

其实并不像听起来这么复杂,来看下面这个例子,使用不同的内存模式:

-Thread 1-       -Thread 2-                   -Thread 3-
y.store (20);    if (x.load() == 10) {        if (y.load() == 10)
x.store (10);      assert (y.load() == 20)      assert (x.load() == 10)
                   y.store (10)
                 }

当线程之间使用 sequentially consistent 模式同步时,所有可见变量都必须在系统中刷新,以便所有的线程都看到相同的状态。因此所有的断言都能通过。

Release/acquire 模式只需要同步涉及的两个线程。这意味着不同线程之间的同步是不一致的。因为线程1、2通过 x.load() 同步,因此线程2的断言肯定会成功。线程3没有涉及线程1和线程2的同步,所以当线程2和线程3通过 y.load() 同步时,线程3的断言可能会失败。线程1和线程3之间没有同步,因此这里 x 的值不确定。

如果存储使用 release、获取使用 consume,结果和使用 release/acquire 是一样的,只是少了一些硬件同步。那为什么不一直使用 consume 呢?原因是这个示例中没有同步任何共享内存。在共享内存的值同步之前,你可能看不到任何对应的值,除非它是存储值的参数。就是说,consume 只对用于计算存储值的共享内存变量进行同步。

如果所有都是 relaxed,所有的断言都可能会失败,因为这里根本没有同步。

七、混合内存模式

最后,来看一下混合模式会怎么样:

-Thread 1-
y.store (20, memory_order_relaxed)
x.store (10, memory_order_seq_cst)

-Thread 2-
if (x.load (memory_order_relaxed) == 10)
  {
    assert (y.load(memory_order_seq_cst) == 20) /* assert A */
    y.store (10, memory_order_relaxed)
  }

-Thread 3-
if (y.load (memory_order_acquire) == 10)
  assert (x.load(memory_order_acquire) == 10) /* assert B */

首先,请不要这么做,这样太具有迷惑性了。

其次,这也是一个公平的问题,所以我们来看一下。想一下在每个同步点上会发生什么。存储器倾向先执行存储,然后执行它们处理器上需要执行的系统刷新。加载器先发出同步指令,获取到已经刷新的所有状态,然后执行加载。

线程1:y 的存储是 relaxed 的,因此它不会发出任何同步指令,并可以被优化器移动位置。x 的存储是 seq_cst 的,所以在处理它之前会强制刷新线程1的状态。因此会在本次同步之前的某个时间,强制将 y 进行存储。

线程2:x 的获取是 relaxed 的,所以不会强制任何同步。尽管线程1刷新了它的状态到系统中,线程2没有做任何事情来保证和系统同步。这意味着一切都处于未知的状态。可能线程2碰巧看到 x 存储为 10,不意味着它同步了线程1 x 存储之前的所有状态。

然而,y 的获取是 seq_cst 的,所以会在处理前强制同步,所以从现在开始事情变的乱七八槽了。

线程3:y 的获取是 acquire 的,它首先会获取线程2中刷新的所有状态。不幸的是,线程2中 y 的存储是 relaxed 的,它不会发出任何同步指令,而且可能被优化器移动位置。因此结果再次不可预测。

归根结底,混合模式是危险的,尤其是卷入 relaxed 模式。seq_cst 和 release/acquire 的混合模式可以被小心的处理,但是需要你真正理解了这其中的微妙之处,当然你也需要一个好的调试工具。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值