无锁的原子操作 & CAS

加锁或消耗资源,会造成线程阻塞。 那在并发处理中,有没有不加锁的方式,来达到线程安全的?

1. 什么是CAS原子操作

在研究无锁之前,我们需要首先了解一下CAS原子操作——Compare & Swap ,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。

操作系统里面关于“原子操作”的概念,一个操作是原子的(atomic),表示这个操作所处的层级的更高层不能发现其内部实现与结构。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者只执行部分。原子操作,意为这一组合操作,就是最小粒度了。

CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。 这也是乐观锁的一种实现方式

2. CAS 的优缺点

优点

没有加锁,那么加锁的消耗就都不存在。多线程抢占锁、阻塞而导致的用户态和内核态切换也不存在。 近乎0消耗

缺点

1 ABA 问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

解决方法:增加版本号的判断。(也就是乐观锁的另一种实现)每次变量更新时把版本号+1,A-B-A就变成了1A-2B-3A。JDK5之后的atomic包提供了AtomicStampedReference来解决ABA问题,它的compareAndSet方法会首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志。全部相等,才会以原子方式将该引用、该标志的值设置为更新值。

2 循环时间长开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

3.CAS 在各个平台下的实现

3.1 Linux GCC 支持的 CAS

GCC4.1+版本中支持CAS的原子操作(完整的原子操作可参看 GCC Atomic Builtins)

bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)

3.2 Windows支持的CAS

在Windows下,你可以使用下面的Windows API来完成CAS:(完整的Windows原子操作可参看MSDN的InterLocked Functions)

InterlockedCompareExchange ( __inout LONG volatile *Target,
                                __in LONG Exchange,
                                __in LONG Comperand);

3.3 C++ 11支持的CAS

C++11中的STL中的atomic类的函数可以让你跨平台。(完整的C++11的原子操作可参看 Atomic Operation Library)

template< class T >
bool atomic_compare_exchange_weak( std::atomic<T>* obj,
                                   T* expected, T desired );
template< class T >
bool atomic_compare_exchange_weak( volatile std::atomic<T>* obj,
                                   T* expected, T desired );

4.Atomic 原子整数类

标准原子类型全部定义于头文件 atomic 中,这些类型的操作都是原子的,但是其内部实现可能使用原子操作或互斥量模拟,所以原子操作可以替代互斥量完成同步操作,但是如果内部使用互斥量实现那么不会有性能提升

这些原子类都禁用了拷贝构造函数和赋值构造函数,原因是原子读和原子写是2个独立原子操作,无法保证2个独立的操作加在一起仍然保证原子性。

atomic 原子操作支持bool、int、char等数据数据类型,但是不支持浮点数类型 

atomic<T>提供了常见且容易理解的方法:

  1. store  存储,也就是写
  2. load   读取,也就是读
  3. exchange   赋值为新值,并返回旧值的拷贝
  4. compare_exchange_weak
  5. compare_exchange_strong

compare_exchange_strong()成员函数。它是强CAS ,它保证不会出现伪失败它比较原子变量的当前值和一个期望值,当两值相等时,存储提供值。当两值不等,期望值就会被更新为原子变量中的值。“比较/交换”函数值是一个bool变量,当返回true时执行存储操作,当false则更新期望值。

对于compare_exchange_weak()函数,是 弱CAS ,可能出现伪失败,当原始值与预期值一致时,存储也可能会不成功;在这个例子中变量的值不会发生改变,并且compare_exchange_weak()的返回是false。这可能发生在缺少独立“比较-交换”指令的机器上,当处理器不能保证这个操作自动的完成——可能是因为线程的操作将指令队列从中间关闭,并且另一个线程安排的指令将会被操作系统所替换(这里线程数多于处理器数量)。这被称为“伪失败”(spurious failure),因为造成这种情况的原因是时间,而不是变量值。

因为compare_exchange_weak()可以“伪失败”,所以这里通常使用一个循环:

bool expected=false;
extern atomic<bool> b; // 设置些什么
while(!b.compare_exchange_weak(expected,true) && !expected);

在这个例子中,循环中expected的值始终是false,表示compare_exchange_weak()会莫名的失败。

5.std::atomic<>主要类的模板

主模板的存在,在除了标准原子类型之外,允许用户使用自定义类型创建一个原子变量。不是任何自定义类型都可以使用std::atomic<>的:需要满足一定的标准才行。

为了使用std::atomic<UDT>(UDT是用户定义类型),这个类型必须有拷贝赋值运算符。这就意味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作。不仅仅是这些,自定义类型中所有的基类和非静态数据成员也都需要支持拷贝赋值操作。这(基本上)就允许编译器使用memcpy(),或赋值操作的等价操作,因为它们的实现中没有用户代码。

还有一个要求:这个类型必须是“位可比的”(bitwise equality comparable)。这与对赋值的要求差不多;你不仅需要确定,一个UDT类型对象可以使用memcpy()进行拷贝,还要确定其对象可以使用memcmp()对位进行比较。之所以要求这么多,是为了保证“比较/交换”操作能正常的工作。

6.原子操作内存顺序

