【响应式编程】 - 深度理解线程池新模型Schedulers包

1,861 阅读7分钟

简介

响应式编程本身已经是异步的非阻塞的,基本用不到多线程、线程池相关的功能,但有时仍然少不了需要多线程技术。为此,Reactor提供了通用的Schedulers这个工具类,提供了一套适配Reactor的线程池类。
其主要会用于subscribeOnpublishOn的方法中,用作类似的worker线程池的功能,将耗时长、阻塞型的任务独立丢给worker线程池来工作,避免Reactor的核心线程被占用,从而提高及时性与吞吐量。
一个典型的用法就是将传统的block io转变为非阻塞的响应式编程方式,例如:

Mono blockingWrapper = Mono.fromCallable(() -> { 
    return /* make a remote synchronous call */ 
});
blockingWrapper = blockingWrapper.subscribeOn(Schedulers.boundedElastic());

对于同步阻塞的调用,如果直接调用会阻塞主线程,而在响应式编程中,主线程数量往往等同于cpu核数,一旦阻塞,就会产生问题导致吞吐量极速下降。
为此,可以只用上面的方法,把一个同步阻塞的代码(比如一次http请求)封装成一个Mono, 并使用subscribeOn方法用Schedulers.boundedElastic()线程池来实现异步效果。
Schedulers.boundedElastic()产生的是一个线程数量有限的线程池,保证了不会由于创建过多线程导致系统崩溃的问题。除了boundedElastic()外,Schedulers里还预设了其他几类线程池,分别是:

  • Schedulers.immediate()
    无线程的线程池,类似no-op的一个封装类,执行Runnable时会立即用当前线程执行,只是为了某种占位时用。
  • Schedulers.single()
    单线程的线程池,只会有一个线程在执行,可以用于一些低优先级场景的情况下。
    If you want a per-call dedicated thread, use Schedulers.newSingle() for each call.
  • Schedulers.elastic()
    无界的线程池,可以创建无数多的线程,只要有任务需要执行且没有空闲的线程时,就会创建新线程,类似于jdk中的Executors.newCachedThreadPool()方式。由于,可创建的线程数不受控制,当任务量大的时候,会导致系统资源被耗尽,进而引起崩溃。
  • Schedulers.boundedElastic()
    有界的线程池,是对于Schedulers.elastic()的改进,在保证能尽可能创建线程的同时,又限定了总数,减少系统崩溃的可能性。默认最多可创建CPU核数的10倍,同时当线程空闲的时候会被回收(默认60s),在线程数达到极限后,单个线程最多会让(默认)100000个任务进入队列,所以总共可容纳的任务数是线程数*100000,基本上已经可以容纳足够的任务数了,效果基本等同于elastic。因此,boundedElastic()相对于elastic()更可控,所以通常推荐使用boundedElastic()
  • Schedulers.parallel()
    固定的线程池,用于执行无阻塞的任务,因此线程数默认等同于CPU核数,类似于类似于jdk中的Executors.newFixedThreadPool()

线程模型与Worker

说到多线程编程,那么肯定离不开线程池,Java原生也提供了线程池,是最为常见的阻塞队列+多线程的一种线程池模型,所有线程共用同一个阻塞队列,每个线程从该队列中拿到任务Task进行执行计算。其线程模型如下:

ThreadExecutor.drawio.png

虽然Java原生线程池基本能用来解决大部分多线程编程的需求,但由于是共用一个队列,灵活性以及性能上稍有欠缺,为此,Project Reactor专门设计了一套Schedulers线程池,来满足reactor在使用时的需求。Scheduler是基于Java原生的线程池ScheduledExecutorService的基础上来实现的,其模型如下:

BoundedElasticScheduler.drawio.png

从模型上看,Scheduler模型要比原生线程池复杂的多,如果说Java原生的线程池是多线程的基础,那么Scheduler则是真正具备工程能力的线程池,对Scheduler而言,它的管理对象就是ScheduledExecutorService线程池,是对N个原生线程池的管理,来增加更高效的线程池利用率;同时,也引入了WorkerWorkerTask的概念,实现了一个底层线程池对应多个Worker的效果,进而实现了对Mono/Flux的解耦。
这几者的关系如下:

ScheduledExecutorService -> WorkerWorkerTask*) -> Mono/Flux

