C++20 · Qt6 · 并发与线程模型

C++/Qt 多线程并发:从 std::jthread 到 QThread、内存序与线程模型

UI 永远只有一个线程在摸——理解了这句话,你就理解了桌面客户端架构的一半

①

今日主题:为什么现在学并发

上一期我们吃透了 Qt 事件循环:UI 必须活在主线程,事件循环一旦被阻塞,界面就冻结。那么问题立刻来了——耗时任务(网络请求、磁盘 IO、图像处理、数据库查询)到底该放哪里跑?答案就是多线程。对从 Web 迁移过来的你,这是知识模型变化最大的一课:JavaScript 天生单线程,你从未真正面对过"两个线程同时改一个变量"的灾难;而 C++ 中这是未定义行为,会以最诡异的方式崩溃。本期从 C++17/20 的标准线程与内存模型讲起,再落到 Qt6 的 QThread 与线程亲和性(thread affinity),建立一套"如何安全地把工作移出 UI 线程"的完整心智。

这不是"会用 std::thread 就行"的入门课,而是架构师视角的并发课:数据竞争为什么是 UB、内存序在干什么、Qt 为什么用"线程亲和性 + 排队信号槽"而不是裸锁——这些原理决定了你未来设计的大型客户端(消息推送、多窗口、后台同步、插件系统)是稳定如 Chrome,还是偶发崩溃如老版 QQ。

②

核心知识:从线程到内存模型,再到 Qt 的线程哲学

2.1 线程是什么:共享地址空间的"并行执行流"

进程是资源分配单位,线程是调度执行单位。同一进程内的线程共享堆、全局变量与代码段,各有独立的栈与寄存器上下文。这意味着线程间"传数据"零成本——直接读写同一块内存即可;但代价是:同一时刻两个线程读写同一非原子变量,就是数据竞争(data race),C++ 标准将其定义为未定义行为。为什么是 UB 而不是"结果随机"?因为编译器在优化时会假设没有并发:它可以把变量缓存进寄存器、重排指令、甚至删除"看起来没用"的读写。一个 32 位 int 在 64 位平台上被撕裂成两个 32 位半字写、两个核同时读写的可见性问题,都只是表象——真正的敌人是优化器的假设。

2.2 C++ 内存模型:原子操作与 happens-before

C++11 为并发引入了正式的内存模型。核心规则:每个线程内部按顺序执行(sequenced-before);线程之间通过同步操作建立 happens-before 关系,从而让一个线程的写入对另一个线程可见且有序。同步操作包括:互斥锁的解锁-加锁、原子变量的释放-获取(release/acquire)、std::thread 的创建与 join、std::async 的 future 就绪等。理解这点后,std::atomic 的六种内存序就顺理成章:memory_order_relaxed 只保证原子性不保证顺序(适合计数器);acquire/release 构成"释放-获取"配对,保证临界区数据可见(无锁队列的核心);默认的 seq_cst 在全局建立单一全序,最直观也最慢。日常开发记住一条即可:默认用 seq_cst,热路径确认语义后再降级 relaxed。

2.3 同步原语家族:从 mutex 到 jthread

互斥:std::mutex 配合 std::lock_guard / C++17 的 std::scoped_lock(可一次性锁多个 mutex 且按固定顺序防死锁)/std::unique_lock(可手动解锁,条件变量必需)。条件变量:std::condition_variable 必须配 unique_lock 且 wait 必须传谓词,因为存在伪唤醒(spurious wakeup)——这是教科书级必考。线程句柄:C++20 新增 std::jthread,析构自动 join(),并支持 std::stop_token 协作式取消——彻底终结"忘记 join 导致 std::terminate"这类事故。std::async 注意:默认策略 std::launch::async | std::launch::deferred 由实现决定是否真开线程;且 std::future 析构会阻塞等待——大量创建 async 任务可能意外串行化。

2.4 Qt 的线程哲学:线程亲和性 + 排队信号槽

