上一篇文章讲了Task Graph的处理,在Task的顺序确定之后,真正被执行前,还涉及到Task的并行调度问题,我们知道gradle是有并行机制的,没有依赖关系的Task可以并行执行,以减少构建耗时
除了线程的并行外,gradle甚至提供了进程级别的并行
下面我们来探究一下gradle是如何保障并行的安全的
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种隔离模式:
noIsolation classLoaderIsolation processIsolation
1. noIsolation表示没有任何隔离措施。
2. classLoaderIsolation是classloader级别隔离。
这种情况通常发生于编译时用到的java版本和执行gradle的不同时。
比如编译用的是java 8,而执行gradle用的是java 11,如果不进行classloader隔离,就会用java 11去编译代码,会有导致代码兼容性问题发生的可能。
3. 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
作者:近地小行星
链接:https://juejin.cn/post/7241492186919354405
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。