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

举报
码事漫谈 发表于 2025/11/11 21:57:38 2025/11/11
【摘要】 1. 为什么需要线程同步?当多个线程并发访问共享数据(内存、文件、网络连接等)时,如果不进行任何同步控制,可能会引发一系列问题,最典型的是:数据竞争:一个线程在读数据时,另一个线程在写数据,导致读到的数据是“脏的”、不完整的或逻辑错误的。破坏不变量:对象在修改过程中,其内部状态可能暂时是不一致的(例如,修改一个链表时)。如果另一个线程在此时访问该对象,会看到这个破碎的状态,导致未定义行为。...

1. 为什么需要线程同步?

当多个线程并发访问共享数据(内存、文件、网络连接等)时,如果不进行任何同步控制,可能会引发一系列问题,最典型的是:

  • 数据竞争:一个线程在读数据时,另一个线程在写数据,导致读到的数据是“脏的”、不完整的或逻辑错误的。
  • 破坏不变量:对象在修改过程中,其内部状态可能暂时是不一致的(例如,修改一个链表时)。如果另一个线程在此时访问该对象,会看到这个破碎的状态,导致未定义行为。

线程同步的核心目的是:通过强制特定代码段的互斥访问或执行顺序,来保证多线程环境下程序行为的正确性和可预测性。


2. C++标准库提供的同步机制

C++11在标准库中引入了 <thread><mutex> 等头文件,提供了丰富的同步原语。

2.1 互斥量 - 保证互斥访问

互斥量是最基础的同步工具,它确保同一时间只有一个线程可以进入被保护的代码段(临界区)。

a) std::mutex
最基本的互斥量,不可递归。

#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包装器,在构造时加锁,析构时自动解锁。

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 更灵活,但开销稍大。它允许延迟加锁、提前解锁、条件变量配合使用等。

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 (可与任何满足基本互斥量概念的类型一起使用,但开销更大)

典型生产者-消费者模型:

#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> 的别名,类似于互斥量,但可由不同线程进行锁和解锁。
#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):一次性锁定多个互斥量,避免死锁。
    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指令级别保证操作的原子性。
    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++程序。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。