资讯动态

并发与资源:让两个线程安全地共享数据的核心实践

发布时间:2026/9/15 12:18:05 来源:尧图企业网站定制
并发这个事我一直觉得是编程里最容易被低估的一个坎。很多人写单线程程序行云流水一碰到两个线程要共享数据程序就开始薛定谔地工作——有时候正常有时候崩溃有时候数据对不上偶尔还直接卡死给你看。这个标题阶段六讲义并发与资源——让两个线程安全地共享数据其实切得非常准它没有去扯那些宏大的分布式并发也没有堆砌一堆高深的术语而是回到最本质、也最容易出错的地方两个线程一份数据怎么才能不出事。这篇文章我就把我摸索多年的并发经验拆开揉碎了讲。不管你是刚接触多线程的新手还是已经写过一段时间并发代码、但总是被各种诡异问题折磨的老手这篇都值得你花十分钟从头看一遍。我不只会讲怎么用锁更会讲清楚为什么需要锁、不加锁到底会发生什么、加锁之后又可能踩到哪些新坑。1. 内容整体设计与思路拆解1.1 并发问题的根源共享可变状态在聊解决方案之前必须先弄清楚问题到底出在哪。很多人对并发的第一印象是多个线程同时执行觉得快就完事了。但实际上线程之间真正的冲突点不在于执行而在于数据。多个线程如果各干各的、互不接触那根本不会有任何并发问题——真正麻烦的是它们访问了同一块数据而且这块数据还是可以修改的。这就涉及一个关键概念共享可变状态。共享意味着它同时被多个线程可见可变意味着它会被修改。当这两个特性叠加在一起就产生了竞争条件Race Condition。我习惯把竞争条件理解为多个线程对同一块数据的执行顺序产生了不确定性而程序结果依赖于这个执行顺序。举个例子你的银行账户里有1000块钱现在两个线程同时往里面存100块钱。如果是线程A先存、线程B再存结果是1200这没问题。但问题是线程A和线程B如果同时执行读余额加100写回余额这三个操作就可能在某个时刻两个线程都读到了1000各自加了100然后都写回了1100。你的账户凭空少了100块。这不是逻辑写错了而是并发访问共享数据时底层的操作被不正确地交织了。1.2 为什么同时执行会变成交错执行你可能会有个困惑计算机不是能同时做很多事吗为什么两个线程同时操作还会互相打扰这就要说到现代CPU的运行模型了。真正的同时执行只在多核CPU上才会发生而且即便是多核也还牵扯到缓存一致性的问题。当一个线程修改了一个变量的值这个值首先更新的是CPU缓存L1/L2 Cache然后什么时候写回主内存是不确定的。也就是说你以为的同时在底层其实是高度交错的指令级别的交错、内存访问的交错、甚至是编译器优化导致的重排序。你写的代码顺序是一回事CPU实际执行的顺序是另一回事。这就像你在厨房里做菜你计划的是切菜→炒菜→装盘但实际执行时可能你切了两刀电话响了你接了电话回来又继续切。每个步骤本身没错但步骤之间被插入了别的操作。理解了这一点你就明白了并发安全的核心在于互斥——即在同一时刻只允许一个线程访问那个共享的可变数据块。这种互斥机制就是并发编程里最核心的同步原语锁。注意这里其实还有一个容易被忽略的点即便只是读取一个共享变量在多线程环境下如果没有正确的同步也可能读到中间状态或过期值。这就是内存可见性问题。所以安全共享数据不仅是写的时候要互斥读的时候也要有同步保障。2. 核心细节解析与实操要点2.1 锁的选型互斥锁、读写锁与自旋锁聊到锁最常见的三种就是互斥锁Mutex、读写锁RWLock和自旋锁Spin Lock。网上有很多对比文章但真正落到项目里你需要根据场景做选择。互斥锁是最基础、最常用的锁。它的语义很简单同一时刻只有一个线程能持有这把锁其他线程必须等待。这种锁适合临界区操作时间较长的场景——比如你要对一段复杂的业务数据做多步更新。因为线程被阻塞时会让出CPU不会白白空转这对系统整体吞吐更友好。读写锁是在互斥锁基础上做的优化。它把并发访问分成了两种模式多个读者可以同时持有读锁但写锁是排他的——只有没有读者和写者时才能获取写锁。这很适合读多写少的场景。比如多线程读取一个配置表偶尔有线程更新它。用读写锁可以把大部分读操作并行化提升性能。但要注意读写锁并不是万能的。如果写操作非常频繁写锁的高优先级策略可能会导致写线程饥饿反之某些实现可能让读线程饥饿。实际使用时要根据自己的读写比例和实时性要求做权衡。自旋锁的逻辑更极端线程在获取不到锁时不会阻塞而是一直循环尝试获取。这避免了线程切换的开销但如果临界区执行时间过长自旋等待就是在烧CPU。它只适合临界区极短例如就几条CPU指令的场景而且最好运行在多核机器上否则自旋毫无意义——单核上持有锁的线程被抢占后自旋等锁的线程只能干等白白浪费CPU时间片。我把这三种锁的核心特性整理成一个表格方便你对比锁类型等待方式适用场景缺点互斥锁阻塞睡眠让出CPU临界区操作时间长竞争不剧烈线程切换有开销读写锁读并发、写排他读多写少、读性能要求高写饥饿/读饥饿策略需调优自旋锁忙等待不切换线程临界区极短多核CPU临界区变长会浪费CPU2.2 锁之外的选择原子操作与线程安全容器锁能解决大多数问题但它的代价是串行化——多个线程本来想并行碰到锁就变成排队。如果临界区里只是简单的读、改、写操作其实还有更轻量的方案原子操作。原子操作听名字就明白了它把读-改-写这个复合操作打包成一个不可分割的步骤。底层通常依赖CPU提供的CASCompare-And-Swap比较并交换指令。CAS的基本逻辑是我先看这个变量的当前值是不是我预期的值如果是就把它更新成新值如果不是说明别的线程改过了我就失败重试。这个操作是CPU指令级别的原子不会被中断。大多数语言都有原子变量类。比如Java里的AtomicIntegerC标准库里的std::atomic。如果你只是对一个计数器做自增或者对某个标志位做切换用原子变量会比锁快一个数量级。另外工程上还有一个很实用的建议优先使用现成的线程安全容器。与其自己加锁管理一个普通的List或Map不如直接用语言提供的安全版本。比如Java的ConcurrentHashMap、C的std::mutex配合std::map这不算容器级安全还是需要手动锁。但现代语言生态里像Java的ConcurrentLinkedQueue、Go的channel、Python的queue.Queue都已经把锁封装好了而且往往做过深度优化比你手动加两把锁要可靠得多。能用现成的就别自己造轮子这是并发编程里最朴素的真理。2.3 同步工具的进阶条件变量与信号量锁解决了互斥但很多并发场景还需要同步——线程需要等某个条件成立才能继续往下走。这时候就要用到条件变量Condition Variable或信号量Semaphore。我自己特别喜欢把面向对象编程讲成约定讲清楚对象之间如何交互如何通过接口协作而不是一上来就谈class、继承、多态这些语法细节。比如可以用点菜来打比方你调用方需要跟厨师后端服务沟通但你又不想自己去后厨盯着他做所以你只需要下单调用接口然后等着被叫号返回结果就行。把这种约定想清楚再谈类与类之间的关系思维就顺多了。条件变量不是用来保护数据的它用来通知其他线程你要等的条件变了。经典的场景是生产者-消费者模型一个线程往队列里放数据另一个线程从队列里取数据。消费者发现自己取不到数据时不应该忙等而是调用wait阻塞自己生产者放入数据后调用notify或signal唤醒等待的消费者。这里面有个极其重要的细节条件变量一定要和互斥锁配合使用。原因在于避免等待-判断的竞态如果消费者先检查队列为空但还没进入睡眠状态此时生产者插入数据并发送通知消费者可能错过这次通知然后沉沉睡去直到下一个数据到来才被唤醒。带锁配合条件变量才能保证检查条件和进入等待是原子操作。3. 实操过程与核心环节实现3.1 热身一个看起来正确的错误示例按照惯例我先给你看一个反面教材。假设我们要用两个线程轮流给一个共享计数器加1每个线程加1万次最后打印结果。直觉上最终结果应该是20000。先看看最典型的错误写法public class BadCounter { private static int count 0; public static void main(String[] args) throws InterruptedException { Runnable task () - { for (int i 0; i 10000; i) { count; // 这个操作不是原子的 } }; Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count count); } }这段代码你跑一次结果大概率是小于20000的而且每次跑结果都不同比如19937、19852、19991。核心问题就在那句count。在字节码层面它实际上分解成了三步读取count、把count加1、把结果写回count。两个线程的交错执行会导致更新丢失。这就是我前面提过的案例——多个线程同时读到了旧值各自加1后写回导致累加次数变少。这种情况在并发领域太典型了很多生产事故的根源都是这种不起眼的读-改-写操作。3.2 用互斥锁解决问题synchronized 与 ReentrantLock最直接的修复方式就是给这个临界区加上锁。Java里最传统的方案是用synchronized关键字它可以修饰方法也可以修饰代码块。public class SafeCounterWithSync { private static int count 0; private static final Object lock new Object(); public static void main(String[] args) throws InterruptedException { Runnable task () - { for (int i 0; i 10000; i) { synchronized (lock) { count; } } }; Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count count); // 稳定输出20000 } }把count包裹进synchronized(lock)就保证了任何时刻只有一个线程能执行这段代码。另一个线程想进入时必须等前者释放锁。synchronized是Java内置的锁用在简单场景非常方便。但如果你需要更灵活的功能比如可中断的加锁、带超时的加锁、公平锁、以及多个条件变量那就得用ReentrantLockimport java.util.concurrent.locks.ReentrantLock; public class SafeCounterWithReentrantLock { private static int count 0; private static final ReentrantLock lock new ReentrantLock(); public static void main(String[] args) throws InterruptedException { Runnable task () - { for (int i 0; i 10000; i) { lock.lock(); try { count; } finally { lock.unlock(); } } }; Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count count); // 稳定输出20000 } }这里有个工程上的硬性要求lock()之后必须紧跟try-finally在finally里释放锁。原因是如果临界区内的代码抛出了异常锁得不到释放其他线程就会永远阻塞直接导致程序卡死。这个是新人最容易犯的错误我在代码评审里见过太多次了。3.3 更进一步用原子变量简化代码上面两种加锁方式虽然安全但每次都加锁解锁其实有性能开销。如果只是做计数累加完全可以用AtomicIntegerimport java.util.concurrent.atomic.AtomicInteger; public class SafeCounterWithAtomic { private static final AtomicInteger count new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Runnable task () - { for (int i 0; i 10000; i) { count.incrementAndGet(); } }; Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count count.get()); // 稳定输出20000 } }原子变量的背后是CAS指令不需要线程上下文切换也没有锁的阻塞唤醒开销。在竞争不激烈的情况下它的性能表现比synchronized好很多。但如果是超高并发且竞争激烈的场景CAS可能因为失败重试次数太多反而导致性能下降——这也是我们在做并发设计时需要实际测量、不能拍脑袋选型的原因。3.4 再进一步用读写锁优化读多写少场景举一个实际一点的操作场景。假设我们有一个共享的配置对象多个工作线程频繁读取配置去执行任务偶尔有一个管理线程更新配置。如果我们用一把互斥锁把读和写都锁住那读取配置这种高频操作也会被串行化性能堪忧。这时可以用读写锁import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock; public class ConfigHolder { private final MapString, String config new HashMap(); private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); public String get(String key) { String value; rwLock.readLock().lock(); try { value config.get(key); } finally { rwLock.readLock().unlock(); } return value; } public void put(String key, String value) { rwLock.writeLock().lock(); try { config.put(key, value); } finally { rwLock.writeLock().unlock(); } } }在这个例子里多个线程可以同时执行get方法因为它们持有的是共享的读锁但put方法需要独占的写锁一旦有线程在写所有读线程也必须等待。这就既保证了数据安全又提升了并发读取的能力。注意读写锁并不是银弹。如果你实际场景里读操作很多但写操作非常频繁写占比超过30%左右读写锁的性能可能还不如普通互斥锁因为读写锁的底层管理逻辑更复杂。选型前最好用压测验证而不是靠感觉。3.5 经典实战生产者-消费者模型现在来看一个更完整的综合案例两个线程共享一个任务队列生产者线程产生任务消费者线程处理任务。这也是面试中最高频的手写题目之一很能体现你对线程同步的理解。import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class TaskQueue { private final QueueString queue new LinkedList(); private final int capacity; private final ReentrantLock lock new ReentrantLock(); private final Condition notEmpty lock.newCondition(); private final Condition notFull lock.newCondition(); public TaskQueue(int capacity) { this.capacity capacity; } public void put(String task) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { notFull.await(); // 队列满了生产者等待 } queue.offer(task); notEmpty.signal(); // 通知消费者有任务了 } finally { lock.unlock(); } } public String take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空了消费者等待 } String task queue.poll(); notFull.signal(); // 通知生产者可以继续放任务了 return task; } finally { lock.unlock(); } } }这里面有几个特别关键的细节值得展开讲一下第一等待必须在while循环里而不是if里。这是为了应对虚假唤醒Spurious Wakeup。操作系统层面的等待被唤醒了但并不代表条件一定满足了。应该在上次等待回来之后重新检查条件如果不满足就继续等。用while而不是if才能确保这个重新检查的逻辑正确执行。第二await方法会释放锁但signal方法不会。当生产者调用notEmpty.signal()时只是通知消费者线程从等待队列移到锁的等待队列并不会立即让消费者执行。消费者真正执行要等生产者释放锁之后。理解这一点你就明白为什么signal一般放在临界区末尾了——它只是打招呼真正让出执行权的是unlock()。第三用while循环包裹await之后代码的结构变成了验证→等待→再次验证的循环这跟前面的银行账户例子、计数器的例子其实是一脉相承的共享资源的并发访问本质上就是对检查条件这个看起来朴素的步骤做严格的串行化。3.6 进阶方案无锁编程与并发容器如果追求更高的性能有些场景可以走无锁路线。比如用Java的ConcurrentLinkedQueue或者LinkedBlockingQueue内部封装了锁或者更高级的LongAdder。无锁编程依赖的是CAS和内存屏障难度更高而且要非常小心ABA问题ABA问题是指一个变量从A变成B又变成ACAS会认为它没变过从而误判。工程上我一般建议优先使用成熟的并发容器只有在性能测试确实达到瓶颈、并且你对底层并发原理有充分把握时才去考虑无锁数据结构。4. 常见问题与排查技巧实录4.1 死锁为什么我的程序卡死不动了死锁是最经典、也最难排查的并发问题。它的发生需要满足四个必要条件互斥、持有并等待、不可剥夺、循环等待。前面三个通常是锁本身的属性我们能控制的就是循环等待。最典型的死锁场景是线程A持有锁1等待锁2线程B持有锁2等待锁1。两者互相僵持谁也无法推进。我在实际项目里最有效的预防手段有两个其一锁的顺序必须全局一致。如果你有多个锁需要获取给它们排个序所有线程都必须按这个顺序获取锁。比如约定先锁A再锁B那所有代码都必须遵守不能有的地方先锁B。其二使用带超时的加锁方法。像ReentrantLock提供了tryLock(timeout)如果等待超过指定时间就放弃然后做善后处理避免无限期地等下去。这虽然不能修复死锁但至少能让程序从死锁中恢复。实操技巧遇到程序卡死先jstack pid抓一下线程栈Java环境看看各个线程正在等待什么锁。如果是C可以用gdbattach到进程后thread apply all bt。记住死锁排查的关键不是靠猜而是靠看线程的调用栈和锁的持有关系。4.2 性能陷阱锁的粒度与锁的争用很多人在并发优化时一味地加锁结果发现性能反而比单线程还差。这很正常——锁的本质是串行化如果临界区过大并发就变成了伪并发。锁的粒度控制要遵循两个原则第一缩小临界区。只对真正需要保护的共享数据加锁不要把无关的耗时操作比如IO、网络请求、复杂的业务计算塞进锁内。例如在生产者-消费者模型里往队列放数据和从队列取数据才需要加锁业务处理逻辑不应该在锁内执行。第二分离锁。如果多个互不相关的资源都可以被独立修改就分别用不同的锁保护避免所有线程争抢同一把全局锁。比如一个程序同时管理用户表和订单表给用户表操作用锁A、订单表操作用锁B比所有操作都抢同一把锁的并发度高得多。还有一点就是要关注锁的公平性与非公平性。ReentrantLock默认是非公平锁它允许线程插队吞吐量更高但可能导致某些线程长时间得不到锁饥饿。公平锁能保证先等待的线程先获得锁但吞吐量会下降。日常系统里非公平锁作为默认选择是合理的但如果你发现某些线程总是迟迟等不到锁可以考虑切换成公平锁策略。4.3 数据不一致加锁了为什么还出错如果你加了锁但数据还是不对那要排查的方向就不是锁本身而是有没有漏掉某些共享变量的保护。常见的坑有使用了非线程安全的容器而且只是部分操作加了锁比如遍历容器、判断大小、修改元素这些环节中某一步没有锁保护。复合操作没有原子化。比如先检查Map里有没有key再决定是否put这在单线程下没问题但多线程下检查-再写入中间可能被别的线程插入操作。消息传输时对同一个对象引用在多个线程间传递但这个对象的内部状态并没有被同步保护。我自己的经验是排查这类问题最笨也最有效的方法是把所有对共享变量的访问点都列出来逐一检查是否加锁。你可以先在代码里搜索所有直接读写共享变量名的地方然后用锁保护覆盖表把每个访问点对应的同步机制标注出来。一漏就一目了然。4.4 可见性问题线程为什么不尊重我的修改除了数据不一致还有一类问题更隐蔽内存可见性。线程A修改了一个共享变量但线程B读到的还是旧值或者线程A的修改在线程B里看起来时有时无。这是因为线程间不共享CPU缓存一个线程对变量的修改如果不及时写回主内存另一个线程就读不到。解决方案就是使用volatile关键字或者加锁。volatile可以保证变量的可见性——每一次读都从主内存读每一次写都立即刷到主内存。但它不保证原子性所以volatile适合做状态标志但不适合做计数器的累加。你可以把volatile理解成一个轻量级的同步通知机制它管你的修改我能看到不管我们不会同时改。4.5 工具与调试技巧如何超越凭感觉写并发代码最后聊点上手就能用的工具。写并发代码不要凭感觉验证一定要用工具来发现竞争问题。Java生态里最简单的是用jvisualvm或者jstack查看线程状态更专业的可以用JMCJava Mission Control看锁等待统计。如果是做压力测试可以用JMeter或者自写的多线程压测脚本跑高并发场景观察有没有报错或者数据不一致。C方向最推荐的是ThreadSanitizerTSan。编译时加上-fsanitizethread运行时它就能在发生数据竞争时给出详细的报告精确到源文件行号和线程栈。用TSan养成自查习惯能省掉大量调试时间。还有一个通用技巧尽量把共享数据封装成少数几个核心类对外提供安全的方法而不是让所有线程直接访问数据成员。数据隐藏得越好并发安全性就越容易保证。这跟面向对象设计里的封装思想是一脉相承的——并发安全不只是用锁更是一种代码设计的纪律。5. 我踩过的几个真实坑提前帮你排掉最后分享几个我在实际项目里踩过的坑希望你别再走一遍。第一个坑是抱着锁睡觉。我曾经在临界区里做了一次数据库查询耗时大概200毫秒结果所有线程都堵在这把锁上系统吞吐量直接从每秒1000掉到不到100。后来把慢操作挪出临界区加锁范围缩短到只保护缓存更新性能立刻回升。锁千万别抱着太久的睡大觉临界区越短越好。第二个坑是写了锁却忘了finally释放。有一次线上偶发死锁排查了大半天最后发现是某个分支提前return了锁没释放。从那以后我给自己定了死规矩加锁之后立即敲出try-finally结构再往里面填业务逻辑。手动lock()配合try-finally是最不容易犯错的写法。第三个坑是过度依赖原子变量。我记得有一次写一个复杂的订单状态流转想着用AtomicLong做状态标记就够了结果在多线程更新状态时中间状态被覆盖了订单直接乱套。后来改成读写锁保护的枚举状态才彻底解决。简单计数器用原子变量复杂状态流转还是老老实实用锁。第四个坑是压测没覆盖竞争路径。很多程序跑单线程测试全绿一到多线程就挂。所以我建议你从一开始就用高并发场景来测试比如用8个线程同时进行读写操作配合JMeter压测接口再看数据是不是一致的。把并发测试纳入你的日常开发流程比事后救火要高效得多。并发这个主题说起来其实不大核心就那些东西——互斥、同步、内存可见性。但要在工程里做到安全共享数据你需要的是反复实践、踩坑、然后总结出自己的代码习惯。希望你读完这篇文章后不只是会背几个概念而是能写出经得起高并发考验、让人放心的线程安全代码。如果你在实操中遇到什么奇怪的问题回头看看这篇文章里的排查思路大概率能帮你定位到方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价