上一篇文章讲了Task Graph的处理,在Task的顺序确定之后,真正被执行前,还涉及到Task的并行调度问题,我们知道gradle是有并行机制的,没有依赖关系的Task可以并行执行,以减少构建耗时
除了线程的并行外,gradle甚至提供了进程级别的并行
下面我们来探究一下gradle是如何保障并行的安全的
作者:近地小行星 链接:https://juejin.cn/post/7241492186919354405
Task的并行可以分为2个方面来看
gradle控制的整体的Task的并行
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都会进入死循环,从任务队列获取任务执行,直到任务队列为空。
下一个任务是通过Plan
的selectNext
方法获取到的,获取下一个任务是需要先获取到锁的,锁的控制是通过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状态等待有人解约
WorkerLeaseWorkerLeaseService
是提供WorkerLease
的服务,锁的获取是由它负责的,lock和unlock类似签约解约过程
这里是判断获取lock
的worker
数量是否超过了最大可允许的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种常见的限制
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时是特殊情况,下面讲到的时候会说
输出为同一个文件
如果一个task有output
或者local state
(task用来保存自己的缓存的文件目录,像kotlin处理自己的增量编译时有用到)相关注解的属性,那么它属于Producer
,表示task会有输出产生
之前提到过Task是被封装在LocalTaskNode
中的,LocalTaskNode
的执行比较特殊,WorkSource
遇到这种Node
,先会给任务计划表中插入一个对应的ResolveMutationNode
,去resolve LocalTaskNode
的mutation
,这个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,需要先定义WorkParameters
和WorkAction
,下面我们定义了一个CustomParameters
,它只有一个参数index
,CustomAction
只是简单的打印了一下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
我们通过调用submit
将WorkAction
添加到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的执行
总结一下就是
如果不使用
await
,那后续的Task不会被block,可以做到parallel
如果使用了
await
,那会等所有work action完成后才执行下一个Task不论有没有使用
await
,gradle都会保证WorkAction
异步任务的执行,gradle不会先于异步任务结束而结束,并且依赖它的Task不会在其异步任务结束前就开始执行
使用Worker API可以享受到2个好处
并行执行Task本身的逻辑
可以让后续任务parallel起来
Isolation
上面我们的例子中获取WorkQueue
时使用的是workerExecutor.noIsolation()
,这个noIsolation
其实是一种隔离模式
Worker API有3种隔离模式
noIsolationclassLoaderIsolationprocessIsolation
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
进行执行,然后将执行结果返回给主进程
虽然是在独立的进程执行,在异步任务执行保障部分说的规则同样是适用的
关注我获取更多知识或者投稿