资讯动态

C++实现线程同步的四种方式总结

发布时间:2026/10/22 0:23:31 来源:尧图企业网站定制
前言多线程编程的核心难点不在「怎么开线程」而在「多个线程同时访问同一份数据时如何保证结果可预期」。C 标准把没有同步保护的并发读写称为数据竞争data race而数据竞争是未定义行为undefined behavior, UB——不是「结果偶尔不对」而是编译器可以假设它不存在从而做出任意优化。一个常见误解是「只要不加锁就不会死锁所以无锁最好」。实际上无锁代码要自己处理内存序、ABA 问题和对象生命周期写错的概率远高于加锁。另一个误解是把volatile当成同步手段它既不做原子性保证也不建立线程间的可见性顺序只能用于内存映射 I/O 等特殊场景。本文按「互斥量、条件变量、原子操作、读写锁」四类梳理 C 的线程同步手段说明各自保证什么、不保证什么并给出可以直接对照的示例。文中代码以 C17 为基准涉及更新的标准会单独标注。一、同步的本质建立 happens-before线程同步要解决两件事互斥同一时刻只有一个线程进入临界区和可见性一个线程的写入能被另一个线程看到。C 内存模型用 happens-before 关系描述后者如果写操作 A happens-before 读操作 B那么 B 一定能看到 A 的结果。互斥量通过 acquire/release 语义建立这种关系加锁是 acquire解锁是 release同一互斥量上的 unlock synchronizes-with 后续的 lock。原子操作则通过内存序参数控制同样的事情。理解这一点才能明白为什么「两个线程各写各的变量」也需要同步——只要它们共享缓存行或存在逻辑依赖缺少 happens-before 就可能读到旧值。方式头文件解决的问题典型开销主要限制std::mutexmutex临界区互斥无竞争时接近原子指令有竞争时可能休眠不可递归不可拷贝std::condition_variablecondition_variable等待某个条件成立等待时释放锁并休眠必须搭配互斥量与谓词std::atomicatomic单个对象的原子读改写通常是一条或几条原子指令只保护它自己不保护关联数据std::shared_mutexshared_mutex读多写少场景的读写分离比普通互斥量重写者可能饥饿C17 起提供二、互斥量最通用的一把锁std::mutex提供lock()、unlock()、try_lock()。手写 lock/unlock 在异常路径上极易漏掉 unlock因此应当用 RAII 包装器std::lock_guardstd::mutex构造时加锁析构时解锁不能提前释放最轻量。std::unique_lockstd::mutex可延迟加锁、可提前unlock()、可转移所有权条件变量必须用它。std::scoped_lockC17可一次锁多个互斥量内部用死锁避免算法替代std::lock加多个lock_guard的写法。// 适用于 C17#include mutexclass Counter {public:void add(int n) {std::lock_guardstd::mutex lk(m_);value_ n;}int get() const {std::lock_guardstd::mutex lk(m_);return value_;}private:mutable std::mutex m_;int value_ 0;};这里m_声明为mutable是因为get()是 const 成员函数但仍需加锁。这不是风格问题如果 const 函数不加锁就读value_它与并发的add()构成数据竞争。std::recursive_mutex允许同一线程重复加锁但它常被用来掩盖「锁的职责不清」的设计问题。能用普通互斥量把临界区规划清楚时不要用递归锁。三、条件变量等待状态变化条件变量解决的是「线程要等到某个条件成立才继续」。它的正确用法有三条硬性要求等待前必须持有互斥量必须用谓词形式等待修改条件后要通知。// 适用于 C17#include condition_variable#include mutex#include queue#include utilitytemplate typename Tclass BlockingQueue {public:void push(T value) {{std::lock_guardstd::mutex lk(m_);q_.push(std::move(value));}cv_.notify_one(); // 通知放在解锁之后减少被唤醒线程的等待}bool pop(T out) {std::unique_lockstd::mutex lk(m_);cv_.wait(lk, [this] { return !q_.empty() || closed_; });if (q_.empty()) return false; // 已关闭且队列为空out std::move(q_.front());q_.pop();return true;}void close() {{std::lock_guardstd::mutex lk(m_);closed_ true;}cv_.notify_all();}private:std::mutex m_;std::condition_variable cv_;std::queueT q_;bool closed_ false;};cv_.wait(lk, pred)的语义是在持有锁的状态下求值pred为假则原子地释放锁并阻塞被唤醒后重新加锁并再次求值。谓词解决了两个问题——虚假唤醒spurious wakeup等待可能无故返回和丢失唤醒通知发生在 wait 之前。绝不能用裸wait(lk)配if因为被唤醒时条件未必已经成立。notify_one与notify_all的选择取决于等待者是否都在等同一个条件消费者队列用notify_one即可而「关闭」「广播配置变更」必须用notify_all否则部分线程永远醒不过来。四、原子操作与读写锁std::atomicT让对单个对象的读、写、读改写成为不可分割的操作。最常用的成员是load、store、fetch_add、exchange和compare_exchange_weak/strong。// 适用于 C17#include atomic#include thread#include vectorstd::atomiclong long hits{0};void worker(int rounds) {for (int i 0; i rounds; i) {hits.fetch_add(1, std::memory_order_relaxed);}}int main() {std::vectorstd::thread pool;for (int i 0; i 4; i) pool.emplace_back(worker, 100000);for (auto t : pool) t.join();return hits.load() 400000 ? 0 : 1;}memory_order_relaxed只保证这个计数器自身的原子性不建立与其他变量的顺序关系。这里只需要计数所以够用一旦要用计数器去「发布」另一块数据例如标志某块缓冲区已写好就必须用release/acquire配对或者干脆用默认的seq_cst。原子类型不能替代互斥量的场景很明确需要同时修改两个及以上相互关联的变量时单个atomic无法保证这组修改的整体一致性。std::atomicT对非平凡类型比如较大的结构体也不保证无锁is_lock_free()的结果依赖实现。读写锁读多写少的分工std::shared_mutexC17提供两种所有权独占写和共享读。用std::unique_lockstd::shared_mutex获取写锁用std::shared_lockstd::shared_mutex获取读锁。多个读者可以同时进入写者必须独占。// 适用于 C17#include map#include shared_mutex#include string#include utilityclass Config {public:std::string get(const std::string key) const {std::shared_lockstd::shared_mutex lk(m_);auto it data_.find(key);return it data_.end() ? std::string{} : it-second;}void set(std::string key, std::string value) {std::unique_lockstd::shared_mutex lk(m_);data_[std::move(key)] std::move(value);}private:mutable std::shared_mutex m_;std::mapstd::string, std::string data_;};读写锁并非「一定比互斥量快」。它的内部状态更复杂短临界区下额外开销可能抵消并发收益而且不同标准库实现的公平策略不同读请求持续不断时写者可能长期得不到锁写者饥饿。是否值得要在自己的负载下用std::chrono::steady_clock测量而不是凭直觉。C20 还提供了std::counting_semaphore和std::binary_semaphore需要 C20 及以上适合「限制同时访问某资源的线程数」这类计数型同步它们不提供所有权概念使用时必须自己保证配对。常见坑点❌ 用volatile标志位做线程间通知认为它「禁止优化所以能同步」。✅ 用std::atomicbool配acquire/release或直接用互斥量volatile不建立 happens-before。❌ 只给写操作加锁读操作直接读共享变量认为「读不会出问题」。✅ 读也要加锁或用原子类型并发的读与写同样构成数据竞争。❌cv.wait(lk)外面套if (q.empty())被虚假唤醒后直接访问空队列。✅ 用cv.wait(lk, pred)或while (!pred()) cv.wait(lk);。❌ 先notify_one()再解锁且通知的对象依赖锁内状态。✅ 一般应在修改完共享状态并解锁后再通知至少在持有锁时修改状态通知位置按语义选择。❌ 用两个互斥量时各自lock()两个线程加锁顺序相反导致死锁。✅ 用 C17 的std::scoped_lock lk(m1, m2);一次获取。❌ 把一个std::atomicint当作「整个结构体的锁」同时读写结构体其他字段。✅ 原子类型只保护自己复合状态用互斥量整体保护。❌ 在持锁期间调用未知的回调、写日志或做阻塞 I/O。✅ 在锁内只做数据修改把耗时操作移到锁外缩小临界区。❌std::thread对象析构前既不join()也不detach()。✅ 确保每条路径都汇合或分离否则析构时调用std::terminate。总结同步手段保证不保证适用场景std::mutex临界区互斥、unlock 与后续 lock 的可见性不防死锁、不可重入绝大多数共享状态保护std::condition_variable等待/通知配对配合谓词防虚假唤醒不存储通知不替代互斥量生产者消费者、等待状态变化std::atomic单个对象的原子性与指定内存序不保护其他变量不保证无锁计数器、标志位、无锁结构的基础件std::shared_mutex多读单写的并发控制不保证公平不保证更快读远多于写的配置、缓存四种方式不是互斥的选择题而是一套工具箱互斥量是默认答案条件变量用来等待原子类型用于单点计数与标志读写锁用于读多写少且临界区不太短的情况。真正决定程序正确性的是有没有为每一份共享数据明确「谁在什么时候可以访问」而不是用了哪种锁。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