
你写过多线程程序吗不管是 C、Java 还是 Go只要项目一上并发基本都会碰到同一类问题数据乱了、线程卡死了、程序莫名其妙崩溃。我这些年排查过的并发 bug 里十有七八不是算法不对而是线程没控制住。这个系列第一篇我们就把“线程控制”这四个字彻底拆开讲清楚从线程的生命周期、锁的底层机制到条件变量、原子操作再到一套可以直接上手的实操案例把最关键的那些坑和原理一次讲透。这篇内容适合刚接触并发编程的开发者也适合已经写过一些线程代码、但对同步机制理解不深的同学。看完之后你能明白线程到底是怎么创建、怎么死亡、怎么协作的遇到数据竞争怎么锁锁不住的时候怎么用条件变量以及怎么排查最常见的死锁和假唤醒问题。1. 线程控制是在控制什么为什么它是并发编程的基石1.1 线程控制的本质你在管理一场多线作战写单线程代码的时候我们的思维是线性的一个循环走完再走下一个一个函数返回再调另一个函数。但多线程程序本质上是同时有多条执行流在抢同一个 CPU或多核 CPU 的多个核心。如果这些执行流不做协同就会出现两个最基础的问题一是相互干扰多个线程同时修改一个变量数据就被撕碎了二是执行顺序不稳定同一个程序跑一百次可能有一两次结果就是错的而且极难复现。线程控制说白了就是解决这两件事第一管住每个线程什么时候启动、什么时候结束、或者什么时候等待第二管住多个线程之间如何安全地共享数据。前者涉及 join、detach、条件变量后者涉及互斥锁、原子操作、读写锁。把这两块搞明白并发编程的基本功就扎实了。还有一点很多人一开始会忽略线程控制不只是写几个 API 那么简单它本质上是一种“编程模式的转变”。你以前习惯把程序想象成一条流水线现在要把程序想象成一组协作的工人。工人们共享一片工作台共享内存需要用信号灯锁和通知机制条件变量来协调否则要么互相踩脚要么一个人干完了另一个人还在傻等。1.2 线程状态机从生到死的全部切换过程要控制线程先得知道线程有哪些状态。操作系统教材里通常会讲就绪、运行、阻塞这些状态但落实到代码层面我们更关心的是下面的转换链条线程创建后进入ready状态等待被调度器分配到 CPU 核心上执行被调度到之后变成running状态正在执行代码如果调用了mutex.lock()但锁已经被别人持有线程会进入blocked状态让出 CPU如果调用wait()等待条件变量线程也会阻塞直到被 notify 唤醒才重新加入调度队列如果线程函数正常返回或被调用detach()后结束线程进入终止状态资源随之释放。理解状态机的一个实际好处是你能解释很多现象。比如为什么加锁能保证互斥因为当线程 A 持有锁时线程 B 尝试拿同一把锁会被系统挂起进入阻塞态不会去执行临界区代码。这里面的关键动作是“让出 CPU”而不是“自旋等待”所以一般来说加锁竞争的线程不会白白吃掉 CPU。知道了这一点你在分析性能问题时就会有方向如果发现某个锁的争用特别高通常是临界区太大线程大量时间都在等待而不是在干活。1.3 直接操作线程与封装策略现代语言下怎么选“线程控制”这四个字在老的 C 时代意味着手动管理pthread_create、pthread_join和一堆结构体错误处理靠返回值代码很容易被繁琐的清理逻辑淹没。现代 C 里我们有了std::thread、RAII资源获取即初始化封装和标准库同步原语控制思路清晰了很多。但封装并不代表可以随便用这里有一个很重要的设计取舍你到底是偏好直接用底层原语还是偏好用高级抽象我的经验是核心的并发组件比如线程池、任务队列值得自己动手写一遍去感受底层机制而业务代码里能用std::async、线程池、Actor 模型之类的高层抽象就用高层抽象别自己造锁的轮子。因为锁用得越多出问题的概率越大控制策略越简单越好。这个系列后续会单独讲线程池和任务编排但这一篇先把底层控制工具吃透。2. 线程的创建、传参与生命周期管理2.1 std::thread 的创建与启动以及传参的那些坑C11 引入了std::thread创建线程非常简洁你只需要构造一个std::thread对象并传入一个可调用对象。实际写代码时通常是这样#include thread #include iostream void worker(int id, const std::string name) { std::cout 线程 id 开始执行, name name std::endl; } int main() { std::thread t1(worker, 1, std::string(cpu-worker)); std::thread t2(worker, 2, std::string(io-worker)); t1.join(); t2.join(); return 0; }这段代码本身不难但有一个细节经常坑人std::thread构造时会把参数按值拷贝到线程内部而不是按引用。如果你想传引用必须显式用std::ref包一层void change(int value) { value 10; } int main() { int count 0; std::thread t(change, std::ref(count)); t.join(); std::cout count std::endl; // 输出 10 }如果漏掉std::ref代码编译会报错因为线程内部想拷贝一个非拷贝的左值这算是好事——报错总比静默出错好。真正危险的是传指针如果有两个线程同时通过指针改同一个对象就变成数据竞争了后果难料这个后面细说。启动线程还有一点要注意线程对象的声明周期。std::thread是一个可移动但不可拷贝的对象所以你不能写std::thread t3 t1这种代码但可以用std::thread t3 std::move(t1)来转移所有权。实际工作中把线程对象放进容器是很常见的需求这时候要记得用std::vectorstd::thread配合emplace_back或push_back(std::move(...))。2.2 join 与 detach两难的抉择线程创建之后你必须决定是join还是detach。这个决定不是随便做做它直接决定了程序的生命周期安全。join()的意思是阻塞等待该线程结束然后回收它的资源。调用join()之后当前线程会一直等着直到被 join 的线程完全退出。detach()的意思是把线程和这个std::thread对象解绑让线程自己在后台跑std::thread对象不再负责管理它。一个最常见的崩溃场景是线程对象被销毁时线程还在运行。如果std::thread对象在析构时仍处于joinable状态既没调用 join 也没调用 detach程序会直接调用std::terminate把整个进程干掉。很多人刚写多线程时都会遇到。解决办法很简单要么保证线程对象销毁前调用过join()要么明确调用detach()。那到底是选 join 还是 detach我强烈建议默认用 join。detach 虽然看起来方便但线程一旦分离你就失去了对它的一切控制你不知道它跑完了没有也无法安全地回收它可能持有的资源。尤其是进程结束前如果还有 detached 线程在跑主线程退出时可能直接把它干掉连清理的机会都不给。只有一种场景我会考虑 detach线程内部完全自包含不依赖外部资源不需要结果回传并且对退出时机不敏感。典型例子是某些异步日志写盘任务。即便如此我也会在代码里反复确认线程函数内部能自行处理一切异常和资源问题。2.3 线程的异常安全与 RAII 封装刚才提到线程对象析构时如果还处于 joinable 状态程序就会终止。这个问题在复杂控制流里尤其致命因为你在正常逻辑中写了 join但中途抛异常导致函数提前退出join 那行代码根本没执行。所以推荐的做法是写一个小工具来封装线程的生命周期class ScopedThread { public: explicit ScopedThread(std::thread t) : t_(std::move(t)) { if (!t_.joinable()) { throw std::logic_error(线程不可 join); } } ~ScopedThread() { if (t_.joinable()) { t_.join(); } } ScopedThread(const ScopedThread) delete; ScopedThread operator(const ScopedThread) delete; private: std::thread t_; };有了这个封装即使函数中途抛出异常析构函数也会保证线程被 join不会出现 destructor 直接 terminate 的惨案。这个模式在线程控制里非常重要因为并发代码里最怕的就是“不可预期的退出路径”。使用 RAII 把资源回收绑定到对象生命周期上让编译器帮你兜底。这个封装可能看着简单但它体现的思想很关键控制线程不只是控制线程本身还要控制线程对象和管理代码之间的关系。对象生命周期理顺了线程控制就成功了一半。3. 同步控制的底层逻辑锁、条件变量与原子操作的选择3.1 数据竞争为什么是万恶之源两个线程同时读写同一个内存位置并且至少有一个是写操作这种场景就叫数据竞争。注意C 标准里数据竞争是一个严重的未定义行为。编译器在做优化时根本不会考虑多线程场景它只会按照单线程语义去调整指令顺序和缓存策略。所以两个线程同时改一个变量结果完全不可预测甚至程序崩溃都是合法现象。举个最经典的例子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; }直觉上你以为输出是 200000但实际跑起来大概率不是。原因很简单counter不是原子操作它被编译器编译成好几条指令——读取 counter 到寄存器寄存器加 1把寄存器写回 counter。两个线程同时在执行这三步会互相覆盖。比如线程 A 读了 counter100线程 B 也读了 counter100然后 A 写成 101B 也写成 101结果两个线程各加了一次counter 却只增了 1。解决数据竞争的唯一办法就是让多个线程对共享数据的访问变成可控的要么让这些操作原子化要么用锁把它们串行化。很多新手搞不清锁和原子操作的区别这里我提前说一句锁解决的是“临界区互斥”原子操作解决的是“单个操作的不可分割性”。这两个工具适用场景并不相同下面分开讲。3.2 互斥锁的正确用法与锁粒度控制互斥锁本质上是给一块临界区代码加一个“一次只能进一个线程”的门禁。所有想进临界区的线程必须在门口先尝试拿锁拿不到的就去休眠等待拿到锁的线程执行完临界区代码再解锁让等待者继续。现代 C 里最基础的写法是#include mutex #include vector std::mutex g_mutex; std::vectorint g_data; void push_data(int value) { std::lock_guardstd::mutex lock(g_mutex); g_data.push_back(value); }std::lock_guard是典型的 RAII 封装构造时加锁析构时自动解锁。这样即使临界区里抛异常锁也一定会被释放不会出现死锁。但仅仅会用 lock_guard 是不够的我见过太多人把所有代码都塞进锁里结果程序性能暴跌或者反过来只锁一行关键代码结果整个共享结构还是乱套了。这就是锁粒度问题。锁粒度要综合考虑三个因素因素偏向大锁偏向小锁正确性越容易保证越容易出错性能争用高吞吐低争用低吞吐高复杂度逻辑简单逻辑复杂一般来说我的实践原则是先保证正确性再考虑优化。临界区尽量只包含真正共享的数据操作不要在里面做耗时计算、日志输出、网络 IO 等无关操作。比如下面的代码就非常不推荐std::lock_guardstd::mutex lock(g_mutex); g_data.push_back(value); log(插入成功); // 日志操作不应该在锁内 compute_expensive(); // 耗时计算也不应该在锁内锁内做的事情越少其他线程等待的时间就越短。真遇到需要同时保护多个共享变量时可以用std::lock一次锁多个互斥量避免死锁风险这个会在常见问题里再细说。3.3 条件变量等待别人干完事的正确姿势互斥锁解决的是“同时访问”但实际业务里还有一个常见需求线程 A 要等线程 B 完成某个任务之后才能继续。简单的轮询写法是while (!ready) { // 空转 }这种写法极其浪费 CPU而且还要自己保证ready变量的读写安全。条件变量就是专门解决这个问题的。条件变量提供两个核心动作wait和notify。线程 A 调用wait时会原子性地做三件事释放传入的锁、把自己挂起、等待被唤醒。线程 B 完成工作后调用notify把自己唤醒。标准写法如下#include condition_variable #include mutex #include thread #include queue #include iostream std::mutex g_mutex; std::condition_variable g_cv; std::queueint g_tasks; void worker() { while (true) { std::unique_lockstd::mutex lock(g_mutex); g_cv.wait(lock, [] { return !g_tasks.empty(); }); int task g_tasks.front(); g_tasks.pop(); lock.unlock(); std::cout 处理任务: task std::endl; } } void producer() { for (int i 0; i 10; i) { { std::lock_guardstd::mutex lock(g_mutex); g_tasks.push(i); } g_cv.notify_one(); } }这里有几个必须掌握的细节第一条件变量必须和互斥锁配合使用wait的第一个参数传锁而且在等待期间锁会被释放。注意这里用的是std::unique_lock不是lock_guard因为unique_lock允许手动加锁、解锁wait内部需要操作锁。第二wait一定要用带谓词的重载或者自己写 while 循环检查条件不能裸用wait。原因在于条件变量存在两个经典问题假唤醒spurious wakeup和丢失唤醒。所谓假唤醒是说操作系统可能在没有 notify 的情况下把线程唤醒不检查条件直接往下走就会出 bug。所谓丢失唤醒是说如果生产者在消费者进入 wait 之前就调用了 notify那么这个通知会丢失消费者可能永远等下去。用 while 循环检查谓词两边都能兜住。第三修改共享数据时要加锁但 notify 调用最好放在锁外。这样做的目的是让唤醒者在第一时间执行锁后的逻辑而不是又要和持有锁的线程竞争。3.4 原子操作与无锁思维什么时候不需要锁锁虽然好用但代价不小。每次加锁解锁都有开销线程争抢锁时还会发生上下文切换高频场景下可能比同步本身还慢。这时候我们还有另一个工具原子操作。C11 的std::atomicT可以让一个变量上的读改写操作变成原子操作底层通常由 CPU 的原子指令保证。最经典的例子是计数器#include atomic std::atomicint counter{0}; void increment() { for (int i 0; i 100000; i) { counter.fetch_add(1); } } // 结果是正确的 200000使用原子变量时有一个取舍点并非所有类型都支持原子操作。对于常用的 int、bool、指针等std::atomic的性能很好但对于自定义的大结构体或者需要多个字段同时一致更新的场景没法做到原子操作这时仍然要回到锁。我在项目里的选择标准很简单只保护一个数字或一个标志位用原子操作保护一组相关联的数据用锁。还有一种混合做法先用原子变量做快速路径的检查不满足条件再上锁。比如无锁队列和带锁队列的分层设计但这个偏进阶系列后面的文章再做展开。4. 实操记录一个多线程任务队列的完整落地4.1 场景设定从自然语言到线程设计纸上谈兵聊完了我们现在把前面所有内容串成一个真实场景。假设你要做一个简单的网络爬虫任务分发器主线程不断从某个接口拉取 URL 列表多个工作线程并发爬取页面爬虫结果统一写入一个共享结果队列最后由主线程统一处理结果。拆解一下需求我们需要控制的点是一个生产者线程负责往任务队列里放 URL三个消费者线程负责从任务队列取 URL模拟爬取并生成结果一个结果队列存放爬取到的内容需要控制粒度合适的锁防止队列被同时读写弄乱。这个场景覆盖了线程创建、互斥锁、条件变量、join 声明周期控制真实程度足够高又不至于臃肿。4.2 版本一不加任何同步的原始写法先来看一个错误的“直觉版”写法方便大家直观感受问题出在哪#include deque #include string #include thread #include iostream std::dequestd::string tasks; std::dequestd::string results; void producer() { for (int i 0; i 100; i) { tasks.push_back(http://example.com/page/ std::to_string(i)); } } void consumer() { while (!tasks.empty()) { std::string url tasks.front(); tasks.pop_front(); // 模拟爬取 std::string res fetched: url; results.push_back(res); } } int main() { std::thread p(producer); std::thread c1(consumer); std::thread c2(consumer); std::thread c3(consumer); p.join(); c1.join(); c2.join(); c3.join(); std::cout 任务总数: tasks.size() std::endl; std::cout 结果总数: results.size() std::endl; return 0; }这个版本跑起来结果总数几乎不可能是 100。因为消费者线程之间会同时操作 tasks同一时刻可能有两个线程都看到tasks不为空都去拿tasks.front()然后一个 pop另一个再 pop 时队列已经空了甚至可能同时对同一个节点做读写程序直接崩溃。这个版本就是我们前面说的数据竞争的具象化。写并发程序的第一步永远是先识别出哪些变量是被多线程共享访问的然后反问自己有没有保护4.3 版本二用互斥锁保住核心数据结构现在给 tasks 和 results 分别加上一把锁并且用条件变量让消费者在有任务时才工作不在空队列上浪费循环。#include deque #include string #include thread #include mutex #include condition_variable #include iostream std::dequestd::string tasks; std::dequestd::string results; std::mutex tasks_mutex; std::mutex results_mutex; std::condition_variable tasks_cv; bool done false; void producer() { for (int i 0; i 100; i) { { std::lock_guardstd::mutex lock(tasks_mutex); tasks.push_back(http://example.com/page/ std::to_string(i)); } tasks_cv.notify_one(); } { std::lock_guardstd::mutex lock(tasks_mutex); done true; } tasks_cv.notify_all(); } void consumer() { while (true) { std::unique_lockstd::mutex lock(tasks_mutex); tasks_cv.wait(lock, [] { return done || !tasks.empty(); }); if (tasks.empty()) { return; } std::string url tasks.front(); tasks.pop_front(); lock.unlock(); // 模拟爬取这步不放在锁里 std::string res fetched: url; { std::lock_guardstd::mutex lock(results_mutex); results.push_back(res); } } } int main() { std::thread p(producer); std::thread c1(consumer); std::thread c2(consumer); std::thread c3(consumer); p.join(); c1.join(); c2.join(); c3.join(); std::cout 结果总数: results.size() std::endl; return 0; }这个版本基本是正确的。我特别想让大家注意的是几个细节第一个消费者线程在tasks_cv.wait的谓词里检查的是done || !tasks.empty()。这个done标志非常重要它解决了“所有任务消费完后消费者如何退出”的问题。上一小节说过的丢失唤醒问题就在这里体现如果生产者在消费者进入 wait 之前就已经通知了最后一次任务那这个通知会丢失消费者永远等不到新任务。加一个done标志意思是“生产者已经彻底结束不会再有任务”消费者必须检查这个标志才能安全退出。第二个在爬取模拟阶段我先lock.unlock()释放了队列锁。这是一个刻意的锁粒度控制爬取网络页面是耗时操作如果握着队列锁不放其他消费者都没法取任务三个线程就退化成串行执行了。把耗时操作移出临界区是并发性能的关键操作。第三个两个共享队列分别用独立的两把锁来保护。这样消费者在写结果时不需要锁任务队列生产者也不会被结果锁拖住。把锁切分得更细能减少锁争用但代价是代码复杂度更高所以一定要在性能需求和正确性之间做权衡。4.4 版本三统一封装线程安全队列版本二能跑但有两个问题一是两把锁分散在业务代码里容易漏加锁二是如果后续扩展任务类型还要重复写一堆加锁逻辑。更好的做法是把队列封装成一个ThreadSafeQueue类对外只暴露安全的接口内部自己管理锁和条件变量。template typename T class ThreadSafeQueue { public: void push(T value) { std::lock_guardstd::mutex lock(m_mutex); m_queue.push(std::move(value)); m_cv.notify_one(); } bool pop(T out) { std::unique_lockstd::mutex lock(m_mutex); m_cv.wait(lock, [this] { return !m_queue.empty(); }); if (m_queue.empty()) { return false; } out std::move(m_queue.front()); m_queue.pop(); return true; } void stop() { std::lock_guardstd::mutex lock(m_mutex); m_stopped true; m_cv.notify_all(); } bool stopped() const { std::lock_guardstd::mutex lock(m_mutex); return m_stopped; } private: std::dequeT m_queue; std::mutex m_mutex; std::condition_variable m_cv; bool m_stopped false; };封装完的好处非常明显业务代码只需要调用push和pop锁的控制逻辑全部收敛在类内部调用方不会因为少加锁而翻车。这里的stop方法是用来通知所有消费者退出的原理是设置m_stopped标志并唤醒所有等待线程等待线程在wait的谓词里检查这个标志后跳出循环。在实际项目中像这样把并发控制收敛到组件内部是推荐的做法。它能让并发 bug 的排查范围急剧缩小——因为同步规则就写在一个类里不需要到处翻找加锁点。4.5 运行验证与性能直觉这个版本直接跑结果总数一定是 100而且线程退出是正常、干净的。你可以在自己的环境里用time命令感受一下和版本一的区别版本一可能直接段错误版本二和版本三都能稳定输出。关于性能我先给一个直觉判断对于这个任务队列案例真正的瓶颈是“模拟爬取”的耗时而不是锁争用。锁的消耗主要在锁本身的获取和释放上如果临界区很短并且没有太多线程同时抢锁性能损耗通常不会太大。真正需要警惕的是临界区里塞了大量耗时操作这种情况无论怎么优化锁都没救只能把耗时操作移出去。5. 常见问题与排查技巧实录5.1 死锁的几种典型姿势与排查方法死锁是线程控制里最常见、也最让人头疼的问题。它的本质是多个线程互相等待对方持有的资源谁都不肯放手导致所有线程都卡死。最典型的场景是“两个锁的互相等待”线程 A 持有锁 1想获取锁 2线程 B 持有锁 2想获取锁 1。std::mutex m1, m2; void thread_a() { std::lock_guardstd::mutex lock1(m1); // 一些工作... std::lock_guardstd::mutex lock2(m2); // 等 m2但 m2 被 thread_b 持有 } void thread_b() { std::lock_guardstd::mutex lock2(m2); // 一些工作... std::lock_guardstd::mutex lock1(m1); // 等 m1但 m1 被 thread_a 持有 }解决办法首选是保持全局一致的加锁顺序比如约定所有线程都必须先锁 m1 再锁 m2这样就不可能出现循环等待。如果加锁顺序实在无法统一可以用std::lock(m1, m2)一次锁多把让标准库帮你规避死锁。排查死锁时我常用的手段是用调试器 attach 到卡死的进程然后查看各线程的调用栈。Linux 下可以用 gdb 的thread apply all bt打印所有线程的堆栈Windows 下用 Visual Studio 的“并行堆栈”窗口。看到循环等待的那一刻问题基本就明朗了。日常开发中修死锁最快的办法就是先确认每一把锁的持有者再确认等待顺序两者形成闭环就是死锁。5.2 假唤醒与丢失唤醒条件变量两大隐藏坑条件变量不是简单的“你唤醒我我就继续执行”。线程被唤醒后竞争到锁进入临界区但此时共享数据的状态未必就是生产者通知时的状态。原因是假唤醒操作系统或硬件可能会让线程在没有收到 notify 的情况下自己醒来虽然不常见但要处理。多个消费者三个消费者都被唤醒但队列里只有一条任务先抢到锁的那个把任务拿走了后抢到锁的两个醒来回头再拿队列已经空了。所以前面反复强调wait必须配合谓词检查。在wait(lock, predicate)形式中谓词为 false 时会重新进入阻塞等待直到谓词变为 true。只要接受了这个模式假唤醒和丢失唤醒就都自动解决了。真实项目里我还遇到过另一种“伪丢失唤醒”生产者把数据放入队列后唤醒消费者的代码写在锁内导致一些消费者争锁很慢生产者又继续生产最后看起来消费者好像“延迟”了。这种不算 bug但会影响吞吐。所以我在代码规范里有一条notify 调用尽量放在锁外减少锁内的时间让等待线程尽早被唤醒。5.3 线程安全队列的边界情况操作线程安全队列时有两个容易被忽略的边界场景。第一个是pop接口在队列为空时的行为。如果业务上“取不到任务”是很正常的状态就不应该让pop阻塞而是返回一个空值或布尔标记。比如我的封装里用了bool pop(T out)就是为了区分“成功取出”和“队列已停止取不到任务”。第二个是停止条件。很多线程池在退出时需要优雅地让消费者停止等待而不是直接杀死线程。正确的做法是设置一个停止标志然后 notify_all。这里注意不能只在停止时设标志而不 notify否则消费者还在条件变量的 wait 里昏睡根本看不到标志。这个细节我见过不止一个项目在退出时卡死最后才发现是少了 notify_all。5.4 线程控制排查路线图如果在自己的并发代码里遇到无法解释的崩溃或卡死我会按下面这个顺序来排查先看数据竞争把所有共享变量的访问列出来确认有没有两个线程同时读写。最暴力的方式是开 ThreadSanitizer-fsanitizethread它能自动检测数据竞争。再看锁的粒度持有锁期间是否执行了耗时操作是否可能调用了其他会抢锁的代码再看条件变量的 wait有没有用谓词有没有可能唤醒丢失再查死锁加锁顺序是否一致有没有可能循环等待最后看生命周期有没有线程对象被销毁时还处于 joinable 状态有没有 detach 的线程在进程退出时还没干完活这套路基本覆盖了 90% 的线程控制问题。每查一步都要问自己一句我这样做能让另一个线程在看到同一份数据时得到确定的结果吗写在最后线程控制说到底是三件事管生命周期、管共享数据、管线程间的协作。这个系列的第一篇我把最底层的机制和最常见的坑都过了一遍。我个人的体会是真正写好多线程代码靠的不只是记住 API而是养成一种“并发思维”——每写一行操作共享数据的代码都下意识地追问一句这个操作如果是两个线程同时进来会怎样这一篇里的线程安全队列我强烈建议你自己动手抄一遍、改一遍、跑一遍然后把里面的锁拆掉再跑一遍感受一下崩溃时的现象。踩过一次数据竞争的坑比看十篇教程都管用。下一篇我准备详细讲讲线程池的搭建和自己如何设计一个严谨的线程池到时候聊怎么控制线程数量、如何优雅退出以及怎么实现任务窃取欢迎继续往下看。