2022-08-01 C++并发编程(四)


前言

在C++多线程并发编程中,各个线程可以共享主线程全局变量,这既是优点,也是缺点。

我们不用特殊机制即可在线程间共享数据,同样意味着,每个线程修改共享数据都会影响所有线程,引起数据竞争,导致计算结果不确定。

因此我们需要了解互斥锁,条件变量等,用以梳理计算顺序,辅助程序逻辑,防止程序错误。


一、互斥锁

通过C++的互斥库<mutex>,可以方便的引入互斥变量,进而在需要防止数据竞争的地方加以保护。

可以用 lock() 函数进行加锁,使得数据只能由持有锁的线程进行读写改动,用 unlock() 函数进行解锁,释放数据的读写权限。

当然,在C++中,更常见的则是利用 std::lock_guard<std::mutex> guard(some_mutex) 对象将互斥对象封装,利用 RAII 机制,自动进行锁释放,防止不恰当的 unlock() 导致的错误。

通过简单的示例,我们可以感受一下互斥锁的基本运用。

标准库的 list 是非线程安全的,我们不能保证添加数据的安全,也不能保证检查是否含有数据的安全。

因为这两个操作不能保证原子性,很可能在添加数据时,多个线程同时找到链表末尾 end ,并分别在 end 后添加数据,导致数据添加混乱,以及内存泄漏。

而检查是否含有数据 A,则可能在一个线程检查时,另一个线程删除 A 或增加数据 A,导致结果的错误。

#include <algorithm>
#include <iostream>
#include <list>
#include <mutex>
#include <thread>
#include <vector>

std::list<int> someList;
std::mutex someMutex;

void addToList(int newValue)
{
    std::lock_guard<std::mutex> guard(someMutex);
    // c++17  std::scoped_lock<std::mutex> guard(someMutex);

    someList.push_back(newValue);
}

auto listContains(int valueToFind) -> bool
{
    std::lock_guard<std::mutex> guard(someMutex);

    return std::find(someList.begin(), someList.end(), valueToFind) !=
           someList.end();
}

void addSome(const std::vector<int> &someValue)
{
    for (const auto &i : someValue)
    {
        addToList(i);
        fprintf(stdout, "Yes %d\n", i);
    }
}

void checkSome(const std::vector<int> &someValue)
{
    for (const auto &i : someValue)
    {
        if (!listContains(i))
        {
            fprintf(stdout, "No %d\n", i);
        }
    }
}

auto main() -> int
{
    std::vector<int> data;
    data.reserve(1000);

    std::vector<int> chkdata;
    chkdata.reserve(10);

    for (int i = 0; i != 1000; ++i)
    {
        data.push_back(i);
    }

    for (int i = 0; i < 1000; i += 100)
    {
        chkdata.push_back(i);
    }

    std::thread thrd(addSome, data);
    std::thread thrd2(checkSome, chkdata);

    thrd.join();
    thrd2.join();

    return 0;
}

二、可能引起互斥锁失效的程序设计

互斥锁的设计,只能保证持有锁的函数入口的调用,能够线程安全,但是如果其他线程通过访问没有锁的函数入口读写数据,则意味着数据保护的失效。

这本质上是程序设计的逻辑出现问题,会导致程序顺利执行,但结果和预期完全不同,一旦涉及较大的程序,则极难排查。

#include <algorithm>
#include <iostream>
#include <list>
#include <mutex>
#include <thread>
#include <vector>

std::list<int> someList;
std::mutex someMutex;

void addToList(int newValue)
{
    std::lock_guard<std::mutex> guard(someMutex);
    // c++17  std::scoped_lock<std::mutex> guard(someMutex);

    someList.push_back(newValue);
}

auto listContains(int valueToFind) -> bool
{
    std::lock_guard<std::mutex> guard(someMutex);

    return std::find(someList.begin(), someList.end(), valueToFind) !=
           someList.end();
}

void addSome(const std::vector<int> &someValue)
{
    for (const auto &i : someValue)
    {
        addToList(i);
        fprintf(stdout, "Yes %d\n", i);
    }
}

void addSomeUnsafe(const std::vector<int> &someValue)
{
    for (const auto &i : someValue)
    {
        someList.push_back(i);
        fprintf(stdout, "Yes %d\n", i);
    }
}

void checkSome(const std::vector<int> &someValue)
{
    for (const auto &i : someValue)
    {
        if (!listContains(i))
        {
            fprintf(stdout, "No %d\n", i);
        }
    }
}

auto main() -> int
{
    std::vector<int> data;
    data.reserve(1000);

    std::vector<int> chkdata;
    chkdata.reserve(10);

    for (int i = 0; i != 1000; ++i)
    {
        data.push_back(i);
    }

    for (int i = 0; i < 1000; i += 100)
    {
        chkdata.push_back(i);
    }

    std::thread thrd(addSome, data);
    std::thread thrd2(checkSome, chkdata);
    std::thread thrd3(addSomeUnsafe, data);

    thrd.join();
    thrd2.join();
    thrd3.join();

    return 0;
}

三、并发逻辑要排除固有条件竞争

对于某些容器操作,比如栈容器,C++标准库提供的接口是天然存在条件竞争的。

比如出栈,第一步是判断栈非空,第二步是出栈操作,逻辑上应该是原子化的,但现实则并不是,如果用于多线程,需要进行相应的改造。

而改造也是需要动些脑筋的,虽然是一步原子操作,如何传出两个讯息,其一,是否为空,其二弹出一个数据。

我们可以用引用类型作为形参,取得弹出的数据,函数返回bool值,确定栈在弹出数据时是否为空,确定引用对象的赋值是否是有意义的赋值。

还有一种思路是函数返回指针,指针为空意味着弹出时栈为空,指针不空意味着栈弹出时不空,结果是有意义的。

#include <deque>
#include <exception>
#include <iostream>
#include <memory>
#include <vector>

struct empty_stack : std::exception
{
    auto what() const noexcept -> const char * override;
};

template <typename T>
struct threadsafe_stack
{
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack &);
    auto operator=(const threadsafe_stack &) -> threadsafe_stack & = delete;
    void push(T new_value);
    auto pop() -> std::shared_ptr<T>;
    auto pop(T &value) -> bool;
    auto empty() const -> bool;
};

总结

多线程访问读写数据共享数据很容易,但做对,得到想要的结果很难。

通过最简单的互斥锁,可以确保单一入口的函数对共享数据访问线程安全,然而一旦混合无锁入口,则线程就不安全了。

同时,设计线程安全的程序还需考虑排除固有的数据竞争,将需要原子化的部分合在一起,要记住,每一步都是线程安全的,组合起来则不一定是线程安全的,需要进行重新的设计。

参考文献:C++并发编程实战(第2版)[英] 安东尼•威廉姆斯(Anthony Williams)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

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

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

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

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

打赏作者

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

抵扣说明:

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

余额充值