news 2026/10/20 22:19:04

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++实现线程同步的四种方式总结

前言


多线程编程的核心难点不在「怎么开线程」,而在「多个线程同时访问同一份数据时,如何保证结果可预期」。C++ 标准把没有同步保护的并发读写称为数据竞争(data race),而数据竞争是未定义行为(undefined behavior, UB)——不是「结果偶尔不对」,而是编译器可以假设它不存在,从而做出任意优化。


一个常见误解是「只要不加锁就不会死锁,所以无锁最好」。实际上无锁代码要自己处理内存序、ABA 问题和对象生命周期,写错的概率远高于加锁。另一个误解是把volatile当成同步手段,它既不做原子性保证也不建立线程间的可见性顺序,只能用于内存映射 I/O 等特殊场景。


本文按「互斥量、条件变量、原子操作、读写锁」四类梳理 C++ 的线程同步手段,说明各自保证什么、不保证什么,并给出可以直接对照的示例。文中代码以 C++17 为基准,涉及更新的标准会单独标注。


一、同步的本质:建立 happens-before


线程同步要解决两件事:互斥(同一时刻只有一个线程进入临界区)和可见性(一个线程的写入能被另一个线程看到)。C++ 内存模型用 happens-before 关系描述后者:如果写操作 A happens-before 读操作 B,那么 B 一定能看到 A 的结果。


互斥量通过 acquire/release 语义建立这种关系:加锁是 acquire,解锁是 release,同一互斥量上的 unlock synchronizes-with 后续的 lock。原子操作则通过内存序参数控制同样的事情。理解这一点,才能明白为什么「两个线程各写各的变量」也需要同步——只要它们共享缓存行或存在逻辑依赖,缺少 happens-before 就可能读到旧值。




方式头文件解决的问题典型开销主要限制



std::mutex<mutex>临界区互斥无竞争时接近原子指令,有竞争时可能休眠不可递归,不可拷贝

std::condition_variable<condition_variable>等待某个条件成立等待时释放锁并休眠必须搭配互斥量与谓词

std::atomic<atomic>单个对象的原子读改写通常是一条或几条原子指令只保护它自己,不保护关联数据

std::shared_mutex<shared_mutex>读多写少场景的读写分离比普通互斥量重写者可能饥饿,C++17 起提供



二、互斥量:最通用的一把锁


std::mutex提供lock()、unlock()、try_lock()。手写 lock/unlock 在异常路径上极易漏掉 unlock,因此应当用 RAII 包装器:



  • std::lock_guard<std::mutex>:构造时加锁,析构时解锁,不能提前释放,最轻量。

  • std::unique_lock<std::mutex>:可延迟加锁、可提前unlock()、可转移所有权,条件变量必须用它。

  • std::scoped_lock(C++17):可一次锁多个互斥量,内部用死锁避免算法,替代std::lock加多个lock_guard的写法。


// 适用于 C++17
#include <mutex>

class Counter {
public:
void add(int n) {
std::lock_guard<std::mutex> lk(m_);
value_ += n;
}
int get() const {
std::lock_guard<std::mutex> lk(m_);
return value_;
}
private:
mutable std::mutex m_;
int value_ = 0;
};

这里m_声明为mutable,是因为get()是 const 成员函数但仍需加锁。这不是风格问题:如果 const 函数不加锁就读value_,它与并发的add()构成数据竞争。


std::recursive_mutex允许同一线程重复加锁,但它常被用来掩盖「锁的职责不清」的设计问题。能用普通互斥量把临界区规划清楚时,不要用递归锁。


三、条件变量:等待状态变化


条件变量解决的是「线程要等到某个条件成立才继续」。它的正确用法有三条硬性要求:等待前必须持有互斥量;必须用谓词形式等待;修改条件后要通知。


// 适用于 C++17
#include <condition_variable>
#include <mutex>
#include <queue>
#include <utility>

