使用多线程的实践思考

本文源自一个线上问题引起的思考。诚然多线程是有益的,但使用不当反而会造成系统吞吐能力下降,甚至发生死锁。在使用多线程时我们可能面临下列情况:

  1. 当写的并发代码包含框架类的方法调用,总是可能存在线程安全问题,因为框架在不停升级,我们不能保证它一直线程安全的;
  2. 线程配置不当会引起安全问题,例如:缓存队列溢出、瞬时任务增多导致线程池打满,我们的业务在不断变化,在一个新的上下文环境中,没有人能保证线程配置一直合理;
  3. 多线程让编程更复杂(需要处理更多情况),例如:控制执行顺序、并发访问变量;
  4. 在多线程中进行远程请求容易对下游服务(数据库服务或其他业务服务)造成压力;

通过以上,我们看到在项目中引入一种技术带来的额外风险,有时这种风险不是线性增长而是指数级别,因此从这些角度看应该谨慎使用多线程。好的实践是先寻找其他解决方案,最后再考虑使用多线程,把多线程当作性能扩展的最后一道防线。

如果不用多线程就不存在上述的问题,我们假设使用多线程背景下来总一些实践技巧。

扒一扒问题的根

多线程引起安全问题的根源是什么?

  • 第一类是内存竞争访问(竞争写,写覆盖),导致数据丢失、数据老旧,引起这类问题的根本原因是内存与 CPU Cache 的不一致;
  • 第二类是编译器在代码优化时会进行指令重排序,导致并发的代码乱序执行,造成不符合开发人员直观认知;
  • 第三类是操作系统乱序调度线程,导致无序执行并发代码容易引起死锁。

知道问题我们就离解决问题更近了一步,接下来分析一下这些问题。

对于第一类问题,站在计算机的角度是为了提升性能。计算机有多级存储体系,硬盘 -> 内存 -> CPU Cache,当 CPU Cache 不能及时失效就会导致运行的程序读取老旧数据,导致程序执行不符合预期,因此后来提出了内存一致模型并制定缓存一致性协议(e.g. MSI、MESI)来解决这类问题。

第二类问题出现在编译器编译和 CPU 执行指令期间的问题,编译器为了提升性能分析程序会进行指令重排序,CPU 分析待执行的指令会调整顺序或并行执行(重排序缓冲区超标量流水线)。一般编程语言都有自己的处理方式,例如 Java 构建了专属的内存模型(JSR 133JSR 133 FAQ),在编辑期间通过插入内存屏障阻止重排序,C++也有类似机制。CPU 通过提供特殊指令避免其重排序,编译器控制这些指令来实施。

第三类问题是线程调度的客观特性产生的,线程的调度通常是抢占式,资源分配不均容易引起死锁。目前解决死锁还没有好手段,大多是通过经验避免一些坑,或者通过死锁检测手段主动释放锁。

与锁的相爱相杀

在程序开发中,最忌讳使用不熟悉的技术,因为不可控会导致风险增加,在不熟悉的情况下使用相当于在程序中埋地雷,而程序员又菜又爱玩,等到系统出问题才悔悟。下面是用锁应的一些实践:

  1. 对于新生请不要自己实现锁,尽量使用标准库或成熟框架中的
  2. 也不一定非用锁,使用并发安全的容器(Java)是不错的选择,也可以使类并发安全(例如无状态类)
  3. 考虑清楚场景使用锁,例如:计算密集型、IO 密集型
  4. 考虑清楚异常情况下锁的释放,甚至发生死锁的情况,这些如何处理这些

使用多线程实践总结

总结一下平常使用多线程和锁的实践,避免出现低级错误,提升多线程的性能。

使用多线程:

  • 对于使用线程的态度是尽量不使用,看是否有其他优化手段,把多线程当作提升处理性能的最后一道防线;
  • 使用线程池而不是每次使用就创建,一来避免频繁创建、销毁开销,二是让系统更加控(知道确定的线程数,而不是线程数忽高忽低);
  • 业务处理上看业务类型是 IO 密集型还是计算密集型,这个决定我们线程数量配置。

使用锁:

  • 锁选择倾向,对于加锁时间短使用乐观锁好些,加锁时间长的悲观锁好些;
  • 锁竞争、加锁、释放锁都存在开销,小粒度的锁更利于并发;
  • 使用无状态类或不可变类,可以大大减少多线程编程的复杂度;
  • 合理加锁,不在锁内进行耗时操作。

(完)