C++ 标准定义了六种原子操作的内存顺序,它们代表了四种种内存模型: 顺序一致性 (Sequentially-consistent ordering)、 获取-释放序 (Acquire-Release ordering)、 消费-释放序 (Consume-Release ordering) 和 自由序 (Relaxed ordering),以下列出所有内存序并按照一致性要求由弱到强排列

释义
memory_order_relaxed 自由序内存模型,没有同步或顺序制约,仅对此操作要求原子性
memory_order_consume 当前线程中依赖于当前加载的该值的读或写不能被重排到此加载前;其他释放同一原子变量的线程对数据依赖变量的写入,为当前线程所可见
memory_order_acquire 当前线程中读或写不能被重排到此加载前;其他释放同一原子变量的线程的所有写入,能为当前线程所见
memory_order_release 当前线程中读或写不能被重排到此存储后;当前线程的所有写入,对其他获取同一原子变量的线程可见,对原子变量的带依赖写入变得对其他消费同一原子对象的线程可见
memory_order_acq_rel 所有释放同一原子变量的线程的写操作在当前线程修改前可见,当前线程改操作对其他获取同一原子变量的线程可见
memory_order_seq_cst 顺序一致性内存模型,原子操作的默认内存序,所有线程以同一顺序观测到所有修改

带标签 memory_order_relaxed 的原子操作,它们不会在同时的内存访问间强加顺序,它们只保证原子性和修改顺序一致性,典型应用场景为 计数器自增

// x = {0}, y = {0}
// 线程 1
r1 = y.load(::std::memory_order_relaxed); // A
x.store(r1, ::std::memory_order_relaxed); // B
// 线程 2
r2 = x.load(::std::memory_order_relaxed); // C
y.store(42, ::std::memory_order_relaxed); // D
assert(r1 != 42 || r2 != 42); // E

A 先序于 B,C 先序于 D,但 D 在 y 上的副效应可能对 A 可见,同时 B 在 x 上的副效应可能对 C 可见,所以允许 E 断言失败。

另外自由序中,当前线程可能看到别的线程的更新,但是更新频率不一定是均匀的,但其值一定是递增的。详细例子可以查看 C++ Concurrency in Action (2rd) 中电话计数员的例子。

若线程 A 中的原子存储带标签 memory_order_release 而线程 B 中来自同一对象的读取存储值的原子加载带标签 memory_order_consume,则线程 A 视角中先发生于原子存储的所有内存写入,会在线程 B 中该加载操作所携带依赖进入的操作中变成可见副效应,即一旦完成原子加载,则保证线程 B 中使用从该加载获得的值的运算符和函数能见到线程 A 写入内存的内容。同步仅在释放和消费同一原子对象的线程间建立,其他线程能见到与被同步线程的一者或两者相异的内存访问顺序。

此顺序的典型使用情景,涉及对 很少被写入 的数据结构的同时时读取,和 有指针中介发布 的发布者-订阅者情形,即当生产者发布消费者能通过其访问信息的指针之时:无需令生产者写入内存的所有其他内容对消费者可见。这种场景的例子之一是 rcu 解引用

::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 携带依赖)
}

若线程 A 中的一个原子存储带标签 memory_order_release,而线程 B 中来自同一变量的原子加载带标签 memory_order_acquire,则从线程 A 的视角先发生于原子存储的所有内存写入,在线程 B 中成为可见副效应,即一旦原子加载完成保证线程 B 能观察到线程 A 写入内存的所有内容。此顺序的典型使用场景是 互斥量

::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); // 断言成功
}

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

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;
    }
}
assert(z.load() != 0); // 断言成功

全序列顺序在所有多核系统上要求完全的内存栅栏 CPU 指令,这可能成为性能瓶颈,因为它强制受影响的内存访问传播到每个核心。

栅栏

栅栏操作会对内存序列进行约束,使其无法对任何数据进行修改,典型的做法是与使用 memory_order_relaxed 约束序的原子操作一起使用。栅栏属于全局操作,执行栅栏操作可以影响到在线程中的其他原子操作,因为这类操作就像画了一条任何代码都无法跨越的线一样,所以栅栏操作通常也被称为 内存栅栏 (memory barriers)。我们以下代码与获取-释放序代码效果相同

::std::atomic<::std::string*> ptr;
int data;
void producer() {
    ::std::string* p  = new ::std::string("Hello");
    data = 42;
    ::std::atomic_thread_fence(::std::memory_order_release);
    ptr.store(p, ::std::memory_order_relaxed);
}
void consumer() {
    ::std::string* p2;
    while (!(p2 = ptr.load(::std::memory_order_relaxed)));
    ::std::atomic_thread_fence(::std::memory_order_acquire);
    assert(*p2 == "Hello"); // 断言成功
    assert(data == 42); // 断言成功
}

 

文章参考: https://blog.ginshio.org/2020/cpp_concurrency_atomic/

文章参考: http://shouce.jb51.net/cpp_concurrency_in_action/content/chapter5/5.3-chinese.html

posted @ 2021-12-23 22:43  Clovran-Wong  阅读(927)  评论(0编辑  收藏  举报