内存模型和原子操作

对象和内存位置#

C++中,对象仅是对数据块的声明,C++标准定义类对象是“存储区域”,但对象可以将自己的特性赋予其他对象。

位域
C/C++中可以指定数据在内存中的存储大小(如果有必要的话),最常见的是为了压缩数据的大小。使用位域需要遵循以下的规则:

  1. 显式指定了位域大小的变量,如果总的位域之和不超过一个最小存储长度的话,一般是8bit,就会被放到同一个位置上(比如在同一个字节中),如果放不下的话,第二个变量会放到下一个位置,而第一个变量所在位置剩下的空间不会被使用。
struct S {
    int a : 2 { 1 };
    int b : 3 { 3 };
    int c : 3 { 4 };
};

比如这样的一个结构,其中a占了2bit,b占了3bit,c占了4bit。如果打印S的大小和其中变量的值,能得到结果

sizeof(S): 4
s.a = 1;
s.b = 3;
s.c = -4;

很神奇,我们定义了3个整型变量,但整个结构的大小还是只有一个int的大小,说明这三个变量都“挤在了一起”。另外,变量c初始化值是4,打印出来的却是-4,这也印证了我们指定了c的位域是3个bit的大小,因为4的二进制表示为0x0000 0100,这里只取最后一个字节表示下,赋值给c后,因为c只有3个bit的大小,于是只存储了后三位100,别忘了int是有符号整型,最高位是符号位,于是c的值就变成了-2²- 0 = -4。

  1. 指定位域的变量不能被单独的使用sizeof,结构体中的位域成员需要和其中最大位域类型对齐,位域类型指的是doubleint等等,因此结构体的大小也会是其中最大位域类型的整数倍。

这解释了为什么上述结构体S中的三个变量按位域来算的话也才8bit,但S的大小却是4字节,因为int类型默认的大小就是4字节,因为没有使用完,所以是一个int的大小。如果我们改成下述这样,那么S的大小就变成了8字节。

struct S {
    int a : 2 { 1 };
    int b : 16 { 3 };
    int c : 16 { 4 };
};

如果再添加一个更大的类型成员,S的大小就会和最大的类型对齐,比如S再加入了一个double类型后,总的大小就变成了16字节,因为64位系统中一个double占8字节,其他位域成员加起来没有超过8字节,但是要对齐到8字节。

struct S {
    double d{ 42 };
    int a : 2 { 1 };
    int b : 3 { 3 };
    int c : 3 { 4 };
};
  1. 位域可以指定为0,只能指定无名位域为0,这时就是用作填充和调整位置,无名位域是不可以使用的。
struct S {
    int a : 2 { 1 };
    int : 0 ;
    int b : 3 { 3 };
    int c : 3 { 4 };
    double d{ 0 };
};

这样将会填满a后的位置,于是b将会从一个新的字节位置开始存储。

原子操作和原子类型#

原子操作是个不可分割的操作,系统中的所有线程都不可能观察到完成了一半的原子操作。如果读取对象的加载操作是原子的,那么这个对象的所有修改操作也都是原子的,所以加载操作得到的值要么是它的初始值,要么是某次修改后的值。
另一方面,非原子操作会被其他线程观察到完成了一半的操作。如果非原子操作是一个读取操作,可能先取到对象的一部分,然后值被另一个线程修改,然后它再取到剩余的部分,所以它取到的既不是第一个值,也不是第二个值。这就构成了数据竞争。

标准原子型#

C++的标准库<atomic>定义了相关的原子类型,例如使用std::atomic<int>可以定义一个int类型的原子变量。
标准原子类型不能进行拷贝和赋值,它们没有拷贝构造函数和拷贝赋值操作符,不过提供了store()load()exchange()compare_exchange_weak()compare_exchange_strong()操作,也支持+=、*=等复合赋值运算符,对于整型和指针的特化类型还支持++--操作,当然也有对应的fetch_add()fetch_sub()函数。
另外,每个操作都支持一个表示内存序的参数,用来决定存储的顺序。
store操作可选memory_order_relaxedmemory_order_releasememory_order_seq_cst;
load操作可选memory_order_relaxedmemory_order_consumememory_order_acquire
其他的读写改操作可以选择memory_order_relaxedmemory_order_consumememory_order_acquirememory_order_releasememory_order_seq_cstmemory_order_acq_rel六种之一。
值得一提的是atomic的compare_exchange_weak()compare_exchange_strong()操作,只有当前值和预期值一致时才会存储新值。这种新型的操作称作“比较/交换”,即著名的CAS(compare and swap),这是原子类型编程的基石。当前值和预期值不相等时,说明也许有别的线程已经操作了这个原子量,那么就会返回false,表示操作失败。其中compare_exchange_weak()可能会发生“伪失败”,虽然预期值相同也不执行交换并且返回失败,这主要出现在缺少单条CAS指令的机器上,不过相比compare_exchange_strong()更加轻,而且CAS执行一般会放在循环中来做,所以大可以使用compare_exchange_weak()

