C++多线程 第五章 C++内存模型和原子类型

第五章 C++内存模型和原子类型


无论其他语言如何,C++是一门系统编程语言.委员会希望不再需要一个比C++低级的语言.

内存模型基础

C++程序中所有的数据均是由 对象(object) 组成的.

C++标准定义对象为"存储区域",经管它会为这些对象分配属于它们的类型和生存期.

无论什么类型,对象均被存储在一个或多个内存位置中.每个这样的内存位置都是一个标量类型的对象.

如果两个线程访问不同的内存位置,是没有问题的.但是如果两个线程访问相同的内存位置,那么将面临数据竞争的危险.

为了避免数据竞争,可行的方法有两种:

  • 使用互斥元
  • 在内存地址使用 原子操作(atomic) 的同步特性

数据竞争意味着未定义行为,因而应当得到谨慎处理.

通过原子操作来访问内存位置并不组织竞争本身,但是可以避免未定义行为.

C++程序中每个对象,都具有一个确定的 修改顺序(modification order).

如果不同线程看到一个变量的不同顺序值,就会有数据竞争和未定义行为.

原子操作及类型

原子操作(atomic operation) 是一个不可分割的操作.

如果对象值的载入操作是 原子的(atomic),那么对其所有修改也是原子的.

标准原子类型可以在 <atomic> 头文件中找到,这种类型上的所有操作都是原子的.

大部分的原子类型通过 std::atomic<> 类模板的特化来访问,其不一定无锁.

而std::atomic_flag是一个特例,其没有is_lock_free()成员函数,仅仅为一个简单的布尔标识.

考虑到原子类型被添加到C++的历史,std::atomic中同样存在一些特化的类型.

原子类型 对应的特化
atomic_bool std::atomic<bool>
atomic_char std::atomic<char>
atomic_schar std::atomic<signed char>
atomic_uchar std::atomic<unsigned char>
atomic_int std::atomic<int>
atomic_short std::atomic<short>
atomic_long std::atomic<long>
atomic_llong std::atomic<long long>
atomic_wchar_t std::atomic<wchar_t>
原子typedef 对应标准库typedef
atomic_size_t size_t

有一个简单的模式:对于标准的typedef T或typename T,其对应的原子类型与之同名并带又atomic_前缀:atomic_T

传统意义上,标准原子类型是不可复制且不可赋值的,因为它们没有拷贝构造函数和拷贝赋值运算符.

但是,事实上,它们确实支持从相应内置类型赋值进行隐式转换并复制,以及load(),store()等成员函数.它们甚至在适当的地方还支持+=,-=,*=,/=等复合赋值运算符.整型还支持++,--等单目运算符.

在原子类型上的每一个操作均具有一个可选的内存顺序参数,它可以用来指定所需的内存顺序语义.