Qt 的答案比裸 C++ 高一层:QObject 有"线程亲和性"(thread affinity)——对象在哪个线程被创建,其事件循环就在哪个线程处理它的事件。跨线程发信号时,如果接收者的亲和线程与发送者不同,连接自动退化为 QueuedConnection:参数被拷贝打包成事件,投递到接收者所在线程的事件队列,由那边的槽执行。这就是 Qt 最优雅的设计:用"消息传递"替代"共享内存 + 锁",线程间通信不需要任何 mutex。因此 Qt 的线程使用规范是:耗时工作放到工作线程(QThread + moveToThread(),或一次性任务用 QtConcurrent::run + QThreadPool),结果通过信号槽回主线程更新 UI。QWidget 不是线程安全的,任何非主线程触碰 UI 都是未定义行为(Qt6 下是崩溃或偶发错乱);QObject 析构也只能在其亲和线程进行,跨线程 delete 要用 deleteLater()。注意 Qt6 中 QtConcurrent::run 返回 QFuture,配合 QFutureWatcher 的 finished 信号即可安全回主线程。

③

实际案例:大型客户端如何组织线程

Chrome:多进程 + 多线程的集大成者。浏览器进程内分主线程(UI/合成)、IO 线程(网络、磁盘)、数据库等专用线程;每个渲染进程还有主线程(Blink 布局绘制)与合成线程。它刻意用进程隔离 + IPC 消息传递而非共享内存,换取崩溃隔离与安全沙箱——这恰是 Qt "线程亲和性 + 排队信号槽"思想的放大版:能传消息就不共享状态。Chrome 网络栈用专用 IO 线程 + 回调回主线程,与 Qt 工作线程 + 信号回主线程是同构的。

VS Code(Electron):主进程管窗口与菜单,渲染进程跑 UI,TypeScript 语言服务 tsserver 跑在独立进程,搜索/文件监听等 CPU 密集任务走 Node 的 worker_threads 或 utilityProcess。它的架构纪律与 Qt 应用完全一致:UI 线程只做渲染与事件分发,永远不阻塞,一切可能超过 16ms 的工作都被移出渲染线程。你未来在 Qt 里写代码分析工具、语法高亮、大文件解析时,应该用完全相同的分工。

Qt Creator:代码索引(Clang Code Model)、构建任务、git 操作全部跑在 QThreadPool 工作线程,UI 保持响应;分析器以子进程形式隔离。这是 Qt 官方对"QThread 该不该继承重写 run()"给出的示范答案:用线程池执行任务,用信号槽回传结果,几乎不需要手写线程生命周期。微信/QQ 桌面端同理:消息收发、加解密、本地数据库(SQLite 连接池)都在工作线程,主线程只负责渲染与输入分发——所以即使数据库卡顿,界面也只是转圈而非白屏冻结。JetBrains IDEA 的 EDT(事件分发线程)思想与之完全同源:单 UI 线程 + 后台线程池 + 消息回传,是桌面客户端的黄金法则。

④

常见错误:并发五大坑

错误①:子线程直接操作 UI——在 lambda 里调用 label->setText(),偶发崩溃且难以复现。原因:QWidget 非线程安全,且跨线程访问无任何保护。避免:信号槽跨线程(自动 QueuedConnection),或 QMetaObject::invokeMethod(obj, ..., Qt::QueuedConnection);拿到结果先 qDebug() << QThread::currentThread() 确认线程。

错误②:std::thread 忘记 join/detach——函数提前 return 时 std::thread 析构,若仍 joinable 直接 std::terminate(),进程无征兆崩溃。原因:C++ 强制你显式决定线程归宿。避免:C++20 用 std::jthread(RAII 自动 join),或封装进类并在析构里 join;永远不要在异常路径上裸持有 std::thread。

错误③:锁顺序不一致导致死锁——线程 A 持锁1等锁2,线程 B 持锁2等锁1,双双挂起。避免:全局固定加锁顺序;std::scoped_lock 一次锁多个;临界区尽量小;持锁时绝不 emit 信号(槽可能同步执行并反向加锁)。

错误④:以为 int 读写是原子的——多线程 counter++,结果永远小于期望。原因:读-改-写三步不是原子,优化器与缓存共同放大丢失更新。避免:std::atomic 的 fetch_add;用 -fsanitize=thread 编译跑一遍,TSan 会精确报出每次数据竞争。