atomic<int> a{ 13 };
int expected = a.load();
int num = 42;
while(!a.compare_exchange_weak(expected, num)) {
    expected = a.load();
}

假设有多个线程在操作这个原子量a,我们想把num的值赋给它,就可以通过一个循环来判断是否交换成功,只有交换成功才会退出。

同步操作和强制排序#

vector<int> data{};
atomic<bool> flag{ false };

void read_thread()
{
    // while(!flag.load()) {    // 1
    //     this_thread::sleep_for(chrono::milliseconds(1));
    // }
    cout << data[0] << endl;    // 2
}

void write_thread()
{
    data.push_back(42);         // 3
    flag = true;                // 4
}

在这段代码中,有两个线程分别对数组data执行读和写,在没有注释部分的情况下,注释2和注释3两个部分是没有规定顺序的,那么就会导致并发中最常见的数据竞争问题,从而导致未定义的行为,也就是说无法保证data读写是正确的。
如果放开注释1的循环部分,可以看到读取1是在读取2之前发生,写入3是在写入4之前发生,又因为只有在执行了写入4之后,读线程才会退出循环执行读取2,因为flag是个原子量,这就进而强制约束了写入3一定是在读取2之前发生,避免了数据竞争的发生。

同步发生#

同步发生只在原子类型间进行,简单的理解就是有A线程对变量x执行了写操作,然后B对变量x执行了读操作,B读到的是A写入的或是之后又修改的值,那么就说A和B是同步的。

先行发生#

先行发生是构建代码块的基础,指定了操作与操作之间的关系和影响。对于单线程来说,代码中写在前面的语句一定比写在后面的语句先执行,可以称为前面的操作先行于后面的操作,但是如果是同时发生的操作就不好说了,比如一个函数的多个入参之间的执行顺序。似乎这都是理所当然的事情,也确实如此,但当扩展到多线程时,就会推导出一条至关重要的关系:如果操作A所在线程先行于另一线程上的操作B,那么A就必然先行于B。线程间的先行关系依赖于同步的概念,这很容易理解。

原子操作的内存序#

原子类型有六种内存顺序:

  1. memory_order_relaxed
  2. memory_order_consume
  3. memory_order_acquire
  4. memory_order_release
  5. memory_order_acq_rel
  6. memory_order_seq_cst

在不指定内存顺序的情况下,默认是memory_order_seq_cst
这六种内存序又代表了三种内存模型:顺序一致性(memory_order_seq_cst),获取-释放序(memory_order_consumememory_order_acquirememory_order_releasememory_order_acq_rel)和自由序(memory_order_relaxed)。不同的内存序在不同的CPU架构下有着不同的功耗,有些(例如arm架构)使用顺序一致性和获取-释放序时需要添加同步指令,如果有多个处理器,同步指令就会造成很大的性能损失,x86-64架构使用了开销较小的技术保证了顺序,不需要再添加额外的同步指令,但性能损耗仍是不可避免的。
顺序一致性是最容易理解,也最符合人类思维方式的模型,即所有操作的执行都是线性的,具有严格的前后约束。也正因这种严格的约束,导致了程序执行性能无法得到充足的释放。
如果为了追求性能,放弃了顺序一致性,那么事情就慢慢变得复杂了,尤其是在多线程执行的情况下。
自由序是指对同一个原子量的操作在同一个线程中仍然是约束了前后关系的,但在不同线程之间是不会保证顺序的,另外即使在同一线程下,对不同的原子量操作,其顺序也将不会保证。

atomic<bool> x;
atomic<bool> y;
atomic<int> z;

void write() {
    x.store(true, std::memory_order_relaxed);   // 1
    y.store(true, std::memory_order_relaxed);   // 2
}

void read() {
    while(!y.load(std::memory_order_relaxed));  // 3
    if(x.load(std::memory_order_relaxed)) {     // 4
        ++z;
    }          
}

int main() {
    x = false;
    y = false;
    z = 0;
    std::thread t1(write);
    std::thread t2(read);
    t1.join();
    t2.join();
    assert(z.load() != 0);      // 5
}

x,y的读写均使用自由序,按照我们的理解,执行完成后z应该是1,断言不该生效,但不幸的是,在自由序的内存模型中,有概率会触发断言,正如前面所说,自由序给予了内存读取极大的自由空间,那么就有概率先执行y的写(2),再执行y的读(3),此时读线程退出了循环,但因为x还没有写入,因此还是false,所以判断不会进入z的自增操作(4),之后执行了x的写(1),但为时已晚,程序会被无情的断言终止。
这还只是有两个原子量是自由序的,如果再增加几个,只能说神仙也难保证程序会按预期执行。
顺序一致性太过保守,而自由序又太过狂野,似乎获取-释放序的内存模型更符合中庸之道一些,它在增加了一点同步约束的条件下,也能获得一个不错的性能。
获取-释放序保证了原子量在线程间的获取和释放这对操作是同步的。