即,一个Scheduler线程池管理多个原生的ScheduledExecutorService,每个ScheduledExecutorService会派生出多个Worker,一个Mono/Flux流与一个Worker一一对应,每次执行onNext等方法时,都会生成一个WorkerTask来给到Worker执行,每个Worker内部记录了这个流对应的所有任务,当需要dispose时,可以从ScheduledExecutorService中,将对应于这个流的任务都取消。
这种实现方式具备了如下优点:

  1. 复用了原生ScheduledExecutorService线程池,简化了使用线程池底层的复杂性,避免重复造轮子
  2. 每个底层的ScheduledExecutorService都是一个线程且最多一个线程,达到了一个线程与一个工作队列的效果,进而减少了线程间的锁竞争(例如开启偏向锁的话,基本具备无锁化的效果)。
  3. 通过在原有线程池的基础上,增加了一层调度层,有效实现了线程数量的管理,特别是对原生线程池的封装,解决了ScheduledExecutorService原生无界队列的问题。(具体参见后面的BoundedElasticScheduler介绍)
  4. 引入了Worker、WorkerTask这个新的逻辑单元,实现了一对多的映射,作为Worker与Mono/Flux之间的对接层;并具备跟踪能力,当dispose一个Mono/Flux流,只会dispose该流对应的Worker以及WorkerTask。
  5. 自带Metrics指标监控能力以及onHook的钩子能力,使其工程能力更完备(详见后面内容)。

注:无界队列在请求量大的情况下,当线程来不及处理任务时,会导致内存不够引发OOM问题。

调度层

下面我们以BoundedElasticScheduler作为例子来详细分析下。
同Scheduler线程模型一样,BoundedElasticScheduler在调度层引入了maxThreads变量来限制最大的线程数,用busy/idle两个队列来进行线程的调度管理,同时使用一个evictor的后台线程来定期释放idle内的线程。
其调度层使用了BoundedServices来封装:

BoundedServices(BoundedElasticScheduler parent) {
    this.parent = parent;
    this.clock = parent.clock;
    this.busyQueue = new PriorityBlockingQueue<>(parent.maxThreads,
                                                 Comparator.comparingInt(bs -> bs.markCount));
    this.idleQueue = new ConcurrentLinkedDeque<>();
}

可以看到,busy队列采用了按单个线程内的task数量进行了优先级排序,即调度层会优先获取任务数少的线程来执行任务,这样保证了线程执行时的平衡,防止出现饥饿现象。
对于单个工作线程,其实是用BoundedScheduledExecutorService来对ScheduledThreadPoolExecutor的包装,其好处之一是前面提及的,利用一个有且只有一个线程的线程池,能够减少对任务队列task queue的竞争争抢,实现类似无锁化的效果。

无界队列有界化

但是,原生的ScheduledThreadPoolExecutor的任务队列是个无界队列,会引起OOM,不具备工程价值,因此BoundedScheduledExecutorService在此基础上,增加了queueCapacity变量来实现最大队列大小的限制。因此一个BoundedElasticScheduler最大可执行的任务数是maxThreads * queueCapacity
其效果是,在每次执行task时,会先调用一次ensureQueueCapacity方法来确定能否再新增更多任务,其代码如下:

@Override
public synchronized ScheduledFuture<?> schedule(
        Runnable command,
        long delay,
        TimeUnit unit) {
    ensureQueueCapacity(1);
    return super.schedule(command, delay, unit);
}

由此可见,Schedulers包的实现者是尽可能的复用JDK的通用能力,在不改变原生的ScheduledThreadPoolExecutor类任何行为的情况下,通过引入新变量queueCapacity来解决了原生无界队列的问题,这种方式符合设计原则中的开闭原则,能够有效的支持未来JDK的更新而不需要修改代码。

通过上面的分析,我们能知道,boundedElastic既限制了最大线程数量同时也限制了最大任何数,是其他几个Scheduler不具备的能力,因此再使用的时候,推荐优先使用boundElastci这个Scheduler。因为其他的Scheduler,如elasticparallersingle等都不具备这个能力,特别是无界队列的问题。其原因是,因为底层用的都是ScheduledExecutorService,而它底层用的队列是Dequeue,该队列是个无界队列,而那些Scheduler又没在上层对任务数量做控制,因此隐含着无界队列的问题,使用时一定要注意,否则可能会产生OOM。

总结

本文介绍了在Project ReactorSchedulers的相关知识与使用方法,并详细介绍了Scheduler与传统ThreadPoolExecutor的区别,特别是在工程使用上的优化,例如采用无锁化的单线程的线程池,引入了调度层以及无界队列问题的解决等,下一篇文章中会进一步介绍其他工程上的优化,例如引入了metrics以及onHook的能力。