多线程
这一课你将能做到
- 用
std::thread 启动线程并 join,说清为什么不能丢弃未 join/detach 的线程对象
- 用
std::mutex + std::lock_guard 保护共享数据,复现并解释一次数据竞争
- 用
std::atomic 实现无锁计数器,说出它与 mutex 加锁的适用边界
为什么要学
判题高峰期,沙箱服务不可能一次只评一份提交:主线程收任务,工作线程池并行评测,计数器实时更新队列长度——全是多线程。Java 的并发工具箱(Thread、synchronized、AtomicInteger、线程池)极其成熟;C++11 把同等能力纳入了标准库,概念一一对应,只是内存模型更底层。
Java 有 GC 和 JVM 兜底,很多并发错误表现为「偶发变慢」;C++ 的数据竞争直接是 UB,可能崩溃可能静默出错。所以 C++ 圈对「先想清楚哪些数据被共享」的要求更苛刻。本课抓三个最核心的工具:thread、mutex、atomic,够你读懂判题沙箱的调度代码。
先建立图像
判题沙箱的线程模型(简化):
主线程 工作线程池(N 个 std::thread)
│ │
├─ 收到提交 ──→ 任务队列 ───→ │ 工作线程1:评测提交A
│ (mutex 保护) │ 工作线程2:评测提交B
├─ 收到提交 ────→ 入队 │ ↓
│ │ 更新统计计数(atomic)
└─ join:等所有评测线程结束再汇总结果
三件事:共享队列要锁、状态计数要原子、等待结束要 join
Java ↔ C++ 并发原语对照:
Thread ↔ std::thread
synchronized 块 ↔ std::lock_guard<std::mutex>(RAII 自动解锁)
AtomicInteger ↔ std::atomic<int>
ReentrantLock ↔ std::mutex / std::recursive_mutex
CountDownLatch ↔ std::latch(C++20)/ 条件变量手写
线程池 ↔ 无标准库线程池(手写或第三方)
跟着做
步骤 1:启动第一个线程
新建 concurrency.cpp:
#include <iostream>
#include <thread>
void judge(int subId) { // 线程入口:普通函数即可
std::cout << "线程评测提交 #" << subId << "\n";
}
int main() {
std::thread t1(judge, 101); // 启动线程,参数按值拷贝传过去
std::thread t2(judge, 102);
t1.join(); // 阻塞等待 t1 结束
t2.join();
std::cout << "全部评测完成\n";
return 0;
}
g++ -std=c++17 -pthread concurrency.cpp -o concurrency && ./concurrency
(Linux/MinGW 必须加 -pthread,忘了会链接报错——这是新手第一坑。)输出顺序每次运行可能不同,唯一保证是 join 之后主线程才继续。
步骤 2:一个不能犯的错——线程对象析构前必须 join 或 detach
#include <iostream>
#include <thread>
int main() {
std::thread t([] {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "工作完成\n";
});
// t.join(); ← 如果两个线程路径都忘调,程序析构 std::thread 时直接 std::terminate
int ret = 0; // 模拟中途 return 的分支
if (ret) {
return 1; // 这里提前 return,t 没人 join → 崩溃!
}
t.join();
return 0;
}
规则记死:std::thread 析构前必须已被 join 或 detach。上面的风险解法还是 RAII——把 join 放进析构函数(标准库没有提供 JoinGuard,工程里常自写十行;C++20 的 std::jthread 直接内置此行为):
#include <thread>
class JoinGuard { // 用上一课的 RAII 思想包线程
public:
explicit JoinGuard(std::thread t) : t_(std::move(t)) {}
~JoinGuard() { if (t_.joinable()) t_.join(); }
JoinGuard(const JoinGuard&) = delete; // 禁拷贝
JoinGuard& operator=(const JoinGuard&) = delete;
private:
std::thread t_;
};
步骤 3:亲眼看看数据竞争
#include <iostream>
#include <thread>
#include <vector>
long long counter = 0; // 共享变量,没有保护
void addTenThousand() {
for (int i = 0; i < 100000; ++i) {
++counter; // 读-加-写三步,线程间互相踩踏
}
}
int main() {
std::vector<std::thread> ts;
for (int i = 0; i < 4; ++i) ts.emplace_back(addTenThousand);
for (auto& t : ts) t.join();
std::cout << "期望 400000,实际 " << counter << "\n";
return 0;
}
g++ -std=c++17 -pthread race.cpp -o race && ./race
++counter 编译后是「读内存 → 寄存器加 1 → 写回」三条指令,两个线程交错执行就会丢更新。这不是概率问题,是 C++ 标准定义的数据竞争 UB——编译器有权生成任何行为。同一个程序在 Java 里可能「碰巧」不炸,在 C++ 里连结果的正确性都无从谈起。
步骤 4:mutex + lock_guard——锁住共享数据
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
long long counter = 0;
std::mutex m; // 一把锁保护一个共享资源
void addTenThousand() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lk(m); // 构造时加锁(可能阻塞)
++counter;
} // 离开作用域自动解锁 ← RAII
}
int main() {
std::vector<std::thread> ts;
for (int i = 0; i < 4; ++i) ts.emplace_back(addTenThousand);
for (auto& t : ts) t.join();
std::cout << "结果恒为 " << counter << "\n"; // 400000
return 0;
}
为什么不直接调 m.lock() / m.unlock()?因为异常或提前 return 会跳过 unlock,死锁整个系统。lock_guard 对应 Java 的 synchronized(m) { ... },但解锁时机是确定的析构而不是「退出同步块」,本质相同。C++17 还有带作用域检查的 std::scoped_lock,可一次锁多把 mutex 并自动避免死锁顺序问题。
步骤 5:atomic——一个数就够了就别上锁
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<long long> counter{0}; // 原子类型:++ 是不可分割的单条操作
void addTenThousand() {
for (int i = 0; i < 100000; ++i) {
++counter; // 原子的读改写,无需锁
}
}
int main() {
std::vector<std::thread> ts;
for (int i = 0; i < 4; ++i) ts.emplace_back(addTenThousand);
for (auto& t : ts) t.join();
std::cout << "恒为 " << counter << "\n"; // 400000
return 0;
}
atomic 由 CPU 特殊指令(x86 上是 lock 前缀指令)保证不可分割,比 mutex 轻量得多。适用边界:单个变量的单个操作(计数、标志位)。要保护「一组变量的一致性」(如同时改两个 map),原子救不了你,还得 mutex。判题沙箱的任务队列则是「队列 + mutex + condition_variable」的组合(即 Java BlockingQueue 的内部原理),等你需要跨线程投递任务时再照此结构搭建。
讲清楚要点
- 数据竞争 = 多线程无同步地访问同一内存且至少一个是写。C++ 标准定义为 UB,不是「可能出错」而是「一切皆允许」。识别共享数据是并发设计第一步:局部变量天然安全,成员/全局/静态要过一遍「谁会同时碰它」。
- 锁的粒度要小,配对要 RAII:
lock_guard 作用域即临界区;持锁做 IO、调用户回调都是事故源。两把锁并存必须全局统一加锁顺序,或用 std::scoped_lock(m1, m2) 一次性拿全。
- atomic 不等于「线程安全」:
std::atomic<int> 只让这个 int 的操作原子。if (!q.empty()) q.pop() 即使 empty 和 pop 各自原子,组合起来照样竞争——复合操作要么都进临界区,要么用 wait/notify 同步。
- join 是语义的一部分:join 不仅等待,还建立 happens-before 关系——工作线程的写结果,join 返回后主线程保证可见。C++ 的内存模型靠这些同步点工作,比 Java volatile 的直觉更严格。
- 线程是贵重资源:创建销毁开销毫秒级,判题沙箱用固定大小线程池复用;标准库无线程池,工程常自写队列 + N 个消费者。
你可能会懵的地方
-
-pthread 到底干了什么?
告诉编译器链接 POSIX 线程库并启用线程安全的代码生成(如 errno 变成线程局部)。MSVC 不需要(Windows API 原生支持),Linux/MinGW 必须。CMake 里 find_package(Threads REQUIRED) + target_link_libraries(x Threads::Threads) 是正解,下一课讲。
-
detach 了会怎样?
线程与 thread 对象脱钩,后台自己跑完自动回收资源。听起来省事,实际上「主程序退出时后台线程还在用已析构的对象」是必炸局。工程守则:不用 detach,用 join 或线程池。
-
volatile 能代替 atomic 吗?
不能。volatile 只禁止编译器优化掉读写,不保证原子性、不建立线程间顺序——Java 的 volatile 是强语义,C++ 的 volatile 是硬件寄存器语义,两者同名不同命。C++ 线程同步一律 atomic/mutex。
-
为什么 wait 要配 unique_lock 而不是 lock_guard?
wait 内部要先解锁再睡眠、被唤醒后再重新加锁,lock_guard 只有构造加锁析构解锁两态,不够用。这是「工具适配场景」的典型案例。
-
死锁怎么产生、怎么破?
线程 A 持锁 1 等锁 2,线程 B 持锁 2 等锁 1。破法:固定加锁顺序、缩小临界区、std::scoped_lock 一次取全部锁、或拆分锁粒度。排查靠日志 + ThreadSanitizer(-fsanitize=thread,下一课末尾会提)。
错法对照
| 错法 | 问题 | 正解 |
|---|
忘记 -pthread 编译 | 链接失败或运行时未定义行为 | 编译/链接都加 -pthread(或 CMake 配 Threads) |
| thread 对象未 join 就析构 | std::terminate 直接终止进程 | join,或用 jthread/JoinGuard |
if (!q.empty()) q.pop() | 检查与操作间被插队,复合竞争 | 锁内判断再操作,或 condition_variable |
| 临界区里做 IO/日志 | 锁持有时间暴增,吞吐崩塌 | 锁内只改共享数据,IO 移到锁外 |
| 用 volatile 想同步 | 无原子性无顺序,竞争依旧 | 用 std::atomic 或 mutex |
卡点急救
- 程序偶发输出错乱/崩溃且次数不定:先怀疑数据竞争。加
-fsanitize=thread 编译重跑(需 Linux/macOS),TSan 会直接报告竞争双方代码行。
- 卡住不动(疑似死锁):gdb 附加进程,
thread apply all bt 看每个线程卡在哪把锁上,两个线程互等的锁就是案发现场。
- 加锁后性能掉一个量级:临界区过大或锁竞争激烈。缩小临界区、按数据分片多锁、纯计数改 atomic,逐步定位。
面试常问
-
std::mutex 与 std::atomic 如何选择?
保护单个变量的简单读改写(计数、标志)用 atomic,轻量无阻塞;保护不变式跨多个变量、临界区内有复合逻辑用 mutex。atomic 无法保证多个变量的一致性,mutex 可以;反之高频单变量计数用 mutex 是浪费。
-
描述一次数据竞争及修复过程。
多线程无同步地读写同一内存(如 ++counter),读改写交错导致更新丢失,C++ 中属 UB。修复:明确共享数据 → 互斥(mutex + lock_guard)或原子化(atomic)→ 用 TSan 验证。核心是把「共享 + 可变」识别出来,二者缺一则安全。
-
C++ 的 join 和 Java 的 Thread.join 语义上有何异同?
相同:都阻塞等待目标线程结束,并建立内存可见性(happens-before)。不同:C++ thread 对象析构前必须 join/detach 否则 terminate,且 join 只能调一次;Java 的 Thread 无此析构约束,由 JVM 与 GC 兜底,但泄漏线程照样浪费资源。C++20 的 jthread 用 RAII 补齐了析构自动 join。
自测三问
std::lock_guard 在什么时刻解锁?它对应 Java 的什么语法?
- 为什么
if (!q.empty()) q.pop(); 不是线程安全的?
std::atomic 的 ++counter 和被 mutex 保护的 ++counter 有什么性能与语义差别?
小结一句话
C++ 并发三板斧:thread + join 管生命周期、mutex + lock_guard(RAII)管共享互斥、atomic 管单变量原子操作——数据竞争是 UB 没有侥幸,识别共享数据永远是第一步。
延伸