void write() {
    x.store(true, std::memory_order_relaxed);   // 1
    y.store(true, std::memory_order_release);   // 2
}

void read() {
    while(!y.load(std::memory_order_acquire));  // 3
    if(x.load(std::memory_order_relaxed)) {     // 4
        ++z;
    }          
}

简单修改自由序demo中的内容,把y的内存模型从自由序换成获取-释放序,因为在获取-释放序中添加了同步的约束,因此保证了y的存储操作(2)肯定是在x的存储操作(1)之后的,然后y的加载操作(3)肯定是先行于x的加载操作(4)的,这样就保证了z一定会执行累加。
memory_order_releasememory_order_acquire这一对操作还保证了对同一原子量的读写顺序,简单理解,y在存储时使用memory_order_release表明原子量y被写入了一个新值,是由线程write写入的,当再调用y的加载时使用memory_order_acquire,实际上是读线程read指明“读取原子量y的值,这个值是由写线程write写入的”,这就保证了写入后释放,然后读取时获取的顺序。
需要注意的是,获取-释放序并不保证线程间的同步,如果在一个程序中存在多对的同一原子量的读取和释放操作,也许程序并不会像你预想的那样执行。
除了读写外,原子量还有"读-改-写"操作,比如compare_exchange_strong,需要一个能同时能进行释放和获取语义,这时可以使用memory_order_acq_rel,表示其中的每一步操作都是对原子量的获取操作,并在写入后执行释放,并可以和其他的获取释放操作同步。
最后还有一个获取-释放序的语义没有提及——memory_order_consume,书中不建议使用,甚至不应该出现在任何代码中,因为它完全依赖于数据,且与线程的先行关系也不同。数据依赖是指第二个操作的结果依赖于第一个操作的结果。如果使用memory_order_consume代替memory_order_acquire来执行获取操作,那么它将仅保证与原子量有依赖关系的操作是同步的。

struct X {
    int i;
    string s;
};

atomic<X*> p;
atomic<int> a;

void create_x {
    X* x = new X;
    x->i = 42;
    x->s = "hello";
    a.store(99, memory_order_relaxed);      // 1
    p.store(x, memory_order_release);       // 2
}

void use_x {
    X* x;
    while(p->load(memory_order_consume)) {  // 3
        this_thread::sleep_for(chrono::milliseconds(1));
    }
    assert(x->i == 42);                         // 4
    assert(x->s == "hello");                    // 5
    assert(a.load(memory_order_relaxed) == 99); //6
}

在这个使用了memory_order_consume来获取指针原子量p的例子中,想要正确获取到p指向的原子量,就需要保证其中的变量i和s被正确的设置了,也就是说获取原子量p的操作依赖于指向的X结构中i和s成员的操作,所以4和5的断言一定不会触发,但是变量a和p指向的变量是没有关系的,那么自由序的变量a就不会被依赖,于是断言(6)可能会被触发。再解释下,在创建线程中因为p的存储语义是memory_order_release,a仍然会先行于p,但是在使用线程中,因为p的加载语义是memory_order_consume,p仅会先行于那些依赖p的操作,而断言a的值并不依赖于p,所以线程use_x执行时,完全有可能先执行了断言(6),然后再执行p的加载(4)。这也是坑的地方,很有可能一个不小心被忽略掉。

栅栏#

栅栏其实应该是对应了操作系统底层的概念,强制在代码中画一条边界,边界以上的操作绝不可能出现在边界以下的操作后面,底层的实现不做探讨,但C++中提供了std::atomic_thread_fence接口来设置栅栏。

std::atomic<bool> x,y;
std::atomic<int> z;
void write_x_then_y()
{
 x.store(true,std::memory_order_relaxed); // 1
 std::atomic_thread_fence(std::memory_order_release); // 2
 y.store(true,std::memory_order_relaxed); // 3
}
void read_y_then_x()
{
 while(!y.load(std::memory_order_relaxed)); // 4
 std::atomic_thread_fence(std::memory_order_acquire); // 5
 if(x.load(std::memory_order_relaxed)) // 6
 ++z;
}

使用起来就像这样,还是通过获取-释放序强行约束了x和y的读写顺序。这种栅栏操作不仅对原子量起作用,非原子的变量也同样受到约束。

作者:cwtxx

出处:https://www.cnblogs.com/cwtxx/p/18718203

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   cwtxx  阅读(5)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示