C++多线程入门:std::thread线程创建与管理实战指南
发布时间:2026/10/3 0:15:20 作者:尧图编辑部 阅读量:1,286

多线程这东西说难也难说简单也简单。我第一次真正用上std::thread的时候是在一个网络下载模块里——单线程下载太慢拆成四路并发之后速度直接翻了将近三倍那一刻才觉得“并发”不是面试八股文里的一个名词而是真能解决问题的手段。C 从 C11 开始把线程库收进标准库std::thread成为跨平台创建和管理线程的统一入口不用再像老项目那样在 Windows 上写CreateThread、在 Linux 上写pthread_create维护两份代码。这篇我把自己实际使用std::thread的经验整理出来重点放在“创建线程”和“管理线程”这两件最基础也最容易踩坑的事情上适合刚接触 C 多线程、或者用std::thread写过几个 demo 但心里没底的读者。看完你至少能回答三个问题一个线程怎么启动、线程什么时候结束、怎么避免因为线程生命周期管理不当导致程序崩溃。1. 内容整体设计与思路拆解1.1 为什么选择 std::thread 而不是直接调系统 API很多人刚接触多线程的时候会纠结我是直接学pthread还是学std::thread这个问题我在不同技术群里被问过很多次。我的建议很明确——新项目直接上std::thread除非你有非常明确的理由必须用到平台专属能力。原因不复杂。std::thread是 C 标准库的一部分它本质上是在不同操作系统上对底层线程 API 做了一层薄封装。你在 Windows 上编译它底层是CreateThread你在 Linux 上编译它底层是pthread_create。同样的代码跨平台编译不需要改动这对项目维护来说省了太多事。更重要的是心智负担。std::thread暴露出来的接口非常简洁构造即启动join()等待线程结束detach()让线程在后台独立运行。这套语义跟 JVM 里的线程模型、Python 的threading模块在概念层面对齐你只要掌握一次到其他语言里也能很快迁移。相比之下pthread虽然功能强大但大量参数是面向底层调度器的比如pthread_attr_t里的栈大小、调度策略、继承属性这些在 90% 的业务场景里根本用不上只会增加初学者的认知负荷。当然std::thread也不是万能的。它没有办法直接设置线程的调度优先级C20 的jthread也没有也不能指定线程运行在哪个 CPU 核心上。如果你的场景确实需要这些能力那还是要回到平台 API或者在std::thread内部调用pthread_setschedparam这类底层接口。但作为默认选择std::thread绝对够用。1.2 线程不是越快越好——先想清楚你的并发模型这是我在实际项目中吃过亏之后才真正理解的一句话。很多人一提到性能优化就想到“加线程”结果线程数上去了程序反而变慢了。为什么因为线程切换是有代价的——每一次上下文切换操作系统都要保存当前线程的寄存器状态、程序计数器、栈指针然后加载下一个线程的状态。这个开销虽然只有几微秒但当线程数量远超 CPU 核心数、并且大量线程都在争抢同一个锁的时候系统的大部分时间都花在了“切换”而不是“干活”上。所以在动手写std::thread之前先问自己三个问题这个任务是不是 CPU 密集型如果是线程数最好不要超过 CPU 核心数否则多出来的线程只能排队等调度。这个任务是不是 IO 密集型如果是线程数可以适当多一些因为线程大部分时间在等网络或磁盘CPU 其实是空闲的。这个任务是天然可拆分的还是必须串行执行如果每个步骤都依赖前一步的结果强行拆线程只会引入大量的同步和通信开销。拿最常见的“多线程下载”举例。一个大文件拆成四段四个线程各下载一段最后拼起来。这个任务天然可拆分而且 IO 密集所以四路并发通常能有接近线性的提速。但如果让你用多线程去计算斐波那契数列那大概率比单线程还慢——因为计算任务本身就有严格的依赖关系拆开之后还要额外处理结果的合并纯属帮倒忙。明确这一点之后再去看std::thread的具体用法你才会有一种“原来如此”的感觉——它只是一个工具用得好不好取决于你对任务本身的理解。2. std::thread 的基本使用——从启动第一个线程开始2.1 线程的三种启动方式std::thread的构造非常直接你传入一个可调用对象callable它就在新线程里执行这个对象。常见的可调用对象包括普通函数、Lambda 表达式、函数对象functor、成员函数指针。我在工作中用得最多的是 Lambda 和普通函数。最简单的方式是把一个普通函数传给std::thread#include iostream #include thread void worker() { std::cout worker thread, id: std::this_thread::get_id() std::endl; } int main() { std::thread t(worker); t.join(); return 0; }注意一个细节t.join()这行不能省。如果不调用join()main函数结束时t这个线程对象会被析构而析构的std::thread如果依然是可 join 的joinable程序会直接调用std::terminate崩溃掉。这是新手最容易踩的第一个坑。第二种方式是 Lambda。Lambda 的优势在于可以就地捕获当前作用域的变量非常灵活int main() { int value 42; std::thread t([value]() { std::cout captured value: value std::endl; }); t.join(); return 0; }第三种方式是函数对象重载了operator()的类这种方式适合需要在线程内维护状态的情况class Counter { public: void operator()(int limit) const { for (int i 0; i limit; i) { // 模拟耗时工作 } } }; int main() { std::thread t(Counter(), 1000); t.join(); return 0; }2.2 让线程真正“跑起来”的关键join 与 detach线程创建之后你就面临一个关键选择让主线程等它还是让它自己跑。这就是join()和detach()的职责。join()的含义是阻塞当前线程直到目标线程执行完毕。可以理解为主线程“挂起等待”目标线程“执行完再回来汇合”。调用join()之后std::thread对象会变成不可 join 状态joinable 返回 false此时再次调用join()会抛出std::system_error。detach()的含义则是把线程彻底“放生”让它在后台独立运行。调用detach()之后std::thread对象与底层线程不再有关联你无法再等待它、无法获取它的状态、无法阻止它继续运行。这个线程什么时候结束完全取决于它自己的执行路径。这里有一个至关重要的经验detach()之后线程还在跑但它引用的任何对象都不再被保证存活。比如你在main函数里创建了一个局部变量然后把它以引用或指针形式传给了detach()的线程一旦main函数执行完毕这个局部变量就被销毁了但后台线程还在试图访问它——这是一个未定义行为undefined behavior程序可能看起来正常可能崩溃可能输出乱码全看运气。所以我的建议是默认使用join()只有在明确知道线程会在主线程退出前结束、或者线程不依赖任何外部对象的生命周期时才考虑detach()。实际项目中很多服务端的作法是用一个容器保存所有std::thread对象程序退出时统一join()这样管理起来最安全。2.3 joinable 的底层逻辑与判断方法joinable()是std::thread的一个成员函数用来判断当前线程对象是否关联了一个“真实的底层线程”。如果返回 true说明你可以对它调用join()或detach()如果返回 false说明这个线程对象要么是默认构造的空对象要么已经被 join 过、detach 过要么被 move 走了。这里有个非常容易忽略的细节一个线程执行完内部的函数并不代表std::thread对象自动变成了不可 join。线程函数执行完毕底层线程退出了但只要你不调用join()或detach()这个std::thread对象依然是 joinable 的。如果此时它被析构程序就会 terminate。这就是为什么很多人写“丢线程”代码时会崩溃——线程明明跑完了但没人去 join 它。正确写法是std::thread t(worker); if (t.joinable()) { t.join(); }这个if判断非常有用。设想一种场景线程的启动是在一个 try 块里构造线程之后、调用join()之前代码抛出了异常。此时线程对象在栈展开stack unwinding时被析构而它还是 joinable 的程序直接崩溃。加上if (t.joinable())判断就能避免这个问题try { std::thread t(worker); doSomethingThatMayThrow(); // 这里抛异常 t.join(); } catch (...) { // t 在这里析构如果它 joinable程序会 terminate }正确做法是把join()放在try-catch的兜底逻辑里或者用一个 RAII 包装类来管理线程的生命周期。关于这个问题我在后面的“常见问题”部分会展开细说。3. 线程参数的传参细节与常见陷阱3.1 参数默认按值传递——但有时候你想传引用std::thread的构造函数是一个可变参数模板你传给它的参数会被“完美转发”给线程函数。这句话的翻译是线程函数的参数默认是按值传递的就像一个拷贝。看个例子void func(int x, const std::string s) { // 处理 } int main() { int a 42; std::string str hello; std::thread t(func, a, str); t.join(); }这里a和str都被拷贝了一份传给func。你不用担心主线程修改a会影响线程内的a因为它们已经是两份独立的数据了。这是好消息。但坏消息是有时候你确实想让线程操作同一份数据——比如多个线程要并发更新同一个计数器。这时候按值传递就行不通了你需要显式地传递引用。直接写std::thread t(func, std::ref(counter))就可以。std::ref是标准库提供的引用包装器它告诉std::thread“别拷贝给我传引用”。#include functional void increment(int x) { x; } int main() { int counter 0; std::thread t(increment, std::ref(counter)); t.join(); std::cout counter std::endl; // 输出 1 }如果你不写std::ref直接写std::thread t(increment, counter)编译会直接报错——因为counter被拷贝成了线程内的局部变量而increment接收的是非常量引用无法绑定到临时对象。这个编译错误其实是 C 在保护你它不让你在不知情的情况下修改一个名为“引用”、实为“拷贝”的东西。3.2 成员函数如何绑定到线程C 类的成员函数有一个隐藏的第一个参数this指针。所以当你把成员函数当作线程函数时你需要同时提供“对象”和“成员函数指针”两个信息。标准的写法是class Task { public: void run(int id) { std::cout task id running std::endl; } }; int main() { Task task; std::thread t(Task::run, task, 1); t.join(); return 0; }这里的Task::run是成员函数指针task是对象的地址也就是this指针1是传给run的参数。如果你的成员函数不需要改对象状态也可以在run后面加const然后把对象按值传进去std::thread t(Task::run, task, 1); // 拷贝 task 到线程内但注意这种方式会拷贝整个对象如果对象里有大量资源比如文件句柄、网络连接拷贝开销不可忽视而且std::thread要求被拷贝的对象必须是可拷贝构造的。默认情况下我还是建议传指针或std::ref。3.3 移动语义与 std::move——线程本身是不可复制的std::thread不支持拷贝构造和拷贝赋值但支持移动构造和移动赋值。什么意思就是你无法创建一个线程对象的“副本”但可以把一个线程对象的所有权“转移”给另一个对象。这个特性非常有用尤其当你想把线程对象存进容器、或者从一个函数返回线程对象时。std::vectorstd::thread threads; for (int i 0; i 4; i) { threads.emplace_back(worker, i); } for (auto t : threads) { t.join(); }emplace_back直接在容器内构造线程对象避免了拷贝。如果线程对象已经存在你想把它移到容器里就需要显式std::movestd::thread t(worker); std::vectorstd::thread threads; threads.push_back(std::move(t)); // 移动不是拷贝移动之后原来的t变成了空的、不可 join 的状态。如果你在移动之后对t调用join()会得到一个std::system_error。这也是为什么joinable()判断那么重要——因为你永远不知道一个线程对象是不是已经被 move 走了。3.4 避坑在线程函数里使用局部变量的引用这条我再三强调线程函数里尽量不要捕获局部变量的引用或指针除非你能百分之百保证线程在局部变量销毁之前完成。我见过一个真实的生产事故。某服务端程序在请求处理函数里创建了一个std::string对象然后启动了一个std::thread去异步上传这个字符串内容最后顺手detach()了。结果请求处理函数很快就执行完了局部std::string被销毁但后台线程还在读这块已经被释放的内存。程序平时跑得好好的一旦并发压力上来偶尔就会崩溃而且崩溃位置千奇百怪定位了很久才发现是这个生命周期问题。正确做法是要么用join()等线程完成再退出要么在线程函数内部自己创建/拷贝需要的数据要么用共享指针std::shared_ptr管理需要跨线程共享的对象。4. 线程安全的第一次亲密接触——数据竞争4.1 数据竞争的本质有了多线程就一定会面临多线程访问共享数据的问题。如果你有两个线程同时读写同一个变量并且至少有一个是写操作但只要没有加锁——这个行为就是未定义行为我们称之为数据竞争data race。先看这段代码很多人一开始会以为输出是 200000#include thread int counter 0; void increment() { for (int i 0; i 100000; i) { counter; } } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout counter std::endl; // 结果不确定 return 0; }实际运行你会发现counter可能是 100000 多、199999、200000什么结果都可能出现。原因在于counter不是一条原子指令它在底层对应了三步操作读取counter的值、加一、写回。两个线程同时执行这三步时可能会互相覆盖对方的结果。比如线程 A 读取了 100线程 B 也读取了 100A 加一写回 101B 加一写回 101最后counter只是 101而不是 102——丢失了一次递增。要解决这个问题最简单的方案是互斥锁。4.2 用互斥锁保护共享变量C11 提供了std::mutex和std::lock_guard。lock_guard是一个 RAII 包装器——构造时加锁析构时自动解锁。好处是即使代码中途抛异常锁也能安全释放不会死锁。#include mutex int counter 0; std::mutex mtx; void increment() { for (int i 0; i 100000; i) { std::lock_guardstd::mutex lock(mtx); counter; } }加了锁之后counter的输出就是确定的 200000。因为同一时刻只有一个线程能进入加锁区域另一个线程只能阻塞等待。这里我想说一下锁粒度的问题。锁粒度是指你加锁保护的代码范围大小。范围越大锁持有时间越长其他线程等待的时间就越长并发效率就越低。上面的例子是一个极端——每次加一都单独加锁解锁效率其实很差。实际工程中更常见的做法是在循环外一次性加锁或者把加锁的范围尽量缩小到只保护共享变量的那一小段代码。4.3 原子操作与锁的选择对于像counter这种简单的操作还有另一个更轻量的选择——原子类型std::atomic。#include atomic std::atomicint counter{0}; void increment() { for (int i 0; i 100000; i) { counter; } }std::atomic适用于单变量的读写场景它通过 CPU 提供的原子指令保证操作的不可分割性不需要锁所以开销比 mutex 小得多。但它只能保证单个变量的原子性如果是“先读 A 再根据 A 的值改 B”这种复合操作原子类型也救不了还是得用锁。我一般的选择原则是只保护单个共享变量的简单操作优先std::atomic保护一段涉及多个变量或复杂逻辑的临界区用std::mutexstd::lock_guard读写比例悬殊读多写少考虑std::shared_mutexC17 提供5. 线程生命周期管理与资源销毁问题5.1 线程对象的生命周期到底如何管理聊完 API 细节和安全问题回到一个更宏观的问题一个线程对象在 C 程序里的生命周期到底怎么管我的经验可以总结成一句话把线程当作一种资源而且是一种必须显式释放的资源。就像你打开了一个文件必须 close、申请了一块内存必须 free你创建了一个可 join 的std::thread对象就必须在某个地方对它join()或detach()否则资源泄漏。资源泄漏的表现不只是内存没释放还可能是程序退出时直接 terminate。我见过不少线上问题日志里只有一句terminate called without an active exception排查到最后都是某个线程对象没被 join。推荐的工程实践是写一个小的 RAII 包装器或者直接用std::jthreadC20。std::jthread的析构函数会自动请求线程停止并 join简化了生命周期管理。但如果你的项目还在 C11/14/17 标准上就得自己注意。我的做法是维护一个线程池或线程列表程序退出时按顺序 joinclass ThreadManager { public: ~ThreadManager() { for (auto t : threads_) { if (t.joinable()) { t.join(); } } } void add(std::thread t) { threads_.push_back(std::move(t)); } private: std::vectorstd::thread threads_; };这样即使 main 函数里某个分支提前 returnThreadManager的析构函数也会把所有线程都 join 掉不会出现线程对象析构时还 joinable 导致崩溃的问题。5.2 detach 之后线程何去何从detach()不是说线程立刻停止而是说这个线程对象不再与底层线程关联。底层线程会继续运行直到它自己的函数执行完毕。这个过程你完全无法控制。这意味着detach()的线程里如果用了外部资源你必须保证这些资源不比线程活得短。这里最常见的解决方案是把所有需要跨线程访问的数据都放到堆上用std::shared_ptr管理——线程持有一个shared_ptr主线程即使不持有了只要线程还在用对象就不会被释放。#include memory void backgroundTask(std::shared_ptrint data) { std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout *data std::endl; } int main() { auto data std::make_sharedint(100); std::thread t(backgroundTask, data); t.detach(); // data 在线程结束前不会被释放因为线程持有它 return 0; }5.3 线程异常安全——别让 throw 毁了整个进程线程函数内部抛出的异常如果在线程函数内部没有被捕获会直接导致std::terminate被调用整个程序直接崩溃没有任何“冒泡到主线程”的可能。这和普通函数异常处理机制完全不同——普通函数可以一直向上抛到 main但线程没有“上层”可以接收异常。所以线程函数里一定要尽量包一层try-catch或者在线程函数入口处做一个统一兜底void safeWorker() { try { // 业务逻辑 doWork(); } catch (const std::exception e) { std::cerr thread exception: e.what() std::endl; } catch (...) { std::cerr unknown thread exception std::endl; } }这是一种防御性编程思维。虽然看起来啰嗦但能避免很多“莫名崩溃”的线上事故。6. 常见问题排查与调试实录6.1 “terminate called without an active exception”之谜这个错误信息几乎每个写过std::thread的人都见过。我用一张表总结一下常见触发场景和解决方法触发场景原因解决方法线程对象析构时仍可 join忘了调用join()或detach()调用join()等待线程结束在异常路径上线程对象析构构造线程后、join 前抛异常用 RAII 包装线程或 try-catch 兜底对已被移动的线程对象调用join()线程所有权已经转移先判断joinable()再调用对同一线程对象多次调用join()第二次 join 时线程已不可 join每个线程对象只 join 一次排查这类问题最快的办法是用 gdb 回溯调用栈看看哪个线程对象的析构触发了 terminate。代码里加上joinable()判断之后90% 的崩溃场景都能提前拦下来。6.2 多线程程序越跑越慢甚至死锁死锁的经典场景是线程 A 持有锁 1等待锁 2线程 B 持有锁 2等待锁 1。两个线程互相等待谁也不让谁程序就卡死了。避免死锁的几个实践尽量减少锁的使用范围锁内不要调用其他“可能会耗时的函数”如果需要持有多把锁保证所有线程按相同的顺序加锁。比如所有线程都先锁 1 再锁 2就不会出现循环等待使用std::lock一次性锁住多个互斥量C11 提供了这个函数来避免加锁顺序不一致导致死锁std::mutex mtx1, mtx2; void safeLock() { std::lock(mtx1, mtx2); // 同时加锁避免死锁 std::lock_guardstd::mutex lock1(mtx1, std::adopt_lock); std::lock_guardstd::mutex lock2(mtx2, std::adopt_lock); // 临界区 }6.3 gdb 调试多线程程序的基本姿势用 gdb 调试多线程程序核心命令就那么几个info threads查看所有线程thread N切换到某个线程bt查看当前线程的调用栈set scheduler-locking on让调试会话只跑当前线程。我第一次调试多线程崩溃时最头疼的是不知道崩溃发生在哪个线程。在 gdb 里运行bt之前先info threads看看每个线程的状态再切到那个状态显示为“可运行”或者 SIGSEGV 信号对应的线程上很大程度上能节省定位时间。(gdb) info threads (gdb) thread 2 (gdb) bt还有一个很实用的思路先让崩溃复现再让崩溃位置收敛。多线程的竞争问题往往不是每次都崩而是特定时序下才崩。这时候可以在可疑代码位置加打印日志或 gdb 断点把线程 id 也打出来观察是哪个线程、哪条路径先触发了异常。日志排除法听起来笨但在多线程定位里往往是最快的方法。结尾的几句心得把这些写下来之后回头看多线程的“第一课”其实真正重要的不是记住std::thread的每个 API而是建立一套正确的资源管理观念线程是资源创建和释放要成对出现共享数据必须有同步机制保护join()还是detach()必须是一个显式的、有依据的设计决策而不是顺手一写。我见过太多半路出家的开发者把多线程用得“像脱缰的野马”线程满天飞最后出了事故根本无从下手。所以我的建议是在你觉得“多线程也不过如此”的时候问自己一句如果这个线程对象在这次运行中出了异常程序能安全退出吗如果答案是“不确定”那你还需要再打磨一下线程的生命周期管理。说实话多线程的内容远不止创建和管理后续还有锁、条件变量、异步任务、线程池这些更庞大的话题但根基一定要稳。这篇文章就当是那块地基。