今日学习:锁与并发原语

从 Web 单线程世界走进 C++ 并发世界:数据竞争、互斥锁、原子操作与条件变量——这是桌面客户端架构师的必修课,也是你知识体系里最缺的一块拼图。

01

今日主题

你已经学会了怎么创建线程(std::thread / QThread),也知道了信号槽可以跨线程通信。但"会开线程"离"敢写并发"还隔着一条河:当多个线程同时读写同一个变量时,C++ 会发生数据竞争(data race)——这是未定义行为,程序可能在你发布之后才偶发崩溃。

今天系统学习锁与并发原语:std::mutex、std::atomic、std::condition_variable 以及 Qt 的 QMutex / QReadWriteLock / QWaitCondition。这是多线程主题的深度延伸,也是 Web 开发者知识体系中最缺失的一块——因为 JS 单线程世界里根本没有"锁"这个概念。

一句话说清并发安全:能传消息就不共享内存;必须共享就用 RAII 锁或原子操作;永远不要在 GUI 线程里等待。
02

核心知识

1. 数据竞争:并发 Bug 的总根源

count++ 在机器层面是三条指令:读、加、写。两个线程同时执行它,就可能出现"都读到 100,都写成 101"——一次更新被吞掉。这就是读-改-写(read-modify-write)非原子导致的丢更新。更隐蔽的是可见性问题:线程 A 写了一个变量,线程 B 读到的可能还是旧值——因为 CPU 缓存、编译器重排、乱序执行都可能在"没有同步机制"时生效。C++ 标准把"两个线程无同步地访问同一非原子变量,且至少一个是写"定义为数据竞争(data race),属于未定义行为(UB)。UB 意味着编译器可以做任何优化,包括"看起来不可能"的优化——"我跑了一万次都没事"只是运气,换 ARM 平台或开 -O3 就可能现形。

对比 JS:浏览器事件循环是单线程的,同一时刻只有一个任务在执行,天然没有数据竞争;Node 的 worker_threads 通过 postMessage 传消息,默认不共享内存。所以你没有"锁"的直觉是正常的——但转向 C++ 桌面开发,这是必须补上的核心概念。

2. 三件套:mutex / atomic / condition_variable

  • std::mutex + std::lock_guard:互斥锁保护临界区。关键在于 lock_guard 是 RAII 包装(呼应 7/30 的主题):构造时加锁、析构时解锁,中途 return 或抛异常也能保证解锁。C++17 的 std::scoped_lock 可以一次锁多把锁,从语言层面消除"锁顺序不一致导致的死锁"。
  • std::atomic<T>:针对单个变量的无锁方案,底层是 CPU 原子指令(x86 的 lock 前缀指令 / CAS)。它比 mutex 轻一个数量级,适合"计数器、标志位"这类场景;fetch_add / compare_exchange_strong 都是原子读-改-写。默认 memory_order_seq_cst 保证全局一致序,代价是性能略低;进阶再研究 relaxed / acquire / release。
  • std::condition_variable:解决"等待某个条件成立"的问题。没有它,你只能忙等待(while (!ready) {} 空转烧 CPU)或轮询 sleep(延迟且浪费)。它的 wait() 必须配合 std::unique_lock,并且必须用 while(谓词) 循环包裹——因为存在虚假唤醒(spurious wakeup)和"先通知后等待"的窗口。

3. Qt 的封装与线程亲和性

  • QMutex + QMutexLocker:与 std 对应;注意 QMutex 默认非递归,同一线程重入会死锁(除非用 QMutex::Recursive)。
  • QReadWriteLock:读多写少场景(配置表、行情快照),允许多个读者并发,写者独占。
  • QWaitCondition:condition_variable 的 Qt 版。
  • 线程亲和性(thread affinity):QObject 属于创建它的线程,跨线程直接"调用"其槽函数是危险的。正确姿势是信号槽连接用 Qt::QueuedConnection(事件投递到接收者线程的队列,线程安全),或 QMetaObject::invokeMethod(obj, ..., Qt::QueuedConnection)。另外,Qt 容器(QList 等)都不是线程安全的。
  • 核心理念:能传消息就不共享内存——信号槽本身就是一套生产者-消费者队列,这是 Qt 替你实现好的"无锁"路径。
03

实际案例

行情软件(同花顺 / 东方财富类 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。

04

常见错误

  1. 裸变量跨线程读写(用 int 当标志位):数据竞争是 UB,"跑了很多次都没事"只是 x86 强内存模型在掩盖问题,换 ARM 或更高优化级别就现形。避免:用 std::atomic<bool> 或用互斥锁保护。
  2. 锁的粒度过大:把网络请求、文件 IO、甚至 UI 操作包进 lock_guard。后果:其他线程全部排队,多线程退化成单线程,还引入卡顿。避免:临界区只保留必要的内存操作,IO 一律移出锁外。
  3. 裸 mutex 手动 lock/unlock:中途 return 或抛异常就忘了解锁,直接死锁。避免:永远用 lock_guard / scoped_lock / QMutexLocker,让 RAII 兜底——这也是为什么上一课(7/30 RAII)是并发安全的前提。
  4. 多把锁顺序不一致:线程 A 先锁 m1 再锁 m2,线程 B 先锁 m2 再锁 m1,交叉等待形成死锁。避免:全项目固定加锁顺序,或直接用 std::scoped_lock(m1, m2) 原子地同时获取多把锁。
  5. GUI 线程 wait():在主线程里 condition_variable::wait() 或 QThread::wait() 等待工作线程结果。后果:事件循环停摆,窗口无响应(白屏/转圈),体验等同于浏览器主线程被长任务卡死。避免:永远用异步通知(信号槽 QueuedConnection / invokeMethod)把结果送回 UI 线程。
05

最佳实践

  • 按场景选武器:单个标志/计数器 → std::atomic;一段复合操作(读-改-写多变量)→ std::mutex + RAII;"等条件成立再继续" → condition_variable;读多写少 → QReadWriteLock。不要一上来就上锁。
  • 持锁时间最小化:锁内只做"读改写共享状态"的内存操作,IO / 网络 / 日志 / UI 全部移出临界区。锁的真实代价不是加解锁本身,而是让其他线程排队等待。
  • 优先消息传递,其次共享内存:能用信号槽 QueuedConnection、QThreadPool + QtConcurrent、任务队列解决的,就不要设计共享状态。共享状态越多,并发 Bug 的排查面越大。
  • 别裸用 std::thread 管理生命周期:优先 QThreadPool / std::async / QtConcurrent,它们帮你管理线程池与任务队列;QObject 跨线程用 moveToThread 明确归属。
  • Trade-off 提醒:atomic 快但只能解决单变量;读写锁在"写多"场景反而比互斥锁慢;自研无锁队列又难写又难调,生产环境优先用现成的(moodycamel::ConcurrentQueue、Qt 事件队列),别重复造轮子。

什么时候不要用锁?

能用"每个线程独立数据 + 最后合并"就不共享(并行归约);能只读共享就用 const + 只读并发;能用 copy-on-write(QSharedData / std::shared_ptr)就不原地修改。锁是用来保护"不得不共享的可变状态"的,而不是用来给所有共享数据兜底的。

06

与Web技术的联系

你可能会惊讶:Web 世界里最让你头疼的是"渲染性能",而 C++ 世界里最让新人头疼的是"并发正确性"。但底层心智模型是相通的:

  • 为什么你从没写过锁?因为 JS 事件循环单线程,同一时刻只有一个任务在跑,天然无竞争。Node 的 worker_threads 通过 postMessage 传递消息(结构化克隆/转移),本质就是"消息传递"范式——和 Qt 推荐的并发方式一模一样。你已有的"跨线程用消息"的直觉,直接迁移即可。
  • SharedArrayBuffer + Atomics:Web 里唯一的共享内存方案,Atomics.wait / Atomics.store 就是简化版的内存模型 API。今天学完 std::atomic 和 memory_order 之后,回头翻 MDN 上 Atomics 的文档,你会发现突然读得懂了。
  • React 不可变更新 ≈ 无锁思想:React 不修改 state,而是生成新状态替换——这正是不共享可变状态的极端形式。C++ 的 copy-on-write(QByteArray 隐式共享)是同一哲学,只是 C++ 需要额外小心引用计数本身的线程安全(std::shared_ptr 的引用计数是原子的,但所指对象不是)。
  • "别阻塞事件循环":浏览器里一个 500ms 的同步任务让页面白屏;Qt 里 GUI 线程 wait() 让窗口无响应——完全同一件事。你已有的"主线程性能预算"心智模型可以直接迁移到 Qt 的 GUI 线程。
维度Web(JS / 浏览器)C++ / Qt
并发模型单线程事件循环 + Web Worker多线程 + 共享内存
数据竞争不存在(同一时刻单任务)存在,且是 UB,必须用锁/原子
线程间通信postMessage(消息传递)信号槽 QueuedConnection / 锁+共享内存
共享内存SharedArrayBuffer + Atomics(极少用)std::atomic / std::mutex(常用)
阻塞主线程长任务卡 UI(白屏)GUI 线程 wait() 冻结(无响应)
状态更新React 不可变 statecopy-on-write(QSharedData)
内存回收GC 自动RAII / 智能指针
07

管理者视角

为什么 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 有效得多。

08

延伸阅读

  • 《C++ Concurrency in Action》(Anthony Williams):并发圣经,第二版覆盖 C++17。把 mutex/atomic/内存模型/无锁结构的"为什么"讲透,而非 API 罗列,适合精读。
  • cppreference.com 的 std::atomic / std::mutex / std::condition_variable 页面:查证 memory_order 细节的权威工具书。
  • Qt 官方文档 Threads and QObjects(doc.qt.io):讲线程亲和性与信号槽跨线程的正确姿势,Qt 并发必读。
  • Herb Sutter 演讲 "atomic<> Weapons: The C++ Memory Model and Modern Generic Programming":两小时把内存模型讲成故事,配合本主题食用效果最佳。
  • KDAB 博客(kdab.com)Qt 线程与性能系列:真实 Qt 项目的并发与性能实践,贴近工程。
09

今日思考题

为什么 std::atomic 的默认内存序是 seq_cst?把它换成 relaxed 之后,什么场景下仍然正确、什么场景下会出错?

两个线程分别"只读"和"只写"同一个 int,不加锁,在 x86 上"看起来没事",为什么在 C++ 标准里仍是 UB?在什么硬件上会真实出错?

condition_variable 的 wait 为什么必须用 while(谓词) 而不是 if(谓词)?"虚假唤醒"究竟从哪来?

Qt 的 QueuedConnection 底层是什么数据结构?为什么说它是"线程安全的"?如果接收者线程没有运行事件循环,会发生什么?

如果要给"多线程写日志"设计方案,你会选全局互斥锁、每线程独立缓冲、还是无锁队列?各自的 trade-off 是什么?

10

今日实践任务

三路并发计数器对比实验(约 40 分钟)

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(多跑几次)
  • 预期输出:plain 行 result < 8000000 且标 WRONG(丢更新);mutex 行 OK 但最慢;atomic 行 OK 且明显更快。
  • 加分项 1:把线程数改成 1,三者结果全部正确——体会"数据竞争需要真正并发才现形"。
  • 加分项 2:用 QMutex + QMutexLocker 重写 mutex 版本,对比 Qt 封装。
  • 思考:这里 atomic 用 relaxed 就够——因为 fetch_add 只要求原子性,不要求与其他操作的排序。
11

一句话总结

并发安全三板斧:能传消息就不共享内存;必须共享就用 RAII 锁或原子操作;永远不要在 GUI 线程里等待。