阅读 22

并发编程必须要知道的几个基本问题

并发编程知识体系

并发编程是计算机学科重要的命题。 如何提纲挈领的掌握并发编程,搭建知识体系尤其重要。 这篇文章基于自己对于并发编程的理解和公开资料的整理,试图拨开迷雾,从整体上介绍并发编程。

主要内容包括:

  1. 并发编程的基本概念:
  • 并发和并行的区别
  • 多线程优点
  • 多线程的三个基本问题
  1. 并发编程实践
  • J.U.C框架
    • Excutor框架
    • Fork/Join框架
  • 并发编程两个基本问题讨论

并发编程基本概念

并发编程的产生

并发编程是伴随计算机发展产生的。

第一代计算机使用笨重的卡片机, 工作人员将计算任务打孔的方式输入到计算机中。 为了提升运算速度,出现了批处理的操作系统。而后出现了分时操作操作系统。

英特尔(Intel)创始人之一戈登·摩尔提出:集成电路上可容纳的晶体管数目,约每隔兩年便会增加一倍。这就是著名的摩尔定律。半导体行业大致按照摩尔定律发展了半个多世纪,对二十世纪后半叶的世界经济增长做出了贡献。

CPU从原来的单核逐渐增加成双核、四核甚至更多。 CPU变的越来越快, 内存和IO的速度的增长却很缓慢。缓存是为了缓解CPU和内存速度之间的速度不匹配, 内存缓解了CPU和IO设备之间的速度不匹配。

现在计算机存储结构如下图所示:

多线程和多进程的产生就是为了充分利用多核CPU的计算优势, 减少IO阻塞而产生的。

并发编程的优势

多线程程序有很多优点:

  1. 处理速度快: 多线程程序充分利用多核CPU的优势,将计算分布在不同的CPU核心提升计算速度。

  2. 减少IO阻塞: 网络请求或者本地IO都是比较慢的操作, 可以利用多线程技术在IO的同时,新建一个线程进行其他操作(异步操作),减少IO阻塞对于响应速度的影响。 之前的操作系统只能新建少量的线程,因此,操作系统提供了高效的IO方式。比如多路复用IO,异步IO等。现代的操作系统,线程数量限制通常很大, 可以使用线程池技术提升处理能力。

  3. 提升界面系统响应速度:

传统的GUI系统很多都是单线程, 通过主事件循环(Main Event Loop)处理界面组件各种事件。在处理比较耗时的事件操作时, 很容易卡住主界面。 现在的GUI系统利用多线程的技术, 使用事件分发机制(Event Dispatch Thread)代替主事件循环。 事件发生时调用对应的事件处理器。 界面系统影响更加灵敏。

  1. 简化建模:

一个线程处理一个事情比一次处理多个事情建模更加容易。 通过多线程的框架可以把事件处理和资源调度、交替执行的操作、异步IO和资源等待隔离开来。现在的并发编程的框架和组件(Servlet,RMI等)使得建模更加简单。

并发编程需要关注的三个问题:安全性问题、活性问题和性能问题

多线程是一把双刃剑、带来性能提升的同时带来安全性、活性和性能问题。

以酒店清洁为例子:

昆泰酒店有很多的房间需要打扫、保洁在主管的监督完成所有酒店房间的清洁工作。 公司资金有限, 只有有限的清洁工具供保洁轮流使用。

这个例子中:清扫房间就是接下来计算机要处理的计算任务。保洁相当于处理任务的线程,主管相当于线程调度器,有限的清洁工具相当于CPU资源或者其他公共资源。

那问题来了,酒店规模很小的时候,整个昆泰酒店只有1名保洁,这名保洁只需要逐个去打扫。处理速度虽然慢,但是不会出错。清洁工具可以被这名保洁独占,只有一名保洁,因此也不需要额外招聘保洁主管。

随着酒店规模变大,1名保洁不能满足酒店的要求。公司雇佣更多的保洁,并为这些保洁人员配置了保洁主管。

多名保洁打扫完房间之后会通知保洁主管该房间已经被打扫。清洁工具在使用完成,交给下一位保洁之前需要清扫干净并标记上自己的工号表示自己在使用(上下文切换)。

保洁之间协同工作和多线程系统很像。

安全性问题

