第五章——内存模型和原子操作

本文详细介绍了C++中的内存模型和原子操作,包括对象和内存位置的关系、并发访问时的注意事项、原子操作的类型以及同步操作和内存序的概念。重点讨论了原子类型如std::atomic_flag、std::atomic及其操作,强调了原子操作在并发编程中的重要性,以及如何通过内存序确保线程间的正确同步。
摘要由CSDN通过智能技术生成

第五章——内存模型和原子操作

5.1 内存模型

C++所有的对象都和内存位置有关

5.1.1 对象和内存位置

C++程序中数据都是由对象构成

无论是怎么样的类型,都会存储在一个或多个内存位置上。每个内存位置不是标量类型的对象,就是标量类型的子对象

这里有四个需要牢记的原则

  1. 每个变量都是对象,包括其成员变量的对象。
  2. 每个对象至少占有一个内存位置。
  3. 基本类型都有确定的内存位置(无论类型大小如何,即使他们是相邻的,或是数组的一部分)。
  4. 相邻位域是相同内存中的一部分。

5.1.2 对象,内存位置和并发

当两个线程访问不同的内存位置时,不会存在任何问题,当两个线程访问同一个内存位置就要小心了。如果线程不更新数据,只读数据不需要保护或同步。当线程对内存位置上的数据进行修改,就可能会产生条件竞争

为了避免条件竞争,线程就要以一定的顺序执行。第一种方式,使用互斥量来确定访问的顺序。当同一互斥量在两个线程同时访问前锁住,那么在同一时间内就只有一个线程能够访问对应的内存位置另一种是使用原子操作决定两个线程的访问顺序,当多个线程访问同一个内存地址时,对每个访问者都需要设定顺序

当程序对同一内存地址中的数据访问存在竞争,可以使用原子操作来避免未定义行为。当然,这不会影响竞争的产生——原子操作并没有指定访问顺序——而原子操作会把程序拉回到定义行为的区域内。

5.1.3 修改顺序

每一个在C++程序中的对象,都有确定好的修改顺序,在初始化开始阶段确定。大多数情况下,这个顺序不同于执行中的顺序,但是在给定的执行程序中,所有线程都需要遵守这个顺序

如果对象不是一个原子类型,你必须要确保有足够的同步操作,来确定每个线程都遵守了变量的修改顺序。当不同线程在不同序列中访问同一个值时,你可能就会遇到数据竞争或未定义行为。如果你使用原子操作,编译器就有责任去替你做必要的同步。

这一要求意味着,投机执行是不允许的,因为当线程按修改顺序访问一个特殊的输入,之后的读操作,必须由线程返回较新的值,并且之后的写操作必须发生在修改顺序之后。同样的,在同一线程上允许读取对象的操作,要不返回一个已写入的值,要不在对象的修改顺序后再写入另一个值。

5.2 原子操作和原子类型

原子操作是个不可分割的操作。系统的所有线程中,不可能观察到原子操作完成了一半。如果读取对象的加载操作是原子的,那么这个对象的所有修改操作也是原子的,所以加载操作得到的值要么是对象的初始值,要么是某次修改操作存入的值。

另一方面,非原子操作可能会被另一个线程观察到只完成一半。如果这个操作是一个存储操作,那么其他线程看到的值,可能既不是存储前的值,也不是存储的值。如果非原子操作是一个读取操作,可能先取到对象的一部分,然后值被另一个线程修改,然后它再取到剩余的部分,所以它取到的既不是第一个值,也不是第二个值。这就构成了数据竞争,出现未定义行为。

5.2.1 标准原子类型

标准原子类型定义在头文件<atomic>中。这些类型的操作都是原子的,语言定义中只有这些类型的操作是原子的,也可以用互斥锁来模拟原子操作。标准原子类型的实现可能是这样的:它们(几乎)都有一个is_lock_free()成员函数,这个函数可以让用户查询某原子类型的操作是直接用的原子指令(x.is_lock_free()返回true),还是内部用了一个锁结构(x.is_lock_free()返回false)。

原子操作可以替代互斥量,来完成同步操作。如果操作内部使用互斥量实现,那么不可能有性能的提升。所以要对原子操作进行实现,最好使用不基于互斥量的实现

标准库提供了一组宏,在编译时对各种整型原子操作是否无锁进行判别。C++17中,所有原子类型有一个static constexpr成员变量,如果相应硬件上的原子类型X是无锁类型,那么X::is_always_lock_free将返回true。例如:给定目标硬件平台std::atomic<int>无锁,那么std::atomic<int>::is_always_lock_free将会返回true。不过std::atomic<uintmax_t>因为这是一个运行时属性,所以std::atomic<uintmax_t>::is_always_lock_free在该平台编译时可能为 false