内存顺序将原子类型上的运算主要分为三种:

  • 存储(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顺序

实际上还有一种新的操作:比较/交换(compare_exchange)

比较/交换操作是使用原子类型的基石:它比较原子变量值与所提供的期待值,如果二者相等则存储期望值;不等则更新期望值为实际值. 其返回值为bool型,执行了存储则为true.

比较/交换操作主要以compare_exchange_weak()与compare_exchange_strong()成员函数出现.

  • std::atomic::compare_exchange_strong: 比较期望值与std::atomic对象返回值,若相同则将对象值替换为新值,并返回true;否则不交换且返回false.
  • std::atomic::compare_exchange_weak: 与compare_exchange_strong类似,但是其比较/交换操作可能会失败,尽管对象值与期望值相同.

compare_exchange_weak()即使原始值等于期望值也可能出现存储不成功,这种情况下变量值将不变.

这是主要因为 伪失败 的原因.

伪失败(spurious failure): 在缺少单个比较并交换指令的机器上,处理器可能因为执行操作的线程在必须的指令序列中间被切换出来而导致无法确保操作被完成.
同时,该线程在多线程多余处理器数量的操作系统中被另一个计划中的线程代替.

由于compare_exchange_weak()可能伪失败,所以它通常必须用在循环中:

bool expected = false;
extern atomic<bool>b;
while(!b.compare_exchange_weak(expected,true)&&!expected);

另一方面,仅当实际值不等于excepted值时,compare_exchange_strong才保证返回false.这样可以消除对循环的需求.

所有操作的默认顺序为:memory_order_seq_cst.

std::atomic_flag

std::atomic_flag是最简单的标准原子类型,它代表一个布尔标志.

该布尔标志可以是两种状态之一:设置或清除.

类型为std::atomic_flag的对象必须用ATOMIC_FLAG_INIT初始化,这将使该标志初始化为清除状态.

这是唯一需要针对初始化进行特殊处理的原子类型,同时也是唯一保证无锁的类型.

一旦标识对象初始化完成,你只能对它做三件事:

  • 销毁(~atomic_flag())
  • 清除(clear())
  • 设置并查询先前值(test_and_set())

其中clear()是一个存储操作,test_and_set()为一个读-修改-写操作.

有限的特性集使得std::atomic_flag理想地适合用于自旋锁互斥元.

下面是使用std::atomic_flag的自旋锁示例:

class spinlock_mutex{
private:
    std::atomic_flag flag;
public:
    spinlock_mutex() :flag(ATOMIC_FLAG_INIT) { return; }
    void lock()
    {
        while (flag.test_and_set(std::memory_order_acquire));
        return;
    }
    void unlock()
    {
        flag.clear(std::memory_order_release);
        return;
    }
};

下面提供一个结合该自旋锁与std::lock_guard的例子:

#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>

class spinlock_mutex{
private:
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    spinlock_mutex() { return; }
    void lock()
    {
        while (flag.test_and_set(std::memory_order_acquire));
        return;
    }
    void unlock()
    {
        flag.clear(std::memory_order_release);
        return;
    }
};

int val = 0;
spinlock_mutex lk;

void func_1()
{
    for (int i = 0; i < 10; i++) {
        std::lock_guard<spinlock_mutex>guard(lk);
        std::cout << "from thread_1, the value is: " << ++val << std::endl;
    }
    return;
}

void func_2()
{
    for (int i = 0; i < 10; i++) {
        std::lock_guard<spinlock_mutex>guard(lk);
        std::cout << "from thread_2, the value is: " << ++val << std::endl;
    }
    return;
}

int main()
{
    std::thread t_1(func_1);
    std::thread t_2(func_2);

    t_1.join();
    t_2.join();

    return 0;
}

不过,由于对std::atomic_flag的限制,其甚至不能用作一个通用布尔标识.

我劝你别用.

std::atomic

最基本的原子整数类型是std::atomic<bool>.

这是一个比std::atomic_flag功能更全的布尔标志.虽然它仍然是不可拷贝构造和拷贝赋值的.

对于原子类型来说,它们所支持的赋值操作符返回值而不是引用.

std::atomic<bool>主要支持三种操作:

  • store():存储操作
  • load():载入操作
  • exchange():读-修改-写操作

然而,实际上,std::atomic<bool>还具有一种另类的操作.

这就是前面提到的比较/交换操作.通过compare_exchange_strong()和compare_exchange_weak()实现.

std::atomic<bool>和std::atomic_flag之间的另外一个区别是std::atomic<bool>可能不是无锁的.

当这一特性重要时,使用is_lock_free()对其进行检查.

std::atmoic<T*>

对于某种类型T的指针的原子形式为std::atomic<T*>.

std::atomic<T*>相较提供的新操作是指针算数操作,通过fetch_add()与fetch_sub()成员函数提供.

为了探究这类操作使用的效果,我们通过下面一个例子说明:

#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>

int arr[10];

int main()
{
    for (int i = 0; i < 10; i++)
        arr[i] = 2 * i + 1;
    std::atomic<int*>pa;
    pa = arr;
    for (int i = 0; i < 10; i++)
        std::cout << "pa:" << *(pa++) << std::endl;

    pa.store(arr);
    for (int i = 0; i < 10; i++)
        std::cout << "pa:" << *(pa.fetch_add(1)) << std::endl;
    return 0;
}

事实上,运行后,我们会发现,两种方式使用原子变量是完全等效的.

该类操作也称为 交换与添加,它是一个原子的读-修改-写操作.

由于fetch_add()和fetch_sub()都是读-修改-写操作,所以它们可以具有任意的内存顺序标签,也可以参与到释放序列中.

std::atomic<>

除了标准的原子类型,初级模板的存在允许用户创建一个用户定义的类型的原子变种.

然而,该类型必须满足一定的准则:这种类型必须有一个 平凡的(trivial) 拷贝赋值运算符.
不仅如此,其每个积累和非静态数据成员也必须有一个平凡拷贝赋值运算符.
最后,该类型必须是按位相等可比较的.不仅要能用memcpy()复制对象,而且要能用memcmp()比较是否相等.

上面的规则可以联系到一条准则:不要在锁定范围之外向受保护数据通过其作为参数传递给用户.

也是因为上面的原因,当你使用std::atomic<float>与std::atomic<double>时,memcpy与memcmp的结果可能是意外的.

这导致了一个结果: 请注意,浮点值没有原子的算术操作.

而且,这些限制同样意味着,你不能创建一个std::atomic<std::vector<int>>,但是你可以把std::atomic与计数器,标识符,指针,简单数据元素的类一同使用.

越是复杂的数据结构,你就越有可能想在它上面进行简单的赋值和比较外的操作. 如果情况这样,你最好还是使用std::mutex. 这样可以确保其得到适当的保护.

如果你的UDT和一个int或一个void*大小相同,大部分常见的平台能够为std::atomic<UDT>使用原子指令.这些平台通常支持一个与compare_exchange_xxx函数对应的所谓的 双字比较和交换(double-word-compare-and-swap)

操作 atomic_flag atomic<bool> atomic<T*> atomic<integral> atomic<other>
test_and_set
clear
is_lock_free
load
store
exchange
compare_exchange_weak
compare_exchange_strong
fetch_add,+=
fetch_sub,-=
fetch_or,|=
fetch_and,&=
fetch_xor,^=
++,--

自由函数

到目前为止,我们涉及到原子类型上操作的内容基本上仅涉及到成员函数.然而实际上,各种原子类型上的所有操作也都有等效的非成员函数.

对于大部分非成员函数,其都是以相应的成员函数来命名的.他们通常命名规则有:

  • 带有atomic_前缀(例如std::atomic_load())
  • 在有机会指定内存顺序标签的地方,存在带有_explicit后缀和额外参数作为内存顺序的标签.
    (例如std::aotmic_store_explicit())

所有这类的非成员函数被称为原子操作的自由函数.然而所有自由函数接受一个指向原子对象的指针作为参数.

自然函数被设计为兼容C语言,所以它们在所有情况下使用指针而不是引用.

C++标准库还提供了为了以原子行为访问std::shared_ptr<>实例的自由函数,可用的原子操作包括:

  • 载入(load)
  • 存储(store)
  • 交换(exchange)
  • 比较/交换(compare/exchange)

这些操作以标准原子类型上的相同操作的重载形式被提供,接受std::shared_ptr<>*作为第一个整数.

提醒:因为C++20已经弃用了atomic关于std::shared_ptr<>的自由函数,因而需要#define _SILENCE_CXX20_OLD_SHARED_PTR_ATOMIC_SUPPORT_DEPRECATION_WARNING才能使用

下面是一个使用atomic_*<shared_ptr<>*>自由函数的例子:

#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>

std::shared_ptr<int>p{new int(20)};
void process_global_data()
{
    std::shared_ptr<int>local = std::atomic_load(&p);
    *p = 100;
    std::cout << "now the data is :" << *p << std::endl;
    return;
}

void update_global_data()
{
    std::shared_ptr<int>local(new int(400));
    std::atomic_store(&p, local);
    std::cout << "now the data is :" << *p << std::endl;
    return;
}

int main()
{
    for (int i = 0; i < 10; i++) {
        std::thread t1(process_global_data);
        std::thread t2(update_global_data);

        t1.join();
        t2.join();
    }
    return 0;
}

由于cout本身不是原子操作,所以运行结果不同很正常.但是没有异常抛出已经提示我们其shared_ptr作为原子量.

同步操作与强制顺序

  • std::memory_order_relaxed: 松弛操作.
  • std::memory_order_consume: 消费操作,当前线程中依赖当前加载值的读和写不能重排至此加载之前.
  • std::memory_order_acquire: 获得操作,当前线程中读或写不能被重排到此加载之前.
  • std::memory_order_release: 释放操作,当前线程中读或写不能重排到此存储之后.
  • std::memory_order_acq_rel: 既是获得操作又是释放操作.
  • std::memory_order_seq_cst: 载入为获得操作,存储为释放操作,读-修改-写既是获得操作又是存储操作.

synchronizes-with关系

synchronizes-with关系: 是你只能在原子类型上的操作之间得到的东西.

其基本思想为:在一个变量x上的一个被适当标记的原子写操作W,
与在x上的一个被适当标记的通过写入(W);
或任意线程在x上的后续原子写操作;
或一系列的原子的读-修改-写操作来读取所存储的值的原子读操作同步.
随后,通过第一个线程读取的值是通过W写的值.

如果线程A存储一个值而线程B读取该值,那么线程A和线程B中的载入之间存在一种synchronizes-with关系.

happens-before关系

happens-before关系: 是程序中操作顺序的基本构件,它指定了哪些操作看到其他操作的结果.

对于单个线程,它是直观的,如果一个操作排在另一个操作之前,那么该操作就发生于另一个操作之前.这就意味着, 源代码中,如果一个操作A发生于另一个操作B之前的语句里,那么A就发生于B之前.

一般来说,单条语句中的操作是非顺序的,而且也没有sequenced-before及happens-before.

这也是一个可传递关系,如果A线程间发生于B之前,B线程间发生于C之前,那么A线程间发生于C之前.

原子操作的内存顺序

有六种内存顺序选项可以应用到原子类型上的操作:

  • memory_order_relaxed
  • memory_order_consume
  • memory_order_acquire
  • memory_order_release
  • memory_order_acq_rel
  • memory_order_seq_cst

一般默认的内存顺序选项都是memory_order_seq_cst.

实际上这六种操作对应了三种模型:

  • 顺序一致模型(sequentially consistent): memory_order_seq_cst
  • 获得-释放模型(acquire-release): memory_order_consume,memory_order_acquire,memory_order_release,memory_order_acq_rel
  • 松散顺序模型(relaxed): memory_order_relaxed

顺序一致模型(sequentially consistent)

所有原子类型实例上的操作是顺序一致的,则所有这些操作就好像由单个线程以某种特定顺序执行一样.

在顺序一致模型中,所有线程看到操作的相同顺序.这意味着,操作不能被重排.
如果你的代码在一个线程中有一个操作在另一个之前,其顺序必须对所有其他的线程可见.

下面是一个我从书上抄来的例子,我觉得这个例子很烂.

但是它说它通过assert的不会被触发说明了内存一致性模型的特征,所以还是看看吧.

这个例子最主要为我们揭示了:x与y不会同时被store,也就是说,线程间的执行是交叉的:

#include <thread>
#include <atomic>
#include <assert.h>

std::atomic<bool> x, y;
std::atomic<int>z;

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;
}
int main()
{
    x = false; y = false; z = 0;
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);

    a.join(); b.join();
    c.join(); d.join();
    assert(z.load() != 0);

    return 0;
}

非顺序一致的内存顺序

在非顺序一致的内存顺序中,事件不再有单一的全局顺序.这意味着不同线程可能看到相同操作的不同方面.

在没有其他的顺序约束时,唯一的要求是所有的线程对每个独立变量的修改顺序达成一致.

不同变量上的操作可以以不同的顺序出现在不同的线程中,前提是所能看到的值与所有附加的顺序约束是一致的.

松散顺序

以松散顺序执行的原子类型上的操作不参与synchronizes-with关系.单线程中的同一个变量的操作仍然服从happens-before关系,但相对于其他线程的顺序几乎没有要求.

唯一的要求是,从同一个线程对单个原子变量的访问不能被重排.

试着比较下面两个例子的结果:

#include <iostream>
#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool>x, y;
std::atomic<int>z;

void write_x()
{
    x.store(true, std::memory_order_relaxed);
}
void write_y()
{
    y.store(true, std::memory_order_relaxed);
}

void read_y_then_x()
{
    while (!y.load(std::memory_order_relaxed));
    if (x.load(std::memory_order_relaxed))
        ++z;
}

const int TURNS = 1000;

int main()
{
    for (int i = 0; i < TURNS; i++) {
        x = false; y = false; z = 0;

        std::thread a(write_x);
        std::thread b(write_y);
        std::thread c(read_y_then_x);
        a.join(); b.join(); c.join();

        if (z == 0)
            std::cout << "i:" << i << ",break up" << std::endl;
    }
    return 0;
}
#include <iostream>
#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool>x, y;
std::atomic<int>z;