保洁打扫一个房间,如果不做任何标记或者通知,别的保洁也可能进入打扫。造成了资源浪费(任务重叠执行)。更不幸的事情,新入住的房客在这个时候进入了房间看到房间一片狼藉,该作何想。

这种情况相当于公共资源访问时的线程安全性问题。 线程安全性问题有时候只是造成资源浪费,跟多的时候会造成严重的错误。

保洁主管发现了这个问题之后,他让保洁在进入房间门打扫之前在门口放置一个打扫中的牌子(锁)。另外一个保洁看到这个牌子(锁失效),就知道房间在打扫中。这个问题也就解决了。

房间的打扫状态相当于Java语言中的锁。 锁不紧是能解决互斥访问,也间接实现了线程之间的通信。

保洁主管为了有限的工具(资源)被高效利用设计了一套资源分配的规则(JMM Java内存模型)。 规则规定了不同工具放置的位置(内存分布)、工具使用规则(保证工具使用状态每个人都可见)、工序优化的规则(重排序)、工作分配的规则(原子性)。

主管指定的资源分配的规则,相当于Java的内存模型。

Java内存模型包括Java内存区域划分和内存使用规则(可见性、原子性和指令重排序)。

活性问题

由于线程资源稀缺性或者程序自身的问题和缺陷导致线程线程一直处于非runnable状态,或者线程虽然处于runnable状态但是其要执行的任务却一直无法进展的现象被称为线程活性故障。 常见的线程活性故障包括死锁、活锁、饥饿。

死锁: 保洁A手里拿着拖把, 她需要抹布, 另外一个保洁手里拿着抹布需要拖把。如果两个人都互不相互谦让的会就会造成死锁问题。

造成死锁需要四个条件: 互斥条件, 请求和保持条件, 不可剥夺和循环等待。 破坏四个条件任何一个,就可以解决死锁的问题。

活锁:并未产生线程阻塞,但是由于某种问题的存在,导致无法继续执行的情况。

  1. 消息重试。当某个消息处理失败的时候,一直重试,但重试由于某种原因,比如消息格式不对,导致解析失败,而它又被重试

这种时候一般是将不可修复的错误不要重试,或者是重试次数限定

  1. 相互协作导致的活锁问题。

比如两个很有礼貌的人在同一条路上相遇,彼此给对方让路,但是又在同一条路上遇到了。互相之间反复的避让下去

这种时候可以选择一个随机退让,使得具备一定的随机性

饥饿

优先级低的线程由于不停的被高优先级线程抢占执行,导致最终无法执行。

引入公平机制可以解决饥饿问题。 比如使用ReentrantLock实现的公平锁。

性能的问题

并发编程存在由于互斥资源争用导致程序吞吐下降,执行变慢等性能问题。

加锁可以解决线程资源争用的问题,但同时带来开销。

synchronized锁优化

在 JDK1.6 之后,出现了各种锁优化技术,如轻量级锁、偏向锁、适应性自旋、锁粗化、锁消除等

synchronized关键字修饰的锁对象会根据互斥资源争用情况选择不同的锁的实现。

  • 当没有锁争用时, 锁变成一个偏向锁,加锁和解锁无需额外的消耗。

  • 当存在锁争用时, 偏向锁会升级为轻量级锁, 线程不会阻塞,通过自旋的方式提升程序响应速度。

  • 当更多的线程来争用锁资源的时候, 轻量级锁会升级会重量级锁, 线程会阻塞等待锁释放。

无锁

当访问共享数据时,通常是要使用同步。如果要避免使用同步,就是不提供共享数据。如果仅在单线程中访问数据,就不需要同步,这种技术就叫做线程封闭。

实现线程封闭主要有三种方式:

  • Ad-hoc的方式:指维护线程封闭是由程序自己去实现和维护。Ad-hoc非常脆弱,因为它没有一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到特定的线程上。

  • 栈封闭:栈封闭简单理解就是通过局部变量来实现线程封闭,多个线程访问对象的同一个方法,方法内部的局部变量会拷贝到每个线程的线程栈当中,只有当前线程才能访问到,互不干扰。所以局部变量是不被多个线程所共享的。

  • ThreadLocal的方式

同时JDK也提供了AtomicXXX使用CAS方式实现线程同步。

并发编程模式

J.U.C模式

J.U.C是指java.util.concurrent包下的并发类。

