
1. 为什么“共享数据”才是多线程编程真正的门槛入行十来年我见过太多刚接触并发编程的同事一上来就抱着pthread_create或者new Thread猛写跑起来感觉挺顺畅结果到线上环境一压测程序要么崩得莫名其妙要么数据错得离谱。多线程本身并不难难的是“多个线程同时访问同一份数据”时怎么保证结果还是对的。这个 “Chapter 3 线程间共享数据” 的归纳总结我从自己多年写后端服务、中间件和嵌入式程序的实际经验出发把它掰开了讲清楚。你不需要把每一行mutex源码背下来但一定要理解“竞争条件”是怎么产生、怎么避免以及不同同步手段各自适合什么场景。无论你用 Java、C、Python 还是 C底层面对的问题都是同一套。这篇总结适合谁看我觉得有三类人收益最大第一类是刚学完线程创建、正想进一步理解并发原理的初学者第二类是工作中已经写了多线程代码、但被偶发 bug 折磨得睡不着觉的开发第三类是面试前想系统梳理“线程安全”知识体系的人。如果你只是想把某个框架用起来那这篇帮不上忙但如果你想真正理解并发程序为什么“难”这篇文章应该能让你少踩很多坑。2. 核心需求拆解共享数据到底“共享”出了什么问题2.1 从一段“看起来没问题”的代码说起先说一个经典的例子。假设两个线程同时对同一个全局变量count做自增操作代码写出来也就几行int count 0; void increment() { for (int i 0; i 100000; i) { count; } }你觉得结果是多少如果你认为是 200000那恭喜你已经掉进了并发编程最经典的陷阱。实际跑一次结果可能是 187654、193209甚至更离谱。这不是编译器的问题也不是 CPU 偷懒而是count在硬件层面根本不是一条指令。count count 1至少要做三件事从内存把count读到寄存器、在寄存器里加 1、把结果写回内存。两个线程如果同时读到同一个旧值各自加完再写回那就等于丢失了一次更新。这就是“数据竞争”的根源多个线程在没有同步的情况下同时访问同一块内存并且至少有一个是在写。2.2 数据竞争为什么如此隐蔽数据竞争最恶心的地方在于它不是每次都会出错。线程调度的顺序受操作系统、CPU 核数、负载情况影响可能你本地跑一百次都没问题一上生产环境就出错。而且出错的时间点具有随机性有时候看起来是功能逻辑错有时候直接段错误排查起来极其痛苦。我在实际项目里见过最典型的一个场景某个服务用多线程处理网络请求每个请求进来后会把一些指标累加到全局统计结构体里。上线前压测没问题到了流量高峰数据就开始对不上。最后定位就是统计代码里裸读写共享结构体没有加任何保护。这类 bug 通常不会让你的程序崩溃但会让监控数据失真进而诱导你做出错误的容量规划危害反而更大。2.3 核心痛点归纳三个“不确定”把问题抽象一下线程共享数据引发的所有麻烦归根结底是三个“不确定”执行顺序不确定你没法确定线程 A 的读操作和线程 B 的写操作谁先发生。操作原子性不确定高级语言里一行代码对应到底层可能是多条指令指令之间随时可能被切换。缓存可见性不确定每个 CPU 核心有自己的缓存一个线程修改了值另一个线程可能很久都看不到。这三个“不确定”是理解后面所有同步机制的主线。你要么用锁来保证互斥访问要么用原子操作让“读-改-写”成为一个整体要么用内存屏障保证缓存一致性。所有的方案本质上都是在和这三点对抗。3. 互斥锁的选型与实操要点3.1 互斥锁为什么是“最笨但最可靠”的方案要解决数据竞争最直接的想法是同一时间只允许一个线程碰那块共享数据。这就是互斥锁Mutex要做的事。pthread_mutex_t lock; pthread_mutex_lock(lock); count; pthread_mutex_unlock(lock);拿锁、访问、释放三步走。看起来很机械但这是并发编程的基石。Java 里对应的是synchronized或者ReentrantLockC 对应std::mutex配合std::lock_guard。虽然语法不同但核心思想一模一样。我在实际写代码的时候强烈建议永远不要把裸的lock()和unlock()分开写在业务逻辑里因为你很容易忘了释放锁或者在中间抛异常导致锁没释放直接死锁。C 里用std::lock_guard或者std::unique_lock让析构函数自动释放Java 里优先用synchronized或try-with-resources配合ReentrantLock。这不仅仅是风格问题是血泪教训。3.2 锁的粒度一个决定的性能的关键选择有了锁之后下一个问题就是锁多大范围这就涉及“锁粒度”的概念。粗粒度锁锁的范围很大比如整个线程池只有一个锁实现简单但并发度极低两个线程排队执行等于回到单线程。细粒度锁锁的范围很小只锁真正需要保护的代码并发度提高但锁数量多了之后获取和释放的开销、死锁的可能性都会增加。我见过有人把锁加在整个“读取数据-处理数据-写入结果”流程的入口和出口结果接口 QPS 直接掉成原来的十分之一。正确的做法是只保护临界区把耗时的计算逻辑尽量移到锁外面。比如你有一个缓存 Map写操作需要加锁但读操作如果数据允许短暂不一致可以考虑用读写锁或者无锁方案这后面会详细说。3.3 死锁四个必要条件与破解思路死锁是互斥锁方案里最著名的坑。它发生的条件有四个缺一不可互斥条件一个资源同一时间只能被一个线程占用。持有并等待线程拿着一个锁又去等另一个锁。不可剥夺已经获得的锁不能强行抢走。循环等待多个线程之间存在环形的锁等待关系。让我用一个实际例子来说明。线程 A 持有锁 1 需要锁 2线程 B 持有锁 2 需要锁 1两边都在等对方释放谁也等不到程序就这么挂着。检测死锁最直接的方法是看线程栈jstackJava、pstackLinux C/C或gdb的thread apply all bt如果发现多个线程停在lock调用上而且等待的锁 ID 形成环形那就基本锁定了。破解死锁最有效的思路有两个一是固定加锁顺序比如所有线程都先锁 1 再锁 2破坏循环等待二是使用try_lock超时机制拿不到锁就不死等先释放自己手里的锁重试或者回退。C 有std::scoped_lock可以一次性锁住多个互斥量从语言层面规避了死锁顺序问题推荐优先用。4. 同步机制进阶从“互斥”到“协作”4.1 条件变量让线程学会“等待”和“通知”互斥锁解决的是“同一时间的互斥访问”但线程之间还需要“协作”。最典型的生产者-消费者模型生产者产生数据消费者处理数据。如果消费者发现队列是空的它该怎么办轮询不停地检查队列浪费 CPU不是好方案。锁 条件变量队列为空就“睡”生产者往队列里放数据后“唤醒”消费者。条件变量Condition Variable就是为这种场景设计的。核心操作只有两个wait和notify。但这里有个及其容易踩坑的细节wait必须和互斥锁配合使用而且必须在循环里判断条件。正确写法长这样C 伪代码std::mutex mtx; std::condition_variable cv; std::queueint q; // 消费者 std::unique_lockstd::mutex lk(mtx); cv.wait(lk, []{ return !q.empty(); }); // 必须用循环判断条件 int data q.front(); q.pop(); lk.unlock(); process(data);这里有两个“必须”第一wait之前必须持有锁因为需要原子地完成“释放锁 挂起等待”这两个动作第二wait的第二个参数谓词必须写而且要在循环里判断因为存在“伪唤醒”的可能——线程被唤醒后条件未必真的满足了。Java 里对应的是synchronized配合wait()/notifyAll()写法不同但原理相同。Java 的wait()同样必须在循环里调用原因是notify只唤醒一个线程被唤醒的线程需要重新检查条件。4.2 原子操作并发工具里的“轻骑兵”互斥锁虽好但毕竟有开销。如果你只是要让一个计数器加一或者设置一个标志位没必要动用重量级锁。C 标准库提供std::atomicJava 提供AtomicInteger和更底层的 Unsafe/CAS 操作硬件层面则对应 CPU 的原子指令。原子操作最核心的概念是 CASCompare-And-Swap比较当前值是否等于预期值如果是就替换成新值整个过程是原子操作。Java 的AtomicInteger底层就是 CAS 自旋重试AtomicInteger count new AtomicInteger(0); // 线程安全的自增 count.incrementAndGet();CAS 好用但有个经典问题叫 ABA线程 A 读到值为 1准备改成 2期间线程 B 把 1 改成 2 又改回 1线程 A 的 CAS 比较发现还是 1就认为没人动过实际上已经改了两轮。很多无锁数据结构会通过版本号字段解决 ABA比如 AtomicStampedReference。实际项目中我对原子操作的使用原则是如果你是写某个简单状态标志、计数器、或者做无锁队列的适配优先考虑原子操作如果你是保护一段复杂的业务流程老老实实用锁。原子操作不是万能钥匙别硬用来实现复杂逻辑那会让你调试到怀疑人生。4.3 读写锁与不变量区分“读”和“写”的代价很多场景下“读共享数据”是多线程最频繁的操作“写”很少。如果所有读操作都用互斥锁串行化性能和并发量很难看。读写锁std::shared_mutex或 Java 的ReentrantReadWriteLock允许“多个读者同时读”但写者独占。我举一个典型的实际案例一个配置管理器里面有几千个配置项系统启动时加载到内存运行中很少更新但每秒钟有成千上万个请求来读取配置。这种情况下用读写锁就非常合适读和读之间不互斥吞吐量比全互斥高出一个数量级。但要注意读写锁也有代价写者饥饿。如果读者非常多写者可能一直等不到机会产生“读者优先”导致写者长时间阻塞。使用时要根据业务权衡有些实现提供“写优先”或公平模式。另外读写锁的实现复杂度高一旦用错排查问题的难度比普通互斥锁更难。5. 实操过程与核心环节实现从需求到线程安全5.1 一个典型场景两个线程分别读写一个大数组从热词里我注意到很多人搜“c两个线程分别读写一个大数组”这是共享数据一个非常典型的实战案例。我也专门写过这种场景现在把完整思路拆给你看。假设有一块大数组大小约 512MB线程 A 负责生成数据并写入这块数组线程 B 负责读取这块数组做校验或汇总。最简单的做法是加一把大锁但这会带来巨大的性能损耗——因为数组很大写入方一旦持有锁读取方就不能访问。实际上我们通常可以把这个问题拆成两层第一层数据更新协议。写入方和读取方之间使用双缓冲写线程只写入“备份缓冲”写完后原子地交换指针或版本号读线程从“当前有效缓冲”读取数据。这样读写之间几乎无需互斥只用原子操作维护一个版本号或指针即可。第二层临界区的范围。如果必须要共享同一块内存那么锁的粒度应该控制在“更新元数据”而不是“拷贝大块数据”上。比如你可以定义每个数据块有一个状态标志写线程更新某个数据块时只对该块加锁读线程看到状态为“更新中”就跳过或者读取旧快照。这里我最想分享的一个经验不要试图通过“大锁保护大数组”来解决问题而要把“数据本身”和“数据的状态信息”分开处理。锁保护的应该是状态机的转换逻辑而不是大海捞针般拷贝几 GB 的内存——尤其在生产环境性能瓶颈和死锁风险往往都出在这个设计谬误上。5.2 线程池与共享队列的完整实现线程池是共享数据最广泛的应用场景之一。线程池的核心是一个“共享任务队列”生产者往里提交任务消费者工作线程从队列中取任务执行。队列必须线程安全否则会出现任务丢失、重复执行等问题。我用 C 写一个最简单的线程池核心逻辑只展示关键部分方便说明class ThreadPool { public: ThreadPool(size_t n) { for (size_t i 0; i n; i) { workers.emplace_back([this] { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(this-queue_mtx); this-cv.wait(lock, [this] { return this-stop || !this-tasks.empty(); }); if (this-stop this-tasks.empty()) return; task std::move(this-tasks.front()); this-tasks.pop(); } task(); } }); } } ~ThreadPool() { { std::unique_lockstd::mutex lock(queue_mtx); stop true; } cv.notify_all(); for (auto worker : workers) worker.join(); } private: std::vectorstd::thread workers; std::queuestd::functionvoid() tasks; std::mutex queue_mtx; std::condition_variable cv; bool stop false; };注意这里cv.wait的第二个参数是一个 lambda它保证只有在有任务或者需要停止的时候才继续执行逃过了伪唤醒的坑。每次取任务都在锁内完成但是执行任务task()时锁已经释放这是非常关键的一个点——如果拿着锁执行任务线程池的性能会瞬间崩掉。我把这个称为“锁外执行原则”。Java 版本更简单直接用ThreadPoolExecutorBlockingQueue就能解决BlockingQueue本身就是线程安全的。但 Java 里更值得研究的是线程池七大参数怎么配置核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略等。我曾经在文章里反复强调队列类型的选择决定了线程池在“高峰流量”下的行为。直接使用无界队列LinkedBlockingQueue会导致任务堆积特别是在下游处理慢时内存会像漏水的桶一样疯狂增长使用有界队列 合适的拒绝策略则能保护服务本身。5.3 锁竞争压力测试与代码评审经验有了锁和多线程代码不能只看逻辑对不对还要看它在高并发下表现如何。我测试线程安全代码最常用的方式有三个用-fsanitizethreadThreadSanitizer简称 TSan跑一遍测试它能检测到数据竞争并打印出发生竞争的代码行号。用高并发压测工具比如 Java 的 jmeter 或自研脚本制造多个线程同时读写共享数据对比结果是否符合预期。检查线程栈先让服务跑一会儿然后用jstack或pstack抓取线程状态分析是否有大量线程阻塞在锁等待上这能看出锁竞争是否激烈。我始终觉得线程安全代码的 code review 比写代码更重要。评审时我通常会问几个问题临界区范围是不是最小锁外有没有可能访问共享数据异常发生的时候锁是否一定能释放有没有可能两个锁相互等待内存序方面是否明确指定了 acquire/release 的语义这几个问题问完绝大多数线程安全 bug 都能在合并前被拦住。6. 常见问题与排查技巧实录6.1 数据竞争排查哪些工具最靠谱这一节是纯实战经验。数据竞争 bug 简直是“幽灵 bug”逻辑上你很难一眼看穿。我排这种问题用过不少工具最终发现最靠谱的还是以下几类ThreadSanitizerTSan对 C/C/Go 都非常友好需要在编译时加上-fsanitizethread它会动态监测程序的数据竞争并输出详细的调用栈。我用它抓出过好几次“看起来永远不可能发生”的崩溃。代价是程序会慢上几倍所以适合测试环境。Java 的 jstack JFR先用jstack抓取线程快照看线程是否卡在锁上再用 JFR 打开“锁竞争”采样能非常直观地看到哪些对象的锁竞争最激烈是排查热点锁的利器。Linux 的perffutex分析如果你用的是 Linux 原生线程可以用perf lock子命令观察内核锁等待事件能帮助判断是否发生了锁竞争风暴。记住一个核心原则不要靠“读代码”去排数据竞争问题除非你写的时候就很小心。人眼对并发时序的把控能力非常弱让工具替你做静态检测、动态插桩才是效率最高的方案。6.2 从“偶发崩溃”到“必现崩溃”复现技巧复现“偶发崩溃”是所有并发问题排查里最折腾人的环节。我自己的经验是一旦遇到偶发崩溃就压低线程调度间隔 增强系统负载。具体操作包括降低vm.max_map_count或使用较小内存资源让系统更频繁发生内存换页增加线程切换概率。在关键代码段之间加入sched_yield()或Thread.yield()主动让出 CPU人为放大线程调度的窗口让竞争更容易暴露。多台机器同时压测跑上几百轮的循环一旦有数据竞争总有机会被触发。不过这里我必须强调如果你加了yield才复现崩溃说明这个 bug 是真实存在而非偶然。很多同事会误以为是“测试环境压力太大导致的偶发问题”然后忽略掉。千万不能这样这种逻辑就是自欺欺人。6.3 锁的使用中最容易忽略的“细节”最后整理几个我踩过的“不起眼但致命”的锁使用细节。这些坑在书里不太好找但实际项目里几乎每天都有机会遇到第一锁的声明周期要短。有的人图省事把锁定义成全局变量业务代码里凡是碰到共享资源就顺手锁一下。这会导致整个进程的锁竞争变成全局串行性能直线下降而且后续排查锁顺序问题时非常难做。锁的粒度粗不等于锁的数量少应该尽量让锁的生命周期和控制范围最小化。第二不要在持锁的状态下调用耗时函数。这包括 IO 操作、网络请求、数据库操作、sleep 等。如果你拿着锁去等网络响应其他线程全部会卡死在锁上轻则性能雪崩重则引发一系列连锁超时。正确做法是把数据从共享区拷贝出来释放锁再去处理耗时的业务。第三小心“自旋锁”和“互斥锁”的选择。在嵌入式环境比如 FreeRTOS中自旋锁常用于多核之间保护极短时间的临界区但如果临界区代码稍长或系统负载过高会导致 CPU 空转浪费甚至引发优先级翻转问题。桌面和服务端应用中一般优先使用阻塞型互斥锁因为阻塞可以主动让出 CPU让其他任务获得执行机会。第四C 内存序不是玄学。从热词里能看出很多人关心“线程安全”但在 C 中只加std::atomic还不够你还要明确内存序。默认的seq_cst是最强的一档也是最慢的acquire/release/relaxed各有适合场景。初学者往往只看到“原子性”忽略了“可见性”导致数据虽然不会撕裂但读到的是旧值。这里我有一个建议用std::atomic的默认参数写代码没有错等性能测试真的成为瓶颈再考虑降内存序千万别一开始就玩火。第五线程等待全部完成时不要用空循环。热词里有“java线程等待都完成”很多人在主线程里写while (thread.isAlive()) {}之类的空转逻辑非常浪费 CPU。正确做法是用CountDownLatch、CyclicBarrierJava、std::thread::joinC或者线程池的awaitTermination。这些同步原语不止是省电更重要的是它们底层的等待/唤醒语义比空轮询可靠得多也不会因为线程调度导致忙等假死。6.4 快速问答常见问题速查表问题原因排查思路运行结果偶尔不对数据竞争count非原子加锁或原子操作程序卡死无响应死锁或活锁jstack/pstack看线程栈多线程性能反而更差锁竞争剧烈或锁粒度太大压测分析锁等待时间Java 线程池任务堆积使用无界队列或 core 线程数过低调整队列策略与拒绝策略读多写少场景慢用了互斥锁而不是读写锁改ReentrantReadWriteLock或shared_mutex嵌入式系统跑飞或崩溃使用了不可重入/非线程安全的库函数检查临界区保护与内存分配函数安全性主线程不会退出忘记join或线程池没shutdown确保资源释放顺序正确这张表不是标准答案但覆盖面足够广。我在实际项目中按这行套路排查大多数线程相关的问题都能在半小时内定位。7. 个人实操体会与扩展方向7.1 我自己的几个经验教训写到这里我分享一下这些年玩多线程的几点体会。第一个体会“线程安全”不是某个数据结构的属性而是整个程序在特定使用场景下的行为。std::map不是线程安全的但如果你用互斥锁保证所有操作互斥它在你的场景里就是安全的反过来一个号称“线程安全队列”的库也只是在你调用它的 API 时安全如果跨队列拷贝数据时没有原子性地维护状态依然可能出问题。第二个体会锁不是罪恶过度设计才是。我看到有些人为了追求极致性能一上来就无锁编程最后写出来的代码极其复杂、极易出错反而比用一把简单的锁慢得多。最好的方案往往是最简单的互斥锁配合合理的临界区只有在通过性能分析确认锁是瓶颈时再去考虑优化。别为了炫技牺牲可维护性。第三个体会线上偶发 bug 要多线程问题首先要查共享可变状态。我在排障时的一贯做法是先把所有“会写”的全局变量列出来然后把它们分别映射到访问它们的线程上逐一对齐是否都有锁保护。这一步往往比我用 TSan 跑测试还要快因为它能帮你快速建立上下文。7.2 这个主题还可以怎么延伸如果你已经把这篇归纳吃透了那下一步就自然而然地会接触几个更进阶的方向无锁编程与 CAS 扩展从原子变量的自旋到 Ring Buffer、无锁栈/队列等。线程池的调优参数不管是 C 还是 Java 线程池核心线程数、最大线程数、队列容量、拒绝策略的组合策略值得专门做压测对比。协程与异步模型很多场景下用协程替代线程反而能规避大量共享数据问题因为它们天然更适合串行化逻辑。分布式环境中的共享数据单纯的多线程问题上升到了多机节点时就会引出分布式锁、分布式事务、共识算法等那是一整个新世界了。我个人建议不要贪多先把线程间共享数据这一章吃透。以前我常说“并发编程的入门在锁进阶在无锁老练在选择适用模型”但前提是你能把锁和同步机制真正用对。这个基础打牢了后面走得才稳。最后再分享一个小技巧写完线程安全代码后哪怕自测没问题也养成交代 review 的习惯。新代码合并前把线程数开到 2 倍、4 倍再跑一轮压测很多隐藏问题会在这一步现原形。这个方法帮我拦住过至少五次生产事故是真的有用。