ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

多线程线程安全从原理到实战:C++/C#/Java并发踩坑与解决方案

多线程线程安全从原理到实战:C++/C#/Java并发踩坑与解决方案 你的程序跑着跑着数据就错了多线程一开就出现莫名其妙的崩溃加了锁反而更慢甚至死锁——这种症状多半就是踩了线程安全的坑。不管你是做C、C#还是Java线程安全都是从“能跑”到“跑得对”之间绕不过去的一道坎。这篇文章我想把自己在实际开发中踩过、填过、总结过的东西梳理一遍尽量用说人话的方式把线程安全的底子讲清楚再分别拆一下C单例模式、C#的List、Java并发容器这几个高频痛点场景。适合刚接触多线程的菜鸟也适合那些写了两三年业务代码、却没系统整理过并发知识的朋友。1. 线程安全到底在说什么先把底层逻辑捋清楚1.1 竞态条件两个线程同时改数据到底谁说了算我一直觉得“线程安全”这个词太术语了说白了就一句话多个线程同时访问一份数据时最终结果不会因为调度顺序的不同而出现错误。举个最直观的例子你开两个线程同时执行counter这个操作。代码看起来只有一行但计算机实际上要分三步走——先把counter的值从内存读到寄存器在寄存器里加1再把新值写回内存。两个线程如果同时走到这三步就可能出现“两个线程都读到100各自加1再分别写回101”的情况明明加了两次结果只加了1。这种“多个线程同时读写共享数据导致结果依赖于执行顺序”的情况就是著名的竞态条件Race Condition。竞态条件有个很烦人的特点它不是每次都会出错而是看运气。运气好跑一万次没事运气差上线第一天就出问题。这也让很多菜鸟在排查问题时无从下手因为单线程调试永远复现不了。1.2 线程安全要解决的三件事可见性、原子性、有序性要真正搞清楚线程安全绕不开JMMJava内存模型或者更广义的并发内存模型。虽然不同语言的底层实现有些差异但核心要解决的问题是同一批。可见性线程A修改了数据线程B能不能立刻看到。现代CPU都有多级缓存线程可能把数据读进自己的CPU缓存而没写回主内存其他线程读到的就是旧值。volatile关键字干的就是这个事强制读写直接走主内存。原子性一系列操作要么全部执行要么一个都不执行不能被中断。上面说的counter在CPU级别就不是原子的。原子性的保证通常需要锁或者CAS一类的原子指令。有序性编译器和CPU为了提高性能可能会对指令进行重排。单线程下重排不影响结果但多线程下可能就乱套了。经典的例子就是双重检查锁里instance new Singleton()这行代码在极端情况下会被拆成“先分配内存、再赋引用、最后构造对象”导致其他线程拿到一个半成品的对象。1.3 什么样的代码才能算线程安全判断一段代码是否线程安全有个很朴素的标准多个线程同时执行它不额外加同步手段最终结果跟单线程执行的结果一致且没有产生任何异常或者中间态的泄漏那它就是线程安全的。注意“不额外加同步手段”这半句——如果你在调用方外部自己加锁那原来的代码仍然可以算线程不安全的。很多菜鸟会混淆这一点。比如写了一个ArrayList在多线程环境下调用方自己lock住了每一次add操作那这个ArrayList本身依然是线程不安全的只是它的使用方式是安全的。另外不可变对象天然线程安全。因为它的状态一旦创建就固定了没有修改操作竞态条件自然无从发生。所以“能定义为不可变就定义为不可变”是我写并发代码时最常用的一条防线。2. C 单例模式的线程安全很多老手都会看走眼的高频雷区2.1 懒汉式单例为什么在多线程下一碰就炸C的单例模式最常见的考点就是懒汉式实现。所谓懒汉式就是等到第一次调用getInstance()时才创建实例而不是程序启动时就创建。class Singleton { public: static Singleton* getInstance() { if (instance nullptr) { instance new Singleton(); } return instance; } private: static Singleton* instance; }; // 在CPP文件中定义 Singleton* Singleton::instance nullptr;这段代码在单线程下天衣无缝。但两个线程同时第一次调用getInstance()时就全乱了线程A判断instance nullptr为真准备执行new Singleton()线程B此时也判断instance nullptr为真也进入if分支去new。结果就是一个类被构造了两次两个线程拿到了不同的对象如果实例还有状态整个程序的行为就会变得不可预测。更致命的是哪怕A先new完了B也可能因为可见性问题读到一个过期的空指针然后继续new一个出来。这不是理论推演我见过生产事故就是这种写法在高并发下偶发抖动最终定位到是双重初始化。2.2 双重检查锁并不是银弹内存序里藏着更大的坑为了既保证线程安全又避免每次都拿锁很多老手自然会想到“双重检查锁”Double-Checked Locking, DCL先不加锁判断一次如果为空再加锁锁内再判断一次。class Singleton { public: static Singleton* getInstance() { if (instance nullptr) { std::lock_guardstd::mutex lock(mtx); if (instance nullptr) { instance new Singleton(); } } return instance; } private: static Singleton* instance; static std::mutex mtx; };外层if用来挡掉已经初始化后的大量读请求内层if用来防止两个线程同时进入等待锁的局面。这个思路看上去无懈可击但在C11之前它实际上是未定义行为。问题出在这一句instance new Singleton()。这行代码在CPU指令层面大致分三步分配内存在内存上调用构造函数把内存地址赋值给instance问题在于第2步和第3步的顺序有可能被重排。如果线程A先执行了第1步和第3步还没来得及执行第2步线程B这个时候进来了它看到instance不是空指针高高兴兴地返回一个还没构造完成的对象。等线程A继续执行完构造函数线程B手里那个对象其实已经被污染了。你拿一个半成品去调用成员函数崩溃或者产生垃圾数据都是正常的。这就是为什么C11提供了std::atomic配合memory_order才能正确写出DCL——必须保证instance的写操作在构造完成后才对其他线程可见。但说实话自己手写这套容易翻车能简化就简化。2.3 C11之后的推荐写法Meyers Singleton与call_onceC11标准发布后一件大好事是函数内局部静态变量的初始化是线程安全的。这意味着最稳妥的单例写法反而回到了最简单的形态。class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton() default; ~Singleton() default; };这段代码通常被称作Meyers Singleton。编译器会自动处理static Singleton instance的线程安全初始化多个线程同时第一次调用getInstance()时只会有一个线程负责构造其他线程会等待构造完成后才拿到引用。它几乎没有锁开销代码量最少也不存在内存序问题。如果你非要用指针形式std::call_once是另一个靠谱选择class Singleton { public: static Singleton getInstance() { std::call_once(flag, []() { instance new Singleton(); }); return *instance; } private: static Singleton* instance; static std::once_flag flag; };std::call_once保证回调只被执行一次即使多个线程同时调用也只有一个线程执行回调其余线程阻塞等待。它比手写DCL安全得多代价是每次调用都要检查once_flag的状态性能略逊于Meyers Singleton但在绝大多数业务场景下根本感知不到差异。2.4 三种方案实测对比菜鸟直接抄作业就行方案线程安全代码量性能推荐程度原始懒汉式否最少高不推荐双重检查锁(手写)需要atomic内存序才安全较多高不推荐易出错Meyers Singleton是(C11标准保证)最少高强烈推荐call_once是中等中等需要指针时推荐我自己在实际项目中基本只用Meyers Singleton。唯一要小心的是析构程序退出时局部静态对象析构的顺序在某些极端场景下可能出现问题但99%的业务代码不需要纠结这一点。如果你负责的模块生命周期跟整个进程一样长直接忽略这个问题。3. C# 的 List 不是线程安全的你应该怎么办3.1 List 在并发下到底是怎么挂掉的C# 世界里ListT大概是集合类中出现频率最高的一个了。很多菜鸟以为它就像数组一样简单可靠于是直接在多线程环境下对它进行Add、Remove、遍历结果要么程序崩溃要么数据量不对要么直接抛异常。ListT内部实现是一个动态数组。当元素数量达到容量上限时它会申请一块更大的内存把老数据拷贝过去然后把内部引用指向新数组。这个“扩容”过程不是原子的如果线程A在扩容拷贝数据的中途线程B又往里Add一个元素就会出现各种诡异的情况——最典型的是索引越界异常或者明明Add了10个元素最后List里却只有9个。另一个常见坑是用foreach遍历 List 时另一个线程在删除元素。ListT的内部有个版本号每次修改都会让版本号加1foreach 在每次移动时会检查版本号一旦发现不一致立刻抛出InvalidOperationException: Collection was modified; enumeration operation may not execute.Listint numbers new Listint(); Parallel.For(0, 10000, i { numbers.Add(i); }); Console.WriteLine(numbers.Count); // 大概率不是10000可能抛异常3.2 加锁方案 vs 并发容器两选一到底怎么用解决这个问题核心思路就两条用锁保护现有代码或者换用线程安全的并发容器。先看加锁。如果你对List的读写操作都不复杂直接加lock是最直观的办法private readonly object _lock new object(); private Listint _numbers new Listint(); public void Add(int item) { lock (_lock) { _numbers.Add(item); } } public int GetCount() { lock (_lock) { return _numbers.Count; } }但这里有个关键问题锁不是只加在写操作上读操作也得加。很多菜鸟只给Add加了锁读取不加锁结果在读取时正好碰到扩容读到一半的数据。记住一个原则对同一个共享数据的所有访问读和写都需要在同一把锁的保护下。锁方案的缺点是代码侵入性较大每操作一次都要写lock容易漏而且并发量高的时候锁竞争会成为瓶颈。此时更推荐使用.NET自带的并发容器。3.3 用 ConcurrentBag / ConcurrentQueue / BlockingCollection 的正确打开方式C# 提供了System.Collections.Concurrent命名空间里面有一整套线程安全的容器。跟加锁相比它们内部使用了更细粒度的同步机制有的用分段锁有的用CAS性能更好而且API用起来更顺手。如果对顺序没有要求只需要一个无序的线程安全集合用ConcurrentBagTConcurrentBagint bag new ConcurrentBagint(); Parallel.For(0, 10000, i { bag.Add(i); }); Console.WriteLine(bag.Count); // 稳定输出10000如果需要先进先出用ConcurrentQueueT需要后进先出用ConcurrentStackT需要按Key读写用ConcurrentDictionaryTKey, TValue。都是线程安全的内部已经处理好了锁或者原子操作。如果做生产者-消费者模式BlockingCollectionT则是最顺手的工具。它可以同时充当队列和同步屏障生产者往里面Add消费者用Take()取数据没数据时消费者会自动阻塞等待BlockingCollectionint queue new BlockingCollectionint(boundedCapacity: 100); // 生产者 Task.Run(() { for (int i 0; i 10000; i) { queue.Add(i); } queue.CompleteAdding(); // 标记不再生产 }); // 消费者 Task.Run(() { foreach (var item in queue.GetConsumingEnumerable()) { // 处理item } });GetConsumingEnumerable()会在集合为空且未完成添加时阻塞一旦集合被标记为CompleteAdding()且队列清空循环就会正常结束。这个模式在处理消息队列、日志批量入库等场景下非常实用。至于选哪个我的建议是有序队列ConcurrentQueueT无序多次读写ConcurrentBagTKey-Value结构ConcurrentDictionaryTKey, TValue生产者-消费者BlockingCollectionT4. Java 多线程开发中的线程安全synchronized、volatile 与并发容器4.1 synchronized 和 volatile 的边界到底在哪里Java 里被问得最多的线程安全相关关键词肯定是synchronized和volatile。两者的分工经常被菜鸟搞混。synchronized是互斥锁它保证同时只有一个线程能进入临界区也就保证了原子性、可见性和有序性。用它可以解决counter这类复合操作的问题public class Counter { private int count 0; public synchronized void increment() { count; } public synchronized int get() { return count; } }但锁是有开销的如果每条读操作都加锁在高并发下会严重影响吞吐量。volatile解决的问题是可见性它保证变量读写的实时性但不保证原子性。也就是说volatile int count;同时两个线程执行count仍然会丢更新因为“读-改-写”三步中间可以被插队。volatile真正的适用场景是多个线程读一个线程写而且写操作不依赖当前值。比如一个标志位public class TaskRunner { private volatile boolean stopped false; public void stop() { stopped true; } public void run() { while (!stopped) { // 执行任务 } } }写线程调用stop()把stopped置为true运行线程马上能读到不需要加锁。这是volatile的标准用法。4.2 ConcurrentHashMap并发容器里最值得用熟的一个Java 并发包里最抢眼的类我认为是ConcurrentHashMap。如果你在多线程环境下需要共享Map不要用HashMap也不要用同步包装后的Collections.synchronizedMap(new HashMap())而是直接用ConcurrentHashMap。Hashtable和同步包装的Map用的大而化之的整表锁所有操作都竞争同一把锁并发越大崩溃越明显。JDK 7 的ConcurrentHashMap用的是“分段锁”思想把整个Map分成若干Segment每个Segment独立加锁不同段的读写互不干扰。JDK 8 之后取消了分段锁改成 CAS 局部synchronized锁的粒度从一段缩小到一个桶数组里的位置性能进一步提升。ConcurrentHashMapString, Integer map new ConcurrentHashMap(); // 多线程安全读写 map.put(key, 1); int value map.get(key);还有一个常用的原子方法computeIfAbsent它保证同一把key只有一个线程执行计算逻辑。我之前写本地缓存时就经常这么用cache.computeIfAbsent(userId, k - queryFromDatabase(k));4.3 原子类AtomicInteger 到底解决什么问题如果只是需要对一个整数做自增又不想背书同步代码AtomicInteger是比synchronized更轻的选择。它基于CASCompare And Swap实现无锁操作但高并发下CAS可能会频繁自旋重试因此并不绝对比锁快只是通常来说在竞争不太极端时更稳。AtomicInteger count new AtomicInteger(0); count.incrementAndGet(); // 等价于 count int value count.get();incrementAndGet()在底层通过 CAS 不断尝试把“当前值1”写回去如果发现别的线程先写了就重新读取再试一次直到成功为止。它保证最终结果的正确性但不会像锁那样阻塞其他线程因此整体吞吐量往往更高。4.4 Spring 单例 Bean 的线程安全业务代码最常见的隐性雷区Java 后端用 Spring 的极多而 Spring 的 Bean 默认是singleton的意味着整个应用共享同一个实例。于是内存里凡是这个 Bean 的可变字段就是天然的共享变量。最常见的错误在 Bean 里定义一个Map或者List或者SimpleDateFormat这样的成员变量多个请求线程同时往里写。我之前就见过一个项目在 Service 里放了个SimpleDateFormat用来格式化日期结果生产环境时不时出现时间错乱就是因为SimpleDateFormat内部有全局变量多线程共享会互相污染。正确的做法有三个层级能不放到成员变量就不放能定义成局部变量就在方法内部创建。必须共享的场景用ThreadLocal给每个线程一份自己的副本。实在不行再用锁保护但这是最后的选择。private ThreadLocalSimpleDateFormat formatter ThreadLocal.withInitial( () - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss) );ThreadLocal的核心语义是“线程隔离”每个线程读写自己那一份自然没有竞争。它常见于日期格式化、数据库连接、请求上下文等场景。5. 如何排查线程安全问题菜鸟的保命排查清单5.1 先说几个我自己踩过、印象极深的真实教训测试跑一万次都没事不代表并发安全。线程安全问题是概率问题跟CPU调度、系统负载都有关系。如果必须用多线程一定要用压力和条件组合去验证比如Parallel.For高并发循环、CountDownLatch让线程同时起跑。复现不了问题的“正常”恰恰是最危险的信号。死锁和活锁是另一类常见坑。你上了锁锁的顺序错了线程A等线程B持有的锁线程B又在等线程A持有的锁两边都不释放程序就永久卡死。排查死锁要善用jstackJava、gdbC、Visual Studio 的并行堆栈与线程窗口。还有一个很经典的手段把线程转储thread dump打出来看看每个线程的锁等待关系一般一眼就能看出循环等待的环。别迷信锁。加锁不一定安全关键要看锁对象是谁。如果你 lock 的是this或者公共对象其他不知道约定的代码也在 lock 同一个对象就会互相影响。建议每个资源设置一个私有锁对象而且锁的粒度越小越好。5.2 定位线程安全问题的实用排查流程当代码出现“时好时坏”的并发 bug 时我一般按照下面这个流程来查先复现。想办法提高并发量或增加循环次数让问题稳定出现。加日志。特别是入口、出口、操作共享数据前打印时间戳、线程名和值对比数据变化过程。缩小范围。临时加一些粗粒度锁比如直接给整个方法加synchronized或lock看问题是否消失。这能快速判断问题是否出在共享数据的并发访问上。检查代码里所有共享变量标记出哪些是可变的、哪些会被多线程访问。使用工具辅助Java 用jstackjvisualvmC 用TSanThreadSanitizer或helgrindC# 用 Visual Studio 的调试工具都能抓到数据竞争信息。其中 TSan 对 C 项目帮助极大。只要编译时加上-fsanitizethread跑一遍单元测试几乎能把隐蔽的 data race 直接报出来比肉眼纠错高效得多。5.3 写并发代码应该长期遵守的三条原则第一能不共享就不共享。这是成本最低的方案。方法内局部变量天然线程安全ThreadLocal是线程私有副本的万金油尽量把可变状态控制在线程内部。第二共享的数据尽量定位为不可变对象。无论是 C 的const、Java 的final还是 C# 的只读风格只要对象创建后无人能改就没有竞争可言。第三必须共享且可变时优先使用现成的并发容器或原子类。比如 Java 的ConcurrentHashMap、AtomicIntegerC# 的ConcurrentBag、BlockingCollectionC 的锁配合不可变设计。自己手写同步逻辑永远是最后的选择因为它最容易出错也最难测试。最后再分享一个我个人的习惯每次写完多线程代码之后都会专门花一点时间做代码评审式的自查标注出每个共享数据是谁在写、谁在读、是否用了同一把锁、是否存在锁顺序不一致的可能。这样一轮检查下来绝大多数线程安全问题其实在上线之前就能暴露。“写完能跑”跟“并发下永远跑得对”之间的差距往往就在这些自查的细节里。
返回列表