C++中的线程同步机制浅析

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 死锁与预防

死锁通常发生在两个或以上线程互相等待对方持有的资源时。

产生条件(四个必要条件)

  1. 互斥访问
  2. 持有并等待
  3. 不可剥夺
  4. 循环等待

预防策略

  • 固定顺序上锁:所有线程都按照相同的全局顺序获取锁。

  • 使用 std::lockstd::scoped_lock (C++17) :一次性锁定多个互斥量,避免死锁。

    cpp 复制代码
    std::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指令级别保证操作的原子性。

    cpp 复制代码
    std::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. 总结与最佳实践

  1. 优先使用RAII :始终使用 std::lock_guard, std::unique_lock, std::scoped_lock,避免手动 lock/unlock
  2. 用互斥量保护数据,而非代码:清晰地知道哪些数据是共享的,并用最小的锁粒度来保护它。
  3. 慎用递归锁:递归锁通常意味着糟糕的设计。
  4. 使用条件变量进行事件等待 :不要使用忙等待(while (!condition) {}),这会浪费CPU资源。
  5. 警惕死锁 :使用锁顺序、std::lock 等策略来预防。
  6. 性能瓶颈在于锁竞争:优化方向是减少共享和缩小临界区,而非盲目追求"无锁"。无锁编程极其复杂且容易出错。
  7. 简单场景用 atomic,复杂同步用 mutex :对于简单的计数器或标志位,std::atomic 是更好的选择。对于复杂的对象或需要等待条件的情况,使用 mutexcondition_variable
  8. 理解工具适用场景
    • mutex:互斥访问。
    • condition_variable:等待条件成立。
    • semaphore:控制资源池访问。
    • latch/barrier:多线程分阶段协同。

通过深入理解这些同步机制的原理、代价和适用场景,你才能写出既正确又高效的多线程C++程序。

相关推荐
不知几秋2 小时前
配置JDK和MAVEN
java·开发语言·maven
没有bug.的程序员2 小时前
Spring Cloud Gateway 路由与过滤器机制
java·开发语言·spring boot·spring·gateway
枫叶丹42 小时前
【Qt开发】布局管理器(五)-> QSpacerItem 控件
开发语言·数据库·c++·qt
月下倩影时2 小时前
ROS1基础入门:从零搭建机器人通信系统(Python/C++)
c++·python·机器人
_OP_CHEN2 小时前
C++进阶:(八)基于红黑树泛型封装实现 map 与 set 容器
开发语言·c++·stl·set·map·红黑树·泛型编程
C116112 小时前
Jupyter中选择不同的python 虚拟环境
开发语言·人工智能·python
无敌最俊朗@2 小时前
C++线程中detach和join的注意点
c++
努力努力再努力wz3 小时前
【Linux进阶系列】:线程(下)
linux·运维·服务器·c语言·数据结构·c++·算法
qq_401700413 小时前
Qt键盘组合
开发语言·qt