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)