6

[译]C++中的内存同步模式(memory order)

 3 years ago
source link: https://blog.csdn.net/tkokof1/article/details/89502937
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

[译]C++中的内存同步模式(memory order)

C++11 引入了一个有些晦涩的主题: 内存模型,不过一般都只会在需要 Lock-Free 编程时才会遇到,这里翻译一篇相关文章,希望能够给有兴趣的朋友多些参考.原文在这里.

内存模型中的同步模式(memory model synchronization modes)

原子变量同步是内存模型中最让人感到困惑的地方.原子(atomic)变量的主要作用就是同步多线程间的共享内存访问,一般来讲,某个线程会创建一些数据,然后给原子变量设置标志数值(译注:此处的原子变量类似于一个flag);其他线程则读取这个原子变量,当发现其数值变为了标志数值之后,之前线程中的共享数据就应该已经创建完成并且可以在当前线程中进行读取了.不同的内存同步模式标识了线程间数据共享机制的"强弱"程度,富有经验的程序员可以使用"较弱"的同步模式来提高程序的执行效率.

每一个原子类型都有一个 load() 方法(用于加载操作)和一个 store() 方法(用于存储操作).使用这些方法(而不是普通的读取操作)可以更清晰的标示出代码中的原子操作.

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

这些方法还支持一个可选参数,这个参数可以用于指定内存模型的同步模式.

目前这些用于线程间同步的内存模式共有 3 种,我们依此来看下~

顺序一致模式(sequentially consistent)

第一种模式是顺序一致模式(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 是没有关联的两个变量,但是代码中指定的内存模型(译注:代码中没有显示指定,则使用默认的内存模式,即顺序一致模式)保证了线程 2 中的断言不会失败.线程 1 中 对 y 的写入 先发生于(happens-before) 对 x 的写入,如果线程 2 读取到了线程 1 对 x 的写入(x.load() == 2),那么线程 1 中 对 x 写入 之前的所有写入操作都必须对线程 2 可见,即使对于那些和 x 无关的写入操作也是如此.这意味着优化操作不能重排线程 1 中的两个写入操作(y = 1 和 x.store (2)),因为当线程 2 读取到线程 1 对 x 的写入之后,线程 1 对 y 的写入也必须对线程 2 可见.

(译注:编译器或者 CPU 会因为性能因素而重排代码指令,这种重排操作对于单线程程序而言是无感知的,但是对于多线程程序而言就不是了,拿上面代码举例,如果将 x.store (2) 重排于 y = 1 之前,那么线程 2 中即使读取发现 x == 2 了,但此时 y 的数值也不一定是 1)

加载操作也有类似的优化限制:

             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)’ 似乎是一个无限循环,编译器编译这段代码时也可能会直接将其优化为一个无限循环(译注:优化为 while (true); 之类的指令);但实际上,我们必须保证每次循环都对 a 执行读取操作(a.load()) 并且将其与 x 进行比较,否则线程 1 和 线程 2 将不能正常工作(译注:线程 1 将进入无限循环,与正确的执行结果不一致).

从实践的角度讲,所有的原子操作都相当于优化屏障(译注:用于阻止优化操作的指令).原子操作(load/store)可以类比为副作用未知的函数调用,优化操作可以在原子操作之间任意的调整代码顺序,但是不能越过原子操作(译注:原子操作类似于是优化调整的边界),当然,线程的私有数据并不受此影响,因为这些数据其他线程并不可见.

顺序一致模式也保证了所有线程间(原子变量(使用 memory_order_seq_cst 模式)的修改顺序)的一致性.以下代码中所有的断言都不会失败(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 观察到的原子变量(使用 memory_order_seq_cst 模式)变更顺序一致),可想而知,这往往需要昂贵的硬件同步.

由于保证顺序一致的特性, 顺序一致模式成为了原子操作中默认使用的内存模式, 当程序员使用这种模式时,一般不太可能获得意外的程序结果.

宽松模式(relaxed)

与顺序一致模式相对的就是 std::memory_order_relaxed 模式,即宽松模式.由于去除了先发生于(happens-before)这个关系限制, 宽松模式仅需极少的同步指令即可实现.这种模式下,不同于之前的顺序一致模式,我们可以对原子变量操作进行各种优化了,譬如执行死代码删除等等.

看一下之前的示例:

-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,那么线程 2 后面对 x 的读取操作将不可能取得数值 1(1 较 2 是 x 更早的写入数值).这一特性导致了一个结果:
如果代码中存在多个对同一变量的宽松模式读取,但是这些读取之间存在对其他引用(可能是之前同一变量的别名)的宽松模式读取,那么我们不能把这多个对同一变量的宽松模式读取合并(多个读取并成一个).

这里还有一个假定就是某一线程对于原子变量的宽松写入将在一段合理的时间内对另一线程可见(通过宽松读取).这意味着,在一些非缓存一致的体系架构上, 宽松操作需要主动的去刷新缓存(当然,刷新操作可以进行合并,譬如在多个宽松操作之后再进行一次刷新操作).

宽松模式最常用的场景就是当我们仅需要一个原子变量,而不需要使用该原子变量同步线程间共享内存的时候.(译注:譬如一个原子计数器)

获得/释放模式(acquire/release)

第三种模式混合了之前的两种模式.获得/释放模式类似于之前的顺序一致模式,不同的是该模式只保证依赖变量间产生先发生于(happens-before)的关系.这也使得独立读取操作和独立写入操作之间只需要比较少的同步.

假设 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 中的两个写入操作并没有先后顺序.

但是如果我们使用顺序一致模式来改写上面的代码,那么这两个写入操作中必然有一个写入先发生于(happens-before)另一个写入(尽管运行时才能确定实际的先后顺序),并且这个顺序是多线程一致的(通过必要的同步操作),所以代码中如果一个断言通过,那么另一个断言就一定会失败.

如果我们在代码中使用非原子变量,那么事情会变的更复杂一些,但是这些非原子变量的可见性同他们是原子变量时是一致的(译注:参看下面代码).任何原子写入操作(使用释放模式)之前的写入对于其他同步的线程(使用获取模式并且读取到了之前释放模式写入的数值)都是可见的.

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

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

线程 1 中对 y 的写入(y = 20)先发生于对 x 的写入(x.store (10, memory_order_release)),因此线程 2 中的断言不会失败(译注:这里说的有些简略,扩展来讲的话应该是线程 1 中 对 y 的写入 先发生于 对 x 的写入, 而线程 1 中 对 x 的写入 又同步于线程 2 中 对 x 的读取, 由于线程 2 中 对 x 的读取 又先发生于 对 y 的断言,于是线程 1 中 对 y 的写入 先发生于线程 2 中 对 y 的断言,这个 对 y 的断言 也就不会失败了).由于有上述的同步要求,原子操作周围的共享内存(非原子变量)操作一样有优化上的限制(译注:不能随意对这些操作进行优化,以上面代码为例,优化操作不能将 y = 20 重排于 x.store (10, memory_order_release) 之后).

消费/释放模式(consume/release)

消费/释放模式是对获取/释放模式进一步的改进,该模式下,非依赖共享变量的先发生于关系不再成立.

假设 n 和 m 是两个一般的共享变量,初始值都为 0,并且假设线程 2 和 线程 3 都读取到了线程 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 的写入 先发生于 对 p 的写入.

但是线程 3 中的断言就可能失败了,因为 p 和 m 没有依赖关系,而线程 3 中读取 p 使用了消费模式,这导致线程 1 中 对 m 的写入 并不能与线程 3 中的 断言 形成先发生于的关系,该 断言 自然也就可能失败了.PowerPC 架构和 ARM 架构中,指针加载的默认内存模式就是消费模式(一些 MIPS 架构可能也是如此).

另外的,线程 1 和 线程 2 都能够正确的读取到 n 的数值,因为 n 和 p 存在依赖关系(译注: p.store (&n, memory_order_release), p 中写入了 n 的地址,于是 p 和 n 形成依赖关系).

内存模式的真正区别其实就是为了同步,硬件需要刷新的状态数量.消费/释放模式相较获取/释放模式而言,执行速度上会更快一些,可以用于一些对性能极度敏感的程序之中.

内存模式其实并不像听起来的那么复杂,为了加深你的理解,我们来看下这个示例:

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

-Thread 3-
if (y.load() == 10)
  assert (x.load() == 10)

当使用顺序一致模式时,所有的共享变量都会在各线程间进行同步,所以线程 2 和 线程 3 中的两个断言都不会失败.

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

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

获取/释放模式则只要求在两个线程间(一个使用释放模式的线程,一个使用获取模式的线程)进行必要的同步.这意味着这两个线程间同步的变量并不一定对其他线程可见.线程 2 中的断言仍然不会失败,因为线程 1 和 线程 2 通过对 x 的写入和读取形成了同步关系(译注:参见之前 获取/释放模式介绍中的说明),但是线程 3 并不参与线程 1 和 线程 2 的同步,所以当线程 2 和 线程 3 通过对 y 的写入和读取发生同步关系时, 线程 1 与 线程 3 并没有发生同步关系, x 的数值自然也不一定对线程 3 可见,所以线程 3 中的断言是可能失败的.

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

