Gradle深入解析 - Task原理(并行篇)

535

上一篇文章讲了Task Graph的处理,在Task的顺序确定之后,真正被执行前,还涉及到Task的并行调度问题,我们知道gradle是有并行机制的,没有依赖关系的Task可以并行执行,以减少构建耗时
除了线程的并行外,gradle甚至提供了进程级别的并行
下面我们来探究一下gradle是如何保障并行的安全的

Task的并行可以分为2个方面来看

  1. gradle控制的整体的Task的并行
  2. Task本身逻辑拆分为多个并行执行

整体Task的并行

先举一个类比,看完这个就能轻松理解gradle Task整体的并行逻辑

有一个工头承包了一个项目,找了一个工程队来帮忙干活
工头先制定好工作计划表,把任务分为3栏,一个是待准备的,一个是准备好了的,还有一个是正常处理中的
老板给的预算有限,不能让工程队所有人都上,只能4个人同时干,包括工头自己也要干活
于是准备了4份租约,自己签一份,从工程队招募了3个工人,都进行签约,必须通过租约来领取任务
还设有一个任务处和一个仓库
任务处负责派发准备好的任务,仓库负责工具的借出、归还
任务处对着计划表看准备好了的那一栏有没有活儿,但不是任务准备好了就能直接开始的,这里准备好只是任务的前置任务完成了,但是任务能直接开始还有其他前提条件,比如有的任务需要工具,完成任务所需的工具仓库这可能只有一件,也可能有多件,如果这个任务的工具只有一件还被借出了,那也没法开始,所以任务处还得先问下仓库,如果都OK了才能派发这个任务
工头负责整个工程,要check工作计划表上的任务都完成了,任务全部完成后还要给其他工人解约

再来一张图对整体概念有个了解

工程队就是ExecutorService
工人就是Worker,其核心就是ExecutorService提供的Thread
最多多少人能同时干活看CPU当时最大可用核心数,默认最大同时运行worker数量是通过Runtime.getRuntime().availableProcessors()获取的,当然也可以通过参数配置手动指定
仓库就是WorkSource
任务处就是CoordinationService

gradle在制定好执行计划后,Task的执行是由PlanExecutor来处理的

gradle使用的是Executors.newCachedThreadPool()创建的worker线程池,其创建的线程名称会以Execution worker作为前缀

按 (最大可使用核心数量 - 1) 创建worker,因为自己本身线程也会充当worker运行任务,并且需要承担其他worker完成任务后的主导工作,可以认为是主线程

创建完后,每个worker都会进入死循环,从任务队列获取任务执行,直到任务队列为空。
下一个任务是通过PlanselectNext方法获取到的,获取下一个任务是需要先获取到锁的,锁的控制是通过ResourceLockCoordinationService来调度的

CoordinationService

CoordinationService主要是用来负责协调worker间资源获取的,核心方法为

stateLockAction表示在这几种状态间变化,类似状态机的控制逻辑。stateLockAction是synchronized同步的,通过state lock控制同时只有一个worker能够执行,worker在这里去获取所需的所有资源锁,通过其返回值ResourceLockState.Disposition将获取锁的结果告知给CoordinationService
ResourceLockState.Disposition的值代表获取资源锁的结果,有3种类型

  • FINISHED
    所有需要的锁都获取到了,可以释放state lock
  • FAILED
    有需要的锁没有获取到,需要回滚到之前的状态,释放所有已获取到的锁,并释放state lock
  • RETRY
    有需要的锁没有获取到,需要回滚到之前的状态,释放所有已获取到的锁,并阻塞等待state改变,就是调用lock.wait,自身进入block状态,释放state lock,等待其他线程执行完然后notify自己重新执行。这种状态的发生,可能是由于worker数量达到上限了或者任务的前置条件没有完成等

stateLockAction中首先通过WorkerLeaseService来获取锁,类似上面类比例子中提到的签订租约,只有签订成功了才能找WorkSource领取任务,如果租约数量达到上限了就没法签,那会返回RETRY,进入block状态等待有人解约

WorkerLease
WorkerLeaseService是提供WorkerLease的服务,锁的获取是由它负责的,lock和unlock类似签约解约过程
这里是判断获取lockworker数量是否超过了最大可允许的parallel数量,没超过才能签约

WorkSource

WorkSource类似于一个仓库,它将任务划分在3个集合中

  • waitingToStartNodes 等待中队列,位于这里的任务,其依赖的前置task有可能还未完成,如果完成了会将它移入到readyNodes
  • readyNodes readyNodes表示有任务ready了,其依赖的task都完成了,但并非可以立即开始,还受限于其他的锁的情况,下面会具体阐述
  • runningNodes 处于运行中的任务

