《C++ Concurrency In Action》Chapter3学习笔记

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

  1. 延时加锁
  2. 需要在不同作用域间转移锁的归属权

按合适的粒度加锁
持锁期间应避免任何耗时操作,如读写文件、获取另一个锁
否则即使用了多线程也无法提升性能。
例子:
主要说明一件事,如果 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()这样的黑科技,拓宽了我的视野。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值