本篇博客讲述如何在多个线程之间共享数据,并且在各种情况下使用互斥量来防止线程之间对数据竞争访问。
互斥量
互斥量的使用很简单,所见即所得:
1 2 3 std::mutex some_mutex; some_mutex.lock (); some_mutex.unlock ();
但是通过这样的方法使用互斥量有点麻烦,因为这意味着需要在函数的每个出口,包括异常处理中调用 unlock()
。一个更加 RAII
的方法是使用模板类 std::lock——guard
,它就像智能指针一样,在自己的生存期结束时自动释放锁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class ThreadSafeList {private : std::list<int > data_list; std::mutex data_mutex; public : void add (int value) { std::lock_guard<std::mutex> lock (data_mutex) ; data_list.push_back (value); } bool contains (int value) { std::lock_guard<std::mutex> lock (data_mutex) ; return std::find (data_list.begin (), data_list.end (), value) != data_list.end (); } };
数据引用的传递
但是一定要注意一个潜在的陷阱 ,不应该在使用 lock_guard
加锁的函数中奖数据的引用传递给其他函数,导致未知的后果。
1 2 3 4 5 6 template <typename Function>void process_data (Function func) { std::lock_guard<std::mutex> l (m) ; func (data); }
设计线程安全的类
在多线程环境中,哪怕很细微的操作都需要注意线程安全。这一小节以设计一个线程安全的栈 为例子讲解线程安全的设计模式。
在设计之前,首先需要明确哪些接口会引发竞争条件。在栈这个例子中,最主要的竞争场景就是 top
和 pop
方法对栈上数据访问的竞争。
面对这一挑战,首先需要考虑的是削减接口 ,以获得最大程度的安全;其次,针对栈这个场景,必须保证在读取栈上数据的同时弹出数据。下面是三个可行的编程实践:
方法1:传入一个引用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <memory> #include <mutex> #include <stack> #include <exception> template <typename T>class ThreadSafeStack {private : std::stack<T> data_stack; mutable std::mutex mtx;public : void push (T value) { std::lock_guard<std::mutex> lock (mtx) ; data_stack.push (std::move (value)); } void pop (T& value) { std::lock_guard<std::mutex> lock (mtx) ; if (data_stack.empty ()) throw std::runtime_error ("Stack is empty!" ); value = data_stack.top (); data_stack.pop (); } bool empty () const { std::lock_guard<std::mutex> lock (mtx) ; return data_stack.empty (); } };
方法2:使用智能指针
1 2 3 4 5 6 7 8 std::shared_ptr<T> pop () { std::lock_guard<std::mutex> lock (mtx) ; if (data_stack.empty ()) return nullptr ; auto res = std::make_shared<T>(data_stack.top ()); data_stack.pop (); return res; }
方法3:采用移动语义
1 2 3 4 5 6 7 T pop () { std::lock_guard<std::mutex> lock (mtx) ; if (data_stack.empty ()) throw std::runtime_error ("Stack is empty!" ); T value = std::move (data_stack.top ()); data_stack.pop (); return value; }
大前提是对象的拷贝构造函数或移动构造函数不抛出异常,否则 pop
方法不执行,将导致数据不同步。另一个前提是栈中储存的对象支持移动语义,否则仍需要进行内存的复制,性能低下。
避免死锁的方法
避免死锁的方法有很多,例如按顺序获取锁等等,这些都是“道”,这一节主要讲避免死锁的两种编程“法”。两种方法的思路都是一样的,就是一次性锁定所有的互斥量。
采用 std::adopt_lock
1 2 3 4 5 6 7 8 9 10 11 12 std::mutex mtx1, mtx2;void thread_work () { std::lock (mtx1, mtx2); std::lock_guard<std::mutex> lock1 (mtx1, std::adopt_lock) ; std::lock_guard<std::mutex> lock2 (mtx2, std::adopt_lock) ; }
std::lock_guard
的第二个参数 std::adopt_lock
主要是用于表面,当前的这个锁已经被获得了,此次调用不需要再次上锁,只需要在生命周期结束时释放就行。
采用 std::unique_lock
+ std::defer_lock
1 2 3 4 5 6 7 8 9 10 11 12 13 std::mutex mtx1, mtx2;void thread_work () { std::unique_lock<std::mutex> lock1 (mtx1, std::defer_lock) ; std::unique_lock<std::mutex> lock2 (mtx2, std::defer_lock) ; std::lock (lock1, lock2); }
lock_guard
和 unique_lock
的区别就是,后者控制起来更灵活,可以在生命期内多次加锁和解锁,但是相应的,所需要的存储空间更大,时间也更多。
也可以把 unique_lock
和 unique_ptr
类比起来,它们也支持移动语义,可以将锁的所有权转移。
初始化保护
当数据初始化的成本很高(如数据库连接)时,使用互斥量会导致不必要的性能开销。可以使用 std::call_once
和 std::once_flag
来安全高效地实现一次性初始化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include <mutex> #include <memory> class ExpensiveResource {public : void init () { } void use () { } }; std::shared_ptr<ExpensiveResource> resource_ptr; std::once_flag resource_flag; void init_resource () { resource_ptr = std::make_shared<ExpensiveResource>(); resource_ptr->init (); }void thread_safe_use () { std::call_once (resource_flag, init_resource); resource_ptr->use (); }
这个也非常好理解,就像是使用了一个 bool
变量来判断连接是否已经被初始化了一样,但是使用 std::call_once
和 std::once_flag
是线程安全的,不会产生竞争。这个编程技巧或许不止适用于多线程编程的场景,在一些单例模式下也可以适用。
读写锁
读写锁在线程之间的同步锁 介绍过,这里主要讲Cpp中相关类的用法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include <map> #include <string> #include <shared_mutex> class DNSCache {private : std::map<std::string, std::string> entries; mutable std::shared_mutex entry_mutex; public : std::string find_entry (const std::string& domain) const { std::shared_lock<std::shared_mutex> lock (entry_mutex) ; auto it = entries.find (domain); return (it != entries.end ()) ? it->second : "" ; } void update_entry (const std::string& domain, const std::string& ip) { std::unique_lock<std::shared_mutex> lock (entry_mutex) ; entries[domain] = ip; } };
如上述代码所示,Cpp中的读写锁使用 shared_mutex
来实现,在读取时,可以允许像 shared_ptr
一样被多个读者(指针)指着,而 unique_lock
就像 unique_ptr
一样,只能允许一个写者(指针),否则会等待直到没有其他线程占用。
这里的 entry_mutex
为什么要声明为 mutable
?
因为 find_entry
方法被声明为 const
,意味着该方法在逻辑上不能修改对象内的数据(用户可见的数据,这个例子中就是DNS缓存),而 mutable
标识符可以认为这个成员变量是个例外,可以在 const
方法中被修改(即这个锁在逻辑上并不是用户可以访问的)。
锁管理器
三种锁管理器,其实上面以及有提到过了,分别是 lock_guard
、unique_lock
以及 shared_lock
。它们就像智能指针一样,对互斥量进行封装,自动管理锁的获得与释放,防止忘记解锁的情况存在。
lock_guard
最简单的锁管理器,构造时加锁,析构时解锁;
不支持手动控制(如中途加锁和延迟加锁);
占用资源少,速度快。
unique_lock
支持延迟加锁、手动加锁、多次加锁;
可以转移所有权,适合跨作用域传递锁。
shared_lock
也允许多次加解锁,专为读写锁设计,允许多线程并发读取;
需要与 shared_mutex
配合使用;
写入时仍需独占锁。