std::mutex
是 C++11 引入的用于 多线程同步 的类,它提供了 互斥锁(mutex)机制,确保同一时刻只有一个线程能够访问某个共享资源,从而防止多个线程同时修改共享数据时引发 数据竞争 问题。
1. 问题场景
在并发编程中,如果多个线程同时读写共享资源而没有合适的同步机制,就可能发生数据竞争,导致结果不可预测。
#if 1 #define _CRT_SECURE_NO_WARNINGS #include<iostream> #include<thread> void thread_function(int& num) { for (int i = 0; i < 100000; ++i) { ++num; } } void test() { int num = 0; std::thread t1(thread_function, std::ref(num)); std::thread t2(thread_function, std::ref(num)); t1.join(); t2.join(); std::cout << "num = " << num << std::endl; } int main() { test(); return EXIT_SUCCESS; } #endif
对于上述代码:
- 预期结果:每个线程会对
num
进行10,0000
次自增,总共应该是20,0000
- 实际结果:由于并发执行,
num
的最终值可能小于200,000
,具体数值是不可预测的
原因是因为 ++num
并不是原子操作,它包含三个步骤:
- 读取
num
的值 - 对
num
加 1 - 将新值写回
num
假设 num
当前值为 10,两个线程几乎同时执行 ++num
:
- 线程 A 和线程 B 都读取了
num
的值(都得到 10) - 线程 A 将
num
增加 1,写回 11 - 线程 B 也将
num
增加 1,写回 11
最终,num
的值为 11,而不是预期的 12,因为两个线程的更新相互覆盖了。这是典型的资源竞争竞争问题导致的。
2. 解决思路
解决思路:使用互斥锁 std::mutex 确保每次只有一个线程修改 num
,避免多个线程同时修改共享资源。
#if 1 #define _CRT_SECURE_NO_WARNINGS #include<iostream> #include<mutex> #include<thread> std::mutex mtx; #if 0 void thread_function(int& num) { for (int i = 0; i < 100000; ++i) { // mtx.lock() 会尝试获取互斥锁。 // 如果当前线程能获取到锁,它会继续执行后续代码。 // 如果锁已经被其他线程持有,当前线程会被阻塞,直到锁被释放。 mtx.lock(); ++num; // mtx.unlock() 用于释放互斥锁,使得其他线程可以获得该锁。 // 当前线程调用 unlock() 时,锁被释放,系统会唤醒在此锁上阻塞的线程,并让它们争取获得锁。 // 如果多个线程等待该锁,系统会根据调度策略决定哪个线程获得锁。 mtx.unlock(); } } #else void thread_function(int& num) { for (int i = 0; i < 100000; ++i) { while (true) { // mtx.try_lock() 尝试非阻塞地获取锁。 // 如果锁已被其他线程占用,则立即返回 false,而不阻塞当前线程。 // 此时,线程可以进行其他任务,而不是一直等待锁的释放。 // 这种方式避免了阻塞等待,使得线程能够更灵活地处理其他任务。 if (mtx.try_lock()) { ++num; mtx.unlock(); break; } else { std::cout << std::this_thread::get_id() << " 真遗憾,没有抢到锁,干点别的事情!" << std::endl; } // 等待一段时间,再去尝试获得锁 std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } } #endif // 最后: // 1. 使用 lock() 的方式会使线程阻塞,直到获取到锁,适合当你希望线程一直等待直到能成功获得锁时。 // 2. 使用 try_lock() 的方式允许线程不阻塞,适合当你希望线程能在锁未获取到时去做其他任务而不是一直等待。 void test() { int num = 0; std::thread t1(thread_function, std::ref(num)); std::thread t2(thread_function, std::ref(num)); t1.join(); t2.join(); std::cout << "num = " << num << std::endl; } int main() { test(); return EXIT_SUCCESS; } #endif
3. 锁的开销
在多线程环境中,加锁(获取锁)和解锁(释放锁)会带来一定的性能开销。具体而言,当一个线程由于未能获得锁而被阻塞时,操作系统需要停止该线程的执行,并调度其他线程。此时,操作系统需要保存该线程的状态,进行线程上下文切换。当线程最终获得锁时,操作系统需要恢复该线程的状态,以便继续执行。这一过程中,线程状态的保存和恢复,以及线程切换和调度,会增加额外的开销。频繁的加锁和解锁操作会导致线程切换频繁,从而影响系统的性能。
简言之,当程序中涉及到频繁的加锁和解锁时,会引入额外的开销。为了降低这种影响,通常需要控制加锁的粒度。
锁粒度的选择
通常,锁的粒度越大,加锁和解锁的次数越少,但并行性可能会较差;而锁的粒度越小,虽然能提升并行性,但加锁和解锁的频率会更高,带来的性能开销也会增加。我们需要根据实际情况做权衡。
示例代码对比:
#if 0 for (int i = 0; i < 1000000; ++i) { // 锁了一行代码 mtx.lock(); ++num; mtx.unlock(); } #else // 锁了三行代码 mtx.lock(); for (int i = 0; i < 1000000; ++i) { ++num; } mtx.unlock(); #endif
在这个示例中:
- 前者:每次对
num
的更新都加锁、解锁一次。加锁解锁的次数更多,会导致更多的线程调度和上下文切换,性能开销较大。然而,这样的实现能够更好地支持并行性,多个线程可以在不同的循环迭代中并行执行。 - 后者:将整个循环的代码块锁住,只加锁一次,减少了加锁解锁的次数,从而减少了线程调度和上下文切换的开销。尽管如此,由于整个循环期间只有一个线程能访问共享数据,其他线程的并行性会受到影响,因此并行性较差。
锁粒度指的是在多线程程序中,锁所涵盖的代码区域或资源的范围。粒度大小的选择通常要根据以下几个方面进行权衡:
线程数量的考虑
- 少量线程:如果程序中运行的线程数量较少,线程之间的竞争不激烈,那么可以使用较粗的锁粒度(例如将多个操作放在同一个临界区内)。这样可以减少加锁和解锁的频率,降低性能开销,而不至于显著影响程序的并发性。
- 大量线程:当程序中有大量线程并行工作时,使用粗粒度锁可能会导致性能瓶颈,因为多个线程需要竞争同一把锁,导致大量线程被阻塞,进而降低并发性能。在这种情况下,使用细粒度锁(即将锁的范围限制在更小的代码区域)可以减少锁竞争,提高并行度,从而提升系统性能。
资源数量与访问频率的考虑
- 资源少且访问不频繁:当需要加锁的资源较少,且这些资源不被频繁访问时,使用粗粒度锁通常是合适的。此时加锁的代价相对较小,而且可以有效避免锁的管理和调度开销。
- 资源多且并发访问量大:如果程序涉及多个资源,且这些资源被多个线程频繁访问,使用粗粒度锁会造成较大的性能瓶颈。细粒度锁能够让不同线程并行访问不同的资源,减少锁竞争和阻塞,从而提升性能。