错误⑤:忙等(while(!flag){})——烧满一个核,且因可见性问题可能永远等不到。原因:没有同步操作就没有 happens-before,flag 可能一直读到旧值。避免:条件变量 + 谓词、Qt 信号槽、或 QFutureWatcher::waitForFinished;需要轮询的场合用 QThread::msleep + 原子标志兜底。

⑤

最佳实践:Qt 并发该怎么做

实践①:一次性耗时任务用 QtConcurrent::run,长期工作者用 QThread + moveToThread。QtConcurrent 走全局 QThreadPool,自动管理线程数,配合 QFutureWatcher 回主线程,代码最短;需要常驻(连接池、消息推送长连接、轮询器)时,把 QObject 子类 moveToThread(thread),用信号槽驱动其生命周期。不要继承 QThread 重写 run()——那是把线程当"任务"用,还要手工管理事件循环,官方不推荐。

实践②:能传消息就不共享状态。信号槽排队连接天然线程安全,参数按值拷贝(自定义类型需 qRegisterMetaType)。共享数据第一选择是"根本不同线程访问",其次才是锁——无共享则无竞争,这是 Qt 比裸 C++ 并发友好的根本原因。

实践③:必须共享时,优先 atomic,其次小临界区 mutex。计数器、标志位用 std::atomic;读写集合用 QReadWriteLock(读多写少);简单状态用 QMutexLocker 保证异常安全。锁的粒度权衡:细锁并发高但易错且锁本身有开销,粗锁安全但串行化——先正确,再 profiling 决定是否细化。

实践④:回主线程更新 UI 只有一条路——排队信号槽/invokeMethod。Qt6 里 QMetaObject::invokeMethod(obj, "setText", Qt::QueuedConnection, Q_ARG(...)) 或 lambda 形式均可;永远不要在工作线程直接调 UI 方法,哪怕"看起来只是改个进度条"。

实践⑤:CI 上跑 ThreadSanitizer。编译加 -fsanitize=thread -g,跑测试与压力场景,TSan 会报告每次数据竞争与错误锁序。何时不用:TSan 有 2 倍以上运行开销且与部分优化冲突,只在 CI 专用 job 启用,不用于发布构建。无锁队列、无锁哈希这类高级技巧,团队没有足够经验前一律不用——AB A 问题与内存序错误的排查成本远超那点性能收益。

⑥

与 Web 技术的联系:你的并发直觉其实已经在线

你在 Node.js 里早已熟悉"单线程事件循环 + 后台线程池":libuv 的线程池跑文件 IO 与 DNS,回调回到主循环——这就是 Qt 的"工作线程 + 排队信号槽回主线程"。把 fs.readFile 的回调映射为信号槽的 QueuedConnection,把 worker_threads 的 postMessage 映射为跨线程信号,心智几乎零迁移成本。区别在于:JS 默认无共享内存(postMessage 是结构化克隆,Worker 之间默认隔离),而 C++ 线程天生共享地址空间——所以 C++ 把"不共享"当作纪律,把"共享"当作需要加锁的例外。

浏览器里的 SharedArrayBuffer + Atomics.wait/notify 正是 C++ 原子操作与内存序的移植版:Atomics.add 对应 fetch_add,Atomics 语义即 seq_cst。你之前对"主线程不能阻塞、长任务要分片/后台化"的直觉(React 并发渲染、Web Worker 处理大计算),在桌面端就是"UI 线程帧预算 16ms、耗时计算下沉 QThreadPool"。再看 async/await:await 之后的代码必然回到主线程执行——等价于 Qt 中工作线程完成后信号槽把结果投递回主线程队列。你已经懂了架构,只是需要把"事件循环"改名为"事件队列 + 线程亲和性"。

⑦

管理者视角:Team Leader 如何把关并发

并发 bug 是 Code Review 最难发现、线上最难复现的一类问题,所以团队必须靠规范前置而不是 review 兜底。建议三条:其一,团队文档里写清"线程模型约定"——哪些对象活在哪个线程、线程间通信只允许信号槽/队列、UI 只能在主线程碰,新人入职先读这篇;其二,Code Review 时逐行问三个问题:这段代码在哪个线程跑?它碰了谁的数据?这个数据还有谁在碰?看到裸 std::thread、手写 while(!flag)、lambda 里直接调 UI 方法,一律打回;其三,把 TSan 跑进 CI 并强制提交前本地跑一遍,用工具把"偶发崩溃"变成"构建失败"。

