7.6 编程中的并发问题
并发编程相比串行编程要复杂很多,很容易出现各种难以排查的bug,这一节我们介绍编程中常见的并发问题,以及排查解决方法和最佳实践。
常见的并发问题
1. 竞态条件(Race Condition)
问题描述:多个线程同时读写共享数据,最终结果取决于线程执行的时序,结果不确定。
- 例子:多个线程同时对计数器做加1操作,结果小于预期值
- 根本原因:对共享数据的访问没有正确同步,临界区没有加锁保护
- 排查方法:
- 代码审查,检查所有共享数据的访问是否都加了正确的锁
- 使用静态代码分析工具检测竞态条件
- 使用ThreadSanitizer等动态检测工具
- 解决方法:
- 对共享资源的访问加锁保护
- 尽量避免共享可变数据,使用消息传递代替共享内存
- 对于简单操作使用原子操作
2. 死锁(Deadlock)
问题描述:多个线程互相等待对方持有的锁,导致所有线程都无限期阻塞,程序卡住无法继续执行。
- 死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待
- 排查方法:
- 查看线程的调用栈,看各个线程都在等待什么锁
- 使用jstack、pstack、gdb等工具查看线程状态
- 使用ThreadSanitizer、jconsole等工具检测死锁
- 解决方法:
- 避免一个线程同时持有多个锁
- 所有线程按相同的顺序获取锁
- 使用带超时的锁获取接口,超时后释放已经持有的锁
- 减少锁的粒度和持有时间
3. 活锁(Livelock)
问题描述:线程没有阻塞,但是一直不断重复相同的操作,无法继续推进执行。
- 例子:两个线程都主动释放自己的锁给对方,结果双方都拿到对方释放的锁,又马上释放,一直重复这个过程
- 和死锁的区别:死锁是线程都阻塞了,活锁是线程还在运行但无法推进
- 解决方法:
- 引入随机等待时间,避免多个线程同时释放和获取锁
- 调整重试的机制,避免无限重试
4. 饥饿(Starvation)
问题描述:某个或某些线程一直得不到需要的资源,一直无法执行。
- 原因:
- 优先级调度,低优先级线程一直被高优先级线程抢占
- 锁不公平,某些线程一直抢不到锁
- 资源被其他线程一直占用不释放
- 解决方法:
- 使用公平锁,按申请顺序分配锁
- 避免设置过高的优先级
- 限制资源持有时间,避免长时间占用资源
5. 伪共享(False Sharing)
问题描述:多个线程修改同一个缓存行中的不同变量,导致缓存行频繁失效,性能急剧下降。
- 原理:之前缓存章节讲过,缓存的最小单位是缓存行(通常64字节),如果两个变量在同一个缓存行,一个线程修改其中一个变量,会导致整个缓存行失效,其他线程访问同缓存行的其他变量也会缓存miss
- 排查方法:
- 使用perf等性能分析工具观察缓存miss率
- 查看并发修改的变量是否在内存中相邻
- 解决方法:
- 缓存行填充,在变量之间填充无用字节,让不同线程修改的变量位于不同的缓存行
- 调整数据结构,避免多个线程频繁修改同一个缓存行的数据
6. 上下文切换开销过高
问题描述:系统中线程数量太多,频繁的上下文切换消耗大量CPU资源,导致真正执行任务的CPU时间很少,系统吞吐量低。
- 表现:CPU使用率中sys占比很高,用户态占比低,系统负载高但处理速度慢
- 原因:
- 线程数量远大于CPU核心数,频繁切换
- 锁冲突严重,线程频繁阻塞和唤醒
- 解决方法:
- 合理设置线程池大小,CPU密集型任务线程数≈核心数,IO密集型可以适当多一点
- 减少锁冲突,降低阻塞概率
- 使用更轻量的并发模型,比如协程,减少上下文切换开销
7. 线程安全问题
很多常用的数据结构不是线程安全的,多个线程同时访问会出现问题:
- 例子:多个线程同时往ArrayList中add元素,可能会出现数组越界、丢失元素、size不正确等问题
- 解决方法:
- 使用线程安全的数据结构,比如ConcurrentHashMap、CopyOnWriteArrayList
- 对非线程安全的数据结构访问加锁保护
- 每个线程使用独立的数据结构,避免共享
并发问题的调试和排查工具
1. 通用工具
- gdb / lldb:C/C++程序调试工具,可以查看线程状态、调用栈、锁持有情况
- strace:跟踪系统调用,查看线程正在做什么操作
- perf:性能分析工具,可以分析上下文切换次数、缓存命中率、CPU使用率等
- ThreadSanitizer(TSAN):Google开发的并发问题检测工具,集成在GCC/Clang中,编译时加上
-fsanitize=thread参数,可以检测竞态条件、死锁等问题,非常强大
2. Java相关工具
- jstack:查看Java进程的线程堆栈信息,查看每个线程的状态,等待的锁
- jconsole / VisualVM:图形化工具,监控线程状态、锁情况、死锁检测
- Arthas:阿里开源的Java诊断工具,可以在线查看线程状态、死锁等问题
3. Go相关工具
- pprof:Go内置的性能分析工具,可以查看goroutine状态、栈信息
- go trace:跟踪程序运行时的调度、Syscall、GC等事件,分析并发问题
- go vet:静态代码检查,检测常见的并发问题
4. 动态检测工具
- Valgrind:包含DRD和Helgrind工具,可以检测C/C++程序的并发问题
- Intel Inspector:Intel的并发问题检测工具
并发编程最佳实践
1. 尽量避免并发
如果业务不需要并发,就不要用并发,并发会大大增加复杂度和bug率。如果确实需要提升性能或者吞吐量,再考虑使用并发。
2. 优先使用成熟的并发库和框架
不要自己实现复杂的同步逻辑,尽量使用标准库或者成熟第三方库提供的并发工具:
- 线程池、协程池
- 线程安全的数据结构
- 同步工具类(CountDownLatch、CyclicBarrier、Semaphore等)
- 消息队列、Actor模型等更高级的并发模型
3. 最小化共享数据
- 尽量不要共享可变数据,从根源上避免竞态条件
- 优先使用消息传递的方式通信,而不是共享内存,比如Go的“不要通过共享内存来通信,要通过通信来共享内存“的原则
- 如果必须共享,优先共享不可变数据,不需要同步
- 共享可变数据一定要加正确的锁保护
4. 正确使用锁
- 锁的粒度越小越好,只在必要的地方加锁,减少锁的持有时间
- 避免在锁中执行耗时操作(IO、sleep等)
- 避免嵌套锁,如果必须持有多个锁,严格按相同的顺序获取锁
- 优先使用可重入锁,避免同一线程重复获取锁死锁
- 使用tryLock带超时的获取锁接口,避免无限等待
- 锁必须在所有路径上释放,包括异常路径,使用RAII机制(C++的lock_guard,Java的try-with-resources,Python的with语句)自动释放锁
5. 正确使用线程池
- 不要无限制创建线程,线程的创建和销毁开销大,而且太多线程会导致上下文切换开销过高
- 合理设置线程池大小:
- CPU密集型任务:线程数≈CPU核心数 + 1
- IO密集型任务:线程数≈核心数 × (1 + 平均等待时间/平均计算时间),或者压测确定最佳值
- 不同类型的任务使用不同的线程池,避免互相影响
- 给线程设置有意义的名字,方便排查问题
6. 做好错误处理和超时
- 并发场景下一定要处理好异常,避免一个线程的异常导致整个程序崩溃
- 阻塞操作一定要设置超时时间,避免无限等待
- 线程池提交的任务要捕获异常,避免异常抛出导致线程退出
7. 测试并发代码
- 并发代码的bug很难复现,要做充分的测试
- 做压力测试,模拟高并发场景
- 用ThreadSanitizer等工具检测并发问题
- 多做代码审查,并发代码的逻辑很难通过测试覆盖所有场景,代码审查非常重要
不同编程语言的并发模型对比
| 语言 | 并发模型 | 特点 | 优点 | 缺点 |
|---|---|---|---|---|
| C/C++ | 多线程/多进程,手动管理同步 | 灵活,性能高 | 复杂度高,容易出问题 | 高性能、底层场景 |
| Java | 多线程,共享内存模型,synchronized/lock | 生态成熟,工具丰富 | 锁开销大,容易出现并发问题 | 后端服务开发 |
| Go | Goroutine + Channel,CSP模型 | 轻量,高并发,语法层面支持并发 | 调度开销小,开发效率高 | 通用后端、高并发场景 |
| Erlang/Elixir | Actor模型,轻量进程,消息传递 | 高可用,容错性好,天生分布式 | 性能略低,学习曲线陡 | 电信、高可用系统 |
| Python/JS | 多线程/多进程/异步IO | 异步IO性能不错,语法简单 | 多线程受GIL限制,CPU密集型性能差 | IO密集型、脚本、前端 |
| Rust | 所有权机制,编译期检查并发安全 | 性能高,内存安全,并发安全 | 学习曲线陡 | 高性能、高可靠场景 |
协程(Coroutine)
协程是用户态的轻量级线程,由用户态调度,上下文切换开销极小,支持高并发:
- 优势:可以轻松支持几十万甚至上百万并发,切换开销比线程小很多
- 适用场景:IO密集型高并发服务,比如Web服务、API网关等
- 代表:Go的goroutine,Java的Project Loom,Python的asyncio,JavaScript的async/await
思考问题
- 你在开发中遇到过哪些并发问题?是怎么排查和解决的?
- 死锁和活锁有什么区别?如何避免活锁?
- 什么是伪共享?如何检测和解决伪共享问题?
- 你觉得什么并发模型最好用?适合什么场景?