Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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生态成熟,工具丰富锁开销大,容易出现并发问题后端服务开发
GoGoroutine + Channel,CSP模型轻量,高并发,语法层面支持并发调度开销小,开发效率高通用后端、高并发场景
Erlang/ElixirActor模型,轻量进程,消息传递高可用,容错性好,天生分布式性能略低,学习曲线陡电信、高可用系统
Python/JS多线程/多进程/异步IO异步IO性能不错,语法简单多线程受GIL限制,CPU密集型性能差IO密集型、脚本、前端
Rust所有权机制,编译期检查并发安全性能高,内存安全,并发安全学习曲线陡高性能、高可靠场景

协程(Coroutine)

协程是用户态的轻量级线程,由用户态调度,上下文切换开销极小,支持高并发:

  • 优势:可以轻松支持几十万甚至上百万并发,切换开销比线程小很多
  • 适用场景:IO密集型高并发服务,比如Web服务、API网关等
  • 代表:Go的goroutine,Java的Project Loom,Python的asyncio,JavaScript的async/await

思考问题

  1. 你在开发中遇到过哪些并发问题?是怎么排查和解决的?
  2. 死锁和活锁有什么区别?如何避免活锁?
  3. 什么是伪共享?如何检测和解决伪共享问题?
  4. 你觉得什么并发模型最好用?适合什么场景?