根据这3个集合情况,WorkSource会有3种状态

  • MaybeWorkReadyToStart 可能有work已经ready可以开始工作
    waitingToStartNodes或者readyNodes不为空时,代表着这个状态
  • NoMoreWorkToStart 如果waitingToStartNodes为空,表示没有任务需要执行了,任务全部派发出去了。如果runningNodes此时也是空的,那表示任务全部完成了
  • NoWorkReadyToStart waitingToStartNodes不为空,但是readyNodes为空时,表示还有任务等待执行,但没有任务ready,造成这种情况的原因可能是waitingToStartNodes的task依赖的task还没有完成,只有等依赖的task完成后才会被加入到readyNodes

ReadyNodes

WorkSource就是不断的从readyNodes中拿出任务,交给worker去处理的 上面提到过了readyNodes中拿出的任务不一定能够立刻开始执行,下面列举2种常见的限制

  1. project间的并行

实际上gradle执行task默认是按序一个一个执行的,不会并行执行
但是如果设置了允许parallel,而且是多project项目时,就可以有同时运行task的可能了
每个project都会对应有一把锁,锁保存在一起,通过project的路径区分
开启了parallel的话,project对应的锁用的是自身project的路径的,没有开启的情况,用的是root project的路径
也就是说开启了parallel的情况下,project间是可以并行运行的,但是每个project内的task还是按序一个一个执行
没有开启parallel的情况,所有的task都一个一个执行,不管它是来自哪个project的
可以认为开启parallel的时候,每个project都有独立的管道,没有开启的时候共用一条管道

关于parallel,使用worker api时是特殊情况,下面讲到的时候会说

  1. 输出为同一个文件

如果一个task有output或者local state(task用来保存自己的缓存的文件目录,像kotlin处理自己的增量编译时有用到)相关注解的属性,那么它属于Producer,表示task会有输出产生
之前提到过Task是被封装在LocalTaskNode中的,LocalTaskNode的执行比较特殊,WorkSource遇到这种Node,先会给任务计划表中插入一个对应的ResolveMutationNode,去resolve LocalTaskNodemutation,这个mutation又是什么呢

mutation的意思是变动,这里指task是否会对外产生影响,主要指是否有生成文件,删除文件等,mutation包含有outputs文件路径,localstate,是否有input files等信息。在Task Graph篇中提到过inputs/outputs分析,这里是同样的方式,用Visitor去访问inputs/outputs的属性将mutation信息提取出来

mutation提取出的输出文件路径信息会和LocalTaskNode关联起来保存在ExecutionNodeAccessHierarchy

有相同输出路径的Producer不允许同时执行,这一点可以通过路径从ExecutionNodeAccessHierarchy查找是否有对应LocalTaskNode正在执行判断出来
如果正在执行的task有outputs是同样文件,或者其文件目录包含了想要运行的task的输出文件的话,gradle是不会立即执行的,需要运行中的task完成任务释放锁之后才能执行

这里只是描述了一下整体的任务执行派发的流程,还有很多细节其实没有涉及的,例如任务被取消的处理,使用了--continue忽略失败的任务继续进行的情况等等

Task内并行

如果想要在Task内异步执行逻辑,其实会有很多问题
比如在Task内手动启动线程,线程内的逻辑异步执行的时候,gradle会认为Task已经执行完了,后续依赖于此的Task就有可能直接执行,或者异步逻辑还未执行完,整体Task的执行流程已经结束,异步逻辑的执行结果无法得到保障

官方提供了异步机制Worker API来处理这些问题

Worker API

还是先来一张图对整体概念有个感性认知

我们从一个简单的例子来看看如何使用Worker API

Worker API使用

要使用Worker API,需要先定义WorkParametersWorkAction,下面我们定义了一个CustomParameters,它只有一个参数indexCustomAction只是简单的打印了一下index和所在线程信息

interface CustomParameters extends WorkParameters {  
    Property<Integer> getIndex()  
}  
  
abstract class CustomAction implements WorkAction<CustomParameters> {  
    @Override  
    void execute() {  
        println "CustomAction: ${parameters.index.get()} in thread: ${Thread.currentThread()}"
    }
}

然后是在Task中使用WorkAction

abstract class CustomTask extends DefaultTask {  
  
    @Inject  
    abstract WorkerExecutor getWorkerExecutor()  
  
    @TaskAction  
    void action() {  
        WorkQueue workQueue = workerExecutor.noIsolation()  
        6.times {  
            workQueue.submit(CustomAction.class, { parameters ->  
                parameters.index = it  
            })  
        }  
    }  
}

