C++11中互斥锁的使用
作者:泽林阿
我们现在有一个需求,我们需要对 g_exceptions
这个 vector 的访问进行同步处理,确保同一时刻只有一个线程能向它插入新的元素。为此我使用了一个 mutex 和一个锁(lock)。mutex 是同步操作的主体,在 C++ 11 的 <mutex>
头文件中,有四种风格的实现:
- mutex:提供了核心的
lock()
unlock()
方法,以及当 mutex 不可用时就会返回的非阻塞方法try_lock()
- recursive_mutex:允许同一线程内对同一 mutex 的多重持有
- timed_mutex: 与
mutex
类似,但多了try_lock_for()
try_lock_until()
两个方法,用于在特定时长里持有 mutex,或持有 mutex 直到某个特定时间点 - recursive_timed_mutex:
recursive_mutex
和timed_mutex
的结合
下面是一个使用 std::mutex
的例子(注意 get_id()
和 sleep_for()
两个辅助方法的使用,上文已有提及)。
#include <iostream> #include <thread> #include <mutex> #include <chrono> std::mutex g_lock; void func() { g_lock.lock(); std::cout << "entered thread " << std::this_thread::get_id() << std::endl; std::this_thread::sleep_for(std::chrono::seconds(rand() % 10)); std::cout << "leaving thread " << std::this_thread::get_id() << std::endl; g_lock.unlock(); } int main() { srand((unsigned int)time(0)); std::thread t1(func); std::thread t2(func); std::thread t3(func); t1.join(); t2.join(); t3.join(); return 0; }
输出如下:
entered thread 10144
leaving thread 10144
entered thread 4188
leaving thread 4188
entered thread 3424
leaving thread 3424
lock()
unlock()
两个方法应该很好懂,前者锁住 mutex,如果该 mutex 不可用,则阻塞线程;稍后,后者解锁线程。
下面一个例子展示了一个简单的线程安全的容器(内部使用了 std::vector
)。该容器提供用于添加单一元素的 add()
方法,以及添加多个元素的 addrange()
方法(内部调用 add()
实现)。
注意:尽管如此,下面会指出,由于 va_args
的使用等原因,这个容器并非真正线程安全。此外,dump()
方法不应属于容器,在实际实现中它应该作为一个独立的辅助函数。这个例子的目的仅仅是展示 mutex 的相关概念,而非实现一个完整的线程安全的容器。
template <typename T> class container { std::mutex _lock; std::vector<T> _elements; public: void add(T element) { _lock.lock(); _elements.push_back(element); _lock.unlock(); } void addrange(int num, ...) { va_list arguments; va_start(arguments, num); for (int i = 0; i < num; i++) { _lock.lock(); add(va_arg(arguments, T)); _lock.unlock(); } va_end(arguments); } void dump() { _lock.lock(); for(auto e : _elements) std::cout << e << std::endl; _lock.unlock(); } }; void func(container<int>& cont) { cont.addrange(3, rand(), rand(), rand()); } int main() { srand((unsigned int)time(0)); container<int> cont; std::thread t1(func, std::ref(cont)); std::thread t2(func, std::ref(cont)); std::thread t3(func, std::ref(cont)); t1.join(); t2.join(); t3.join(); cont.dump(); return 0; }
当你运行这个程序时,会进入死锁。原因:在 mutex 被释放前,容器尝试多次持有它,这显然不可能。这就是为什么引入 std::recursive_mutex
,它允许一个线程对 mutex 多重持有。允许的最大持有次数并不确定,但当达到上限时,线程锁会抛出 std::system_error 错误。因此,要解决上面例子的错误,除了修改 addrange
令其不再调用 lock
和 unlock
之外,可以用 std::recursive_mutex
代替 mutex
。
template <typename T> class container { std::recursive_mutex _lock; // ... };
成功输出:
6334
18467
41
6334
18467
41
6334
18467
41
敏锐的读者可能注意到,每次调用 func()
输出的都是相同的数字。这是因为,seed 是线程局部量,调用 srand()
只会在主线程中初始化 seed,在其他工作线程中 seed 并未被初始化,所以每次得到的数字都是一样的。
手动加锁和解锁可能造成问题,比如忘记解锁或锁的次序出错,都会造成死锁。C++ 11 标准提供了若干类和函数来解决这个问题。封装类允许以 RAII 风格使用 mutex,在一个锁的生存周期内自动加锁和解锁。这些封装类包括:
- lock_guard:当一个实例被创建时,会尝试持有 mutex (通过调用
lock()
);当实例销毁时,自动释放 mutex (通过调用unlock()
)。不允许拷贝。- unique_lock:通用 mutex 封装类,与
lock_guard
不同,还支持延迟锁、计时锁、递归锁、移交锁的持有权,以及使用条件变量。不允许拷贝,但允许转移(move)。
借助这些封装类,可以把容器改写为:
template <typename T> class container { std::recursive_mutex _lock; std::vector<T> _elements; public: void add(T element) { std::lock_guard<std::recursive_mutex> locker(_lock); _elements.push_back(element); } void addrange(int num, ...) { va_list arguments; va_start(arguments, num); for (int i = 0; i < num; i++) { std::lock_guard<std::recursive_mutex> locker(_lock); add(va_arg(arguments, T)); } va_end(arguments); } void dump() { std::lock_guard<std::recursive_mutex> locker(_lock); for(auto e : _elements) std::cout << e << std::endl; } }
读者可能会提出, dump()
方法不更改容器的状态,应该设为 const。但如果你添加 const 关键字,会得到如下编译错误:
‘std::lock_guard<_Mutex>::lock_guard(_Mutex &)' : cannot convert parameter 1 from ‘const std::recursive_mutex' to ‘std::recursive_mutex &'
一个 mutex (不管何种风格)必须被持有和释放,这意味着 lock()
unlock
方法必被调用,这两个方法是 non-const 的。所以,逻辑上 lock_guard
的声明不能是 const (若该方法 为 const,则 mutex 也为 const)。这个问题的解决办法是,将 mutex 设为 mutable
。mutable
允许由 const 方法更改 mutex 状态。不过,这种用法仅限于隐式的,或「元(meta)」状态——譬如,运算过的高速缓存、检索完成的数据,使得下次调用能瞬间完成;或者,改变像 mutex 之类的位元,仅仅作为一个对象的实际状态的补充。
template <typename T> class container { mutable std::recursive_mutex _lock; std::vector<T> _elements; public: void dump() const { std::lock_guard<std::recursive_mutex> locker(_lock); for(auto e : _elements) std::cout << e << std::endl; } };
这些封装类锁的构造函数可以通过重载的声明来指定锁的策略。可用的策略有:
defer_lock_t
类型的defer_lock
:不持有 mutextry_to_lock_t
类型的try_to_lock
: 尝试持有 mutex 而不阻塞线程adopt_lock_t
类型的adopt_lock
:假定调用它的线程已持有 mutex
这些策略的声明方式如下:
struct defer_lock_t { }; struct try_to_lock_t { }; struct adopt_lock_t { }; constexpr std::defer_lock_t defer_lock = std::defer_lock_t(); constexpr std::try_to_lock_t try_to_lock = std::try_to_lock_t(); constexpr std::adopt_lock_t adopt_lock = std::adopt_lock_t();
除了这些 mutex 封装类之外,标准库还提供了两个方法用于锁住一个或多个 mutex:
- lock:锁住 mutex,通过一个避免了死锁的算法(通过调用
lock()
,try_lock()
和unlock()
实现) - try_lock:尝试通过调用
try_lock()
来调用多个 mutex,调用次序由 mutex 的指定次序而定
下面是一个死锁案例:有一个元素容器,以及一个 exchange()
函数用于互换两个容器里的某个元素。为了实现线程安全,这个函数通过一个和容器关联的 mutex,对这两个容器的访问进行同步。
template <typename T> class container { public: std::mutex _lock; std::set<T> _elements; void add(T element) { _elements.insert(element); } void remove(T element) { _elements.erase(element); } }; void exchange(container<int>& cont1, container<int>& cont2, int value) { cont1._lock.lock(); std::this_thread::sleep_for(std::chrono::seconds(1)); // <-- forces context switch to simulate the deadlock cont2._lock.lock(); cont1.remove(value); cont2.add(value); cont1._lock.unlock(); cont2._lock.unlock(); }
假如这个函数在两个线程中被调用,在其中一个线程中,一个元素被移出容器 1 而加到容器 2;在另一个线程中,它被移出容器 2 而加到容器 1。这可能导致死锁——当一个线程刚持有第一个锁,程序马上切入另一个线程的时候。
int main() { srand((unsigned int)time(NULL)); container<int> cont1; cont1.add(1); cont1.add(2); cont1.add(3); container<int> cont2; cont2.add(4); cont2.add(5); cont2.add(6); std::thread t1(exchange, std::ref(cont1), std::ref(cont2), 3); std::thread t2(exchange, std::ref(cont2), std::ref(cont1), 6); t1.join(); t2.join(); return 0; }
要解决这个问题,可以使用 std::lock
,保证所有的锁都以不会死锁的方式被持有:
void exchange(container<int>& cont1, container<int>& cont2, int value) { std::lock(cont1._lock, cont2._lock); cont1.remove(value); cont2.add(value); cont1._lock.unlock(); cont2._lock.unlock(); }
总结
- 创建一个mutex对象:使用std::mutex创建一个互斥锁。
- 加锁操作:在进入临界区之前调用lock()方法,以获取独占式访问权限。
- 解锁操作:在退出临界区时调用unlock()方法释放持有的独占式访问权限。
- 使用RAII进行自动加解锁管理:可以通过定义 std::unique_lock/std::shared_lock/ std::scoped_lock 来简化加解锁过程并避免手工管理死锁等风险。
- 防止死锁问题:如果需要同时获得多个互斥器上的所有权,请确保按照相同顺序获取它们,否则可能会发生死锁。另外,应尽量减小临界区大小以提高性能,并考虑使用其他同步原语如条件变量、信号量等来实现更复杂的同步需求。
- 尽可能地避免使用全局变量: 在多线程编程环境中, 全局变量很容易导致竞态条件(race condition),因此我们应该尽可能地将共享数据限制到某些具体的作用域,如对象内部等。
- 小心使用递归锁:std::recursive_mutex允许同一个线程多次获得锁,并在最后一次解除锁定。但是,在实际应用中,这种机制可能会导致死锁问题和性能瓶颈等问题,因此必须谨慎地使用。
到此这篇关于C++11中互斥锁的使用的文章就介绍到这了,更多相关C++11 互斥锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!