相反,并发编程是一项综合性的技术,同时,它与现实生活中 的场景有着紧密的联系。
并发编程有三大核心问题:
- 分工问题
- 同步问题
- 互斥问题
本文就对这三大核心问题进行简单的介绍。
1 分工问题
关于分工,比较官方的解释是:一个比较大的任务被拆分成多个大小合适的任务,这些大小合适的任务被交给合适的线程去执行。
分工强调的是执行的性能。
▊ 类比现实案例
可以类比现实生活中的场景来理解分工,例如,如果你是一家上市公司的 CEO,那么,你的主要工作就是规划公司的战略方向和管理好公司。就如何管理好公司而言,涉及的任务就比较多了。
这里,可以将管理好公司看作一个很大的任务,这个很大的任务可以包括人员招聘与管理、 产品设计、产品开发、产品运营、产品推广、税务统计和计算等。如果将这些工作任务都交给 CEO一个人去做,那么估计 CEO 会被累趴下的。CEO一人做完公司所有日常工作如图1所示。
图1 CEO 一人做完公司所有日常工作
如图1 所示,公司 CEO 一个人做完公司所有日常工作是一种非常不可取的方式,这将导致公司无法正常经营,那么应该如何做呢?
有一种很好的方式是分解公司的日常工作,将人员招聘与管理工作交给人力资源部,将产 品设计工作交给设计部,将产品开发工作交给研发部,将产品运营和产品推广工作分别交给运 营部和市场部,将公司的税务统计和计算工作交给财务部。
这样,CEO 的重点工作就变成了及时了解各部门的工作情况,统筹并协调各部门的工作, 并思考如何规划公司的战略。
公司分工后的日常工作如图2所示。
图2 公司分工后的日常工作
将公司的日常工作分工后,可以发现,各部门之间的工作是可以并行推进的。例如,在人力资源部进行员工的绩效考核时,设计部和研发部正在设计和开发公司的产品,与此同时,公司的运营人员正在和设计人员与研发人员沟通如何更好地完善公司的产品,而市场部正在加大力度宣传和推广公司的产品,财务部正在统计和计算公司的各种财务报表等。一切都是那么有条不紊。
所以,在现实生活中,安排合适的人去做合适的事情是非常重要的。映射到并发编程领域 也是同样的道理。
▊ 并发编程中的分工
在并发编程中,同样需要将一个大的任务拆分成若干比较小的任务,并将这些小任务交给 不同的线程去执行,如图3所示。
图3 将一个大的任务拆分成若干比较小的任务
在并发编程中,由于多个线程可以并发执行,所以在一定程度上能够提高任务的执行效率。
在并发编程领域,还需要注意一个问题就是:将任务分给合适的线程去做。也就是说,该由主线程执行的任务不要交给子线程去做,否则,是解决不了问题的。
这就好比一家公司的 CEO 将规划公司未来的工作交给一位产品开发人员一样,不仅不能规划好公司的未来,甚至会与公司的价值观背道而驰。
在 Java 中,线程池、Fork/Join 框架和 Future 接口都是实现分工的方式。在多线程设计模式中,Guarded Suspension 模式、Thread-Per-Message 模式、生产者—消费者模式、两阶段终止模式、Worker-Thread 模式和 Balking 模式都是分工问题的实现方式。
2 同步问题
在并发编程中,同步指一个线程执行完自己的任务后,以何种方式来通知其他的线程继续执行任务,也可以将其理解为线程之间的协作,同步强调的是执行的性能。
▊ 类比现实案例
可以在现实生活中找到与并发编程中的同步问题相似的案例。
例如,张三、李四和王五共同开发一个项目,张三是一名前端开发人员,他需要等待李四的开发接口任务完成再开始渲染 页面,而李四又需要等待王五的服务开发工作完成再写接口。
也就是说,任务之间是存在依赖关系的,前面的任务完成后,才能执行后面的任务。
在现实生活中,这种任务的同步,更多的是靠人与人之间的交流和沟通来实现的。例如,王五的服务开发任务完成了,告诉李四,李四马上开始执行开发接口任务。等李四的接口开发完成后,再告诉张三,张三马上调用李四开发的接口将返回的数据渲染到页面上。现实生活中 的同步模型如图4所示。
图4 现实生活中的同步模型
由图4可以看出,在现实生活中,张三、李四和王五的任务之间是有依赖关系的,张三渲染页面的任务依赖李四开发接口的任务完成,李四开发接口的任务依赖王五开发服务的任务完成。
▊ 并发编程中的同步
在并发编程领域,同步机制指一个线程的任务执行完成后,通知其他线程继续执行任务的方式,并发编程同步简易模型如图5所示。
图5 并发编程同步简易模型
由图5可以看出,在并发编程中,多个线程之间的任务是有依赖关系的。
线程 A 需要阻塞等待线程 B 执行完任务才能开始执行任务,线程 B 需要阻塞等待线程 C 执行完任务才能开始执行任务。线程 C 执行完任务会唤醒线程 B 继续执行任务,线程 B 执行完任务会唤醒线程 A 继续执行任务。
这种线程之间的同步机制,可以使用如下的 if 伪代码来表示。
if(依赖的任务完成){
执行当前任务
}else{
继续等待依赖任务的执行
}
上述 if 伪代码所代表的含义是:当依赖的任务完成时,执行当前任务,否则,继续等待依 赖任务的执行。
在实际场景中,往往需要及时判断出依赖的任务是否已经完成,这时就可以使用 while 循 环来代替 if 判断, while 伪代码如下。
while(依赖的任务未完成){
继续等待依赖任务的执行
}
执行当前任务
上述 while 伪代码所代表的含义是:如果依赖的任务未完成,则一直等待,直到依赖的任务完成,才执行当前任务。
在并发编程领域,同步机制有一个非常经典的模型——生产者—消费者模型。如果队列已满,则生产者线程需要等待,如果队列不满,则需要唤醒生产者线程;如果队列为空,则消费者线程需要等待,如果队列不为空,则需要唤醒消费者。
可以使用下面的伪代码来表示生产者—消费者模型。
- 生产者伪代码
while(队列已满){
生产者线程等待
}
唤醒生产者
- 消费者伪代码
while(队列为空){
消费者等待
}
唤醒消费者
在Java 中,Semaphore、Lock、synchronized.、CountDownLatch、CyclicBarrier、Exchanger 和 Phaser 等工具类或框架实现了同步机制。
3 互斥问题
在并发编程中,互斥问题一般指在同一时刻只允许一个线程访问临界区的共享资源。互斥强调的是多个线程执行任务时的正确性。
▊ 类比现实案例
互斥问题在现实中的一个典型场景就是交叉路口的多辆车汇入一个单行道,如图6所示。
图6 交叉路口的多辆车汇入一个单行道
从图6 可以看出,当多辆车经过交叉路口汇入同一个单行道时,由于单行道的入口只能容纳一辆车通过,所以其他的车辆需要等待前面的车辆通过单行道入口后,再依次有序通过单行道入口。这就是现实生活中的互斥场景。
▊ 并发编程中的互斥
在并发编程中,分工和同步强调的是任务的执行性能,而互斥强调的则是执行任务的正确性,也就是线程的安全问题。
如果在并发编程中,多个线程同时进入临界区访问同一个共享变量,则可能产生线程安全问题,这是由线程的原子性、可见性和有序性问题导致的。
而在并发编程中解决原子性、可见性和有序性问题的核心方案就是线程之间的互斥。
例如,可以使用JVM中提供的synchronized锁来实现多个线程之间的互斥,使用synchronized锁的伪代码如下。
- 修饰方法
public synchronized void methodName(){
//省略具体方法
}
- 修饰代码块
public void methodName(){
synchronized(this){
//省略具体方法
}
}
public void methodName(){
synchronized(obj){
//省略具体方法
}
}
public void methodName(){
synchronized(ClassName.class){
//省略具体方法
}
}
- 修饰静态方法
public synchronized static void staticMethodName(){
//省略具体方法
}
除了 synchronized 锁,Java 还提供了 ThreadLocal、CAS、原子类和以CopyOnWrite 开头的并发容器类、Lock 锁及读/写锁等,它们都实现了线程的互斥机制。