首先我们需要一个WorkerExecutor,它是由gradle通过依赖注入给我们的,我们也无法自己初始化它,所以需要给它注解上@javax.inject.Inject

调用workerExecutor.noIsolation()获取到WorkQueue,在获取到WorkQueue后,调用它的submit方法,传入WorkAction的class对象和WorkParameters的初始化action就完成了
(noIsolation先按下不表,后面会进行说明)

这里我们简单的提交了6个WorkAction,来看看执行结果

CustomAction: 2 in thread: Thread[WorkerExecutor Queue Thread 3,5,main]
CustomAction: 0 in thread: Thread[WorkerExecutor Queue,5,main]
CustomAction: 1 in thread: Thread[WorkerExecutor Queue Thread 2,5,main]
CustomAction: 3 in thread: Thread[WorkerExecutor Queue Thread 4,5,main]
CustomAction: 5 in thread: Thread[WorkerExecutor Queue,5,main]
CustomAction: 4 in thread: Thread[WorkerExecutor Queue Thread 3,5,main]

因为是异步执行,WorkAction执行的先后顺序并不确定,上面是一种可能的输出情况

从上面的例子可以看出Worker API有3个重要的类

WorkAction
WorkQueue
WorkerExecutor

我们通过调用submitWorkAction添加到WorkQueue中,然后WorkerExecutor从中取出WorkAction进行执行,具体安排在哪个线程执行也是WorkerExecutor来负责处理

这里的线程数量也会受到parallel配置org.gradle.workers.max的影响,和整体Task间并行使用的线程数量加起来不能超过这个限制

异步任务执行保障

submit之后WorkAction就开始被安排在其他线程执行了,Task action的也就到此结束了,但是如果不想action就此退出的话,可以submit之后调用workQueue.await,它会让当前线程等待所有WorkAction完成任务

await的使用不是必须的,即使没有主动调用,gradle也能主动等待所有WorkAction的完成
这是通过AsyncWorkTracker来实现的
顾名思义,AsyncWorkTracker是用来追踪所有异步任务的,在action执行完后,AsyncWorkTracker会wait等待所有WorkAction的完成,在其wait期间会释放project锁等资源,这样就让后续的Task也有并行执行的机会了

但是主动调用await发生在Task action的内部,在AsyncWorkTracker之前,而await是不会释放锁的,所以会block后续Task的执行

总结一下就是

  1. 如果不使用await,那后续的Task不会被block,可以做到parallel
  2. 如果使用了await,那会等所有work action完成后才执行下一个Task
  3. 不论有没有使用await,gradle都会保证WorkAction异步任务的执行,gradle不会先于异步任务结束而结束,并且依赖它的Task不会在其异步任务结束前就开始执行

使用Worker API可以享受到2个好处

  • 并行执行Task本身的逻辑
  • 可以让后续任务parallel起来

Isolation

上面我们的例子中获取WorkQueue时使用的是workerExecutor.noIsolation(),这个noIsolation其实是一种隔离模式

Worker API有3种隔离模式

noIsolation classLoaderIsolation processIsolation

noIsolation表示没有任何隔离措施

classLoaderIsolation是classloader级别隔离
这种情况通常发生于编译时用到的java版本和执行gradle的不同时
比如编译用的是java 8,而执行gradle用的是java 11,如果不进行classloader隔离,就会用java 11去编译代码,会有导致代码兼容性问题发生的可能

processIsolation进程间隔离,是级别最高的隔离方式,它会启动后台Daemon进程来执行WorkAction

Daemon Process

我们来看看processIsolation是如何调度Daemon进程的

使用这种隔离方式,在上面的线程框架基础上,WorkExecutor的子线程和进程进行通信来完成WorkAction

首先它们会去缓存中查看是否有闲置的进程,有的话就复用,没有的话就重新启动一个进程,复用需要forkOptions一致,只能获取到以相同forkOptions启动的进程,forkOptions是在获取WorkQueue的时候设置的,它可以用来配置heapSize、环境变量等

启动进程是使用的ProcessBuilder.start的方式,同时执行命令 java GradleWorkerMain(简化后的,实际还有很多classpath,参数等等),就是通过java来执行GradleWorkerMain

GradleWorkerMain就是Daemon进程的执行入口了,它和主进程通过socket通信

主进程负责将WorkAction的参数,WorkAction的类型信息等数据组织好,进行序列化传输给Daemon进程

Daemon进程从InputStream读取主进程发过去的指令,将数据反序列化出来,反射实例化Work Action进行执行,然后将执行结果返回给主进程

虽然是在独立的进程执行,在异步任务执行保障部分说的规则同样是适用的

参考资料

Developing Parallel Tasks using the Worker API
Developing Custom Gradle Task Types