2022-09-14 C++并发编程(二十二)


老林的C语言新课, 想快速入门点此 <C 语言编程核心突破>



前言

当我们把原子操作的内存次序带入程序中,会出现程序运行逻辑的改变,这是相当费脑筋的活儿。

我们原本的逻辑中,先写入变量的如果在后写入变量的之后读取,那如果后写入的变量被成功读取更新值,则先写入的变量被读取时,也必然是更新值。

但涉及多线程时,以上逻辑不一定成立。

做个类比:

高考,小明和小丽是邻居,爸妈都相互认识,二人在同一考场,他们的父母都在外等候。

小明先于小丽交卷,随后小丽也交卷。然后小明去了个洗手间,小丽直接出了考场。

当小丽的父母见到小丽时,如果小丽不告诉小明的父母,小明先自己交了考卷,那小明的父母怎么知道小明是否已经交卷?

但如果小丽见到了小明的父母说小明先于我交卷,已经出来了,没见到是因为去了厕所,则小明的父母自然知道小明已经交卷。

单线程就是考场老师,必然知道小明先于小丽交了卷子。

多线程就是场外的父母,无法知道小明是否先于小丽交了卷子,除非小丽告诉他们,否则谁知道小明是没交卷还是交完卷子上厕所。


一、先后一致次序

最严格的次序,就是最简单的次序,所有操作都服从先后顺序,包括多个线程之间也是如此。

如下示例:x, y, 分别在不同的线程中进行写入操作,如果线程 readXThenY() 中,x 读取了更新值,则 y 在此线程中可能读取了更新值,也可能没有读取更新值。

如果 y 没有读取更新值,在线程 readYThenX() 中,会阻塞在第一步 while (!y.load(std::memory_order_seq_cst)),当 y 读取更新值后,x 必然读取更新值,因为前一个进程 readXThenY() 中 x 读取更新值是先于 y 的。

反过来也是同样道理。

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

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

void writeX()
{
    x.store(true, std::memory_order_seq_cst);
}

void writeY()
{
    y.store(true, std::memory_order_seq_cst);
}

void readXThenY()
{
    while (!x.load(std::memory_order_seq_cst))
    {
    }

    if (y.load(std::memory_order_seq_cst))
    {
        ++z;
    }
}

void readYThenX()
{
    while (!y.load(std::memory_order_seq_cst))
    {
    }

    if (x.load(std::memory_order_seq_cst))
    {
        ++z;
    }
}

auto main() -> int
{
    x = false;
    y = false;
    z = 0;

    std::thread a(writeX);
    std::thread b(writeY);
    std::thread c(readXThenY);
    std::thread d(readYThenX);

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

    return 0;
}

二、宽松次序

宽松次序,字面上就是虽然在一个线程内遵循先行关系,但在对应的读写线程中,不保证同步。

以下示例中,虽然在 writeXThenY() 线程中,x 先于 y 进行原子写入,但在 readYThenX() 线程中,当 y 读取了更新值,却没有发出同步指令,以至于 x 是否读取了更新值,成了一个迷。

于是 assert(z.load(std::memory_order_relaxed) != 0); 断言有可能被出发。

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

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

void writeXThenY()
{
    x.store(true, std::memory_order_relaxed);
    y.store(true, std::memory_order_relaxed);
    std::cout << "x, y, yes\n";
}

void readYThenX()
{
    while (!y.load(std::memory_order_relaxed))
    {
        std::cout << "y, not yes\n";
    }

    if (x.load(std::memory_order_relaxed))
    {
        ++z;
        std::cout << "x, yes\n";
    }
}

auto main() -> int
{
    x = false;
    y = false;
    z = 0;

    std::thread a(writeXThenY);
    std::thread b(readYThenX);
    a.join();
    b.join();
    assert(z.load(std::memory_order_relaxed) != 0);

    return 0;
}

在多个线程上进行内存宽松次序的读写操作,最终只能导致完全的乱序。

以下示例中,x, y, z 的原子读写完全时宽松次序的,也就是说,只有在一个线程内部,一个原子变量的读取和写入操作是可以预期的,其他只进行读操作的原子变量,无法保证读取的是更新值。

当然,由于多线程本身的乱序逻辑,就算用最严格的先后一致次序,结果也是相似的。

输出的结果,如(0,0,0),会保证其中一个位置的值每次递增 1,如(1,0,0) (2,0,0) (3,0,0),其余的两个位置,递增与否,每次递增值是多少,则完全是个谜。

如果要在高速运行的计算机上看到上述结果,需要调整 const unsigned loopCount = 1000; 否则你看到的就是貌似非常有规律的递增。

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

std::atomic<int> x(0);
std::atomic<int> y(0);
std::atomic<int> z(0);

std::atomic<bool> go(false);

const unsigned loopCount = 10;

struct readValues
{
    int x;
    int y;
    int z;
};

readValues values1[loopCount];
readValues values2[loopCount];
readValues values3[loopCount];
readValues values4[loopCount];
readValues values5[loopCount];

void increment(std::atomic<int> *varToInc, readValues *values)
{
    while (!go)
    {
        // CPU时间片让渡,如有其他线程争夺此CPU时间,让其先执行
        std::this_thread::yield();
    }

    for (unsigned i = 0; i < loopCount; ++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);
        varToInc->store(static_cast<int>(i) + 1, std::memory_order_relaxed);
        //    std::this_thread::yield();
    }
}

void readVals(readValues *values)
{
    while (!go)
    {
        std::this_thread::yield();
    }

    for (unsigned i = 0; i < loopCount; ++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();
    }
}

void print(readValues *v)
{
    for (unsigned i = 0; i != loopCount; ++i)
    {
        if (i != 0U)
        {
            std::cout << ",";
        }
        std::cout << "(" << v[i].x << "," << v[i].y << "," << v[i].z << ")";
    }
    std::cout << std::endl;
}

auto main() -> int
{
    std::thread t1(increment, &x, values1);
    std::thread t2(increment, &y, values2);
    std::thread t3(increment, &z, values3);
    std::thread t4(readVals, values4);
    std::thread t5(readVals, 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,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,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,10)
//(10,0,0),(10,0,1),(10,0,2),(10,0,3),(10,0,4),(10,0,5),(10,0,6),(10,0,7),(10,0,8),(10,0,9)
//(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,10)
//(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10),(10,10,10)

三、获取-释放次序

获取-释放操作不会构成单一的全局总操作序列,

如果没有明确的先行关系,则原子操作只能保证本身的读写同步,以下示例中,在原子写入时 x,y 之间没有先行关系,所以会出现多种结果:

线程 readXThenY() 和 readYThenX() 中 x 读取了更新的值,y 也读取了更新的值。 不会触发断言 assert(z.load() != 0);

线程 readXThenY() 和 readYThenX() 中,一个线程 x 读取了更新的值,y 也读取了更新的值,但另一个线程中,只有一个原子变量读取了更新值。 不会触发断言 assert(z.load() != 0);

线程 readXThenY() 和 readYThenX() 中, x,y 没有同时读取更新的值。 触发断言 assert(z.load() != 0); 本例中可能性极小,但确实是有可能出现此种情况的。

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

std::atomic<bool> x;
std::atomic<bool> y;

std::atomic<int> z;

void writeX()
{
    //以std::memory_order_release 内存次序保证配对线程中 x 的读写同步
    x.store(true, std::memory_order_release);
}

void writeY()
{
    //以std::memory_order_release 内存次序保证配对线程中 y 的读写同步
    y.store(true, std::memory_order_release);
}

void readXThenY()
{
    //由于 x,y 没有明确的先行关系或同步关系,只能保证 x 的同步
    while (!x.load(std::memory_order_acquire))
    {
    }
    if (y.load(std::memory_order_acquire))
    {
        ++z;
    }
}

void readYThenX()
{
    //由于 x,y 没有明确的先行关系或同步关系,只能保证 y 的同步
    while (!y.load(std::memory_order_acquire))
    {
    }
    if (x.load(std::memory_order_acquire))
    {
        ++z;
    }
}

auto main() -> int
{
    x = false;
    y = false;
    z = 0;
    //
    std::thread a(writeX);
    std::thread b(writeY);
    std::thread c(readXThenY);
    std::thread d(readYThenX);
    
    a.join();
    b.join();
    c.join();
    d.join();
    
    assert(z.load() != 0);
    
    return 0;
}

通过线程内的先行关系和线程间的同步关系,进行线程间同步传递:

以下示例演示了如何传递同步:

y 在同线程内保证 x 完成写入操作后才完成写入,在另一配对线程,y 保证在 x 完成读取操作前读取原子写入操作后的值。

逻辑上保证了,如果 y 成功读取了原子写入操作的值,那么 x 读取的,必然是原子写入操作的值。

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

std::atomic<bool> x;
std::atomic<bool> y;

std::atomic<int> z;

void writeXThenY()
{
    x.store(true, std::memory_order_relaxed);
    //通过 std::memory_order_release 内存次序保证上面的写操作完成
    y.store(true, std::memory_order_release);
}

void readYThenX()
{
    //当以std::memory_order_acquire 内存次序同步读取 y 值,保证了 x 读取的同步
    while (!y.load(std::memory_order_acquire))
    {
    }
    if (x.load(std::memory_order_relaxed))
    {
        ++z;
    }
}

auto main() -> int
{
    x = false;
    y = false;
    z = 0;

    std::thread a(writeXThenY);
    std::thread b(readYThenX);

    a.join();
    b.join();

    assert(z.load() != 0);

    return 0;
}

四、通过获取-释放次序传递同步

因在一个线程内部遵循先行关系,将线程内的最后一个原子写操作辅以 std::memory_order_release 内存次序,保证其之前所有的原子写操作完成。

通过中间线程,读取最后写操作的值,并辅以 std::memory_order_acquire 内存次序,同步所有写操作。

并以 std::memory_order_release 内存次序在同一线程同步读操作之后,写入另一个值。

并通过其他线程的 std::memory_order_acquire 内存次序读操作同步上述写操作,将第一个线程的所有写操作同步给本线程,完成同步操作的传递。

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

std::atomic<int> data[5];

std::atomic<bool> sync1(false);
std::atomic<bool> sync2(false);

void thread1()
{
    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(2003, std::memory_order_relaxed);
    //内存次序为release:
    //本线程中,所有之前的写操作完成后才能执行本条原子操作
    //保证此条原子语句完成时上面所有的写操作均已完成
    sync1.store(true, std::memory_order_release);
}

void thread2()
{
    //内存次序为acquire:
    //本线程中,所有后续的读操作必须在本条原子操作完成后执行
    //保证此条原子语句完成时,thread1 中所有的写操作对应的读操作均已同步
    while (!sync1.load(std::memory_order_acquire))
    {
    }
    //内存次序为release:
    //本线程中,所有之前的写操作完成后才能执行本条原子操作
    //用以传递同步,将 thread1 的写操作同步给 thread3 中的读操作
    sync2.store(true, std::memory_order_release);
}

void thread3()
{
    //内存次序为acquire:
    //本线程中,所有后续的读操作必须在本条原子操作完成后执行
    //保证此条原子语句完成时,thread2 中所有的写操作对应的读操作均已同步
    //将 thread1 的同步操作传递给 thread3
    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) == 2003);
}

auto main() -> int
{
    std::thread t1(thread1);
    std::thread t2(thread2);
    std::thread t3(thread3);

    t1.join();
    t2.join();
    t3.join();

    return 0;
}

总结

辅以内存次序的原子操作,会有不同的执行逻辑,较单线程的顺序逻辑,复杂了不是一点半点。

所以在程序设计时,一旦涉及此种情况,逻辑一定要清晰,能简单的务必不要复杂化,否则很容易把自己绕进去。


老林的C语言新课, 想快速入门点此 <C 语言编程核心突破>


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不停感叹的老林_<C 语言编程核心突破>

不打赏的人, 看完也学不会.

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值