void write_x_then_y()
{
    x.store(true, std::memory_order_relaxed);
    y.store(true, std::memory_order_relaxed);
}

void read_y_then_x()
{
    while (!y.load(std::memory_order_relaxed));
    if (x.load(std::memory_order_relaxed))
        ++z;
}

const int TURNS = 1000;

int main()
{
    for (int i = 0; i < TURNS; i++) {
        x = false; y = false; z = 0;

        std::thread a(write_x_then_y);
        std::thread b(read_y_then_x);
        a.join(); b.join();

        if (z == 0)
            std::cout << "i:" << i << ",break up" << std::endl;
    }
    return 0;
}

然后,为了进一步说明,提供另一个例子.

这个例子中有3个线程负责写内存使原子量增加,2个线程只负责读内存.

最后通过主函数将其格式化输出.

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int>x(0), y(0), z(0);
std::atomic<bool>go(false);
unsigned const loop_count = 10;

struct read_values {
    int x, y, z;
};

read_values values1[loop_count],
values2[loop_count],
values3[loop_count],
values4[loop_count],
values5[loop_count];

void increment(std::atomic<int>* var_to_inc, read_values* values)
{
    while (!go)
        std::this_thread::yield();
    for (int i = 0; i < loop_count; i++) {
        values[i].x = x.load(std::memory_order_relaxed);
        values[i].y = y.load(std::memory_order_relaxed);
        values[i].z = z.load(std::memory_order_relaxed);
        var_to_inc->store(i + 1, std::memory_order_relaxed);
        std::this_thread::yield();
    }
    return;
}

void read_vals(read_values* values)
{
    while (!go)
        std::this_thread::yield();
    for (int i = 0; i < loop_count; ++i) {
        values[i].x = x.load(std::memory_order_relaxed);
        values[i].y = y.load(std::memory_order_relaxed);
        values[i].z = z.load(std::memory_order_relaxed);
        std::this_thread::yield();
    }
    return;
}

void print(read_values* v)
{
    for (int i = 0; i < loop_count; ++i) {
        if (i)
            std::cout << ",";
        std::cout << "(" << v[i].x << "," << v[i].y << "," << v[i].z << ")";
    }
    std::cout << std::endl;
    return;
}

int main()
{
    std::thread t1(increment, &x, values1);
    std::thread t2(increment, &y, values2);
    std::thread t3(increment, &z, values3);
    std::thread t4(read_vals, values4);
    std::thread t5(read_vals, values5);

    go = true;
    t5.join(); t4.join(); t3.join(); t2.join(); t1.join();
    print(values1); print(values2); print(values3);
    print(values4); print(values5);

    return 0;
}

一个可能的运行结果为:

(0,0,0),(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5),(6,6,6),(7,7,7),(8,8,8),(9,9,9)
(1,0,0),(2,1,1),(3,2,2),(4,3,3),(5,4,4),(6,5,5),(7,6,6),(8,7,7),(9,8,8),(10,9,9)
(1,1,0),(2,2,1),(3,3,2),(4,4,3),(5,5,4),(6,6,5),(7,7,6),(8,8,7),(9,9,8),(10,10,9)
(2,1,1),(3,2,2),(4,3,3),(5,4,4),(6,5,5),(7,6,6),(8,7,7),(9,8,8),(10,9,9),(10,10,10)
(2,1,1),(3,2,2),(4,3,3),(5,4,4),(6,5,5),(7,6,6),(8,7,7),(9,8,8),(10,9,9),(10,10,10)

而将其中的内存模型转换为std::memory_order_seq_cst,则有可能出现下面这样的结果:

(0,0,0),(1,0,0),(2,0,0),(3,0,0),(4,0,0),(5,0,0),(6,0,0),(7,0,0),(8,0,0),(9,0,0)
(10,0,0),(10,1,0),(10,2,0),(10,3,0),(10,4,0),(10,5,0),(10,6,0),(10,7,0),(10,8,0),(10,9,0)
(10,10,0),(10,10,1),(10,10,2),(10,10,3),(10,10,4),(10,10,5),(10,10,6),(10,10,7),(10,10,8),(10,10,9)
(0,0,0),(0,0,0),(0,0,0),(0,0,0),(0,0,0),(0,0,0),(0,0,0),(0,0,0),(0,0,0),(0,0,0)
(10,10,0),(10,10,0),(10,10,0),(10,10,0),(10,10,0),(10,10,0),(10,10,0),(10,10,0),(10,10,0),(10,10,0)

这进一步说明了内存一致模型与松弛模型间的区别.

同时,值得注意的,在程序中出现了如此的一种结构:

    while (!go)
        std::this_thread::yield();
    for (int i = 0; i < loop_count; ++i) {
        //do-something
        std::this_thread::yield();
    }
  • 让线程让渡出自己的CPU时间片并执行其他线程:
std::this_thread::yield();

如此的一种结构可以让所有线程在尽可能接近的时间内开始循环.这是因为启动线程是一个昂贵的操作.

获取-释放顺序

获取-释放顺序是松散顺序的进步,操作仍然没有总的顺序,但是的确引入了一些同步.

在这种顺序下:

  • 原子载入操作是 获取(aquire) 操作(memory_order_acquire)
  • 原子存储操作是 释放(release) 操作(memory_order_release)
  • 原子读-修改-写操作是 获取,释放或两者兼备 (memory_order_acq_rel)

同步在进行释放的线程和进行获取的线程之间是对偶的.释放操作与读取写入值的获取操作同步.

这意味着不同线程仍然可以看到不同的排序,但是这些顺序是受到限制的.

下面给出一个例子来说明这种顺序:

#include <iostream>
#include <thread>
#include <atomic>
#include <assert.h>

std::atomic<bool>x, y;
std::atomic<int>z;

void write_x()
{
    x.store(true, std::memory_order_release);
}
void write_y()
{
    y.store(true, std::memory_order_release);
}
void read_x_then_y()
{
    while (!x.load(std::memory_order_acquire));
    if (y.load(std::memory_order_acquire))
        ++z;

    while (!y.load(std::memory_order_acquire));
    if (x.load(std::memory_order_acquire))
        ++z;
}
const int TURNS = 1000;

int main()
{
    for (int i = 0; i < TURNS; i++) {
        x = false; y = false; z = 0;

        std::thread a(write_x); std::thread b(write_y);
        std::thread c(read_x_then_y);
        a.join(); b.join(); c.join();

        if (z != 2)
            std::cout << "i:" << i << ",z:" << z << std::endl;
    }

    return 0;
}

在这个例子中,有时z的值为1,有时z的值为2.

这是因为在其中,x和y由不同的线程写入,所以每种情况下从释放到获取的顺序对于另一个线程的操作是没有影响的.

为了进一步说明,我们给出另一个例子:

#include <iostream>
#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool>x, y;
std::atomic<int>z;

void write_x_then_y()
{
    x.store(true, std::memory_order_relaxed);
    y.store(true, std::memory_order_release);
}
void read_y_then_x()
{
    while (!y.load(std::memory_order_acquire));
    if (x.load(std::memory_order_relaxed))
        ++z;
}

const int TURNS = 1000;
int main()
{
    for (int i = 0; i < TURNS; i++) {
        x = false; y = false; z = 0;

        std::thread a(write_x_then_y);
        std::thread b(read_y_then_x);
        a.join(); b.join();

        if (z != 1)
            std::cout << "i:" << i << ",z:" << z << std::endl;
    }
    return 0;
}

在这个例子中,z的值只会为1.

由于对y的存储与载入同步,因而对x的存储发生在对y的存储之前.

于是,现在让我们来考虑复杂一点的情景: 获取-释放传递性同步

为了考虑传递性顺序,至少需要三个线程:

  • 第一个线程修改一些共享变量,其中一个进行存储-释放
  • 第二个线程对应于存储-释放,使用载入-获取来读取该变量,并在第二个共享变量执行存储-释放
  • 第三个线程在第二个共享变量上进行载入-获取

下面是一个原型:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<int>data[5];
std::atomic<bool>sync1(false), sync2(false);

void thread_1()
{
    data[0].store(42, std::memory_order_relaxed);
    data[1].store(97, std::memory_order_relaxed);
    data[2].store(17, std::memory_order_relaxed);
    data[3].store(-141, std::memory_order_relaxed);
    data[4].store(2024, std::memory_order_relaxed);
    sync1.store(true, std::memory_order_release);
}
void thread_2()
{
    while (!sync1.load(std::memory_order_acquire));
    sync2.store(true, std::memory_order_release);
}
void thread_3()
{
    while (!sync2.load(std::memory_order_acquire));
    assert(data[0].load(std::memory_order_relaxed) == 42);
    assert(data[1].load(std::memory_order_relaxed) == 97);
    assert(data[2].load(std::memory_order_relaxed) == 17);
    assert(data[3].load(std::memory_order_relaxed) == -141);
    assert(data[4].load(std::memory_order_relaxed) == 2024);
}

const int TURNS = 1000;
int main()
{
    for (int i = 0; i < TURNS; i++) {
        std::thread a(thread_1);
        std::thread b(thread_2);
        std::thread c(thread_3);
        a.join(); b.join(); c.join();
    }

    return 0;
}

我们不难发现,这就有点像是我们先前所学的OpenMP中的depend子句, 但是其实不然.

下面则给出了一个只需要一个sync原子变量的例子.

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<int>data[5];
std::atomic<int>sync1(0);

void thread_1()
{
    data[0].store(42, std::memory_order_relaxed);
    data[1].store(97, std::memory_order_relaxed);
    data[2].store(17, std::memory_order_relaxed);
    data[3].store(-141, std::memory_order_relaxed);
    data[4].store(2024, std::memory_order_relaxed);
    sync1.store(1, std::memory_order_release);
}
void thread_2()
{
    int expected = 1;
    while (!sync1.compare_exchange_strong(expected,2,std::memory_order_acq_rel))
        expected = 1;
}
void thread_3()
{
    while (sync1.load(std::memory_order_acquire) < 2);
    assert(data[0].load(std::memory_order_relaxed) == 42);
    assert(data[1].load(std::memory_order_relaxed) == 97);
    assert(data[2].load(std::memory_order_relaxed) == 17);
    assert(data[3].load(std::memory_order_relaxed) == -141);
    assert(data[4].load(std::memory_order_relaxed) == 2024);
}

const int TURNS = 1000;
int main()
{
    for (int i = 0; i < TURNS; i++) {
        sync1 = 0;
        std::thread a(thread_1);
        std::thread b(thread_2);
        std::thread c(thread_3);
        a.join(); b.join(); c.join();
    }

    return 0;
}

这主要是为了演示在使用读-修改-写操作时应该使用memory_order_acq_rel语义.

现在,我们引入memory_order_consume来解决数据依赖相关问题:

为了处理数据依赖,我们有两个新的关系:

  • dependency-ordered-before(依赖顺序在其之前): 适用于线程间.通过使用memory_order_comsume原子载入操作引入.如果A依赖顺序在B之前,则A也是线程间发生于B之前.
  • carries-a-dependency-to(带有对其的依赖): 严格适用于单个线程之内,是操作间数据依赖的基本模型.如果操作A的结果被用作操作B的操作数,那么A带有对B的依赖.这种操作也具有传递性.

下面给出一个例子:

struct X { int i; std::string s; };
std::atomic<X*>p;
std::atomic<int>a;

void create_x()
{
    X* x = new X; x->i = 42; x->s = "hello";
    a.store(99, std::memory_order_relaxed);
    p.store(x, std::memory_order_release);
}
void use_x()
{
    X* x;
    while (!(x = p.load(std::memory_order_consume)))
        std::this_thread::sleep_for(std::chrono::microseconds(1));
    assert(x->i == 42); assert(x->s == "hello");
    assert(a.load(std::memory_order_relaxed) == 99);
}

int main()
{
    std::thread t1(create_x);
    std::thread t2(use_x);
    t1.join(); t2.join();

    return 0;
}

与前面的获取-释放顺序不同,std::memory_order_cosume能够限制线程间执行顺序.

然而,有时候,你并不希望四处带着依赖所带来的开销.

你可能想让编译器能够在寄存器中缓存值,并且对操作进行重新排序以优化代码而不用担心依赖.

在这些情景下,可以使用std::kill_dependency()显式打破依赖链条.

但是正常人似乎一般都用不到.于是在下面,我们来介绍释放序列.

释放序列(release sequence): 如果存储被标记为memory_order_release, memory_order_acq_rel 或 memory_order_seq_cst;载入被标记为memory_order_consume, memory_order_acquire 或 memory_order_seq_cst.并且链条中的每个操作都由先前操作写入值,那么该操作链条就构成了一个释放队列.

下面给出一个例子:

#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
#include <assert.h>

std::vector<int> queue_data;
std::atomic<int>count;

void populate_queue()
{
    unsigned const number_of_items = 30;
    queue_data.clear();
    for (unsigned i = 0; i < number_of_items; i++)
        queue_data.push_back(i);
    count.store(number_of_items, std::memory_order_release);
}

void process_data(int data)
{
    std::cout << "for " << data << ", i*i is :" << data * data << std::endl;
    std::this_thread::yield();
}

void consume_queue_items()
{
    while (true) {
        int item_index;
        if ((item_index = count.fetch_sub(1, std::memory_order_acquire)) <= 0) {
            std::this_thread::sleep_for(std::chrono::microseconds(100));
            return;
        }
        process_data(queue_data[item_index - 1]);
    }
}

int main()
{
    std::thread t1(populate_queue);
    std::thread t2(consume_queue_items);
    std::thread t3(consume_queue_items);
    t1.join(); t2.join(); t3.join();

    return 0;
}

屏障(fence)

尽管大多数的同步关系来自于应用到原子变量操作的内存顺序语义,但是C++也允许通过屏障(fence)来引入额外的顺序约束

屏障是全局操作,能在执行该屏障的线程里影响其他原子操作的顺序.

屏障一般也被称为 内存栅栏(memory barriers).

#include <thread>
#include <atomic>
#include <assert.h>

std::atomic<bool>x, y;
std::atomic<int>z;

void write_x_then_y()
{
    x.store(true, std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_release);
    y.store(true, std::memory_order_relaxed);
}
void read_y_then_x()
{
    while (!y.load(std::memory_order_relaxed));
    std::atomic_thread_fence(std::memory_order_acquire);
    if (x.load(std::memory_order_relaxed))
        z++;
}

const int TURNS = 1000;
int main()
{
    for (int i = 0; i < TURNS; i++) {
        x = false; y = false; z = 0;

        std::thread a(write_x_then_y);
        std::thread b(read_y_then_x);
        a.join(); b.join();
        assert(z.load() != 0);
    }

    return 0;
}

在这里释放屏障与获取屏障同步,这使得对x的存储必然发生在对x的载入之前.

屏障的整体思路为:如果获取操作看到了释放屏障后发生的存储的结果,该屏障与获取操作同步;如果在获取屏障之前发生的载入看到释放操作的结果,该释放操作与获取屏障同步.

有了屏障之后,我们甚至可以使用原子操作排序非原子操作:

#include <thread>
#include <atomic>
#include <assert.h>

std::atomic<bool>y;
std::atomic<int>z;
bool x = false;

void write_x_then_y()
{
    x = true;
    std::atomic_thread_fence(std::memory_order_release);
    y.store(true, std::memory_order_relaxed);
}
void read_y_then_x()
{
    while (!y.load(std::memory_order_relaxed));
    std::atomic_thread_fence(std::memory_order_acquire);
    if (x)
        z++;
}

const int TURNS = 1000;
int main()
{
    for (int i = 0; i < TURNS; i++) {
        x = false; y = false; z = 0;

        std::thread a(write_x_then_y);
        std::thread b(read_y_then_x);
        a.join(); b.join();
        assert(z.load() != 0);;
    }

    return 0;
}

我们会发现,这其实在上面例子的基础上并没有改动.

而且,并不是只有屏障才能排序非原子操作.但是通过原子操作来排序非原子操作,是happens-before中sequenced-before变得如此重要的所在.

posted @ 2024-02-14 20:21  Mesonoxian  阅读(76)  评论(0编辑  收藏  举报