template <typename T>
class BlockingQueue {
public:
void push(T value) {
{
std::lock_guard<std::mutex> lk(m_);
q_.push(std::move(value));
}
cv_.notify_one(); // 通知放在解锁之后,减少被唤醒线程的等待
}

bool pop(T& out) {
std::unique_lock<std::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_guard<std::mutex> lk(m_);
closed_ = true;
}
cv_.notify_all();
}

private:
std::mutex m_;
std::condition_variable cv_;
std::queue<T> q_;
bool closed_ = false;
};

cv_.wait(lk, pred)的语义是:在持有锁的状态下求值pred,为假则原子地释放锁并阻塞;被唤醒后重新加锁并再次求值。谓词解决了两个问题——虚假唤醒(spurious wakeup,等待可能无故返回)和丢失唤醒(通知发生在 wait 之前)。绝不能用裸wait(lk)配if,因为被唤醒时条件未必已经成立。


notify_one与notify_all的选择取决于等待者是否都在等同一个条件:消费者队列用notify_one即可,而「关闭」「广播配置变更」必须用notify_all,否则部分线程永远醒不过来。


四、原子操作与读写锁


std::atomic<T>让对单个对象的读、写、读改写成为不可分割的操作。最常用的成员是load、store、fetch_add、exchange和compare_exchange_weak/strong。


// 适用于 C++17
#include <atomic>
#include <thread>
#include <vector>

std::atomic<long 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::vector<std::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::atomic<T>对非平凡类型(比如较大的结构体)也不保证无锁,is_lock_free()的结果依赖实现。


读写锁:读多写少的分工


std::shared_mutex(C++17)提供两种所有权:独占(写)和共享(读)。用std::unique_lock<std::shared_mutex>获取写锁,用std::shared_lock<std::shared_mutex>获取读锁。多个读者可以同时进入,写者必须独占。


// 适用于 C++17
#include <map>
#include <shared_mutex>
#include <string>
#include <utility>

class Config {
public:
std::string get(const std::string& key) const {
std::shared_lock<std::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_lock<std::shared_mutex> lk(m_);
data_[std::move(key)] = std::move(value);
}

private:
mutable std::shared_mutex m_;
std::map<std::string, std::string> data_;
};

读写锁并非「一定比互斥量快」。它的内部状态更复杂,短临界区下额外开销可能抵消并发收益;而且不同标准库实现的公平策略不同,读请求持续不断时写者可能长期得不到锁(写者饥饿)。是否值得,要在自己的负载下用std::chrono::steady_clock测量,而不是凭直觉。


C++20 还提供了std::counting_semaphore和std::binary_semaphore(需要 C++20 及以上),适合「限制同时访问某资源的线程数」这类计数型同步;它们不提供所有权概念,使用时必须自己保证配对。


常见坑点



  1. ❌ 用volatile标志位做线程间通知,认为它「禁止优化所以能同步」。✅ 用std::atomic<bool>配acquire/release,或直接用互斥量;volatile不建立 happens-before。

  2. ❌ 只给写操作加锁,读操作直接读共享变量,认为「读不会出问题」。✅ 读也要加锁或用原子类型;并发的读与写同样构成数据竞争。

  3. ❌cv.wait(lk)外面套if (q.empty()),被虚假唤醒后直接访问空队列。✅ 用cv.wait(lk, pred)或while (!pred()) cv.wait(lk);。

  4. ❌ 先notify_one()再解锁,且通知的对象依赖锁内状态。✅ 一般应在修改完共享状态并解锁后再通知;至少在持有锁时修改状态,通知位置按语义选择。

  5. ❌ 用两个互斥量时各自lock(),两个线程加锁顺序相反导致死锁。✅ 用 C++17 的std::scoped_lock lk(m1, m2);一次获取。

  6. ❌ 把一个std::atomic<int>当作「整个结构体的锁」,同时读写结构体其他字段。✅ 原子类型只保护自己;复合状态用互斥量整体保护。

  7. ❌ 在持锁期间调用未知的回调、写日志或做阻塞 I/O。✅ 在锁内只做数据修改,把耗时操作移到锁外,缩小临界区。

  8. ❌std::thread对象析构前既不join()也不detach()。✅ 确保每条路径都汇合或分离,否则析构时调用std::terminate。