宏都有ATOMIC_BOOL_LOCK_FREE , ATOMIC_CHAR_LOCK_FREE , ATOMIC_CHAR16_T_LOCK_FREE , ATOMIC_CHAR32_T_LOCK_FREEATOMIC_WCHAR_T_LOCK_FREEATOMIC_SHORT_LOCK_FREE , ATOMIC_INT_LOCK_FREE , ATOMIC_LONG_LOCK_FREE , ATOMIC_LLONG_LOCK_FREEATOMIC_POINTER_LOCK_FREE它们指定了内置原子类型的无锁状态和无符号对应类型(LLONG对应long longPOINTER对应所有指针类型)。如果原子类型不是无锁结构,那么值为0。如果原子类型是无锁结构,那么值为2。如果原子类型的无锁状态在运行时才能确定,那么值为1

只有std::atomic_flag类型不提供 is_lock_free()。该类型是一个简单的布尔标志,并且在这种类型上的操作都是无锁的。当有一个简单无锁的布尔标志时,可以使用该类型实现一个简单的锁,并且可以实现其他基础原子类型。对std::atomic_flag明确初始化后,做查询和设置(使用test_and_set()成员函数),或清除(使用clear()成员函数)都很容易:无赋值,无拷贝,没有测试和清除,没有任何多余操作。

剩下的原子类型都可以通过特化std::atomic<>得到,并且拥有更多的功能,但不可能都是无锁的

通常,标准原子类型不能进行拷贝和赋值,它们没有拷贝构造函数和拷贝赋值操作符。但是,可以隐式转化成对应的内置类型,所以这些类型依旧支持赋值,可以使用load()store()exchange()compare_exchange_weak()compare_exchange_strong()。它们都支持复合赋值符:+=, -=, *=, |=等等。并且使用整型和指针的特化类型还支持++--操作。当然,这些操作也有功能相同的成员函数所对应:fetch_add(), fetch_or()等等。赋值操作和成员函数的返回值,要么是存储值(赋值操作),要么是操作值(命名函数),这就能避免赋值操作符返回引用。

std::atomic<>类模板不仅仅是一套可特化的类型,作为原发模板也可以使用自定义类型创建对应的原子变量。因为是通用类模板,操作限制为load()store()(赋值和转换为用户类型),exchange()compare_exchange_weak()compare_exchange_strong()

每种函数类型的操作都有一个内存序参数,这个参数可以用来指定存储的顺序

  • Store操作,可选如下内存序:memory_order_relaxed, memory_order_release, memory_order_seq_cst
  • Load操作,可选如下内存序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst
  • Read-modify-write(读-改-写)操作:可选如下内存序:memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst

5.2.2 std::atomic_flag

std::atomic_flag是最简单的原子类型,这个类型的对象可以在两个状态间切换:设置和清除。就是这么简单,只作为构建块存在

std::atomic_flag类型的对象必须被ATOMIC_FLAG_INIT初始化。初始化标志位是“清除”状态。这里没得选择,这个标志总是初始化为“清除”:

std::atomic_flag f = ATOMIC_FLAG_INIT;

这适用于任何对象的声明,是唯一需要以如此特殊的方式初始化的原子类型,但也是唯一保证无锁的类型。首次使用时,需要初始化。如果std::atomic_flag是静态存储的,那么就得保证其是静态初始化的,也就意味着没有初始化顺序问题。

当标志对象已初始化,只能做三件事情:销毁,清除或设置(查询之前的值)。这些操作对应的函数分别是:clear()成员函数和test_and_set()成员函数

clear()test_and_set()成员函数可以指定好内存顺序。clear()是一个存储操作,所以不能有memory_order_acquirememory_order_acq_rel语义,但test_and_set()是一个“读-改-写”操作,可以应用于任何内存顺序。每一个原子操作,默认的内存序都是memory_order_seq_cst

f.clear(std::memory_order_release);  // 1
bool x=f.test_and_set();  // 2

调用clear()①明确要求,使用释放语义清除标志,当调用test_and_set()②使用默认内存序设置表示,并且检索旧值。

不能拷贝构造std::atomic_flag对象,不能将一个对象赋予另一个std::atomic_flag对象。这不是std::atomic_flag特有的属性,而是所有原子类型共有的属性。原子类型的所有操作都是原子的,而赋值和拷贝调用了两个对象,这就就破坏了操作的原子性。这样的话,拷贝构造和拷贝赋值都会将第一个对象的值进行读取,然后再写入另外一个。对于两个独立的对象,这里就有两个独立的操作了,合并这两个操作必定是不原子的。因此,操作就不被允许。

有限的特性使得std::atomic_flag非常适合于作自旋锁。初始化标志是“清除”,并且互斥量处于解锁状态。为了锁

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值