Chapter3 在线程间共享数据
3.2 用互斥保护共享数据
使用互斥锁 std::mutex
然而有时可能会忘记 unlock() 或异常处理的时候 unlock() 比较麻烦。为了减轻程序员的心智负担,我们可以使用 std::lock_guard<>
来持有锁,其会自动调用unlock
解锁,属于 RAII 手法
C++17中有类模板参数推导的特性,编译器根据类型自己实例化,这允许我们这样写
std::mutex mtx;
{
// std::lock_guard<std::mutex> lock(mtx);
std::lock_guard lock(mtx);
}
然而有的情况只能由我们自己解决,如:如果向锁作用域外传递保护共享对象,则互斥就失去了作用。
std::mutex mtx;
{
std::lock_guard<std::mutex> lock(mtx);
// data 是一个引用
func(data);
}
如果 func 没有上锁,则 data 并没有被保护
所以一个警告
不要向锁作用域外传递共享保护对象的指针或引用
书上的一个线程安全的栈,我们通过 pop() 返回指向栈顶元素的指针
其实就是对每一步都加锁。。。
#include <memory>
#include <stack>
#include <mutex>
#include <thread>
#include <exception>
template<typename T>
class threadsafe_stack {
private:
std::stack<T> data;
mutable std::mutex mtx;
public:
threadsafe_stack() = default;
threadsafe_stack(const threadsafe_stack<T> &);
threadsafe_stack<T> & operator=(const threadsafe_stack<T> &) = delete;
std::shared_ptr<T> pop();
void push(T);
bool empty() const;
size_t size() const;
};
template<typename T>
size_t threadsafe_stack<T>::size() const {
std::lock_guard lock(mtx);
return data.size();
}
template<typename T>
bool threadsafe_stack<T>::empty() const {
std::lock_guard lock(mtx);
return data.empty();
}
template<typename T>
void threadsafe_stack<T>::push(T value) {
std::lock_guard lock(mtx);
data.push(std::move(value));
}
template<typename T>
std::shared_ptr<T> threadsafe_stack<T>::pop() {
std::lock_guard lock(mtx);
if (data.empty()) {
throw std::out_of_range("Stack is empty!!");
}
std::shared_ptr<T> res = std::make_shared<T>(data.top());
data.pop();
return res;
}
template<typename T>
threadsafe_stack<T>::threadsafe_stack(const threadsafe_stack<T> &lhs) {
std::lock_guard lock(lhs.mtx);
data = lhs.data;
}
其中锁要定义为 mutable,否则在 const 方法中我们将无法上锁。
死锁
防范死锁的建议:永远保持相同加锁顺序进行加锁
同一时间获得锁时,使用 std::lock
std::mutex mtx1, mtx2;
{
std::lock(mtx1, mtx2);
// std::adopt_lock 表示锁已经上过了
std::lock_guard g1(mtx1, std::adopt_lock);
std::lock_guard g2(mtx2, std::adopt_lock);
}
// 等价于下述代码
// after C++17
{
std::scoped_lock(mtx1, mtx2);
}
在C++17中使用 std::scoped_lock
可以既防范死锁又帮忙管理锁。
综上,如果代码同时获取多个锁,使用上面的方法不会死锁。但如果获取锁的时间线不一样,仍有可能发生死锁。(如锁的互相调用)
防范死锁准则
1.避免嵌套锁
假如已经持有锁,尽量不要再试图获取第二个锁
2. 依从固定顺序获取锁
假如多个锁是必要的,我们需要保证按序加锁
3. 按层级加锁
即准则2的具象化,我们对每个互斥赋予等级。如果线程已经对低层级的线程加锁,则不允许其再对高层级的线程加锁
书上给出了一个 example
#include <mutex>
#include <thread>
#include <climits>
class hierarchical_mutex {
private:
std::mutex internel_mutex;
const unsigned long hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value;
void check_for_hierarchy_violation() const {
if (this_thread_hierarchy_value <= hierarchy_value) {
throw std::logic_error("mutex hierarchy violated");
}
}
void update_hierarchy_value() {
previous_hierarchy_value = this_thread_hierarchy_value;
this_thread_hierarchy_value = hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value) :
hierarchy_value(value), previous_hierarchy_value(0) {}
void lock() {
check_for_hierarchy_violation();
internel_mutex.lock();
update_hierarchy_value();
}
void unlock() {
if (this_thread_hierarchy_value != hierarchy_value) {
throw std::logic_error("mutex hierarchy violated");
}
this_thread_hierarchy_value = previous_hierarchy_value;
internel_mutex.unlock();
}
bool try_lock() {
check_for_hierarchy_violation();
if (!internel_mutex.try_lock()) {
return false;
}
update_hierarchy_value();
return true;
}
};
thread_local unsigned long
hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);
hierarchical_mutex high_mutex(10000);
hierarchical_mutex mid_mutex(7000);
hierarchical_mutex low_mutex(5000);
void do_high();
void do_mid();
void do_low();
void do_high() {
std::lock_guard guard1(high_mutex);
do_mid();
do_mid();
}
void do_mid() {
std::lock_guard guard1(mid_mutex);
do_low();
}
void do_low() {
std::lock_guard guard1(low_mutex);
}
void do_wrong_low() {
std::lock_guard guard1(low_mutex);
do_mid();
}
int main() {
std::thread thread1{do_high};
// std::thread thread2{do_wrong_low};
thread1.join();
// thread2.join();
}
这里使用的thread local
表示线程作用域的变量,和 static 一起用表示这个线程的类共有这一个值。
-
hierarchy_value:
这把锁的层级
-previous_hierarchy_value:
记录获取锁时这个线程的最高层级,在解锁后用于恢复线程层级
-this_thread_hierarchy_value:
线程当前层级,不允许获取超过其层级的锁
这里我们可以用 lock_guard
管理我们的 hierarchical_mutex
,理由是我们的类实现了lock()、unlock()、try_lock()
三个函数,满足了互斥概念所具备的操作。这里感觉很像 go 里的接口概念
经过查阅,cppreference lock_guard
我们看到 其构造函数调用 lock 、析构函数调用 unlock 。
经过测验,我们不难发现其优缺点:
- 优点:保证不会死锁
- 缺点:过于严格,在上面的
do_wrong_low
函数中,其并未达成死锁,然而还是会报错。
4. 将准则推广到锁操作以外
任何同步操作都可能导致死锁,也值得为这些情况运用上述准则。
运用 std::unique_lock<>灵活加锁
unique_lock 可以随时上锁再解锁,更加灵活,可以用于减小锁的粒度,然而其比std::lock_guard
更慢、占据更多空间。(因为其要保留一个内部标志,表示当前锁的状态,用于正确地析构)
std::mutex mtx1;
std::mutex mtx2;
{
std::unique_lock lock1(mtx1, std::defer_lock);
std::unique_lock lock2(mtx2, std::defer_lock);
std::lock(mtx1, mtx2);
}
这里 std::defer_lock
表示 lock 延时加锁,即初始化后处于无锁状态。最终在 lock 中加锁
两类情况可以使用 std::unique_lock
- 延时加锁
- 需要在不同作用域间转移锁的归属权
按合适的粒度加锁
持锁期间应避免任何耗时操作,如读写文件、获取另一个锁
否则即使用了多线程也无法提升性能。
例子:
主要说明一件事,如果 proocess() 不用持有锁,应当为其解锁之后再加锁
void get_and_process_data() {
std::unique_lock my_lock(the_mutex);
some_class data_to_process = get_next_data();
my_lock.unlock();
result_type result = process(data_to_process);
my_lock.lock();
write_result(data_to_process, result);
}
3.3 保护共享数据的其他工具
介绍了 std::call_once()
,读写锁std::shared_mutex(C++17)
,递归锁
std::call_once( )
专门用于保证在多线程场景下只初始化一次,搭配 one_flag 使用
下面是一个单例模式,我们不用再借助两次 if 循环了。
#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>
class UniqueInstance {
private:
UniqueInstance();
public:
static UniqueInstance *instance;
static UniqueInstance* GetInstance();
};
// .cpp
UniqueInstance* UniqueInstance::instance;
UniqueInstance::UniqueInstance() {
std::cout << "hello world" << std::endl;
}
UniqueInstance* UniqueInstance::GetInstance() {
static std::once_flag flag;
std::call_once(flag, []{ instance = new UniqueInstance(); });
return instance;
}
void do_something (std::atomic_int val) {
UniqueInstance* instance = UniqueInstance::GetInstance();
std::cout << "Time: " << val << " Address: " <<instance << std::endl;
}
int main() {
std::atomic_int time = 0;
std::thread thread1{do_something, ++time};
std::thread thread2{do_something, ++time};
std::thread thread3{do_something, ++time};
std::thread thread4{do_something, ++time};
thread1.join();
thread2.join();
thread3.join();
thread4.join();
}
std::shared_mutex
即读写锁,多个读者可以同时上锁,但当有写者上锁时将会阻塞任何读者上锁,写者锁是排他锁,只能被独占。它允许多个读线程进行并发访问,只允许一个写线程进行排他的访问。
书上举了 dns缓存
的例子,大多时候都是直接取值,但 dns 缓存过期后需要进行更新,这时候不能允许再有读线程进行读,因为这样会导致不同步。
#include <map>
#include <mutex>
#include <shared_mutex>
#include <string>
// dns 条目
class dns_entry;
class dns_cache {
private:
std::map<std::string, dns_entry> entries;
mutable std::shared_mutex entry_mutex;
public:
// read
dns_entry find(const std::string &domain) const {
std::shared_lock<std::shared_mutex> lk(entry_mutex);
const auto it = entries.find(domain);
return it == entries.end() ? dns_entry() : it->second;
}
void update_or_add_entry(const std::string &domain,
const dns_entry &dns_detail) {
std::lock_guard lk(entry_mutex);
entries[domain] = dns_detail;
}
};
这里使用了 std::shared_lock<std::shared_mutex>
共享锁管理读锁,其允许多个线程获取读锁。用std::lock_guard<std::shared_mutex>
管理写锁。若有线程获取写锁,则阻塞直到所有读锁被释放。
代码中,find_entry() 使用 std::shared_lock 保护共享的只读的访问,所以多个线程得以调用 find_entry()
update_or_add_entry 中使用 std::lock_guard 进行独占的访问。
递归加锁
一般不会用到,用到很有可能代表设计的不好。
例如如果两个公有函数循环调用,我们可以考虑提取公有函数的共同部分变成私有函数。
总结
这章介绍了锁的使用,对如何使用锁认识更深了,非常开心。并且还介绍了层级锁、std::call_once()
这样的黑科技,拓宽了我的视野。