1. 为什么需要线程同步?
当多个线程并发访问共享数据(内存、文件、网络连接等)时,如果不进行任何同步控制,可能会引发一系列问题,最典型的是:
- 数据竞争:一个线程在读数据时,另一个线程在写数据,导致读到的数据是"脏的"、不完整的或逻辑错误的。
- 破坏不变量:对象在修改过程中,其内部状态可能暂时是不一致的(例如,修改一个链表时)。如果另一个线程在此时访问该对象,会看到这个破碎的状态,导致未定义行为。
线程同步的核心目的是:通过强制特定代码段的互斥访问或执行顺序,来保证多线程环境下程序行为的正确性和可预测性。
2. C++标准库提供的同步机制
C++11在标准库中引入了 <thread> 和 <mutex> 等头文件,提供了丰富的同步原语。
2.1 互斥量 - 保证互斥访问
互斥量是最基础的同步工具,它确保同一时间只有一个线程可以进入被保护的代码段(临界区)。
a) std::mutex 最基本的互斥量,不可递归。
cpp
#include <iostream>
#include <thread>
#include <mutex>
std::mutex g_mutex;
int shared_data = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
g_mutex.lock(); // 加锁
++shared_data; // 临界区
g_mutex.unlock(); // 解锁
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final value: " << shared_data << std::endl; // 一定是 200000
return 0;
}
注意 :直接使用 lock() 和 unlock() 是危险的,如果临界区代码抛出异常,可能导致互斥量无法解锁,引发死锁。永远优先使用RAII包装器。
b) std::lock_guard 最简单的RAII包装器,在构造时加锁,析构时自动解锁。
cpp
void safe_increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(g_mutex); // 构造时加锁
++shared_data; // 临界区
} // 作用域结束,lock析构,自动解锁
}
c) std::unique_lock 比 lock_guard 更灵活,但开销稍大。它允许延迟加锁、提前解锁、条件变量配合使用等。
cpp
void flexible_increment() {
for (int i = 0; i < 100000; ++i) {
std::unique_lock<std::mutex> lock(g_mutex, std::defer_lock); // 延迟加锁
// ... 一些不涉及共享数据的操作 ...
lock.lock(); // 手动加锁
++shared_data;
lock.unlock(); // 可以手动提前解锁
// ... 其他操作 ...
}
}
d) std::recursive_mutex 允许同一个线程多次获取同一个互斥量而不会死锁。用于可能递归调用或需要多次加锁的场景。应谨慎使用,通常表明设计可能有问题。
2.2 条件变量 - 线程间的通信与等待
条件变量允许线程阻塞等待某个条件成立,或在条件成立时通知其他线程。它必须与互斥量配合使用。
std::condition_variable(推荐,通常更高效)std::condition_variable_any(可与任何满足基本互斥量概念的类型一起使用,但开销更大)
典型生产者-消费者模型:
cpp
#include <queue>
#include <condition_variable>
std::queue<int> g_queue;
std::mutex g_mutex;
std::condition_variable g_cv;
bool g_done = false;
void producer() {
for (int i = 0; i < 10; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
{
std::lock_guard<std::mutex> lock(g_mutex);
g_queue.push(i);
std::cout << "Produced: " << i << std::endl;
}
g_cv.notify_one(); // 通知一个等待的消费者
}
{
std::lock_guard<std::mutex> lock(g_mutex);
g_done = true;
}
g_cv.notify_all(); // 通知所有消费者结束
}
void consumer(int id) {
while (true) {
std::unique_lock<std::mutex> lock(g_mutex);
// 等待条件:队列不为空或生产结束
g_cv.wait(lock, [] { return !g_queue.empty() || g_done; });
// 被唤醒后,需要重新检查条件
if (g_done && g_queue.empty()) {
break;
}
// 消费数据
int data = g_queue.front();
g_queue.pop();
lock.unlock(); // 尽早释放锁
std::cout << "Consumer " << id << " consumed: " << data << std::endl;
}
}
关键点:
wait操作会原子地释放互斥锁并使线程休眠。- 被唤醒时,它会重新获取互斥锁,然后检查条件(使用提供的谓词)。必须使用循环或带谓词的wait来防止"虚假唤醒"。
2.3 信号量 - C++20
信号量是一个更底层的同步原语,它维护一个计数器,用于控制对特定数量资源的访问。
std::counting_semaphore:允许至少LeastMaxValue个并发访问。std::binary_semaphore:是std::counting_semaphore<1>的别名,类似于互斥量,但可由不同线程进行锁和解锁。
cpp
#include <semaphore>
std::binary_semaphore smph(0); // 初始值为0
void waiter() {
std::cout << "Waiting...\n";
smph.acquire(); // 等待信号量值>0,然后减1
std::cout << "Finished waiting!\n";
}
void notifier() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Notifying...\n";
smph.release(); // 信号量值加1,唤醒等待者
}
2.4 锁存器和屏障 - C++20
用于管理一组线程的同步点。
std::latch:一次性使用的倒计时门闩。线程在arrive_and_wait上阻塞,直到内部计数器减为0,所有阻塞线程被同时释放。不可重复使用。std::barrier:可重复使用的同步机制。它允许一组线程执行一系列阶段。在每个阶段,线程到达屏障并阻塞,直到所有线程都到达,然后所有线程被释放,屏障进入下一个阶段。
3. 高级话题与底层原理
3.1 死锁与预防
死锁通常发生在两个或以上线程互相等待对方持有的资源时。
产生条件(四个必要条件):
- 互斥访问
- 持有并等待
- 不可剥夺
- 循环等待
预防策略:
-
固定顺序上锁:所有线程都按照相同的全局顺序获取锁。
-
使用
std::lock或std::scoped_lock(C++17) :一次性锁定多个互斥量,避免死锁。cppstd::mutex mutex1, mutex2; void safe_lock() { // std::lock 使用死锁避免算法(如Dijkstra算法)来同时锁定多个互斥量 std::lock(mutex1, mutex2); // 使用 std::adopt_lock 表示互斥量已被锁定,lock_guard只需接管所有权 std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock); std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock); // ... } // C++17 更简洁的方式: void safer_lock() { std::scoped_lock lock(mutex1, mutex2); // 自动使用死锁避免算法 // ... } -
避免嵌套锁:如果可能,尽量只持有一个锁。
-
使用层次锁:为锁分配层级编号,只允许以编号递减的顺序获取锁。
3.2 性能考量
- 锁的粒度:锁保护的临界区应尽可能小。在临界区内不要进行耗时操作(如I/O)。
- 锁竞争 :当多个线程频繁尝试获取同一个锁时,会发生激烈竞争,导致大量线程在用户态和内核态之间切换,严重降低性能。
- 解决方案 :使用无锁数据结构、减少共享数据、使用读写锁(
std::shared_mutex)、或者将数据分区(每个线程处理自己的数据副本,最后再合并)。
- 解决方案 :使用无锁数据结构、减少共享数据、使用读写锁(
3.3 内存模型与原子操作
同步机制的底层与C++内存模型紧密相关。
-
std::atomic:提供了无需互斥锁的线程安全访问。对于基本数据类型(如int,bool,pointer),使用std::atomic通常比mutex效率更高,因为它直接在CPU指令级别保证操作的原子性。cppstd::atomic<int> atomic_counter(0); void atomic_increment() { for (int i = 0; i < 100000; ++i) { atomic_counter.fetch_add(1, std::memory_order_relaxed); } } -
内存序 :
std::memory_order允许你控制原子操作周围的非原子内存访问的可见性顺序。这是为了在保证正确性的前提下,追求极致的性能。memory_order_seq_cst(顺序一致性):最强保证,默认选项,性能开销最大。memory_order_acquire/memory_order_release/memory_order_acq_rel:用于实现"同步于"关系。memory_order_relaxed:只保证原子性,不提供同步和顺序保证。
除非你是专家,否则请使用 std::atomic 的默认内存序(memory_order_seq_cst)。
4. 总结与最佳实践
- 优先使用RAII :始终使用
std::lock_guard,std::unique_lock,std::scoped_lock,避免手动lock/unlock。 - 用互斥量保护数据,而非代码:清晰地知道哪些数据是共享的,并用最小的锁粒度来保护它。
- 慎用递归锁:递归锁通常意味着糟糕的设计。
- 使用条件变量进行事件等待 :不要使用忙等待(
while (!condition) {}),这会浪费CPU资源。 - 警惕死锁 :使用锁顺序、
std::lock等策略来预防。 - 性能瓶颈在于锁竞争:优化方向是减少共享和缩小临界区,而非盲目追求"无锁"。无锁编程极其复杂且容易出错。
- 简单场景用
atomic,复杂同步用mutex:对于简单的计数器或标志位,std::atomic是更好的选择。对于复杂的对象或需要等待条件的情况,使用mutex和condition_variable。 - 理解工具适用场景 :
mutex:互斥访问。condition_variable:等待条件成立。semaphore:控制资源池访问。latch/barrier:多线程分阶段协同。
通过深入理解这些同步机制的原理、代价和适用场景,你才能写出既正确又高效的多线程C++程序。