cpp 内存顺序 笔记

前言

学过操作系统的同学应该了解过,cpu 不同核心之间有各自独立的缓存,这就产生了缓存一致性问题,总线嗅探是为了解决这个问题所诞生的一种技术(协议)。cpp为了追求极致的性能,允许某些核间没必要同步的缓存不进行同步,或者将某些指令的执行顺序进行调换,当我们进行多线程编程时,尤其要注意这些内存操作。

关于MESI(缓存一致性协议)与编程的关系参考 C++ Lock-free编程基础上

内存顺序

定义

cpp为我们提供了6中内存模型进行编程。

typedef enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
} memory_order;

从上到下,限制越来越严格

关于,先序于,依赖优先于(Sequenced-before, Dependency-ordered before) 等定义见 cpp reference

解释

memory_order_relaxed 宽松定序

被标以 memory_order_relaxed 的原子操作不是同步操作;它们不会为并发的内存访问行为添加定序约束。它们只保证原子性和修改顺序的一致性。换句话说,该操作只保证线程内的操作顺序,以及操作的原子性。

// 线程 1:
void thread1() {
	r1 = y.load(std::memory_order_relaxed); // A
	x.store(r1, std::memory_order_relaxed); // B
}
// 线程 2:
void thread2() {
	r2 = x.load(std::memory_order_relaxed); // C 
	y.store(42, std::memory_order_relaxed); // D
}
int main() [
	std::thread t1(thread1);
	std::thread t2(thread2);
	t1.join(); t2.join();
}

在上述代码中,r1 = 42 && r2 = 42 是可能的,因为 D 可能先于 C 执行,D 执行后 A,B执行,最后是 C 执行,因为在 thread2 中,CD 之间没有依赖关系,编译器可以将D提前到C前执行。

memory_order_relaxed 的典型应用是 std::shared_ptr 的引用计数器(注意 shared_ptr 计数器自减要求与析构函数进行获得-释放同步)。

cpp reference 中的例子
#include <vector>
#include <iostream>
#include <thread>
#include <atomic>
 
std::atomic<int> cnt = {0};
 
void f()
{
    for (int n = 0; n < 1000; ++n)
        cnt.fetch_add(1, std::memory_order_relaxed);
}
 
int main()
{
    std::vector<std::thread> v;
    for (int n = 0; n < 10; ++n)
        v.emplace_back(f);
    for (auto& t : v)
        t.join();
    std::cout << "最终计数器值为 " << cnt << '\n';
}

out

最终计数器值为 10000

释放-获取定序(memory_order_release, memory_order_acquire)

若 线程B 中的 acquire 读到了 线程A 中的 release 操作,则 A中的存储 同步于 B中的加载。
线程A 中先发生于 release 中的写入,包括(relaxed 和 非原子),在线程B 中都是可见的(可见副效应)。
即一旦原子加载完成,则保证线程 B 能观察到线程 A 写入内存的所有内容。仅当 B 实际上返回了 A 所存储的值或其释放序列中后面的值时,才有此保证。(换句话说,B需要A中release前的值的时候才会加载A中release前的值)。
同步关系仅建立在 AB 之间,其他线程观察到的内存顺序可能不同。

在强顺序系统(x86、SPARC TSO、IBM 大型机)上,释放-获得定序对于多数操作是自动进行的。无需为此同步模式发出额外的 CPU 指令,只有某些编译器优化受影响(例如,编译器被禁止将非原子存储移到原子存储-释放之后,或将非原子加载移到原子加载-获得之前)。在弱顺序系统(ARM、Itanium、Power PC)上,必须使用特别的 CPU 加载或内存栅栏指令。

互斥锁(例如 std::mutex 或原子自旋锁)是释放-获得同步的例子:线程 A 释放锁而线程 B 获得它时,发生于线程 A 上下文的临界区(释放之前)中的所有事件,必须对于执行同一临界区的线程 B(获得之后)可见。

#include <atomic>
#include <cassert>
#include <string>
#include <thread>
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
    assert(*p2 == "Hello"); // 绝无问题
    assert(data == 42); // 绝无问题
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

释放序列例子

#include <atomic>
#include <cassert>
#include <thread>
#include <vector>
 
std::vector<int> data;
std::atomic<int> flag = {0};
 
void thread_1()
{
    data.push_back(42);
    flag.store(1, std::memory_order_release);
}
 
