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_locklock_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 小时前
在高并发场景下,动态数据源切换与Seata全局事务锁管理如何协同避免性能瓶颈?
后端
码事漫谈2 小时前
CI/CD集成工程师前景分析:与开发岗位的全面对比
后端
间彧2 小时前
在微服务架构下,如何结合Spring Cloud实现动态数据源的路由管理?
后端
间彧2 小时前
动态数据源切换与Seata分布式事务如何协同工作?
后端
间彧2 小时前
除了AOP切面,还有哪些更灵活的数据源切换策略?比如基于注解或自定义路由规则
数据库·后端
已黑化的小白3 小时前
Rust 的所有权系统,是一场对“共享即混乱”的编程革命
开发语言·后端·rust
程序定小飞5 小时前
基于springboot的健身房管理系统开发与设计
java·spring boot·后端
Moonbit5 小时前
你行你上!MoonBit LOGO 重构有奖征集令
前端·后端·设计
华仔啊6 小时前
开源一款 SpringBoot3 + Vue3 数据库文档工具,自动生成 Markdown/HTML
vue.js·spring boot·后端