C++ 标准库 并发之 原子操作:atomic

原文链接:并发之(原子操作:atomic)

  • 本文先介绍atomic的高层接口:它所提供的操作将使用默认保证,不论内存访问次序如何。这个默认保证提供了顺序一致性,意思是在线程之中atomic操作保证一定“像代码出现的次序”那样地发生
  • 然后再介绍atomic的底层接口带有“放宽之次序保证”的操作
  • C++标准库并不区分atomic的高层或底层接口:
    • 底层是Hans Boehm说的,他是这个程序库的作者之一
    • 某些时候atomic底层接口也被称为weak或relaxed接口,而高层接口被称为normal或strong接口

一、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章。或其他资料
  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值