J.U.C包里的类有部分:

  1. 线程安全相关的类
  2. 线程池框架
  3. Fork/Join框架

线程安全相关的类包括如下三类:

  1. Atomic类:AtomicLong等
  2. 锁对象:ReentrantLock,ReadWriteLock等
  3. 线程安全容器:ConcurrentHashMap,CopyOnWriteArrayList等

类图如下所示:

ExecutorService模式

为了提升房间清扫和人员使用的效率,主管提议使用动态人员的方式配置保洁(线程池)。 酒店每天至少有2名保洁值班(coreSize), 客人离店之后派遣2名保洁打扫。为了提升服务体验,如果有超过2个以上房客离店,主管将会从其他分部调派保洁。不幸的是总部规定每个酒店最多只能有10个保洁(maxSize)。如果没有可用的房间,新来要入店的房客只能等待(Queue)。酒店资源有限,等待的队列不能无限的长(有界队列),否则有可能把酒店挤爆,哈哈哈,这种情况酒店就赚发了。在等待队列也满的时候, 需要合理策略(拒绝策略)处理。酒店前台可选的策略可以有: 拒绝接待新客人(DiscardPolicy), 不接待新客人通知总部(AbortPolicy,引发RejectExecutionException异常), 让客人自己打扫房间(CallerRunsPolicy), 队尾位置让给新来的客人(DiscardOldestPolicy)。

这就是线程池的模型。线程池模型核心关注:3个容量大小(coreSize, maxSize, queueSize)和4种拒绝策略(DiscardPolicy,AbortPolicy,CallerRunsPolicy,DiscardOldPolicy)。总部会根据酒店客流量情况安排资源池配置参数(设置coreSize,maxSize和QueueSize),这即是如何设置线程池参数的问题。

在默认ThreadPoolExecutor.AbortPolicy ,处理程序会引发运行RejectedExecutionException后排斥反应。 在ThreadPoolExecutor.CallerRunsPolicy中,调用execute本身的线程运行任务。 这提供了一个简单的反馈控制机制,将降低新任务提交的速度。 在ThreadPoolExecutor.DiscardPolicy中 ,简单地删除无法执行的任务。 在ThreadPoolExecutor.DiscardOldestPolicy中

Fork/Join模式

需要打扫的房间不多的时候, ExcutorSevice的模式运行效率很好。中午12点之后,大量空闲房间需要打扫。 如果还按照线程池的模式进行任务分配, 保洁人员刚打扫完4层的房间,保洁主管打电话过来说下一步打扫1楼的房间。保洁主管发现这种模式在需要打扫房间比较多的时间(任务比较多)时效率并不高。

为了解决这个难题, 保洁主管想到了一个办法。给每个保洁分配一层楼(一个线程一个工作队列),有楼层退房比较多, 有的比较少,为了解决公平和效率的问题。保洁完成本层任务后,随机去其他楼层帮忙(work stealing)。

保洁主管想到的这个方法就是Fork/Join模型。 Fork/Join类似ExecutorService 又有很多不同的地方。 Fork/Join可以把大任务(酒店),拆分成小任务(房间/楼层)。 Fork/Join模型和ExecutorService模型最主要两点区别是

(1) 一个线程一个工作队列:每个人负责一个楼层。 (2) 工作窃取:忙完自己的楼层,随机去其他楼层帮忙。

并发编程两个核心问题

多线程编程是实现并发编程主要途径。 通过线程能够大大提升系统吞吐和性能。但是,对于多线程编程要解决两个核心问题:

  1. 线程通信问题:即线程之间如何交换数据和状态。
  2. 线程同步问题: 如何在竞态条件下保证程序正确运行。

我们可以使用等待/通知,共享变量,管道等方式进行线程通信,比如Object的wait/notify, Condition的await/notify, LockSupport的park/unpark, volatile,InheritableThreadLocal, PipeInputStream/PipeOutputStream等。

线程同步我们可以加锁的技术(synchronized,Lock),也可以使用无锁技术(CAS)。 由于篇幅的原因。关于线程通信和线程同步的问题,会单独写一篇文章去讲解。

什么是架构设计?架构设计看这篇文章就够了

Redis为什么这么快?

重磅:解读2020年最新JVM生态报告

BIO,NIO,AIO 总结

JDK8的新特性,你知道多少?

回复“资料”,免费获取 一份独家呕心整理的技术资料!

image