void thread_2()
{
    int expected=1;
    // memory_order_relaxed 是可以的,因为这是一个 RMW 操作
    // 而 RMW(以任意定序)跟在释放之后将组成释放序列
    while (!flag.compare_exchange_strong(expected, 2, std::memory_order_relaxed))
    {
        expected = 1;
    }
}
 
void thread_3()
{
    while (flag.load(std::memory_order_acquire) < 2)
        ;
    // 如果我们从 atomic flag 中读到 2,将看到 vector 中储存 42
    assert(data.at(0) == 42); //决不出错
}
 
int main()
{
    std::thread a(thread_1);
    std::thread b(thread_2);
    std::thread c(thread_3);
    a.join(); b.join(); c.join();
}

这里的重点是 relaxed 跟在了 release 后,形成了 release 序列,若第一次 acquire 就获取了 release,则不执行thread2中的代码也可以读到 data[0] = 42

释放-消费定序

若线程 A 中的原子存储被标以 memory_order_release,而线程 B 中从同一变量的原子加载被标以 memory_order_consume,而线程 B 中的加载读到了由线程 A 中的存储所写入的值,则线程 A 中的存储按依赖先序于线程 B 中的加载。
线程 A 视角中先发生于原子存储的所有内存写入(非原子和宽松原子的),会在线程 B 中该加载操作所携带依赖进入的操作中变成可见副效应,即一旦完成原子加载,则保证线程 B 中,使用从该加载获得的值的运算符和函数,能见到线程 A 写入内存的内容。
这句话的意思是 B 只能观察到 A中 与 release 操作有依赖关系的写入。

在除 DEC Alpha 之外的所有主流 CPU 上,依赖定序是自动的,无需为此同步模式发出额外的 CPU 指令,只有某些编译器优化会受影响(例如,编译器被禁止牵涉到依赖链的对象上的推测性加载)。

此定序的典型使用情况,包括对很少被写入的并发数据结构(路由表、配置、安全策略、防火墙规则等)的读取访问,和有指针中介发布的发布者-订阅者的情形,即生产者所发布的指针,消费者能通过其访问信息:无需令生产者写入内存的所有其他内容对消费者可见(这在弱顺序架构上可能是昂贵的操作)。这种场景的例子之一是 rcu 解引用。

细粒度依赖链控制可参阅 std::kill_dependency 及 [[carries_dependency]] 。
注意到 2015 年 2 月为止没有任何已知产品级编译器跟踪依赖链:消费操作均被提升为获得操作。

释放消费定序的规范正在修订中,而且暂时不鼓励使用 memory_order_consume。(C++17 起)

示例演示用于指针中介的发布的依赖定序同步:int data 不由数据依赖关系关联到指向字符串的指针,从而其值在消费者中未定义。

#include <atomic>
#include <cassert>
#include <string>
#include <thread>
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume)))
        ;
    assert(*p2 == "Hello"); // 绝无出错: *p2 从 ptr 携带依赖
    assert(data == 42); // 可能也可能不会出错: data 不从 ptr 携带依赖
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

序列一致定序 memory_order_seq_cst

被标为 memory_order_seq_cst 的原子操作不仅以与释放-获得定序相同的方式进行内存定序(在一个线程中先发生于存储的任何副作用都变成进行加载的线程中的可见副作用),还对所有带此标签的内存操作建立了一个单独全序。

定义非常的长,详情见 cpp reference
简要的说就是,释放-获取定序保证两个线程间,内存的一致性。而序列一致定序则保证所以线程对这两个线程的观测是一致的(见下方例子),但是这个操作的开销是最大的,可能导致性能瓶颈。

在用的时候只需要牢记
正式定义确保:

  1. 单独全序与任何原子对象的修改顺序一致。
  2. memory_order_seq_cst 加载到的值,要么来自最后一次 memory_order_seq_cst 修改,要么来自某个不先发生于顺序中之前的 memory_order_seq_cst 修改操作的非 memory_order_seq_cst 修改。
    单独全序可能与先发生于不一致。这允许 memory_order_acquire 与 memory_order_release 在某些 CPU 上的更高效实现。当 memory_order_acquire 及 memory_order_release 与 memory_order_seq_cst 混合时,这能产生令人惊讶的结果。
    1. 一旦出现未标记 memory_order_seq_cst 的原子操作,程序的序列一致保证就会立即丧失,
    2. 多数情况下,memory_order_seq_cst 原子操作相对于同一线程所进行的其他原子操作可重排。

