原文链接:并发之(原子操作:atomic)
- 本文先介绍atomic的高层接口:它所提供的操作将使用默认保证,不论内存访问次序如何。这个默认保证提供了顺序一致性,意思是在线程之中atomic操作保证一定“像代码出现的次序”那样地发生
- 然后再介绍atomic的底层接口:带有“放宽之次序保证”的操作
- C++标准库并不区分atomic的高层或底层接口:
- 底层是Hans Boehm说的,他是这个程序库的作者之一
- 某些时候atomic底层接口也被称为weak或relaxed接口,而高层接口被称为normal或strong接口
- 本文只列出了C++部分的原子接口,更多详细内容可以参阅:https://en.cppreference.com/w/cpp/atomic
- gcc/g++编译器提供的原子操作可以参阅:https://blog.csdn.net/qq_41453285/article/details/106591952
一、atomic的使用案例
- 我们先看看在前几篇文章中使用的案例:
- thread1()和thread2()两个函数被不同的线程调用执行
- thread1()函数中对锁住muex,然后将readyFlag设置为true
- thread2()函数会不断地对mutex进行加锁和解锁,等待readyFlag变为true
- 因此,总体来说,就是thread2()不断地循环等待thread1()将某种条件设置为true
bool readyFlag;
std::mutex readyFlagMutex;
void thread1()
{
//做一些thread2需要的准备工作
//...
std::lock_guard<std::mutex> lg(readyFlagMutex);
readyFlag = true;
}
void thread2()
{
//等待readyFlag变为true
{
std::unique_lock<std::mutex> ul(readyFlagMutex);
//如果readyFlag仍未false,说明thread1还没有锁定,那么持续等待
while (!readyFlag)
{
ul.unlock();
std::this_thread::yield();
std:this_thread::sleep_for(std::chrono::milliseconds(100));
ul.lock();
}
}//释放lock
//在thread1锁定之后,做相应的事情
}
- 上面的演示案例我们为了保证程序的并发性,使用了mutex对共享数据进行保护访问。但是我们也可以使用原子操作来对共享数据进行操作。代码修改如下:
/原子变量,其类型为bool类型
std::atomic<bool> readyFlag(false);
void thread1()
{
//原子地将readyFlag设置为true
readyFlag.store(true);
}
void thread2()
{
//每次原子地判断readyFlag为true还是false
//load()返回readyFlag中的值
while (!readyFlag.load())
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
二、生产者消费者演示案例
#include <iostream>
#include <thread>
#include <future>
#include <chrono>
#include <atomic>
using namespace std;
long data;
std::atomic<bool> readyFlag(false);
void provider()
{
std::cout << "<return>" << std::endl;
std::cin.get();
::data = 42;
//原子地设置readyFlag
readyFlag.store(true);
}
void consumber()
{
//原子的检查readyFlag为true还是false
while (!readyFlag.load())
{
std::cout.put('.').flush();
this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "\nvalue:" << ::data << std::endl;
}
int main()
{
auto p = std::async(std::launch::async, provider);
auto c = std::async(std::launch::async, consumber);
this_thread::sleep_for(std::chrono::minutes(1));
}
三、atomic的高层接口
- atomic<>是一个模板,可以适用于任何一般类型上。另外特化版本针对于bool、所有整数类型以及pointer:
- atomic的高层操作
- 下图列出了atomic支持的高层操作。如果可能它们将直接映射至相关的CPU命令
- 相关说明:
- triv列表示:针对std::atomic<bool>及“其他普通类型之atomic”提供的操作
- int type列表示:针对std::atomic<>且使用整数类型而提供的操作
- ptr type列表示:针对std::atomic<>且使用pointer类型而提供的操作
- 一些知识点的补充:
- 一般而言,这些操作获得的是copy而不是reference
- Default构造函数并未能够完全将object初始化。Default构造函数之后唯一合法的操作就是调用atomic_init()完成初始化
- 接受相关类型值的那个构造函数并不是atomic
- 所有函数,除了构造函数,都被重载为volatile和non-volatile两个版本。例如,atomic<int>之内声明了以下的赋值操作
-
is_lock_free()
- 借助该函数,你可以检查atomic类型内部是否由于使用lock才成为atomic
- 如果不是,你的硬件就是拥有对atomic操作的固有支持(那是“在signal handler内使用atomic”的一个必要条件)
- compare_exchange_strong()、compare_exchange_weak()
- 这两个函数都是所谓compare-and-swap(CAS)操作
- CPU常常提供这个atomic操作用以比较“某内存区内容”和“某给定值”,并且唯有在它们相同时才将该内存区内容更新为另一给定的新值
- 这可保证新值乃根据最新信息计算出来。这样的效果有点像以下的伪代码:
- 因此,如果数值就在这一段时间里被另一线程更新,它会返回false并以expected承载新值
- 上述两种形式中,weak形式有可能出现假失败,亦即期望值出现它仍然返回false。但是weak形式有时候比strong形式更高效
四、atomic的C-Style接口
- 针对C++的atomic提案,C有一份对应提案,它应该提供相同语义但是(当然)不使用诸如template、reference和member function等C++特性。整个atomic接口有一个C-style对等品,称为C standard的一份扩充
例如
-
你可以声明atomic_bool取代atomic<bool>,并替换store()和load,改用global函数,后者接受一个pointer指向对象
- C另有一个接口,采用_Atomic和_Atomic(),因此C-style接口一般只用于“需要在C和C++之间保持兼容”的代码身上
C-style的atomic数据类型
- 然而在C++中使用C-style atomic类型并不罕见
- 下图列出了最重要的atomic类型名称,除此之外还有更多,适用于较不常见的类型,例如atomic_int_fast32_t乃是针对atomic<int_fast32_t>类型的
- 针对shared_ptr还提供了特殊的atomic操作。原因是注入atomic<shared_ptr<T>>这样的声明不被允许,因为shared_ptr并非可被复制。Atomic操作遵循C-style接口的命名规范
五、atomic的底层接口
- atomic底层接口意味着使用atomic操作时不保证顺序一致性。因此编译器和硬件有可能(局部)重排对atomic的处理次序
- atomic底层操作如下图所示:
- 如图所示,load、store、CAS等操作多提供了一个参数,允许你额外传递一个内存次序实参
- 另外若干函数被额外提供出来,用以手动控制内存访问。例如atomic_thread_fence()和atomic_signal_fence()被用来手动编写fence,那是“内存访问重安排”的界线
演示说明
- 在文章上面生产者与消费者的演示案例如下:
long data;
std::atomic<bool> readyFlag(false);
void provider()
{
std::cout << "<return>" << std::endl;
std::cin.get();
::data = 42;
readyFlag.store(true);
}
void consumber()
{
while (!readyFlag.load())
{
std::cout.put('.').flush();
this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "\nvalue:" << ::data << std::endl;
}
- 其中生产者rpovider()函数负责供应数据:
- 消费者负责消费此数据
- 我们使用默认的内存处理次序,于是保证顺序一致性。事实上,我们真正的调用如下图所示,其中调用函数都有一个默认实参std::memory_order_seq_cst(该参数用来执行内存次序,是成员函数的默认值)
- 如果我们手动指定另一种内存处理次序,我们就可以削弱对次序的保证:
- 在这个例子中我们可以要求provider不推迟atomic store之后的操作,而consumer不会在atomic之后带来向前操作
- 代码如下:
- 如果放宽(relaxing)atomic操作次序上的所有约束,会导致不明确的行为。代码如下,原因在于:
- std::memoey_order_relaxed不保证此前所有内存操作在store发挥效用前都变得“可被其他线程看见”
- 因此,provider线程有可能在设置ready flag之后才写data,于是consumer线程有可能在data正被写时读它,这就会造成data race
- 你也修改一下代码,让data成为atomic并以std::memoey_order_relaxed作为内存次序。代码如下:
- 严格来说,这并非不明确行为,因为我们并未遭遇data race。然而这却也难以预期般地运行,因为data的结果值有可能(尚未)不是42(memory order对此仍无保证)。其行为会导致data拥有一个无法具体说明的值
- 只有当我们在atomic变量上读/写动作彼此独立,memory_order_relaxed才能显出用途。例如一个global计时器,不同的线程可能会对它累加或递减,而我们只需在所有线程终结之后获得该计数器的最终值即可
- 本文没有对底层接口详细介绍,因为它们是为真正的并发专家或想成为专家人准备的
- 如果想要仔细研究可以参与C++ Concurrency in Action的第5章和第7章。或其他资料