CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
C++ 语言入门/课时/多线程
RawBlame
课时目录12 节

C++ 语言入门

  1. 01C++ 是什么
  2. 02基础语法
  3. 03指针与引用
  4. 04面向对象
  5. 05内存与 RAII
  6. 06STL 容器与算法
  7. 07模板基础
  8. 08现代 C++ 特性
  9. 09多线程
  10. 10构建与依赖
  11. 11调试与测试
  12. 12综合练习

多线程

std::thread、mutex、async 基础。

35 分钟

多线程

这一课你将能做到

  1. 用 std::thread 启动线程并 join,说清为什么不能丢弃未 join/detach 的线程对象
  2. 用 std::mutex + std::lock_guard 保护共享数据,复现并解释一次数据竞争
  3. 用 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

典型输出:

期望 400000,实际 231657

++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 的内部原理),等你需要跨线程投递任务时再照此结构搭建。

讲清楚要点

  1. 数据竞争 = 多线程无同步地访问同一内存且至少一个是写。C++ 标准定义为 UB,不是「可能出错」而是「一切皆允许」。识别共享数据是并发设计第一步:局部变量天然安全,成员/全局/静态要过一遍「谁会同时碰它」。
  2. 锁的粒度要小,配对要 RAII:lock_guard 作用域即临界区;持锁做 IO、调用户回调都是事故源。两把锁并存必须全局统一加锁顺序,或用 std::scoped_lock(m1, m2) 一次性拿全。
  3. atomic 不等于「线程安全」:std::atomic<int> 只让这个 int 的操作原子。if (!q.empty()) q.pop() 即使 empty 和 pop 各自原子,组合起来照样竞争——复合操作要么都进临界区,要么用 wait/notify 同步。
  4. join 是语义的一部分:join 不仅等待,还建立 happens-before 关系——工作线程的写结果,join 返回后主线程保证可见。C++ 的内存模型靠这些同步点工作,比 Java volatile 的直觉更严格。
  5. 线程是贵重资源:创建销毁开销毫秒级,判题沙箱用固定大小线程池复用;标准库无线程池,工程常自写队列 + 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

卡点急救

  1. 程序偶发输出错乱/崩溃且次数不定:先怀疑数据竞争。加 -fsanitize=thread 编译重跑(需 Linux/macOS),TSan 会直接报告竞争双方代码行。
  2. 卡住不动(疑似死锁):gdb 附加进程,thread apply all bt 看每个线程卡在哪把锁上,两个线程互等的锁就是案发现场。
  3. 加锁后性能掉一个量级:临界区过大或锁竞争激烈。缩小临界区、按数据分片多锁、纯计数改 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。

自测三问

  1. std::lock_guard 在什么时刻解锁?它对应 Java 的什么语法?
  2. 为什么 if (!q.empty()) q.pop(); 不是线程安全的?
  3. std::atomic 的 ++counter 和被 mutex 保护的 ++counter 有什么性能与语义差别?

小结一句话

C++ 并发三板斧:thread + join 管生命周期、mutex + lock_guard(RAII)管共享互斥、atomic 管单变量原子操作——数据竞争是 UB 没有侥幸,识别共享数据永远是第一步。

延伸

  • cppreference:线程支持库
  • cppreference:std::atomic
  • 下一课:构建与依赖——CMake 与 vcpkg,C++ 世界的 Maven。
加载本节练习…
← 上一课现代 C++ 特性下一课 →构建与依赖