指导新人时,先让他用本期实践任务亲手制造一次数据竞争并观察 TSan 报告——比讲十遍理论都有效。排期上要预留并发改造的缓冲时间:线程化重构的 bug 密度远高于功能开发,切忌在版本末尾压测期做"顺手加个线程"。你作为从 Web 转型的 Leader,最大的优势恰恰是懂"事件循环思维"——把 Node 的异步纪律带进 C++ 团队,就是独特的技术领导力。

⑧

延伸阅读

《C++ Concurrency in Action》第二版(Anthony Williams)——并发领域的圣经,作者是 C++ 标准委员会并发小组核心成员,内存模型、无锁编程讲得透彻,覆盖 C++17/20 的 jthread 与 stop_token。

cppreference.com 的 memory_order 页面——六种内存序的精确定义与图示,配合 Preshing 博客的 "The Synchronizes-With Relation" 系列(并发内存模型最清晰的科普),值得反复读。

Qt 官方文档 "Threads and QObjects" 与 Qt Concurrent 模块——线程亲和性的权威解释与推荐用法,比任何二手教程都准确。

Chromium 官方多进程架构文档——大型客户端线程/进程架构的实战范本,理解"为什么隔离比共享更可靠"的最佳案例。

Herb Sutter 的 "atomic<> Weapons" 演讲——用 2 小时把内存序讲到可视化级别,C++ 并发进阶的必看视频。

⑨

今日思考题

Q1:为什么 C++ 把数据竞争定义为未定义行为,而 JavaScript 单线程天然没有这个问题?这对"从 Web 迁移到桌面"意味着哪些习惯要改?
Q2:信号槽的 QueuedConnection 与 Node 的 postMessage 有何异同?各自的能力边界在哪里(参数类型、所有权、性能)?
Q3:在什么场景下,一个精心设计的无锁队列反而比加锁队列更慢?(提示:考虑竞争强度、内存序开销、缓存行伪共享)
Q4:如果 Qt 没有线程亲和性概念、信号槽跨线程不自动排队,桌面应用开发会变成什么样?
Q5:Chrome 选择"进程隔离 + 消息传递",而大多数 Qt 应用选择"线程 + 信号槽"——分界线在哪里?什么规模的客户端值得升级到多进程?
⑩

今日实践任务:亲手制造并修复一次数据竞争

30 分钟。用 8 个线程并发累加 1000 万次,先观察数据竞争的后果,再体会 TSan 的价值。步骤:①按下面代码编译运行(应输出正确值);②把 std::atomic<long long> 换成 long long、fetch_add 换成 sum += 1 再跑,观察结果丢失;③加 -fsanitize=thread 编译,观察 TSan 精确报告竞争位置;④(可选)把求和改成"每个线程先累加私有变量、最后在主线程汇总",体会"无共享"方案。

// race_demo.cpp — 编译: g++ -std=c++20 -O2 -pthread race_demo.cpp -o race_demo && ./race_demo
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

int main() {
    constexpr int kThreads = 8;
    constexpr int kLoop    = 1'000'000;

    std::atomic<long long> sum{0};   // 改成 long long sum = 0; 试试?

    std::vector<std::jthread> workers;   // jthread: 析构自动 join
    for (int t = 0; t < kThreads; ++t) {
        workers.emplace_back([&sum] {
            for (int i = 0; i < kLoop; ++i)
                sum.fetch_add(1, std::memory_order_relaxed);
        });
    }
    // workers 析构时自动等待所有线程结束(等价于 join)

    std::cout << "sum = " << sum.load()
              << "(期望 " << kThreads * kLoop << ")\n";
}

进阶思考:本任务的 memory_order_relaxed 是安全的(每个 fetch_add 独立、无顺序依赖);若改为"先写一个标志、另一个线程读标志后读数据",就必须 acquire/release 或 mutex——动手验证一下。

⑪

一句话总结

UI 线程只做一件事,其余全部交给工作线程,并用信号槽把结果安全地送回来——这是桌面客户端并发架构的第一性原理。