多线程编程基础
1. 基础概念
多线程编程之前,先搞清楚几个基本概念:并发、并行、进程、线程、协程。它们经常被混用,但含义差别很大,而且这些差异最终都落在硬件层面。
1.1 并发与并行
并发(concurrency)指多个任务在同一时间段内交替推进——宏观上看它们"同时"在进行,微观上可能只有一个执行单元在轮流处理。并行(parallelism)指多个任务在同一时刻真正同时执行,这要求有多个执行单元(多个 CPU 核心)。
两者的关系可以用一个经典表述概括:并发是程序结构层面的性质,并行是执行层面的性质。一个程序可以设计成并发的(多个任务交替推进),但只有在多核机器上运行时才可能并行。
单核并发(时间片轮流):
CPU 核心: [任务A][任务B][任务A][任务B][任务A][任务B] ...
└── 每个时刻只有 1 个任务在跑 ──┘
双核并行:
核心 1: [任务A][任务A][任务A][任务A] ...
核心 2: [任务B][任务B][任务B][任务B] ...
└── 同一时刻有 2 个任务在跑 ──┘
注意:并发程序即使只有单核也能写(靠调度器切换),而并行程序必须有多核硬件。反过来,并发也是并行的前提——没有把程序拆成多个可独立推进的任务,再多核心也只有一个任务在跑。
1.2 进程
进程(process)是操作系统进行资源分配的基本单位。每个进程拥有独立的虚拟地址空间:自己的代码段、数据段、堆、栈,还有独立的文件描述符表、信号处理器等资源。
进程之间天然隔离,一个进程崩溃不会直接拖垮另一个进程。但也正因隔离,进程间要交换数据必须走进程间通信(IPC,Inter-Process Communication)机制:管道、消息队列、共享内存、套接字等,这些机制要么有系统调用开销,要么需要额外的同步。
创建进程的开销比较大:Linux 上 fork() 要复制页表、文件描述符表、信号处理等内核数据结构(写时复制技术只缓解了内存页的复制),Windows 上 CreateProcess 更是要完整加载一个新的地址空间。
1.3 线程
线程(thread)是操作系统进行调度的基本单位,是进程内部的一条独立执行流。一个进程至少有一个线程(主线程),也可以有多个线程。
同一进程内的所有线程共享进程的地址空间:全局变量、堆内存、文件描述符都能直接访问,不需要 IPC。每个线程私有的东西只有:寄存器状态、栈(所以局部变量是线程私有的)、程序计数器、线程局部存储。
进程 A 的内存布局:
┌─────────────────────────────┐
│ 代码段 / 全局数据 / 堆 │ ← 所有线程共享
├──────────────┬──────────────┤
│ 线程 1 的栈 │ 线程 2 的栈 │ ← 每个线程独立
├──────────────┴──────────────┤
│ 线程 1 的寄存器、PC、TLS │
│ 线程 2 的寄存器、PC、TLS │
└─────────────────────────────┘
由于共享地址空间,线程之间交换数据只需要直接读写共享变量,代价远低于 IPC;但共享也意味着互相干扰的风险——两个线程同时改同一个变量就会出问题(详见《数据共享问题与锁》)。
1.4 进程与线程的对比
| 对比项 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 相互独立 | 同进程内共享 |
| 资源开销 | 大(页表、内核结构) | 小(只需一个栈和少量内核结构) |
| 通信方式 | IPC(管道、共享内存等) | 直接读写共享变量 |
| 调度单位 | 进程(内核调度) | 线程(内核调度) |
| 切换开销 | 大(地址空间切换,TLB 失效) | 较小(同一地址空间内切换) |
| 隔离性 | 强,崩溃互不影响 | 弱,一个线程崩溃(如非法访问)整个进程退出 |
| 适用场景 | 需要强隔离、多程序 | 需要频繁协作、共享数据 |
注:严格来说 Linux 的调度单位是线程,
fork出来的子进程和线程在内核里都对应task_struct,区别在于是否共享地址空间。所以"进程切换开销大"的本质是地址空间切换(TLB 缓存失效)带来的,而不是调度本身。
1.5 协程
协程(coroutine)是用户态的轻量级执行流:它由程序自己(或运行时库)调度,不经过操作系统内核。协程的切换只发生在用户态,保存/恢复少量寄存器即可,不需要陷入内核,所以切换开销比线程低一两个数量级。
一个线程上可以挂很多协程,它们轮流使用这个线程的算力。协程的特点是挂起(suspend)时主动让出执行权,等条件满足再恢复(resume)。C++20 引入了语言级协程支持(co_await、co_yield 等),但需要配合库(如 Boost.Asio)使用才方便。
协程与线程不是替代关系:协程跑在线程上。一个多线程程序里,每个线程都可以运行自己的协程调度器。可以这样理解层级:
硬件线程(CPU 核心/逻辑核心)
└── 内核线程(操作系统调度)
└── 协程(用户态调度,跑在线程上)
注意:协程的挂起/恢复是在协作式前提下工作的——一个协程如果不主动让出,同线程的其他协程就永远得不到执行。线程则是抢占式调度,内核随时可能切换它。
1.6 硬件视角:CPU 核心与超线程
并发/并行最终要落到硬件上。现代 CPU 的几个关键事实:
1. 物理核心(core):一个物理核心包含完整的执行单元(算术逻辑单元、浮点单元等),能独立执行指令流。有多少个物理核心,就最多支持多少个真正的并行执行流。
2. 超线程(SMT,同步多线程):Intel 的超线程技术让一个物理核心对外表现为两个逻辑核心——两个执行流共享同一套执行单元,但各自有独立的寄存器状态和中断处理。当一条指令流在执行单元上停顿(比如等缓存命中)时,另一条可以趁机利用执行单元。所以:
- 逻辑核心数 = 物理核心数 × 每核心线程数(通常 2)
std::thread::hardware_concurrency()返回的就是逻辑核心数- 超线程对吞吐量有帮助,但对单线程性能没有提升,某些场景甚至略有下降
3. 缓存层级:每个核心有自己的 L1、L2 缓存,多个核心共享 L3 缓存,最后才是主内存。每个核心看到的内存视图可能不一致——核心 A 改了变量,核心 B 的缓存里还是旧值。这靠缓存一致性协议(如 MESI)来协调,代价是跨核心同步数据比单核读写慢得多(详见《原子操作》)。
4. 伪共享(false sharing):缓存以缓存行(通常 64 字节)为单位加载。如果两个线程频繁读写不同的变量,但这两个变量恰好落在同一条缓存行上,缓存一致性协议会让这两个核心不断互相失效缓存行,性能急剧下降。这是多线程性能优化中最隐蔽的坑之一(《原子操作》中会展开)。
注:判断"该开多少个线程"时,参考
hardware_concurrency(),但超线程带来的逻辑核心对计算密集任务帮助有限,IO 密集任务则往往可以开更多线程。
1.7 多线程的资源共享
多线程的核心优势来自共享:同一进程的线程直接共享全局变量、堆内存。共享的收益是:
- 数据交换零拷贝、零系统调用,读写一个共享变量就能通信
- 天然适合"多个任务协作处理同一份数据"的场景(如并行求和、生产者消费者)
代价是正确性问题:多个线程同时读写同一内存位置时,执行顺序不确定,结果不确定。这种"多个执行流竞争访问同一数据"的情况统称数据竞争(data race),在 C++ 中属于未定义行为(UB,Undefined Behavior)。
C++ 标准对数据竞争的界定很严格:
如果两个线程同时访问同一内存位置,其中至少一个是写操作,且两者之间没有任何同步关系(锁、原子操作等),就是数据竞争,程序行为未定义。
"未定义"意味着编译器可以任意优化:变量可能被缓存到寄存器里不写回内存、读写可能被重排、循环可能被拆掉——程序跑出任何结果都不违反标准。所以多线程编程的第一原则是:凡是共享的数据,必须用同步机制(锁或原子操作)保护。这些内容在后续文章中详细展开。
1.8 多线程的开销
多线程不是免费的。每创建一个线程、每切换一次执行流都有真实开销:
1. 创建与销毁开销:创建线程需要系统调用,内核要分配线程栈(Linux 默认 8 MB 虚拟内存,Windows 默认 1 MB)、初始化调度器数据结构。高频创建/销毁线程非常浪费,所以工程上常用线程池复用线程(《多线程编程进阶》会实现一个)。
2. 上下文切换开销:线程切换时内核要保存当前线程的寄存器、程序计数器,恢复目标线程的,还要刷新部分缓存/TLB。一次切换大约几微秒,看似不多,但切换后缓存是冷的(cold cache),新线程要重新把数据加载进缓存,这个代价常常比切换本身还大。
3. 同步开销:锁、原子操作都会引入额外的指令和缓存同步流量。互斥锁的加解锁在无竞争时大约几十纳秒,有竞争时可能让线程进入睡眠、再被唤醒,耗时达到微秒甚至毫秒级。
4. 内存开销:每个线程独立的栈占虚拟内存(按需提交物理页,实际用量取决于栈深度),成千上万个线程会耗尽虚拟地址空间或触发系统线程数上限。
三种执行流开销对比(量级参考,平台相关):
| 执行流 | 创建开销 | 切换开销 | 调度者 |
|---|---|---|---|
| 进程 | 高(毫秒级) | 高(地址空间切换) | 内核 |
| 线程 | 中(几十微秒级) | 中(几微秒) | 内核 |
| 协程 | 低(几百纳秒级) | 极低(用户态,纳秒级) | 用户态运行时 |
注意:这些开销只有在任务量远大于切换成本时才值得用多线程。任务本身只有几微秒却频繁切换线程,性能反而更差。
1.9 多线程的优缺点
优点:
- 利用多核:把计算拆成多个独立部分并行执行,是单线程无法获得的吞吐提升
- 隐藏延迟:一个线程阻塞在 IO 上时(磁盘、网络),其他线程继续用 CPU,提高整体利用率
- 提升响应性:GUI 程序把耗时任务放到后台线程,界面不卡顿
- 天然适合协作:共享内存模型下,生产者/消费者这类协作模式实现直接
缺点:
- 正确性风险:数据竞争、死锁、活锁、饥饿,任何一个都是难以定位的 bug
- 非确定性:线程调度顺序由内核决定,同一份代码每次运行结果可能不同,bug 难以复现
- 调试困难:断点会改变时序,单步调试多线程程序往往"一调就好"
- 额外开销:创建、切换、同步都有成本,小任务用多线程得不偿失
- 复杂度:并发程序的推理难度远高于单线程,代码更难维护
适用判断:任务可以拆成多个独立子任务,且每个子任务足够大(大到抵消切换开销)、子任务间共享数据少或同步成本可控——这时多线程是值得的。反过来,任务小、依赖强、对确定性要求极高,就保持单线程。
2. C++11 多线程编程
C++11 之前,C++ 标准库没有线程支持,多线程程序要么用 POSIX 线程(pthread,Linux/macOS),要么用 Win32 线程 API(Windows),代码不可移植。C++11 在标准库中引入了 <thread> 等头文件,从此可以用一套标准 API 写跨平台多线程代码。
本篇文章给出最基础的用法:线程的创建、启动、等待、结束。更深入的内容(传参、同步、进阶设施)在系列后续文章中展开。
2.1 第一个线程示例
std::thread 是标准库提供的线程类,构造它就会创建并启动一个线程,线程入口是传入的可调用对象:
#include <iostream>
#include <thread>
void hello()
{
std::cout << "这里是一个子线程,线程id为 " << std::this_thread::get_id() << std::endl;
}
int main()
{
std::cout << "程序开始" << std::endl;
std::thread t(hello);
t.join();
std::cout << "主线程id为 " << std::this_thread::get_id() << std::endl;
std::cout << "主线程结束" << std::endl;
return 0;
}
编译并运行,看到以下输出:
程序开始
这里是一个子线程,线程id为 0xb8
主线程id为 0xb8
主线程结束
2.2 线程入口:可调用对象
std::thread 的构造函数接受任何可调用对象(callable):普通函数、函数指针、lambda、仿函数、成员函数、std::bind 结果等。参数会按值拷贝到线程内部(传参细节见线程传参):
#include <iostream>
#include <thread>
void func(int x)
{
std::cout << "这是一个函数,传入了一个 int 类型的参数: " << x << std::endl;
}
struct Functor
{
void operator()(int x)
{
std::cout << "这是一个仿函数,传入了一个 int 类型的参数: " << x << std::endl;
}
void member_func(int x)
{
std::cout << "这是一个成员函数,传入了一个 int 类型的参数: " << x << std::endl;
}
};
int main()
{
// 1. 传入普通函数
std::thread t1(func, 10);
// 2. 传入 Lambda 表达式
std::thread t2([](int x)
{
std::cout << "这是一个 Lambda 表达式,传入了一个 int 类型的参数: " << x << std::endl;
},
20);
// 3. 传入仿函数
std::thread t3(Functor{}, 30);
// 4. 传入成员函数,需要先传入对象的地址(相当于第一个参数是 this 指针)
Functor instance;
std::thread t4(&Functor::member_func, &instance, 40);
t1.join();
t2.join();
t3.join();
t4.join();
return 0;
}
不难理解,成员函数的写法是 std::thread(&类::成员函数, 对象指针, 参数...),第一个参数是成员函数指针,第二个是调用它的对象地址,后续是实参。
运行后,得到以下输出:
这是一个函数,传入了一个 int 类型的参数: 10
这是一个 Lambda 表达式,传入了一个 int 类型的参数: 20
这是一个仿函数,传入了一个 int 类型的参数: 30
这是一个成员函数,传入了一个 int 类型的参数: 40
2.3 join:等待线程结束
join() 阻塞当前线程,直到目标线程执行完毕。join 的作用是同步:保证"线程 t 的代码已全部执行完"这个事实,在 join 返回后对当前线程可见。
#include <iostream>
#include <thread>
// 可以被所有线程访问的全局变量
int global_var = 10;
// 在线程中访问全局变量
void func()
{
std::cout << "进入线程函数 func ,线程 id 为:" << std::this_thread::get_id() << std::endl;
// 对全局变量进行修改
global_var += 114514;
}
int main()
{
std::thread t(func); // 在 t 刚创建出来时,func 立即开始执行
t.join(); // 主线程会在这里一直等待子线程 t 执行完毕
std::cout << "线程 t 已经执行完毕,可以安全地访问它产生的结果:" << global_var << std::endl;
return 0;
}
如果不 join,主线程可能先执行完 main 并退出——进程退出时所有线程都会被强制终止,子线程可能根本没跑完。所以凡是需要子线程完整执行完的场景,都必须 join。
注:
1. 一个用法示例:多个线程并行计算,最后汇总。先 join 所有线程,再读取共享结果:
#include <iostream>
#include <thread>
#include <vector>
int main()
{
const int num_threads = 20;
std::vector<std::thread> threads;
std::vector<int> results(num_threads, 0);
// 各个线程只写自己的结果,无数据竞争问题
for(int i = 0; i < num_threads; ++i)
{
threads.emplace_back([i, &results]
{
// 如果这里的计算操作非常耗时,就可以体现出多线程的优势
results[i] = i * i;
});
}
for(auto& t : threads)
{
t.join(); // 全部结束后再统一读取结果
}
for(auto result : results)
{
std::cout << result << std::endl;
}
return 0;
}
2. 一个错误示例:join的不当使用
#include <iostream>
#include <thread>
#include <vector>
void do_something(int i)
{
i++;i--; ++i;--i;
}
int main()
{
std::vector<std::thread> threads;
for(int i = 0; i < 100; ++i)
{
auto t = std::thread(do_something, i);
t.join(); // 在这里进行 join 会直接阻塞,无法实现多线程并发执行
// t只能移动,不能拷贝
threads.push_back(std::move(t));
}
// 以上步骤相当于
// std::thread t1(do_something, 0); t1.join();
// std::thread t2(do_something, 1); t2.join();
// ...
// t1.join() 阻塞主线程直至 t1 执行完后,才会创建 t2,开始执行 t2
// 以此类推
// 完全没有起到并发执行的作用
return 0;
}
2.4 detach:分离线程
detach() 把 std::thread 对象与它管理的线程解除关联:此后线程在后台继续运行,std::thread 对象不再代表任何线程,既不能对它 join,析构时也不会触发 terminate——它已经不持有线程了。线程的收尾交给运行时:执行完毕后自动释放资源,不需要任何人对它负责。
对比 join 来说:join 是"等待子线程结束";detach 则是 “不再关心子线程是否结束”。主线程调用 detach 后立刻继续执行自己的逻辑,甚至可以直接结束。这就是 detach 的作用——甩掉对线程的管理责任,让它作为后台任务自己跑完,通常称为fire-and-forget(似乎是可以翻译为 “发射后不管”,但是感觉怪怪的)。
#include <iostream>
#include <thread>
int main()
{
std::thread t([] {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "后台线程执行完毕" << std::endl;
});
t.detach(); // 分离:主线程不再持有这个线程
std::cout << "主线程继续执行, 不等待后台线程" << std::endl;
return 0;
}
编译并运行,输出只有一行:
主线程继续执行, 不等待后台线程
"后台线程执行完毕"没有出现:后台线程要先睡 1 秒,而主线程打印完就 return 了,进程随即退出,后台线程被强制终止,永远醒不过来。
让主线程多活一会儿,就能看到后台线程的输出:
t.detach();
std::cout << "主线程继续执行" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2)); // 进程保持存活
return 0;
主线程继续执行
后台线程执行完毕
两个版本唯一的区别是进程活了多久。这揭示了 detach 后台线程的真实处境:它能不能跑完,取决于进程什么时候退出。
进程退出时,所有线程——包括 detach 的——都会被强制终止。detach 只解除了 C++ 层面的管理关系,线程在操作系统层面始终属于进程,不存在"脱离进程独立存活"的线程。main 返回后依次发生:
main的局部变量按构造的逆序析构(此时后台线程还在跑)- 全局/静态对象析构、
atexit注册的回调执行 - 运行时调用操作系统的进程终止接口(Linux 上是
exit_group系统调用),内核把该进程的所有线程一并终止
第 3 步是内核层面的强杀:线程收不到任何通知,没有机会执行清理代码,栈上对象的析构函数不会运行,整个地址空间直接回收。所以 detach 的线程最多比 main 函数多活一小会儿(第 1、2 步的时间窗口),不可能比进程活得久。
回头看第一个示例:主线程 return 0 后,进程在第 3 步终止了还在睡觉的后台线程,std::cout 那行代码从未获得执行机会。这也说明"detach 然后指望它把活干完"常常是错的——要保证线程完整执行,进程就必须活着等它,这正是 join 存在的意义:
| 对比项 | join() | detach() |
|---|---|---|
| 语义 | 等待线程执行完毕 | 放弃对线程的管理 |
调用后 std::thread 对象 |
变为非 joinable | 变为非 joinable |
| 线程能否保证执行完 | 能,join 返回时线程已结束 |
不能,进程退出即被终止 |
| 进程退出时 | 主线程阻塞在 join 处,不会退出 |
后台线程被强制终止 |
| 线程产生的结果 | join 返回后可安全读取 |
无法确定何时结束,不能安全读取 |
| 典型用途 | 并行计算后汇总结果 | 与进程同生命周期的后台任务 |
那 detach 到底什么时候用?答案是线程本来就打算与进程同生共死的场景:后台日志线程、心跳上报线程、监控采样线程。它们通常是一个循环,主线程从头到尾都不打算等它,进程退出时被一并终止反而是期望行为:
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
std::atomic<bool> running{true};
void heartbeat()
{
while (running.load())
{
// 周期性上报心跳(这里省略实际工作)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main()
{
std::thread hb(heartbeat);
hb.detach(); // 主线程从没打算等心跳线程结束——它本来就不会结束
for (int i = 0; i < 5; ++i) // 主线程干自己的活
{
std::cout << "主线程工作中 " << i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
return 0; // 进程退出, 心跳线程一并终止, 不需要 join
}
注:真实程序可以在退出前
running.store(false)让心跳线程优雅结束;就算忘了置位,进程退出时它也会被一并终止——detach 的线程不会阻止进程退出。
除了"被截断",detach 还有更隐蔽的风险:线程入口若引用/指向了主线程的局部变量,main 返回后这些变量在第 1 步就析构了,后台线程若还在跑,访问它们就是悬空引用(dangling reference),未定义行为:
#include <iostream>
#include <thread>
int main()
{
int local = 42;
std::thread t([&local] { // 捕获了局部变量的引用
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << local << std::endl; // 危险!main 返回后 local 已销毁
});
t.detach();
return 0; // main 立即结束, local 销毁
}
所以 detach 只应在线程完全自给自足(不依赖任何外部对象)时使用。工程上更推荐显式 join 管理线程生命周期,或者用 std::async(见多线程编程进阶)代替裸线程。
2.5 joinable 与线程对象约束
std::thread 对象有可连接(joinable)状态:构造时绑定了线程即为 joinable;已经 join() 或 detach() 后变为非 joinable。规则:
join()/detach()各自只能调用一次,重复调用会抛出std::system_error- 对非 joinable 的对象调用
join()/detach()同样抛出std::system_error - 析构时如果对象仍然 joinable,会调用
std::terminate直接终止整个程序(不是抛异常)
#include <iostream>
#include <thread>
int main()
{
std::thread t([] {});
t.join();
t.join(); // t 已不是 joinable, 抛出 std::system_error
return 0;
}
运行时抛出:
terminate called after throwing an instance of 'std::system_error'
what(): Invalid argument
std::thread 可移动、不可拷贝:移动后原对象变为非 joinable,新对象接管线程,因而可以通过 joinable() 进行线程对象的安全管理:
#include <iostream>
#include <thread>
#include <vector>
int main()
{
std::vector<std::thread> pool;
for(int i = 0; i < 3; ++i)
{
std::thread t([i] { std::cout << "线程 " << i << std::endl; });
pool.push_back(std::move(t)); // 线程不可拷贝, 只能移动进容器
}
for(auto& t : pool)
{
if(t.joinable())
{
t.join();
}
}
return 0;
}
注意:析构时 joinable 会
terminate,所以容器里残留未 join 的线程对象是常见崩溃源。joinable()检查 +join()是安全的收尾模式
2.6 线程 ID 与硬件并发数
std::this_thread::get_id() 返回当前线程的 ID(std::thread::id 类型,可用 << 输出);std::thread::hardware_concurrency() 返回硬件支持的并发线程数(逻辑核心数),实现不支持时返回 0:
#include <iostream>
#include <thread>
int main()
{
std::cout << "当前线程 id: " << std::this_thread::get_id() << std::endl;
std::cout << "硬件并发数: " << std::thread::hardware_concurrency() << std::endl;
return 0;
}
当前线程 id: 0x2B
硬件并发数: 24
硬件并发数相关细节
硬件并发数常用来决定线程池的默认规模(如"计算密集任务开 hardware_concurrency() 个线程"),但要留意它返回的是逻辑核心数,包含超线程虚拟出的核心。
以 Core i7‑14650HX 为例,它有 8 个大核(P-core)+ 8 个小核(E-core),大核开启超线程,小核则没有,因而每个大核 2 线程,每个小核 1 线程,总共 24 个逻辑核心。hardware_concurrency() 返回的就是这个数。
如果有些沉重的计算任务跑在小核上,可能会拖慢整体吞吐量;如果有些轻量任务跑在大核上,可能会抢占大核的算力,影响其他线程。C++本身无法区分大核/小核,也无法控制线程绑定到哪个核心,如果要进行更精细的调度,需要用平台特定的 API(Linux 上是 pthread_setaffinity_np,Windows 上是 SetThreadAffinityMask)。
std::this_thread::yield()
std::this_thread::yield() 主动让出当前线程的 CPU 时间片,把执行机会让给同优先级的其他线程。自旋等待(忙等)场景下配合使用可以降低 CPU 占用:
#include <atomic>
#include <iostream>
#include <thread>
int main()
{
std::atomic<bool> ready{false};
std::thread worker([&ready] {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
ready.store(true);
});
while (!ready.load()) // 忙等, 注意: 这里用原子变量避免数据竞争
{
std::this_thread::yield(); // 让出 CPU, 减少空转
}
std::cout << "ready!" << std::endl;
worker.join();
return 0;
}
注:忙等 +
yield只是演示,生产代码更推荐用条件变量或std::async等待(多线程编程进阶 )。原子变量的细节见 原子操作。
2.7 常见错误小结
1. 忘记 join/detach,线程对象析构时 terminate
void foo()
{
std::thread t([] { /* 长时间任务 */ });
}
int main()
{
foo(); // t 析构时仍然 joinable, 调用 std::terminate
std::cout << "main 正常结束" << std::endl;
return 0;
}
实际情况下,无法执行到 “main 正常结束” 的输出,只能看到:
terminate called without an active exception
2. 局部变量被 detach 的线程访问,悬空引用
int main()
{
int value = 42;
std::thread t([&value] { std::cout << value << std::endl; });
t.detach(); // main 返回后 value 销毁, 子线程访问悬空引用
return 0;
}
3. 线程入口抛异常且未被捕获:线程入口函数中的异常若没有被捕获,会调用 std::terminate 终止进程。所以线程入口最好整体包在 try-catch 中,或保证内部不会抛出。
void worker()
{
throw std::runtime_error("boom");
}
int main()
{
std::thread t(worker); // worker 抛异常, 线程内无人接住
t.join();
return 0; // 实际上到这里之前进程已经终止
}
4. 不检查 joinable() 就 join:线程可能已经被移动走(容器管理的常见场景),直接 join() 抛 std::system_error。
到这一步,你已经能创建、启动、等待、分离线程了。但真实程序里线程几乎总是要传参数、共享数据、互相通信——这正是系列后续文章的内容。
建议 进一步学习:


