Worker Thread模式类比的是工厂里车间工人的工作模式。
现实世界中,工厂里还有一种流水线的工作模式,类比到编程领域就是生产者-消费者模式。
Java线程池本质上就是用生产者-消费者模式实现的,每当使用线程池的时候,其实就是在应用生产者-消费者模式。为了提升性能,Log4j2中异步Appender内部也用到了生产者-消费者模式。
生产者-消费者模式的优点
生产者-消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,消费者线程从任务队列中获取任务并执行。
从架构设计的角度来看,生产者-消费者模式有一个很重要的优点-解耦。解耦对于大型系统的设计非常重要,解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。
在生产者-消费者模式中,生产者和消费者没有任何依赖关系,它们彼此之间的通信只能通过任务队列,所以生产者-消费者模式是一个不错的解耦方案。
除了架构设计,生产者-消费者模式还有一个重要的优点-支持异步,并且能够平衡生产者和消费者的速度差异。在生产者-消费者模式中,生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完,也就是说任务的生产和消费时异步的,这是与传统的方法之间调用的本质区别,传统的方法之间调用是同步的。
异步化处理最简单的方式就是创建一个新的线程去处理,中间增加一个任务队列,主要是用于平衡生产者和消费者的速度差异。假设生产者的速率很慢,而消费者的速率很高,假设1:3,如果生产者有3个线程,采用创建新的线程的方式,会创建3个消费线程;而采用生产者-消费者模式,消费者线程只需要1个就可以。
Java语言里,Java线程和操作系统线程是一一对应的,线程创建的太多,会增加上下文切换的成本,所以Java线程不是越多越好,适量即可。生产者-消费者模式支持采用适量的线程。
支持批量执行以提升性能
Thread-Per-Message模式中,如果使用轻量级线程,就没有必要平衡生产者和消费者的速度差异了,因为轻量级线程本身就是廉价的。但在批量执行任务的并发场景,还是更适宜使用生产者-消费者模式。
在数据库里Insert1000条数据,两种方案:1用1000个线程并发执行,每个线程insert一条数据;2用1个线程,执行一个批量的SQL,一次性把1000条数据insert进去。方案2效率更高。
两阶段终止模式中,监控系统动态采集,最终回传的监控数据要存入数据库。但被监控系统往往有很多,如果每一条回传数据都直接insert到数据库,那么采用生产者-消费者模式批量执行sql是更好的方案。
利用生产者-消费者模式实现批量执行SQL非常简单:将原来直接Insert数据到数据库的线程作为生产者线程,生产者线程只需要将数据添加到任务队列,然后消费者线程负责将任务从任务队列中批量取出并批量执行。
1 | //创建5个消费者线程负责批量执行sql |
支持分阶段提交以提升性能
利用生产者-消费者模式还可以轻松的支持分阶段提交的应用场景。
写文件如果同步刷盘性能会很慢,对于不是很重要的数据,往往采用异步刷盘的方式。如日志组件
- error级别的日志需要立即刷盘;
- 数据积累到500条需要立即刷盘;
- 存在未刷盘数据,且5秒钟内未曾刷盘,需要立即刷盘。
这种日志组件的异步刷盘操作本质上就是一种分阶段提交。
1 | class Logger { |
毒丸
采用生产者-消费者模式实现分阶段提交。通过调用info()和error()方法写入日志,这两个方法都是创建了一个日志任务LogMsg,并添加到阻塞队列中。
调用info()和error()方法的线程是生产者;真正将日志写入文件的是消费者线程,在Logger这个类中,只创建了1个消费者线程,在这个消费者线程中,会根据刷盘规则执行刷盘操作。
总结
Java语言提供的线程池本身就是一种生产者-消费者模式的实现,但是线程池中的线程每次只能从任务队列中消费一个任务来执行,对于大部分并发场景这种策略都没有问题,但是需要批量执行以及分阶段提交的场景还是需要自己来实现。
生产者-消费者模式在分布式计算中的应用非常广泛。在分布式场景下,可以借助分布式消息队列(MQ)来实现生产者-消费者模式。MQ一般都支持两种消息模型,一种是点对点模型,一种是发布订阅模型。这两种模型的区别:点对点模型里一个消息只会被一个消费者消费,和Java的线程池非常类似;发布订阅模型里一个消息会被多个消费者消费,本质上是一种消息的广播,在多线程编程领域,可以结合观察者模式实现广播功能。