ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

线程间共享数据:从数据竞争到同步机制全解析

线程间共享数据:从数据竞争到同步机制全解析 线程间共享数据到底在共享什么麻烦并发编程里有一句话我特别认同“共享数据才是万恶之源。”单线程程序里变量就是变量读写顺序清清楚楚。可一旦进入多线程世界事情立刻变得微妙起来。两个线程同时读一个变量没问题一个读一个写就可能读到“半新不旧”的值两个线程同时写更是直接进入“谁先谁后全靠命”的混沌状态。这就是经典的数据竞争——也是《Chapter 3 线程间共享数据》这一章花了大量篇幅要解决的问题。这篇文章算是我对这部分内容的完整归纳总结会从为什么共享数据会出问题讲起接着把互斥锁、死锁、条件变量、原子操作这几个核心武器挨个拆开穿插一些实际代码、踩坑记录和我在项目里的习惯用法。无论你是刚看完书还处于“懂了但不会用”的阶段还是已经在项目里被并发bug折磨过几轮这篇文章应该都能给你一些参考。说明文中所有代码均以 C11 及以上标准为例这是这一章最典型的语境。如果你用的是 C 语言的 pthread、Java 的 synchronized 或者 Go 的 channel思路完全一致只是工具形态不同。1. 数据竞争的根源比你想的更深一层1.1 CPU、缓存与内存的三方博弈先别急着上锁。我们得先明白一个问题为什么“同时读写一个变量”会出错难道 CPU 不是按指令一条条执行的吗问题出在“同时”这两个字上。现代 CPU 为了提速普遍采用了多层缓存架构每个核心有自己的 L1/L2 缓存多个核心共享 L3 缓存最后才是内存。当一个线程在核心 A 上修改了变量x这个修改可能还停留在核心 A 的 L1 缓存里没有立即写回内存。这时核心 B 上的另一个线程去读x它从自己的缓存里读到的是旧值——这就产生了不一致。更麻烦的是即使缓存最终一致了指令重排也会出来捣乱。编译器和 CPU 为了优化执行效率可能在保证单线程语义不变的前提下对指令顺序进行调整。单线程下这毫无问题但多线程下一个线程看到的“顺序”和另一个线程看到的“顺序”可能完全不一样。所以数据竞争的本质不是“两个线程碰巧同时操作”而是缺乏同步机制时多个线程对同一内存位置的访问顺序未被定义。一旦出现数据竞争程序行为就是未定义的不是“可能出错”而是“一切皆有可能”——包括看起来完全不可能发生的错误。1.2 一个段子引发的思考i 为什么不是原子操作几乎所有并发教程都会拿i说事这个例子确实经典到无法绕过。i在高级语言里是一条语句但在机器层面它至少包含三个操作从内存读取i到寄存器、对寄存器加 1、把寄存器写回内存。这三个步骤不是原子的意味着线程 A 执行到第二步时线程 B 可能已经读到了旧的i。我见过一个很形象的比喻两个人同时往一个本子上记录同一个数字A 把“5”读进脑子准备改成“6”B 也把“5”读进脑子准备改成“6”然后 A 写下“6”B 也写下“6”。明明加了两次结果却只加了 1。这就是经典的“丢失更新”。在我实际项目中这种 bug 最可怕的地方在于它在低并发时几乎不出现压测规模一上来就随机报错而且崩溃现场还特别难复现。所以处理共享数据的第一个原则就是——不要抱侥幸心理。只要存在数据竞争它一定会出问题只是时间早晚。1.3 共享数据的典型场景清单哪些场景容易踩到共享数据的雷我根据自己的经验列一个清单全局计数器/统计量比如在线用户数、请求总数、缓存命中次数。这类最容易被忽略因为看起来“只是加个一”。共享缓存/池比如连接池、对象池多个线程同时获取/释放资源。发布-订阅模式中的共享状态生产者线程写入数据消费者线程读取。典型如任务队列。延迟初始化多个线程同时首次访问某个单例对象如果没做好同步可能构造出多个实例或者拿到半初始化对象。这些场景有一个共同点数据本身是共享资源而访问它没有明确的先后约束。要安全地处理它们核心思路只有一个——在访问共享数据时引入同步机制把“混乱的并发”转化为“有序的串行”。2. 互斥锁最简单的保护也最容易用错2.1 锁的基本模型与 std::mutex互斥锁mutexmutual exclusion的思想极其朴素在访问共享数据前先“上锁”访问结束后“解锁”。同一时刻只允许一个线程持有锁其他线程必须等待。这就像公共厕所的门锁——有人进去就锁上其他人只能在门口排队里面的人出来才放进下一个。C11 开始标准库提供了std::mutex基本用法如下#include mutex std::mutex mtx; int shared_counter 0; void increment() { mtx.lock(); shared_counter; // 临界区受保护的区域 mtx.unlock(); }这段代码本身没毛病但它有一个隐患如果shared_counter这行代码抛出异常unlock()永远不会执行锁就永远被占着其他线程全部卡死。在真实项目里临界区往往不止一行代码申请资源、写日志、调第三方接口都可能抛异常。2.2 lock_guard让异常也拿不走你的锁std::lock_guard就是为解决这个问题而生的。它是一个 RAIIResource Acquisition Is Initialization风格的包装器构造时加锁析构时自动解锁。即使临界区抛出异常栈上的lock_guard对象也会在栈展开时被正常析构锁随之释放。void increment() { std::lock_guardstd::mutex lock(mtx); shared_counter; // 即使这里抛异常lock 析构时也会自动解锁 }这段代码的意图非常清晰lock对象的生命周期就圈定了临界区的范围。离开大括号锁就释放。用好 RAII能从根本上杜绝“忘记解锁”和“异常导致死锁”这两类低级错误。2.3 unique_lock比 lock_guard 更灵活的进阶选择std::unique_lock是lock_guard的增强版。它除了 RAII 管理锁之外还允许你手动解锁、延迟加锁、转移锁所有权。这在很多场景下非常有用std::unique_lockstd::mutex lock(mtx, std::defer_lock); // 先不锁 // 做一些不需要锁的准备工作 lock.lock(); // 需要时再锁 // 临界区操作 lock.unlock(); // 提前解锁让其他线程进入 // 做不需要锁的后置工作我特别常用到它的一种场景是持锁时间很长但临界区中间有一段耗时操作不需要保护。用unique_lock在中间先unlock做完耗时操作再lock能显著降低锁的争用提升并发性能。不过要提醒一句能用lock_guard就别用unique_lock。unique_lock的对象体积更大操作略慢而且手动的unlock/lock增加了出错概率。灵活是有代价的按需选择。2.4 全局锁还是细粒度锁锁的粒度设计这是我们在设计阶段就会遇到的问题一把大锁锁住所有共享数据简单省事但并发性能极差每个数据配一把小锁并发高但死锁风险剧增。我做过多线程任务调度系统对此深有体会。一开始图省事用一个全局std::mutex保护所有任务队列结果压测时发现 CPU 占用率很低吞吐量上不去——大量时间花在等锁上。后来改成“每个队列一把锁”吞吐量立刻翻了几倍。锁的粒度选择本质上是一个并发度与复杂度的平衡。我总结出三个判断维度临界区代码执行时间如果临界区只有几条指令用锁的代价相对较高可以考虑原子操作替代。共享数据的访问频率访问越频繁锁的争用越严重越需要减小粒度。持有锁时是否可能调用外部接口我有一条铁律——持锁期间绝不调用可能阻塞的第三方接口。网络请求、磁盘IO、远程调用都不能放在临界区内。否则一旦对方响应慢所有等待这把锁的线程都会跟着遭殃这会引发不可控的连锁故障。2.5 只在需要保护的地方加锁还有一个容易踩坑的地方保护范围过大。比如一个对象既有共享数据又有线程无关的私有方法结果你给整个类的方法都加了锁。表面上是“安全”实际上是给所有操作制造了不必要的串行化。我见过最极端的案例有人给getters方法也加了锁。明明只是读一个常量字段永远不变也被锁保护着。这种代码会无端拖慢所有线程。正确的做法是先分析哪些数据是真正共享且可变的然后只对这些数据的访问路径加锁。const成员函数里如果只读不可变数据就不要加锁如果读共享的可变数据即使只是读也仍然需要加锁——因为读的同时可能有人在写读到一半的数据会造成不一致。3. 死锁比数据竞争更隐蔽的噩梦3.1 死锁的四个必要条件如果说数据竞争是“并发程序的癌症”死锁就是“并发程序的心脏骤停”。数据竞争至少还能让你看到错误的结果死锁则是程序直接挂起所有线程卡死日志停更监控告警服务无响应。死锁的经典触发场景是线程 A 持有锁 1等待锁 2线程 B 持有锁 2等待锁 1。两个线程互相等待谁也不肯放手于是永远僵持。产生死锁需要同时满足四个条件互斥至少有一个资源被非共享地持有锁本身就是互斥的。持有并等待一个线程持有一个资源同时等待获取其他资源。不可剥夺资源不能被强制从持有者手中抢走。循环等待存在一个线程循环链每个线程都在等待下一个线程持有的资源。四个条件缺一不可。所以避免死锁的思路也很清晰——破坏其中任何一个条件即可。3.2 用 std::lock 一次性锁多个互斥量C 标准库提供了一个非常实用的工具std::lock。它可以在一次调用中锁住多个互斥量并且内部采用特殊算法避免因锁顺序不同导致死锁。std::mutex mtx1, mtx2; void transfer(int amount) { std::lock(mtx1, mtx2); // 同时锁住两把锁不会死锁 std::lock_guardstd::mutex lock1(mtx1, std::adopt_lock); // 接管锁的所有权 std::lock_guardstd::mutex lock2(mtx2, std::adopt_lock); // 安全地操作两个共享数据 }注意这里的关键点std::lock负责“锁住”的动作两个lock_guard用std::adopt_lock参数接管已经持有的锁这样即使后续代码抛异常锁也能在析构时被正确释放。在我印象里书上原话我记不太清了这个方案是我们提到“同时锁多个互斥量”时的首选方案因为它在语法层面就根除了“锁顺序不一致”的问题。我自己在实现跨账户转账这类场景时一定会用std::lock而不是分别lock两个互斥量。3.3 锁顺序不一致最常见的死锁教科书案例std::lock能解决问题但它有一个限制必须在同一行代码里同时锁住所有锁。有时候你不得不在不同函数里分别对同一组锁加锁这时就必须非常小心锁的顺序。比如线程 A 调用func1()按“先锁 mtx1再锁 mtx2”的顺序加锁线程 B 调用func2()按“先锁 mtx2再锁 mtx1”的顺序加锁。当 A 锁住 mtx1 等待 mtx2、B 锁住 mtx2 等待 mtx1 时死锁就发生了。解决这类问题的通用手段是约定统一的加锁顺序。比如明确规定“加锁顺序必须按照互斥量地址从低到高”。大家按同一规则排队就不会出现循环等待。这个方法看起来笨但在真实代码库里非常管用——它是可以在 Code Review 阶段通过检查代码逻辑来保证的。3.4 层级锁给锁排个优先级比“统一顺序”更进一步的做法是层级锁hierarchical mutex。思路是给每把锁分配一个层级编号代码运行时检查加锁顺序是否严格按层级从高到低或从低到高进行。class HierarchicalMutex { public: explicit HierarchicalMutex(unsigned level) : m_level(level) {} void lock() { check_for_violation(); // 检查当前线程的层级约束 m_mutex.lock(); m_thread_level m_level; } void unlock() { m_thread_level m_prev_level; m_mutex.unlock(); } private: void check_for_violation() { if (m_thread_level m_level) { throw std::logic_error(mutex hierarchy violated); } m_prev_level m_thread_level; } std::mutex m_mutex; unsigned m_level; static thread_local unsigned m_thread_level; // 当前线程已持有的最高层级 unsigned m_prev_level; };层级锁的核心逻辑是线程当前已持有的锁的最高层级必须高于它想获取的下一把锁的层级。这样锁的获取形成严格的单向顺序打破了循环等待条件死锁在运行前就被挡住了。这套方案我在实际的中间价系统里用过效果很好。缺点是它牺牲了一定的灵活性——如果确实需要临时改变加锁顺序就得仔细调整层级编号。3.5 避免死锁的通用建议清单死锁排查非常痛苦我整理一份我自己的避坑清单送给各位优先使用std::lock一次锁多个互斥量不要让“分别加锁”出现在代码里。如果无法一次锁多个锁必须在全项目范围内约定并遵守统一的加锁顺序。用std::unique_lockstd::try_lock做超时尝试。如果在一段时间内拿不到锁就释放已持有的锁、回退重试。这是一种“宁可退一步也不要僵持”的策略。锁的持有时间尽可能短不要持锁调用外部接口。考虑使用层级锁从体制上防止无意的加锁顺序颠倒。“多把锁 多线程 不同顺序”是并发编程的经典雷区。上面这些建议都是我踩过坑之后沉淀下来的血泪经验每一项背后都对应过我真实排过的问题。4. 条件变量让线程学会等待和通知4.1 轮询等待 vs 条件变量很多时候线程之间需要协作一个线程等某个条件成立另一个线程负责让条件成立。最简单粗暴的做法是轮询——自旋检查标志位std::atomicbool ready{false}; void worker() { while (!ready.load()) { std::this_thread::yield(); // 让出CPU时间片但线程仍在不停检查 } // 继续干活 }这个方案的缺点是显而易见的while循环会持续消耗 CPU 时间片。即使加了yield线程依然处于可运行状态反复被调度、反复检查。如果等待时间较长CPU 浪费会非常严重。条件变量就是为了解决“高效等待”而生的。它提供了一种机制线程可以挂起等待某个条件直到另一个线程显式“唤醒”它。等待期间不占用 CPU被唤醒后才重新参与调度。4.2 条件变量的标准用法与源码剖析C 标准库的std::condition_variable提供了wait和notify两类操作。下面是它的经典用法#include condition_variable #include mutex #include queue std::mutex mtx; std::condition_variable cv; std::queueint data_queue; void producer() { int item 42; { std::lock_guardstd::mutex lock(mtx); data_queue.push(item); } cv.notify_one(); // 通知等待中的消费者 } void consumer() { while (true) { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return !data_queue.empty(); }); // 等待条件成立 int item data_queue.front(); data_queue.pop(); lock.unlock(); // 处理 item } }有几个细节必须掰开揉碎了讲wait必须配合std::unique_lock。因为wait的内部逻辑是先将线程阻塞同时释放锁让其他线程有机会进入临界区被唤醒后重新获取锁再继续执行。这个“释放-重新获取”的操作只有unique_lock能做到lock_guard不具备手动解锁的能力。wait的第二个参数是谓词predicate。它是一个可调用对象返回bool。wait内部会循环检查这个谓词如果条件不满足继续休眠条件满足才返回。这个设计解决了一个经典陷阱——假唤醒spurious wakeup。notify_one与notify_all。notify_one唤醒一个等待线程notify_all唤醒所有。如果多个线程等待的是不同条件用notify_all更稳妥但同时唤醒太多线程也可能导致“惊群效应”增加调度开销。4.3 丢失唤醒与假唤醒条件变量两大阴影先说丢失唤醒。如果生产者在线程wait之前就完成了notify唤醒信号已经发出但此时没有线程在等待信号就丢失了。之后消费者才进入wait它会一直睡下去永远不会被唤醒。解决方法是“先检查条件再进入等待”也就是让谓词检查先行std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return !data_queue.empty(); }); // 本质上等价于 while 循环检查因为wait带着谓词时它会先检查谓词再进入休眠。如果条件已经满足它就不会睡直接返回。所以在“生产者先于消费者执行”的场景里带谓词的wait也能正确工作——这就是它存在的重要理由。再说假唤醒。C 标准并不保证wait只会在调用notify时返回某些环境下线程可能在没有收到通知的情况下被唤醒。如果wait没有谓词醒来的线程会直接往下执行此时被共享数据的条件可能并不满足程序就出错了。而带谓词的wait在醒来后会重新检查条件不满足就继续睡天然免疫假唤醒。所以在实际开发中我有一条原则永远使用带谓词的wait写法不要直接调cv.wait(lock)这种裸写法除非你有十足的理由。这条原则可以在 Code Review 中列为硬性规定。4.4 条件变量使用中的几个实操坑与条件变量相关的坑非常多我挑几个常见的列出来notify必须在持锁或解锁后调用。技术上notify不要求在锁内调用但如果在锁内调用被唤醒的线程会立即尝试获取锁而此时锁还持有者醒来的线程只能阻塞等待。极端情况下可能带来一点点调度开销。我的习惯是先解锁再notify。条件变量没有状态记忆。notify_one只负责唤醒一个正在等待的线程不记录“曾经有人 notify 过”。所以条件变量必须配合实际条件比如队列是否为空一起使用。这个问题本质就是“丢失唤醒”。同一个条件变量别混用多个等待条件。如果有两个消费者分别等待不同条件notify_all会同时唤醒它们但其中只有一个条件满足另一个醒过来后会继续睡。这虽然不会出大问题但会造成无效唤醒性能受损。5. 原子操作别一上来就上锁5.1 什么时候应该用原子操作替代互斥锁互斥锁是同步的“万能药”但它不是“高效药”。每次加锁解锁都有开销而且锁的争用会导致线程阻塞、唤醒仅这两步就是不小的性能代价。有一种情况完全不需要锁当操作只有单个内存访问指令时。比如std::atomicint counter{0}; void increment() { counter.fetch_add(1); // 原子地做 1 }fetch_add在底层编译成一条原子指令如 x86 的LOCK XADD对整个操作而言它是原子的。不需要锁不需要阻塞性能远高于互斥锁。原子操作适用的场景包括计数器统计请求次数、错误次数、命中次数。标志位线程安全的启停控制。无锁队列中的指针更新利用 CAS比较并交换实现无锁数据结构的基础。但原子操作也有限制它只能保护单个变量。如果你需要同时更新多个变量或者操作逻辑是“读-判断-写”的复合流程原子操作就力不从心了。这时还是得上锁。5.2 std::atomic 的核心操作速查std::atomic模板支持几乎所有内置类型int、bool、指针都可以。常用操作如下操作含义示例load()原子读取当前值int x flag.load();store(v)原子写入值flag.store(true);exchange(v)原子交换返回旧值int old counter.exchange(0);compare_exchange_weak/strongCAS比较并交换见下方代码fetch_add/fetch_sub原子加减counter.fetch_add(1);/-//--运算符重载counter;CAS 是很多无锁数据结构的基础操作。它的语义是如果当前值等于预期值则更新为目标值返回true否则不更新返回false。经典用法如下std::atomicint value{0}; void update() { int expected value.load(); int target expected 10; while (!value.compare_exchange_weak(expected, target)) { // 说明 value 被其他线程修改了expected 已被更新为最新值 // 重新计算 target target expected 10; } }注意这里用了compare_exchange_weak。这个 weak 版本在极少数硬件环境下可能“无辜失败”——即使值匹配也会返回失败所以一般要放在循环里重试。compare_exchange_strong不会无辜失败但开销稍大。5.3 内存序原子操作的灵魂如果你只记住atomic的load/store/fetch_add就真刀真枪上生产环境那你大概率会踩一个更隐蔽的坑——内存序memory order。原子操作保证了“操作本身的原子性”但没规定“操作之间的顺序”。CPU 和编译器依然可以重排指令。C 标准给atomic提供了几个内存序选项memory_order_relaxed只保证原子性不保证任何顺序。适合纯粹的计数器累加。memory_order_acquire该读操作之后的读写不能被重排到它之前。常用于加锁。memory_order_release该写操作之前的读写不能被重排到它之后。常用于解锁。memory_order_seq_cst默认全序一致最严格的顺序保证。我见过最多的 bug 就是滥用memory_order_relaxed。它确实快但如果你用它保护一个“先写数据再置标志位”的场景消费者可能看到“标志位已置位但数据还没写完”的诡异中间态。这时候必须用release写标志位用acquire读标志位才能保证“写数据 happens-before 写标志位”的传递关系。给个实际场景生产者写一个全局配置项然后置ready_flag true消费者等待ready_flag再读配置项。正确的写法是std::atomicbool ready_flag{false}; Config g_config; void producer() { g_config load_from_file(); // 普通写 ready_flag.store(true, std::memory_order_release); // 释放写 } void consumer() { while (!ready_flag.load(std::memory_order_acquire)) { // 获取读 std::this_thread::sleep_for(std::chrono::milliseconds(1)); } use_config(g_config); // 这里读到的 g_config 一定是完整的 }这种 release-acquire 配对是并发编程里最常用的同步模式我建议每个人都把它刻进肌肉记忆里。默认的seq_cst虽然最稳但性能开销最大。当你能清晰说明为什么更宽松的顺序不会出错时再考虑relaxed或acquire/release。6. 实战总结与工程化建议6.1 从“能跑”到“设计良好”的三个层次回顾整章的归纳我觉得可以分三个层次来评估自己对线程间共享数据的掌握程度第一层是能跑。知道加锁会用lock_guard遇到问题会在关键代码前后加锁。但可能锁粒度过大性能不佳偶尔死锁自己排查要花半天。第二层是好用。理解锁的粒度选择会std::lock一次锁多个互斥量知道条件变量要配谓词懂原子操作的适用边界。能写出正确、性能可接受的并发代码。第三层是优雅。能从架构层面避免不必要的共享。比如把共享数据封装在单一模块里对外只暴露线程安全的接口比如设计阶段就规划好数据流尽量让线程之间通过消息传递而不是直接共享内存。说到这一层我想起书里其实还提到过一个很有用的设计建议大意如此与其设计复杂的锁方案不如重新审视数据流看能不能根本不共享数据或者把共享数据封装成线程安全的类。这也是我经过多年实战后最想强调的——锁不是银弹能减少共享就不需要用锁。6.2 我常用的线程安全类封装模板把共享数据封装成线程安全的类是我推荐度最高的工程实践。它有几个明显好处同步逻辑集中在一处避免散落各处的锁测试时只需要针对类的接口做并发测试调用方完全不用关心底层同步机制。一个示例模板template typename T class ThreadSafeQueue { public: void push(const T item) { std::lock_guardstd::mutex lock(m_mtx); m_queue.push(item); m_cv.notify_one(); } bool try_pop(T item) { std::unique_lockstd::mutex lock(m_mtx); m_cv.wait(lock, [this] { return !m_queue.empty() || m_done; }); if (m_queue.empty()) return false; item m_queue.front(); m_queue.pop(); return true; } void shutdown() { std::lock_guardstd::mutex lock(m_mtx); m_done true; m_cv.notify_all(); } private: std::mutex m_mtx; std::condition_variable m_cv; std::queueT m_queue; bool m_done false; };调用方永远不需要接触mutex和condition_variable所有同步细节都被封装在类内部。这就是“把并发复杂度关进笼子里”的典型做法。强烈推荐在项目的核心模块里采用这种设计。6.3 排查并发问题的三个技巧真到了线上出了并发问题怎么排查我把自己常用的三板斧分享出来加日志但要加带时间戳的顺序日志。把每次加锁、解锁、修改共享变量的行为打成带序号的日志。单线程看日志可能看不出问题多线程看时序就能发现争夺热点。应用线程安全检测工具。C 领域ThreadSanitizerTSan是神器。在编译时加上-fsanitizethread运行时就能报告数据竞争发生的位置。虽然性能开销大只适合测试环境但排查问题效率极高。最小化复现。把并发场景裁剪到最小比如 2 个线程、1 个共享变量尝试用不同的线程调度顺序去触发问题。很多时候写着写着就发现是自己对某个 API 的理解有偏差。6.4 我的最终体会并发编程的核心是“减少共享”折腾了这么多年的线程间共享数据如果用一句话来总结我的经验那就是并发编程的最终目标不是学会更多的同步工具而是让同步变得不再必要。如果一个系统里到处是锁、条件变量、原子操作那么它的并发逻辑必然复杂难以推理bug 频发。更好的做法是尽量使用无共享设计——每个线程独立的数据集不跨线程访问。必须共享时尝试消息传递——用队列让线程之间交换数据而不是直接操作同一块内存。真正需要共享可变数据时把同步封装成类让调用方看不到锁的存在。我在做任务调度系统时核心模块几乎只有两个线程安全的队列所有线程之间的数据传递都走队列。锁只在队列内部出现外部一个锁都没有。这套设计让我在之后相当长的一段时间里几乎再也没有因为并发问题加过夜班。线程间共享数据说到底是并发编程里最基础也最考验功力的一块。基础知识固然重要但真正让你脱颖而出的是面对复杂场景时的设计取舍能力。希望这篇归纳总结能帮你把知识体系理顺下次遇到并发 bug 时心里更有底气。最后再分享一个小技巧写并发代码时永远假设“会有另一个线程在你最意想不到的时候插一脚”。这种心态会逼着你把每一个共享变量的访问路径都想清楚,把同步写到位而不是依赖“试几次没问题”就上线的侥幸。
返回列表