尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

并发编程核心:共享数据保护与锁、原子操作、死锁实战全解析

并发编程核心:共享数据保护与锁、原子操作、死锁实战全解析 自己写并发代码也有些年头了从最开始用线程池做任务分发到后来啃各种锁和无锁队列踩过的坑能装满一卡车。线程间共享数据永远是绕不过去的一道坎哪怕你用现代C、用Go、用Java只要涉及多线程就一定会撞上“数据被多个线程同时碰”这件事。很多人觉得加个锁就完事了但真等项目跑出诡异数据、偶尔死锁、性能忽高忽低的时候才明白共享数据这个题目远没有想象中简单。这篇内容整理自一本经典并发编程书籍第三章的学习归纳加上我自己大量实操中的验证和补充。帖子会从最核心的竞争条件讲起把互斥量、锁的层级、条件变量、原子操作、内存序、基于设计的锁无关方案全部过一遍最后再集中聊聊死锁排查和真实项目里的避坑经验。不管你是刚开始接触并发还是已经写过不少多线程代码但总觉得没吃透这篇内容都值得你花上十几分钟认真读一读。1. 为什么共享数据这么容易出问题从一次“不可能出错”的计数说起先看一个最经典的场景10个线程同时对同一个整数做100万次自增操作按正常逻辑最后的结果应该是1000万。但实际跑出来结果经常是800多万、900多万甚至更离谱。很多人第一次遇到这个问题时都会觉得是编译器或者CPU出了Bug实际上问题出在自增操作本身。自增操作count在底层并不是一条指令。它被拆成了三步把count的值读入寄存器、寄存器加一、把新值写回内存。两个线程同时执行这三步时完全可能出现交错执行线程A读到了100还没写回去线程B也读到了100两个线程各自加一后都写回101结果本来应该变成102最终却只加了1。这就是典型的数据竞争也就是多个线程无任何同步机制地访问同一块内存至少有一个线程在做写操作。更让人头疼的是C标准里对数据竞争的定义是非常严格的只要存在数据竞争整个程序的行为就是未定义的。也就是说不仅结果可能是错的编译器优化后可能让错误以更诡异的方式暴露出来程序崩溃、死循环、数据被撕裂都不奇怪。不要拿“我加了volatile”来对抗这个问题volatile在C里只告诉编译器这个变量可能被外部修改每次都要从内存中读取但它并不能保证读-改-写这个序列是原子的数据竞争照样存在未定义行为照样发生。竞态条件则是比数据竞争更宽泛的概念。数据竞争是底层的内存访问问题而竞态条件描述的是“结果依赖于多个线程的时序安排”这一现象。只有当某个结果取决于哪个线程先到达某段代码时竞态条件才可能触发问题。在并发编程里我们说的“把数据放在锁的保护下”核心目的就是消除这种时序上的不确定性。这里给出一个简单到不能再简单的复现程序#include atomic #include chrono #include iostream #include thread #include vector int main() { int count 0; std::vectorstd::thread threads; for (int i 0; i 10; i) { threads.emplace_back([count]() { for (int j 0; j 1000000; j) { count; } }); } for (auto t : threads) t.join(); std::cout count std::endl; return 0; }我用GCC -O2在x86-64上跑过很多次结果从来没得到过1000万。有时候是990多万有时候是800多万看起来非常随机。这个随机性能帮我们理解一个关键点竞态条件属于时序类Bug低概率出现的错误比稳定复现的错误更危险因为它几乎没法靠跑几次测试来发现。注意写并发代码时第一原则是“默认共享数据是不安全的”。任何不通过同步机制保护的共享读写都不要心存侥幸。2. 保护共享数据的第一道防线互斥量与锁互斥量mutex名字听起来很高端本质就是一个厕所门锁。线程进入临界区之前先lock()试图霸占这片区域如果已经被别人占着就老老实实等着用完以后unlock()把门打开。这样带来的效果就是同一时间只允许一个线程执行这段代码读写不再交错数据竞争也就被消除了。2.1 std::mutex的基本用法与lock_guardC11里提供了std::mutex最简单的用法是手动lock/unlock。但手动加锁有一个巨大的安全隐患如果临界区里抛出异常unlock()这行代码就不会被执行锁永远无法释放其他线程全部卡死。实战中代码越写越复杂临界区里放一个可能抛异常的函数太常见了。所以一个人写生产级代码的老手会告诉你永远不要直接调用lock()和unlock()。正确做法是使用RAII封装让锁的作用域结束时就自动释放这就是std::lock_guard存在的意义。#include mutex #include vector std::mutex mtx; int shared_count 0; void safe_increment() { std::lock_guardstd::mutex lock(mtx); shared_count; }lock_guard在构造时调用mtx.lock()在析构时调用mtx.unlock()RAII机制保证了无论临界区里发生什么函数退出时锁一定被释放。这一点怎么强调都不过分我见过太多线上事故是new抛异常或者条件分支提前return导致死锁而lock_guard可以从根源上预防这类问题。2.2 何时用unique_lock以及它的灵活之处std::unique_lock和lock_guard类似但它更灵活。它以独占锁的所有权为代价允许你延迟加锁、手动解锁、尝试加锁还可以和条件变量配合使用。比如unique_lock可以被移动可以判断自己是否拥有锁这些能力在复杂逻辑里非常有用。std::unique_lockstd::mutex lock(mtx, std::defer_lock); // 做一些不涉及共享数据的准备工作 lock.lock(); // 临界区 lock.unlock(); // 临界区之外做其他事如果你的临界区只需要锁保护没有任何特殊需求就用lock_guard。如果后面要用到条件变量、需要提前解锁、或者要转移锁的所有权就用unique_lock。别为了“灵活”而让代码失去简单性能用lock_guard就不要用unique_lock。2.3 递归锁看起来方便实则是设计缺陷的遮羞布std::recursive_mutex允许同一个线程在已经持有锁的情况下再次加锁这样在递归函数里就不用担心自己锁死自己。但我要泼一盆冷水递归锁几乎总是说明你的锁粒度设计出了问题。假设你在两个函数A和B里分别用同一个递归锁保护了不同的共享数据然后A内部调用了B或B调用了A这个执行流立刻变得晦涩。更致命的是锁的“保护范围”跨度大很难判断什么时候数据真正是安全的。书上归纳的一个核心观点我很认同如果锁的粒度跨越了整个调用链你就等于在用一个大锁保护所有东西这既容易引发死锁性能也很差。实际工程中我处理过的递归锁需求90%都能通过重构消除。比如把需要锁保护的公共部分提取为不依赖外部锁的私有方法或者调整锁的粒度让每个函数只锁定自己真正访问的那一小部分数据。剩下的10%用递归锁确实省事但务必写清楚注释说明为什么这里的递归调用是必须的。2.4 锁的选择与性能观测标准库提供了好几种互斥量它们的取舍往往比写代码本身更值得关注。简单的对比可以看下面的表互斥量类型特点适用场景std::mutex最基础不支持递归绝大多数临界区std::recursive_mutex同线程可重入特殊递归场景慎用std::timed_mutex支持try_lock_for/try_lock_until不允许无限等待的场景std::shared_mutex读写锁大量读、少量写关于shared_mutex我想多说两句。它允许“多个读者同时持有读锁只有一个写者能持有写锁”这对读多写少的场景优化非常明显。比如一个配置管理器几百个线程同时读取配置写入频率极低把mutex换成shared_mutex能显著降低读线程之间的争用。但代价是维护读写锁状态本身有系统开销如果你的临界区里干活时间极短这个开销反而可能比mutex还大。这里没有银弹要在真实场景里用性能工具实测后做决策。3. 不只是互斥同步与协作的条件变量互斥量解决的是“同一时间只有一个人进入厕所”的问题但现实中还有另一个典型场景某个线程要等一个条件满足后才继续干活比如生产者线程要等队列有空位才能放入新任务消费者线程要等队列有数据才能取走任务。轮询当然能实现但会白白消耗CPU而且检查条件也要使用锁非常难看。条件变量就是解决这类“等待-通知”问题的标准工具。3.1 wait/notify的经典配合条件变量的核心用法有两个等待方调用wait()通知方调用notify_one()或notify_all()。C里的std::condition_variable必须配合std::unique_lock使用因为wait内部需要原子性地“释放锁 进入等待状态”之后再在唤醒时重新拿回锁。一个典型的生产者-消费者模型长这样#include condition_variable #include deque #include mutex std::mutex cv_mtx; std::condition_variable cv; std::dequeint queue; const int MAX_QUEUE 10; void producer() { for (int i 0;; i) { std::unique_lockstd::mutex lock(cv_mtx); cv.wait(lock, [] { return queue.size() MAX_QUEUE; }); queue.push_back(i); cv.notify_one(); } } void consumer() { while (true) { std::unique_lockstd::mutex lock(cv_mtx); cv.wait(lock, [] { return !queue.empty(); }); int value queue.front(); queue.pop_front(); cv.notify_one(); } }注意这里wait的第二个参数是谓词predicate它等价于下面这段逻辑while (!predicate()) { cv.wait(lock); }也就是说wait会先检查谓词是否满足如果不满足就原子性地释放锁并挂起被唤醒后先重新获取锁再检查谓词。多这一层检查非常关键我自己写代码时从来不会省略这个谓词原因我放在下一节详细说。3.2 虚假唤醒与唤醒丢失wait前必须检查条件的本质原因虚假唤醒spurious wakeup是操作系统底层实现注定的——线程可能在没有收到notify的情况下被唤醒无论你是Linux还是Windows都会遇到。原因是某些平台上的信号、中断处理或者线程调度的内部机制会意外唤醒等待中的线程。如果你直接写cv.wait(lock)不检查条件那么虚假唤醒时会走出wait继续往下执行可能队列还是空的你却直接取front()结果就是空指针或未定义行为。唤醒丢失则和另一个场景有关如果notify发生在wait进入等待状态之前这个notify就没人接收线程可能会永远睡下去。带谓词的wait能有效避免这个问题因为wait之后会检查谓词如果真值真的满足了即使notify已经“丢失”线程也会立刻返回、不会挂起。这两个问题乍听像是理论但在真实项目中我都遇到过。有一个消息转发模块在压测时偶尔出现线程“卡死”但进程不崩溃的情况查了很久才发现是某个地方在wait时省略了谓词碰到了虚假唤醒而另一种“偶发延迟变长”的问题则是因为跨线程的notify时机不巧唤醒通知丢失了。从那以后我给自己立了一个规矩条件变量wait永远带谓词没有例外。3.3 notify_one还是notify_all性能与正确性的平衡notify_one会唤醒一个等待线程notify_all会唤醒所有等待线程。在只有单个消费者等待的场景下notify_one效率最高但如果可能有多个消费者等待同一个条件而条件的变化一次能满足多个消费者的需求那就必须用notify_all否则可能出现“明明条件满足了但只有一个人被通知其他人还在睡”的尴尬。一个经典误区是生产者在放入一个元素后总是用notify_all。这虽然能保证唤醒所有等待者但如果等待者是100个消费者其中99个醒过来发现队列还是空的因为它们都抢同一个元素就又回去睡了白白引发大量上下文切换和锁竞争。所以正确做法是只有当你确信一个通知足以覆盖所有被唤醒线程的需求时才用notify_one否则用notify_all。这里也补充一个工程细节通知不需要一定在锁内进行。你完全可以先释放锁再调用notify_one()或notify_all()。这能稍微减轻拿到锁但还没wait的线程被不必要的唤醒拖住的概率因为等待线程一旦被唤醒要先抢锁而锁还没释放它就只能继续阻塞等待。我在高并发队列里实际测量过先解锁再通知比锁内通知在某些场景下能降低5%~10%的锁持有时间效果虽不夸张但胜在改动成本极低。4. 不阻塞的同步原子操作与内存序锁能解决所有共享数据问题但锁不是免费的。每进入一次临界区都要付出系统调用或至少是一次用户态原子操作的开销更关键的是锁可能让线程休眠和唤醒这些调度延迟在高频操作下会被放大。对于某些极其简单的共享数据——比如一个计数器、一个状态标志位、一个指针——我们可以用无锁的方式也就是原子操作来同步。4.1 原子类型与读-改-写操作的真正含义C11引入了std::atomicT它保证对目标变量的操作是不可分割的。也就是说std::atomicint的load()、store()、fetch_add()、compare_exchange_weak()这些操作都是原子的不会被其他线程隔断。最典型的例子就是把上一节的count改成std::atomicint的fetch_add自增操作就变成了原子操作不会出现数据竞争。但这里有一个大坑你仍然需要结合内存序来保证操作的可见性。下面这份代码虽然用了原子变量但如果使用错误的内存序结果依然不符合直觉std::atomicint count{0}; void increment() { count.fetch_add(1, std::memory_order_relaxed); }在x86上memory_order_relaxed对单变量的原子性没有影响fetch_add结果依然是原子的。但它不提供任何跨变量的顺序保证其他线程看到的可能不是最新的值或者看到不同线程写入的顺序不一致。4.2 内存序能让代码正确、也能让代码疯狂的六个模型C定义了六种内存序可以分成三类memory_order_relaxed只保证原子性和修改顺序一致不提供跨线程的顺序约束。memory_order_consume/memory_order_acquire/memory_order_release用于建立“释放-获取”同步关系。memory_order_acq_rel结合了acquire和release用于读-改-写操作。memory_order_seq_cst最强约束也是默认值保证所有线程观察到同一个全局顺序。对大多数开发者来说我的建议是在没有Profiler指导的前提下一律使用默认的seq_cst。它最安全语义也最直观。只有当你真的测定出原子操作成了性能瓶颈且能清楚论证更弱内存序的正确性才去换更弱的内存序。这里给一个最简单的无锁计数器示例class AtomicCounter { public: int increment() { return count_.fetch_add(1, std::memory_order_relaxed); } int load() const { return count_.load(std::memory_order_seq_cst); } private: std::atomicint count_{0}; };fetch_add用relaxed是成立的因为单个计数器之间并没有与其他变量的依赖关系但load用来读取计数器当前值用于对外展示时用seq_cst能保证看到某一时刻切实存在过的值不至于在逻辑上引入额外的不一致。4.3 自旋锁原子标志的最简单应用原子操作最经典的应用之一是自旋锁。它不需要系统调用而是通过忙等的方式反复尝试获取锁。这对于临界区极短、竞争不激烈的场景非常高效。class SpinLock { public: void lock() { while (flag_.test_and_set(std::memory_order_acquire)) { // 自旋等待 } } void unlock() { flag_.clear(std::memory_order_release); } private: std::atomic_flag flag_ ATOMIC_FLAG_INIT; };但自旋锁是“吃CPU的怪物”。一旦锁被持有时线程太多大量线程都在自旋CPU会被烧到100%。所以在实际项目中自旋锁只适合临界区极短、线程数量可控、且都在足够多的处理器核心上运行的情况。我在网络库的事件循环线程里用过自旋锁来保护任务队列的头尾指针效果不错但如果换成几百个业务线程抢同一把锁性能立刻崩溃。4.4 无锁数据结构看起来美写起来要命无锁数据结构是指不使用锁、仅靠原子操作来保证并发安全的数据结构比如无锁队列、无锁栈。这类结构听起来很吸引人但真正实现起来极其复杂需要考虑ABA问题、存取顺序、内存管理等一系列细节而且还要保证正确性——这是用再多的测试都很难验证的。我个人的态度一直很明确除非你是专门的并发库开发者否则不要从零写一个无锁容器。优先使用经过大规模验证的第三方库比如Boost.Lockfree、Intel TBB等。就算自己写也只在核心路径上使用并且要有极其充分的理由。理由不够就回去用锁性能不够就用更好的锁方案而不是轻易上无锁。一个很有价值的经验无锁代码的正确性远比你想象的难而它的性能优势也没有你想象的那么大。现代锁的实现已经优化得很好了很多场景下锁与无锁的性能差距不到20%而无锁写错的风险却是灾难级别的。5. 降低共享的架构思维能不共享就别共享锁、条件变量、原子操作这些东西都是用来保护共享数据的。但很多时候更聪明的方式是从设计上减少共享数据本身。这个思路是书籍第三章里一个容易被忽略却极有价值的观点如果你压根不共享就可以完全不考虑同步难题。5.1 thread_local每个线程拥有一份独立副本thread_local关键字可以声明一个线程局部存储TLS变量。每个线程访问它时得到的是自己的独立副本彼此之间完全隔离不存在竞争。一个很典型的使用场景是线程池里的任务本地缓存。比如每个工作线程在处理一批任务时都需要一个临时缓冲区如果共用一个全局缓冲区所有线程都得加锁但如果每个线程一个缓冲区就让“每次处理任务时先清空缓冲区再使用”这个流程变得完全线程安全。thread_local std::vectorint t_cache; void process_task(const std::vectorint data) { t_cache.clear(); // 使用 t_cache 存储中间结果 for (int x : data) { t_cache.push_back(x * 2); } }但使用thread_local也要注意两点一是每个线程都会持有自己那份副本线程多了会占额外内存二是thread_local变量的生命周期是整个线程生命周期里面如果装了很大的容器线程空闲时也不释放内存压力会变大。因此用完最好释放掉或缩小容量。5.2 只读共享最便宜的“同步”如果一块数据在创建之后就不再修改那它天然是线程安全的。任何线程都可以随意读它不需要加锁、不需要原子操作。这个思想常用于配置表、字典数据、预计算结果的共享。实践中的做法是在启动阶段构造这些数据发布到工作线程之前确保数据已经完全构造好并通过一次acquire操作比如启动线程时的join或者是把指向数据的指针放入一个原子指针建立一种同步关系之后所有线程就可以放心大胆地只读访问。5.3 消息传递用“状态拷贝”代替“状态共享”有时候把数据从一个线程传给另一个线程时我们并不仅仅是想让两个线程操作同一个对象而是传递一个“快照”。这时与其共享对象不如复制一份再传递。最常见的实现是使用无锁或有锁的并发队列把任务对象直接“移动”或“拷贝”过去。这样每个数据同一时刻只被一个线程拥有天然不需要锁。Actor模型、通道Channel、消息队列本质都是这个思路。比起共享状态的锁方案消息传递的调试和推理难度要低很多因为它把交互行为线性化了。我在很多项目里会优先设计成“单生产者-单消费者”的消息通道即使底层仍是队列但因为只有两个线程接触同一个数据结构锁竞争几乎为零加上条件变量性能比多生产者多消费者上大锁高一个量级。5.4 数据共享粒度与容器的选择如果你真的需要多个线程共享一个容器锁的粒度是决定性能和正确性的关键。常用的几个方向使用std::shared_mutex保护整个容器适合读多写少。把大容器拆分成多个“分段”每个段一把锁降低竞争。使用专门设计的并发容器如TBB的concurrent_queue、并发哈希表它们内部通常已经做了分段锁或无锁实现。工程上拆分容器是我非常推荐的折中方案。比如一个全局哈希表按照key的hash值把桶分成多个子表每个子表一把锁这样不同key的访问天然不冲突。这个方案的实现成本不高但收益非常明显。6. 死锁、安全性与可靠性总是令人头疼的工程问题前面介绍的方案能把数据竞争解决大半但并发编程另一个大坑——死锁——随时可能把整个进程拖入深渊。死锁的定义是多个线程各自持有一把锁同时又在等待对方持有的锁结果是互相等待谁也没法继续推进。6.1 死锁的四个必要条件与一个最简示例死锁要发生必须同时满足四个条件互斥资源一次只能被一个线程占有、持有并等待线程持有资源时又在等待其他资源、不可剥夺资源只能由持有人主动释放、循环等待多个线程的等待关系形成环。最经典的两个锁死锁示例std::mutex m1, m2; void thread_a() { std::lock_guardstd::mutex lock1(m1); std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 增加交错概率 std::lock_guardstd::mutex lock2(m2); // ... } void thread_b() { std::lock_guardstd::mutex lock1(m2); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guardstd::mutex lock2(m1); // ... }线程A持有m1等待m2线程B持有m2等待m1死锁就发生了。很多死锁bug的可怕之处在于如果两个线程到达的时间差很小可能根本不会触发死锁但只要交错时机碰上了整个系统就冻结。而且冻结后你还很难定位因为进程仍然活着线程却全部卡在等待中。6.2 解法一用std::lock一次锁住多个锁std::lock可以一次锁定多个互斥量并以一种避免死锁的内部算法来管理锁的顺序。它的一个核心保证是要么全部锁成功要么一个都不锁失败时已获取的锁会被释放从机制上消除了“部分获取导致循环等待”的死锁条件。void safe_lock_two_mutex() { std::lock(m1, m2); std::lock_guardstd::mutex lock1(m1, std::adopt_lock); std::lock_guardstd::mutex lock2(m2, std::adopt_lock); // 此时安全持有 m1 和 m2 }注意使用std::adopt_lock告诉lock_guard“锁已经获取了你只管在析构时释放”避免重复加锁。6.3 解法二固定加锁顺序如果在代码中有多个地方都需要同时加多个锁最简单有效的规则就是所有线程都按照相同的顺序加锁。比如所有逻辑都“先锁A再锁B”线程A先A后B线程B也先A后B循环等待就被打破了。这个规则看似简单但在大型代码里执行难度其实很高因为调用层次深、锁分散在不同的类里很容易出现“方法X持有锁A时调用了方法YY内部想锁B”这类隐式顺序问题。我建议在代码Review时尤其注意锁的嵌套情况最好在类注释里写清楚本类锁的使用顺序。6.4 其他死锁风险同一个线程“重复加锁”和锁的泄漏还有一种非常隐蔽的死锁同一个线程连续两次对同一个非递归的std::mutex加锁。第一次lock成功第二次lock就会阻塞但这个互斥量不会因为你“就是同一个线程”就放行于是白白死锁。这种最常见的原因是加锁和调用其他函数之间没有理清调用层次自锁而不自知。排查这类问题时打开编译器的ThreadSanitizer或者运行时库的死锁检测工具会给力很多。另外在调试期可以给锁加上“持锁线程信息”日志打印每次加锁和释放的调用栈这样复现问题后基本一眼就能看出锁的持有链。6.5 数据竞争排查实战别再靠打印日志了死锁相对好排查因为卡住的位置容易用gdb抓到栈。数据竞争则麻烦得多因为错误不一定稳定复现。打印日志方式属于“看运气”而且日志本身还会改变时序有时加了日志问题反而不出现了简直折磨人。我的推荐工具链有这么几样ThreadSanitizerTSanGCC和Clang都支持编译时加-fsanitizethread运行时会报告数据竞争的准确位置、参与线程的栈。这是面对数据竞争时的第一选择。HelgrindValgrind套件能检测锁顺序问题和部分数据竞争但性能开销很大适用于中小规模测试。静态分析工具Clang-Tidy、PVS-Studio等能找出一些显而易见的并发问题但作为辅助即可不能替代动态检测。我自己的经验是遇到可疑数据竞争第一步不是看日志而是先用TSan跑一遍。两三分钟之内往往直接告诉你哪个文件哪一行访问了共享变量没有加锁比人肉肉眼查找高效太多。7. 用锁的智慧和工程上的最后几条建议把多线程交织在一起的文章写到这里核心知识点基本都说全了。但真正干过项目的人都知道纸上得来终觉浅。最后我再补几条实战经验算是对整篇内容的一个自然收尾。第一条锁的粒度要尽量小但也不能太小。临界区太长会严重拖慢其他线程临界区太短又可能因为频繁加解锁反而增加开销。一个经验准则是临界区里不要做I/O、不要打日志、不要调用不熟悉的外部回调函数尽量只做和共享数据强相关的内存操作。必要时要拆锁把耗时部分移出临界区。第二条给共享数据一个明确的“看守者”。在代码结构上建议把共享数据封装成一个独立的类把锁声明成私有成员所有对共享数据的访问都通过成员函数完成。这样能保证不会有一处代码“悄悄绕过锁”直接操作裸变量。我见过太多事故是因为某人图方便在某个角落直接改了共享容器没有任何锁保护。第三条在高并发场景下优先用无锁队列解耦线程。当一个模块的处理速度跟不上上游的数据产出时直接共享状态加锁常常会造成大量线程阻塞。反过来如果在上游和下游之间插入一个有界并发队列写线程只负责入队读线程只负责出队两侧的耦合和锁竞争都会大大降低。队列积压还能天然形成背压backpressure防止下游被冲垮。这个模式是我在多个服务端项目里验证过最实用的架构手段之一。第四条时刻记住“安全性优先性能在后”。并发代码一旦有数据竞争你优先要做的不是优化性能而是消除竞争。先把正确的锁加上用TSan跑干净再考虑能不能缩小临界区、能不能换无锁方案。性能优化必须基于Profiler数据不能靠猜。在实际项目里共享数据的每一次设计选择本质都是在正确性、性能和可维护性之间做权衡。多线程没有银弹但如果你能把互斥锁、条件变量、原子操作、内存序、减少共享这些手段理解透彻并且知道每种方案适合什么场景那大多数并发问题在你面前都会显得清清楚楚。希望这篇归纳总结能让你少走一些我曾经走过的弯路。
返回列表