总结




同步手段保证不保证适用场景



std::mutex临界区互斥、unlock 与后续 lock 的可见性不防死锁、不可重入绝大多数共享状态保护

std::condition_variable等待/通知配对,配合谓词防虚假唤醒不存储通知,不替代互斥量生产者消费者、等待状态变化

std::atomic单个对象的原子性与指定内存序不保护其他变量,不保证无锁计数器、标志位、无锁结构的基础件

std::shared_mutex多读单写的并发控制不保证公平,不保证更快读远多于写的配置、缓存



四种方式不是互斥的选择题,而是一套工具箱:互斥量是默认答案,条件变量用来等待,原子类型用于单点计数与标志,读写锁用于读多写少且临界区不太短的情况。真正决定程序正确性的,是有没有为每一份共享数据明确「谁在什么时候可以访问」,而不是用了哪种锁。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 22:18:55

Rsync 增量备份实战:从算法原理到集中备份架构

1. 为什么 Rsync 是运维备份场景里的常青树 干了这么多年运维&#xff0c;经手的备份工具少说也有十几种&#xff0c;从早期的 tar 加 scp 组合拳&#xff0c;到后来各种商业备份套件&#xff0c;兜兜转转一圈下来&#xff0c;日常用得最顺手、最频繁的&#xff0c;还是 r…

作者头像 李华
网站建设 2026/10/20 22:17:47

WorkBuddy一键生成微信小程序:不写代码从想法到上线全流程

1. 从“有个想法”到“小程序上线”之间&#xff0c;到底隔着什么 很多人脑子里都闪过做一个小程序的念头——可能是给自家小店做个点单工具&#xff0c;可能是给社群做个打卡应用&#xff0c;也可能只是想练手做个作品集。但念头刚冒出来&#xff0c;紧接着就是一盆冷水&#…

作者头像 李华
网站建设 2026/10/20 22:17:39

人工精翻技术博客实战:术语表、风格指南与AI辅助校验

1. 从一篇翻译博客说起&#xff1a;为什么人工翻译在AI时代反而更值得做做技术内容这行十来年&#xff0c;我经手过不少翻译项目&#xff0c;从早期的文档本地化到后来的社区协作翻译&#xff0c;再到最近两年各种AI辅助翻译工具的兴起。说实话&#xff0c;每次技术浪潮打过来&…

作者头像 李华
网站建设 2026/10/20 22:16:25

Windows 上实现 Linux 命令接口:从课程设计到可扩展架构

简介&#xff1a;这份资源是面向计算机专业学生与系统编程初学者的课程设计作品&#xff0c;目标是在Windows平台上构建一套兼容Unix/Linux风格命令的接口&#xff0c;解决Windows命令行环境下缺少类Unix杂项命令的问题。设计涵盖login、password、logout、sort、more、printf等…

作者头像 李华
网站建设 2026/10/20 22:16:22

VC++ ODBC数据库编程实战:从连接配置到查询优化与排错

说句实在话&#xff0c;第一次在VC项目里见到“SQLAllocHandle、SQLConnect、SQLExecDirect”这一串名字时&#xff0c;我是有点懵的。那会儿刚接手一套老系统&#xff0c;满屏ODBC调用&#xff0c;代码是别人十年前写的&#xff0c;注释几乎没有&#xff0c;一行行看下去像在读…

作者头像 李华
网站建设 2026/10/20 22:16:19

本地大模型驱动Web冒烟测试:Ollama+QwQ-32B+OpenClaw实战指南

做测试自动化这么多年&#xff0c;我一直觉得传统脚本有天花板&#xff1a;写死了的元素路径和步骤顺序&#xff0c;一旦产品样式改版&#xff0c;测试代码就得跟着返工。最近折腾了一套新的组合方式&#xff0c;用ollama在本地跑QwQ-32B模型&#xff0c;配合OpenClaw框架直接驱…

作者头像 李华