在多生产者-多消费者的情形中,若所有消费者都必须以相同顺序观察到所有生产者的动作出现,则可能必须进行序列定序。
全序列定序在所有多核系统上都要求完全的内存栅栏 CPU 指令。这可能成为性能瓶颈,因为它强制受影响的内存访问传播到每个核心。
此示例演示序列一致定序为必要的场合。任何其他定序都可能触发 assert,因为可能令线程 c 和 d 观测到原子对象 x 和 y 以相反顺序更改。

#include <atomic>
#include <cassert>
#include <thread>
 
std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};
 
void write_x()
{
    x.store(true, std::memory_order_seq_cst);
}
 
void write_y()
{
    y.store(true, std::memory_order_seq_cst);
}
 
void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst))
        ;
    if (y.load(std::memory_order_seq_cst))
        ++z;
}
 
void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst))
        ;
    if (x.load(std::memory_order_seq_cst))
        ++z;
}
 
int main()
{
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z.load() != 0);  //决不发生
}

如何实现内存顺序

总结自 下篇:说说无锁Lock-Free编程那些事
这两篇文章个人认为非常好 上篇:说说无锁Lock-Free编程那些

MESI协议

关于无锁编程中的各种各样的问题,几乎都可以从MESI中找到答案。现代cpu架构中,每个核心拥有自己的缓存(L1,L2),这些和其他核心不共享的缓存我们称之为 local store。我们可以认为,每个 local store 中以 cache line 为粒度管理数据。在mac上我们可以运行 sysctl machdep.cpu | grep cache看到机器的 cache line 大小。通常一个linesize是64。
每个cache line配备了两个标志位,这两个标志位可以表达出4种状态,对应MESI中的四种状态,MESI则是以chache line为粒度管理缓存状态的一种协议。
如果cpu严格按照MESI协议运行,则完全不会出现各种内存顺序问题,但是MESI要通过总线传输数据,cpu为了提高速度将同步的等待变为异步等待,并在cpu与缓存间增加了 store buffer 和 invalidate queue,分别对应写入的“缓存”,和回应的“缓存”,程序员在编写代码时,可以通过设置内存栅栏(memory barriers)来手动flush这些“缓存”,保证内存顺序的正确。

内存栅栏

各种不同架构的cpu提供了不同的内存栅栏实现方式,c++将这些不同的内存栅栏抽象成了6种内存顺序,保证了程序的可移植性。

  • 16
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
顺序表是一种常见的数据结构,它的基本操作有初始化、获取元素、插入元素、删除元素和查找元素。在C++中,可以使用数组来实现顺序表。 首先,我们需要定义一个顺序表的结构体,其中包含数组和长度变量。 ```cpp const int MAX_SIZE = 100; // 假设顺序表的最大容量为100 struct ArrayList { int data[MAX_SIZE]; // 用于存储元素的数组 int length; // 当前元素个数 }; ``` 接下来是初始化操作,该操作可以将顺序表的长度设置为0。 ```cpp void init(ArrayList& list) { list.length = 0; } ``` 然后是获取元素的操作,我们需要传入顺序表和待获取元素的索引,返回对应索引位置的元素。 ```cpp int get(ArrayList list, int index) { if (index < 0 || index >= list.length) { // 索引越界,返回一个非法值 return -1; } return list.data[index]; } ``` 插入元素的操作需要传入顺序表、待插入的元素和插入位置的索引。该操作会在指定位置插入元素,并将原来位置及之后的元素向后移动一位。 ```cpp void insert(ArrayList& list, int element, int index) { if (index < 0 || index > list.length || list.length >= MAX_SIZE) { // 索引越界或顺序表已满,插入失败 return; } // 后移元素 for (int i = list.length - 1; i >= index; i--) { list.data[i + 1] = list.data[i]; } // 在指定位置插入元素 list.data[index] = element; list.length++; } ``` 删除元素的操作需要传入顺序表和待删除元素的索引。该操作会将指定位置的元素删除,并将之后的元素向前移动一位。 ```cpp void remove(ArrayList& list, int index) { if (index < 0 || index >= list.length) { // 索引越界,删除失败 return; } // 前移元素 for (int i = index + 1; i < list.length; i++) { list.data[i - 1] = list.data[i]; } list.length--; } ``` 最后是查找元素的操作,该操作需要传入顺序表和待查找的元素值,返回元素在顺序表中的索引。 ```cpp int find(ArrayList list, int element) { for (int i = 0; i < list.length; i++) { if (list.data[i] == element) { return i; } } return -1; // 未找到元素 } ``` 这些基本操作的实现可以帮助我们对顺序表进行各种操作,包括初始化、获取元素、插入元素、删除元素和查找元素。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值