从 Web 单线程世界走进 C++ 并发世界:数据竞争、互斥锁、原子操作与条件变量——这是桌面客户端架构师的必修课,也是你知识体系里最缺的一块拼图。
你已经学会了怎么创建线程(std::thread / QThread),也知道了信号槽可以跨线程通信。但"会开线程"离"敢写并发"还隔着一条河:当多个线程同时读写同一个变量时,C++ 会发生数据竞争(data race)——这是未定义行为,程序可能在你发布之后才偶发崩溃。
今天系统学习锁与并发原语:std::mutex、std::atomic、std::condition_variable 以及 Qt 的 QMutex / QReadWriteLock / QWaitCondition。这是多线程主题的深度延伸,也是 Web 开发者知识体系中最缺失的一块——因为 JS 单线程世界里根本没有"锁"这个概念。
一句话说清并发安全:能传消息就不共享内存;必须共享就用 RAII 锁或原子操作;永远不要在 GUI 线程里等待。
count++ 在机器层面是三条指令:读、加、写。两个线程同时执行它,就可能出现"都读到 100,都写成 101"——一次更新被吞掉。这就是读-改-写(read-modify-write)非原子导致的丢更新。更隐蔽的是可见性问题:线程 A 写了一个变量,线程 B 读到的可能还是旧值——因为 CPU 缓存、编译器重排、乱序执行都可能在"没有同步机制"时生效。C++ 标准把"两个线程无同步地访问同一非原子变量,且至少一个是写"定义为数据竞争(data race),属于未定义行为(UB)。UB 意味着编译器可以做任何优化,包括"看起来不可能"的优化——"我跑了一万次都没事"只是运气,换 ARM 平台或开 -O3 就可能现形。
对比 JS:浏览器事件循环是单线程的,同一时刻只有一个任务在执行,天然没有数据竞争;Node 的 worker_threads 通过 postMessage 传消息,默认不共享内存。所以你没有"锁"的直觉是正常的——但转向 C++ 桌面开发,这是必须补上的核心概念。
lock_guard 是 RAII 包装(呼应 7/30 的主题):构造时加锁、析构时解锁,中途 return 或抛异常也能保证解锁。C++17 的 std::scoped_lock 可以一次锁多把锁,从语言层面消除"锁顺序不一致导致的死锁"。lock 前缀指令 / CAS)。它比 mutex 轻一个数量级,适合"计数器、标志位"这类场景;fetch_add / compare_exchange_strong 都是原子读-改-写。默认 memory_order_seq_cst 保证全局一致序,代价是性能略低;进阶再研究 relaxed / acquire / release。while (!ready) {} 空转烧 CPU)或轮询 sleep(延迟且浪费)。它的 wait() 必须配合 std::unique_lock,并且必须用 while(谓词) 循环包裹——因为存在虚假唤醒(spurious wakeup)和"先通知后等待"的窗口。QMutex + QMutexLocker:与 std 对应;注意 QMutex 默认非递归,同一线程重入会死锁(除非用 QMutex::Recursive)。QReadWriteLock:读多写少场景(配置表、行情快照),允许多个读者并发,写者独占。QWaitCondition:condition_variable 的 Qt 版。QObject 属于创建它的线程,跨线程直接"调用"其槽函数是危险的。正确姿势是信号槽连接用 Qt::QueuedConnection(事件投递到接收者线程的队列,线程安全),或 QMetaObject::invokeMethod(obj, ..., Qt::QueuedConnection)。另外,Qt 容器(QList 等)都不是线程安全的。行情软件(同花顺 / 东方财富类 Windows 客户端):网络线程每秒接收数千只股票的 tick 流,UI 线程要实时刷新表格。若让 UI 线程直接加锁读共享容器,锁竞争和数据拷贝会瞬间卡死界面。业界方案是双缓冲(double buffer):后台线程把新数据写入"后备缓冲",写完后原子地切换版本号/指针;UI 定时器(约 100ms)只读当前快照。数据更新与渲染完全解耦——"锁放在哪里、粒度多大"直接决定行情软件能不能在几千行同时跳动时不卡顿。
客户端日志系统:多线程打日志,若每行日志都抢同一把全局 mutex,写盘就成了全局瓶颈。生产级做法:每个线程一个无锁 SPSC 环形缓冲(ring buffer),或攒批后交给单一日志线程落盘——把"锁竞争"变成"队列分发",这也是 spdlog 这类库的经典设计。
Chromium:base::Lock 封装平台互斥量,但更出名的是大量 lock-free 结构(任务队列、引用计数)。Chromium 的铁律是"不要在 IO/UI 线程上阻塞",与 Qt 的"别在 GUI 线程 wait()"是同一个原则。
Qt 信号槽的 QueuedConnection 本身:跨线程发信号时,事件被投递到接收者线程的事件队列,由接收者在自己的事件循环中处理——这是"消息传递替代共享内存"的活教材。Qt Creator 的文件索引线程、各种 Qt 客户端的后台任务,都靠这个模式把工作线程结果安全地送回 UI。
int 当标志位):数据竞争是 UB,"跑了很多次都没事"只是 x86 强内存模型在掩盖问题,换 ARM 或更高优化级别就现形。避免:用 std::atomic<bool> 或用互斥锁保护。lock_guard。后果:其他线程全部排队,多线程退化成单线程,还引入卡顿。避免:临界区只保留必要的内存操作,IO 一律移出锁外。return 或抛异常就忘了解锁,直接死锁。避免:永远用 lock_guard / scoped_lock / QMutexLocker,让 RAII 兜底——这也是为什么上一课(7/30 RAII)是并发安全的前提。m1 再锁 m2,线程 B 先锁 m2 再锁 m1,交叉等待形成死锁。避免:全项目固定加锁顺序,或直接用 std::scoped_lock(m1, m2) 原子地同时获取多把锁。condition_variable::wait() 或 QThread::wait() 等待工作线程结果。后果:事件循环停摆,窗口无响应(白屏/转圈),体验等同于浏览器主线程被长任务卡死。避免:永远用异步通知(信号槽 QueuedConnection / invokeMethod)把结果送回 UI 线程。std::atomic;一段复合操作(读-改-写多变量)→ std::mutex + RAII;"等条件成立再继续" → condition_variable;读多写少 → QReadWriteLock。不要一上来就上锁。QueuedConnection、QThreadPool + QtConcurrent、任务队列解决的,就不要设计共享状态。共享状态越多,并发 Bug 的排查面越大。QThreadPool / std::async / QtConcurrent,它们帮你管理线程池与任务队列;QObject 跨线程用 moveToThread 明确归属。能用"每个线程独立数据 + 最后合并"就不共享(并行归约);能只读共享就用 const + 只读并发;能用 copy-on-write(QSharedData / std::shared_ptr)就不原地修改。锁是用来保护"不得不共享的可变状态"的,而不是用来给所有共享数据兜底的。
你可能会惊讶:Web 世界里最让你头疼的是"渲染性能",而 C++ 世界里最让新人头疼的是"并发正确性"。但底层心智模型是相通的:
worker_threads 通过 postMessage 传递消息(结构化克隆/转移),本质就是"消息传递"范式——和 Qt 推荐的并发方式一模一样。你已有的"跨线程用消息"的直觉,直接迁移即可。Atomics.wait / Atomics.store 就是简化版的内存模型 API。今天学完 std::atomic 和 memory_order 之后,回头翻 MDN 上 Atomics 的文档,你会发现突然读得懂了。QByteArray 隐式共享)是同一哲学,只是 C++ 需要额外小心引用计数本身的线程安全(std::shared_ptr 的引用计数是原子的,但所指对象不是)。wait() 让窗口无响应——完全同一件事。你已有的"主线程性能预算"心智模型可以直接迁移到 Qt 的 GUI 线程。| 维度 | Web(JS / 浏览器) | C++ / Qt |
|---|---|---|
| 并发模型 | 单线程事件循环 + Web Worker | 多线程 + 共享内存 |
| 数据竞争 | 不存在(同一时刻单任务) | 存在,且是 UB,必须用锁/原子 |
| 线程间通信 | postMessage(消息传递) | 信号槽 QueuedConnection / 锁+共享内存 |
| 共享内存 | SharedArrayBuffer + Atomics(极少用) | std::atomic / std::mutex(常用) |
| 阻塞主线程 | 长任务卡 UI(白屏) | GUI 线程 wait() 冻结(无响应) |
| 状态更新 | React 不可变 state | copy-on-write(QSharedData) |
| 内存回收 | GC 自动 | RAII / 智能指针 |
为什么 TL 必须懂并发?并发 Bug 是线上事故的重灾区:偶发、难复现、崩溃现场诡异(数据错乱、死锁、卡死)。它无法靠测试覆盖率兜底——代码评审是第一道也是最后一道防线。一个看不懂锁的 TL,无法为团队把关。
Code Review 清单:1)每个共享变量是否都有同步保护?2)锁粒度是否合理(有没有把 IO 包进临界区)?3)GUI 线程是否可能被阻塞(wait / join / sleep)?4)多把锁的获取顺序是否全局一致?5)跨线程访问 QObject 是否都走了信号槽?
制定规范:禁止裸 lock/unlock;禁止 GUI 线程等待;共享数据一律 RAII 保护;跨线程通信只用信号槽;并发代码必须过 Review。把这些写进团队 checklist,比事后救火便宜十倍。
培养新人:先花 30 分钟讲"内存模型 + 数据竞争",再让新人 review 一段故意埋雷的并发代码——比讲十遍 API 有效得多。
std::atomic / std::mutex / std::condition_variable 页面:查证 memory_order 细节的权威工具书。为什么 std::atomic 的默认内存序是 seq_cst?把它换成 relaxed 之后,什么场景下仍然正确、什么场景下会出错?
两个线程分别"只读"和"只写"同一个 int,不加锁,在 x86 上"看起来没事",为什么在 C++ 标准里仍是 UB?在什么硬件上会真实出错?
condition_variable 的 wait 为什么必须用 while(谓词) 而不是 if(谓词)?"虚假唤醒"究竟从哪来?
Qt 的 QueuedConnection 底层是什么数据结构?为什么说它是"线程安全的"?如果接收者线程没有运行事件循环,会发生什么?
如果要给"多线程写日志"设计方案,你会选全局互斥锁、每线程独立缓冲、还是无锁队列?各自的 trade-off 是什么?
8 个线程各对同一个计数器累加 100 万次,分别用裸变量、mutex、atomic 实现,观察结果正确性与耗时差异。你将亲眼看到数据竞争"吞掉"更新,并量化三种同步手段的性能差距。
// counter.cpp — C++17
#include <atomic>
#include <chrono>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>
constexpr int kThreads = 8;
constexpr int kPerThread = 1'000'000;
template <typename T, typename F>
void run(const char* tag, T& counter, F addOne) {
auto t0 = std::chrono::steady_clock::now();
std::vector<std::thread> pool;
for (int i = 0; i < kThreads; ++i)
pool.emplace_back([&] { for (int j = 0; j < kPerThread; ++j) addOne(); });
for (auto& t : pool) t.join();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
long long val = static_cast<long long>(counter);
std::printf("[%s] result = %lld %s (%lld ms)\n", tag, val,
val == (long long)kThreads * kPerThread ? "OK" : "WRONG!", ms);
}
int main() {
long long plain = 0;
run("plain ", plain, [&] { ++plain; }); // ① 裸变量:数据竞争,大概率 WRONG
std::mutex m;
long long locked = 0;
run("mutex ", locked, [&] { std::lock_guard<std::mutex> g(m); ++locked; }); // ② 互斥锁
std::atomic<long long> atom{0};
run("atomic", atom, [&] { atom.fetch_add(1, std::memory_order_relaxed); }); // ③ 原子
}
g++ -std=c++17 -O2 -pthread counter.cpp -o counter && ./counter(多跑几次)QMutex + QMutexLocker 重写 mutex 版本,对比 Qt 封装。relaxed 就够——因为 fetch_add 只要求原子性,不要求与其他操作的排序。