-Thread 3-
if (y.load(memory_order_consume) == 10)
  assert (x.load(memory_order_consume) == 10)

使用消费/释放模式的结果与获取/释放模式是一致的,区别只是 消费/释放模式需要更少的硬件同步操作,那么我们为什么不一直使用 消费/释放模式(而不使用获取/释放模式)呢?那是因为这个例子中没有涉及(非原子)共享变量,如果示例中的 y 是一个(非原子)共享变量,由于其与 x 不存在依赖关系(依赖关系是指原子变量的写入数值由(非原子)共享变量计算而得),那么我们并不一定能够在线程 2 中看到 y 的当前数值(20),即便线程 2 已经读取到 x 的数值为 10.

(译注:这里说因为没有涉及(非原子)共享变量所以导致消费/释放模式和获取/释放模式表现一致应该是不准确的,将示例中的 assert (y.load(memory_order_consume) == 20) 修改为 assert (y.load(memory_order_relaxed) == 20) 应该也能体现出消费/释放模式和获取/释放模式之间的不同,更多的细节可以参看文章最后的示例)

-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) 
  y.store (10, memory_order_relaxed)
}

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

如果所有操作都使用宽松模式,那么代码中的两个断言都可能失败,因为 宽松模式下没有同步操作发生.

混合使用内存模式

最后,我们来看下混合使用内存模式会发生什么:

-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 */

首先,我必须提醒你不要这么做(混合使用内存模式),因为这会让人极度困惑! ?

但这仍然是一个存在的问题,所以让我们来试着"求解"一下…

想一想代码中各个同步点到底会发生了什么:

  • 写入(store)同步会首先执行写入指令,然后执行必要的系统状态刷新指令
  • 读取(load)同步会首先执行必要的系统状态获取指令,然后执行加载指令

线程 1 : y.store 使用了宽松模式,所以这个写入操作不会产生同步指令(即系统状态刷新指令),并且该操作可能被优化操作重排,接下来的 x.store 使用了顺序一致模式,所以该操作会强制刷新线程 1 中的各个状态(用于线程间的同步),并且会保证之前的 y.store 先发生于 x.store.

线程 2 : x.load 使用了宽松模式,所以该操作不会产生同步指令,即便线程 1 将其状态刷新到了系统之中, 线程 2 也并没有确保自己与系统之间的同步(因为没有执行同步指令).这意味着线程 2 中的数据处于一种未知状态之中,即使线程 2 读取到了 x 的数值为 10, 线程 1 中 x.store(10) 之前的写入(y.store (20, memory_order_relaxed))对线程 2 也不一定是可见的,所以线程 2 中的断言可能会失败.

但奇怪的是, 线程 2 中对 y 的读取使用了顺序一致模式(y.load(memory_order_seq_cst)),这会产生一个同步操作(在读取操作之前),进而导致线程 2 与系统发生同步(读取到 y 的最新数值),于是断言就不会失败了… 有些混乱,对吧~

线程 3 : y.load 使用了获取模式,所以他会在读取之前执行获取系统状态的指令,但不幸的是,线程 2 中的 y.store 使用的是宽松模式,所以不会产生系统状态刷新的指令,并且可能被优化操作重排(译注:重排的影响在这个例子中应该可以忽略),所以线程 3 中的断言仍然可能是失败的.

最后要说明的一点是: 混合使用内存模式是危险的,尤其是当模式中包含宽松模式的时候.小心的混合使用 顺序一致模式(seq_cst) 和 获取/释放模式(acquire/release) 应该是可行的,但是需要你熟稔这两个模式的各种工作细节,除此之外,你可能还需要一些优秀的调试工具!!!

  • 关于 std:memory_order_consume, 自 C++11 引入以来,似乎从来没有被编译器正确实现过(编译器都直接将其当作 std:memory_order_acquire 来处理), C++17 则直接将其列为暂时不推荐使用的特性, C++20 中有可能将其废弃.
  • 内存模型这个话题确实有些晦涩,网上相关的资料也很多,初次接触的朋友推荐从这里的系列博文开始.
  • 网上还有不少很好的文章,譬如这里,这里这里.
  • 感到疑问的朋友也可以直接留言,大家一起讨论.

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK