2022-08-04 C++并发编程(七)


前言

除了锁,C++还有其他方式保护共享数据,比如一些生成后就只读的数据,生成后极少更改的数据。

此外,还有一种可递归的锁,虽然极少使用,甚至不推荐使用。


一、只需在初始化中保护的共享数据

我们设计程序时,可能用到延迟初始化,如下代码:

#include <iostream>
#include <memory>
#include <mutex>
#include <thread>

struct someResource
{
    void doSomething() const
    {
        std::cout << a << std::endl;
    }

    int a = 0;
};

std::shared_ptr<someResource> resourcePtr;

void foo()
{
    if (!resourcePtr)
    {
        resourcePtr = std::make_shared<someResource>();
    }
    resourcePtr->doSomething();
}

auto main() -> int
{}

为了将以上代码用于多线程,一般需要在判断语句前加锁:

std::shared_ptr<someResource> resourcePtr;

void foo()
{
    std::unique_lock<std::mutex> lk(resourceMutex);

    if (!resourcePtr)
    {
        resourcePtr = std::make_shared<someResource>();
    }

    lk.unlock();

    resourcePtr->doSomething();
}

然而,其后果是由于所有线程都需要竞争锁,导致了多线程逻辑又回到单线程逻辑,除了徒添烦恼,没有什么卵用。

于是为了改进以上代码,实施了会导致 UB 的双检验:

std::shared_ptr<someResource> resourcePtr;

void undefinedBehaviourWithDoubleCheckedLocking()
{
    if (!resourcePtr)
    {
        std::lock_guard<std::mutex> lk(resourceMutex);
        if (!resourcePtr)
        {
            resourcePtr = std::make_shared<someResource>();
        }
    }
    resourcePtr->doSomething();
}

如果上述代码中,智能指针指向对象的初始化是原子性的,问题不大,但如果分成两步,初始化指针指向的对象,操作指针指向对象,以达到最终可用状态,那么无论过程是否上锁,都会出现指针非空,但对象并未可用状态,然而其他线程已经开始使用此指针指向的对象数据了,这时一种隐蔽很深的逻辑错误。

为了改良设计,我们可以用 std::call_once() 函数进行初始化:

std::once_flag resourceFlag;
std::shared_ptr<someResource> resourcePtr;

void initResource()
{
    resourcePtr = std::make_shared<someResource>();
}

void fooInitOnce()
{
    std::call_once(resourceFlag, initResource);
    resourcePtr->doSomething();
}

auto main() -> int
{
    fooInitOnce();
    return 0;
}

同样,可以利用 std::call_once() 做线程安全的类成员数据延迟初始化。

例如取得连接很耗时,也只需一次,则可以在操作类函数,进行收或发数据时进行。

struct X
{
    explicit X(const connectionInfo &connectionDetails_)
        : connectionDetails(connectionDetails_)
    {}

    void sendData(const dataPacket &data)
    {
        std::call_once(connectionInitFlag, &X::openConnection, this);
        connection.sendData(data);
    }

    auto receiveData() -> dataPacket
    {
        std::call_once(connectionInitFlag, &X::openConnection, this);
        return connection.receiveData();
    }

  private:
    void openConnection()
    {
        connection = connectionManager.open(connectionDetails);
    }

    connectionInfo connectionDetails;
    connectionHandle connection;
    std::once_flag connectionInitFlag;
};

对于全局只需唯一单例的设计,C++11后一般用局部静态返回函数完成,不必用 std::call_once()

struct myClass
{
    int a = 0;
};

auto getMyClassInstance() -> myClass &
{
    static myClass instance;
    return instance;
}

局部静态变量,只初始化一次,且一直存在直至程序结束。

二、对于读多,写极少的共享数据

有些数据,一旦写入则极少更改,而且基本是只需要读,C++是否有能够锁写不锁读的锁呢?

有但只在至少C++14以上支持,读写锁或称共享锁 std::shared_mutex,通过 std::shared_lock<std::shared_mutex> 模板加锁,其他线程可共享持有 std::shared_lock<std::shared_mutex>,但一旦有线程要持有普通锁 std::lock_guard<std::shared_mutex> 则会阻塞,直到所有持有共享锁的线程释放。

反之只要一个线程持有普通锁,所有线程不可持有共享锁,直到普通锁释放。

注意,一般互斥变量要用mutable修饰,因为用锁的函数很可能是 const 成员函数。

#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <string>
#include <thread>

struct dnsEntry
{
    int a = 0;
};

struct dnsCache
{
    auto findEntry(const std::string &domain) const -> dnsEntry
    {
        std::shared_lock<std::shared_mutex> lk(entryMutex);
        const auto it = entries.find(domain);
        return (it == entries.end()) ? dnsEntry() : it->second;
    }

    void updateOrAddEntry(const std::string &domain, const dnsEntry &dnsDetails)
    {
        std::lock_guard<std::shared_mutex> lk(entryMutex);
        entries[domain] = dnsDetails;
    }

  private:
    std::map<std::string, dnsEntry> entries;
    mutable std::shared_mutex entryMutex;
};

auto main() -> int
{
    dnsEntry a;
    dnsCache b;
    b.updateOrAddEntry("test: ", a);
    b.updateOrAddEntry("test2: ", a);
    auto c = b.findEntry("test: ");

    return 0;
}

三、递归加锁

首先,一般互斥变量 std::mutex 不可以重复加锁,但递归互斥变量 std::recursive_mutex 除外。

#include <mutex>

std::recursive_mutex m;

auto fn(int b) -> int
{
    std::lock_guard<std::recursive_mutex> lk(m);
    if (b == 0)
    {
        return 0;
    }
    return b + fn(b - 1);
}

auto main() -> int
{
    int c = fn(5);
    return 0;
}

其次,一般来讲,用到递归锁,说明程序设计可能出了问题,比如一个类中的两个公有函数,两个函数都上锁,其中一个函数调用另一个函数,那就必然重复上锁,一般互斥锁会报错,而暴力的使用递归互斥就可以解决,但这明显不是递归锁的使用场景。

以上情况需要尽量避免。


总结

对于只需初始化一次,后续不变的公有变量,我们 可以通过一次初始化而非一直加锁解决,对于读多写少的数据,可以使用读写锁解决。

对于可能出现的递归加锁,我们有相应的工具,但通常是设计出了问题,需要理清逻辑更改设计,而非简单粗暴的递归加锁。

参考文献: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、付费专栏及课程。

余额充值