Gradle 提供了一个 API,可以将任务分割成可并行执行的部分。

writing tasks 5

这使得 Gradle 可以充分利用可用资源,更快地完成构建。

Worker API

Worker API 提供了将任务操作的执行分解为离散的工作单元,然后并发异步执行这些工作单元的能力。

Worker API 示例

理解如何使用此 API 的最佳方法是学习将现有自定义任务转换为使用 Worker API 的过程。

  1. 您将首先创建一个自定义任务类,该类为一组可配置的文件生成 MD5 哈希值。

  2. 然后,您将把这个自定义任务转换为使用 Worker API。

  3. 接着,我们将探讨在不同隔离级别下运行任务。

在此过程中,您将了解 Worker API 的基础知识及其提供的功能。

步骤 1. 创建一个自定义任务类

首先,创建一个自定义任务,该任务为一组可配置的文件生成 MD5 哈希值。

在新目录中,创建一个 buildSrc/build.gradle(.kts) 文件

buildSrc/build.gradle.kts
repositories {
    mavenCentral()
}

dependencies {
    implementation("commons-io:commons-io:2.5")
    implementation("commons-codec:commons-codec:1.9") (1)
}
buildSrc/build.gradle
repositories {
    mavenCentral()
}

dependencies {
    implementation 'commons-io:commons-io:2.5'
    implementation 'commons-codec:commons-codec:1.9' (1)
}
1 您的自定义任务类将使用 Apache Commons Codec 来生成 MD5 哈希值。

接下来,在您的 buildSrc/src/main/java 目录中创建一个自定义任务类。您应该将此类命名为 CreateMD5

buildSrc/src/main/java/CreateMD5.java
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FileUtils;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFile;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.SourceTask;
import org.gradle.api.tasks.TaskAction;
import org.gradle.workers.WorkerExecutor;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

abstract public class CreateMD5 extends SourceTask { (1)

    @OutputDirectory
    abstract public DirectoryProperty getDestinationDirectory(); (2)

    @TaskAction
    public void createHashes() {
        for (File sourceFile : getSource().getFiles()) { (3)
            try {
                InputStream stream = new FileInputStream(sourceFile);
                System.out.println("Generating MD5 for " + sourceFile.getName() + "...");
                // Artificially make this task slower.
                Thread.sleep(3000); (4)
                Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");  (5)
                FileUtils.writeStringToFile(md5File.get().getAsFile(), DigestUtils.md5Hex(stream), (String) null);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}
1 SourceTask 是一种方便的类型,用于操作一组源文件的任务。
2 任务输出将放入一个配置好的目录中。
3 任务会遍历所有定义为“源文件”的文件,并为每个文件创建一个 MD5 哈希值。
4 插入人工延迟以模拟对大文件进行哈希(示例文件不会那么大)。
5 每个文件的 MD5 哈希值会写入输出目录中,文件名为原文件名加上“.md5”扩展名。

接下来,创建一个 build.gradle(.kts) 文件,用于注册您的新 CreateMD5 任务

build.gradle.kts
plugins { id("base") } (1)

tasks.register<CreateMD5>("md5") {
    destinationDirectory = project.layout.buildDirectory.dir("md5") (2)
    source(project.layout.projectDirectory.file("src")) (3)
}
build.gradle
plugins { id 'base' } (1)

tasks.register("md5", CreateMD5) {
    destinationDirectory = project.layout.buildDirectory.dir("md5") (2)
    source(project.layout.projectDirectory.file('src')) (3)
}
1 应用 base 插件,这样您就可以使用 clean 任务来移除输出。
2 MD5 哈希文件将写入 build/md5 目录。
3 此任务将为 src 目录中的每个文件生成 MD5 哈希文件。

您需要一些源文件来生成 MD5 哈希值。在 src 目录中创建三个文件

src/einstein.txt
Intellectual growth should commence at birth and cease only at death.
src/feynman.txt
I was born not knowing and have had only a little time to change that here and there.
src/hawking.txt
Intelligence is the ability to adapt to change.

此时,您可以通过运行 ./gradlew md5 来测试您的任务

$ gradle md5

输出应类似于

> Task :md5
Generating MD5 for einstein.txt...
Generating MD5 for feynman.txt...
Generating MD5 for hawking.txt...

BUILD SUCCESSFUL in 9s
3 actionable tasks: 3 executed

build/md5 目录中,您现在应该看到带有 md5 扩展名的对应文件,其中包含来自 src 目录文件的 MD5 哈希值。请注意,该任务运行至少需要 9 秒,因为它一次只对一个文件进行哈希(即三个文件,每个文件大约 3 秒)。

步骤 2. 转换为使用 Worker API

尽管此任务按顺序处理每个文件,但每个文件的处理都独立于其他文件。这项工作可以并行执行,并利用多个处理器。Worker API 在此方面可以提供帮助。

要使用 Worker API,您需要定义一个接口来表示每个工作单元的参数,并该接口必须扩展 org.gradle.workers.WorkParameters

对于生成 MD5 哈希文件,工作单元需要两个参数

  1. 需要进行哈希的文件,以及

  2. 写入哈希值的文件。

无需创建具体实现,因为 Gradle 会在运行时为我们生成一个。

buildSrc/src/main/java/MD5WorkParameters.java
import org.gradle.api.file.RegularFileProperty;
import org.gradle.workers.WorkParameters;

public interface MD5WorkParameters extends WorkParameters {
    RegularFileProperty getSourceFile(); (1)
    RegularFileProperty getMD5File();
}
1 使用 Property 对象来表示源文件和 MD5 哈希文件。

然后,您需要将自定义任务中负责处理每个独立文件的部分重构到一个单独的类中。这个类是您的“工作单元”实现,它应该是一个抽象类,并扩展 org.gradle.workers.WorkAction

buildSrc/src/main/java/GenerateMD5.java
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FileUtils;
import org.gradle.workers.WorkAction;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

public abstract class GenerateMD5 implements WorkAction<MD5WorkParameters> { (1)
    @Override
    public void execute() {
        try {
            File sourceFile = getParameters().getSourceFile().getAsFile().get();
            File md5File = getParameters().getMD5File().getAsFile().get();
            InputStream stream = new FileInputStream(sourceFile);
            System.out.println("Generating MD5 for " + sourceFile.getName() + "...");
            // Artificially make this task slower.
            Thread.sleep(3000);
            FileUtils.writeStringToFile(md5File, DigestUtils.md5Hex(stream), (String) null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
1 不要实现 getParameters() 方法 - Gradle 会在运行时注入此方法。

现在,更改您的自定义任务类,使其向 WorkerExecutor 提交工作,而不是自己执行工作。

buildSrc/src/main/java/CreateMD5.java
import org.gradle.api.Action;
import org.gradle.api.file.RegularFile;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.*;
import org.gradle.workers.*;
import org.gradle.api.file.DirectoryProperty;

import javax.inject.Inject;
import java.io.File;

abstract public class CreateMD5 extends SourceTask {

    @OutputDirectory
    abstract public DirectoryProperty getDestinationDirectory();

    @Inject
    abstract public WorkerExecutor getWorkerExecutor(); (1)

    @TaskAction
    public void createHashes() {
        WorkQueue workQueue = getWorkerExecutor().noIsolation(); (2)

        for (File sourceFile : getSource().getFiles()) {
            Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");
            workQueue.submit(GenerateMD5.class, parameters -> { (3)
                parameters.getSourceFile().set(sourceFile);
                parameters.getMD5File().set(md5File);
            });
        }
    }
}
1 需要 WorkerExecutor 服务才能提交工作。创建一个使用 javax.inject.Inject 注解的抽象 getter 方法,Gradle 会在创建任务时在运行时注入该服务。
2 在提交工作之前,获取一个带有所需隔离模式(如下所述)的 WorkQueue 对象。
3 提交工作单元时,指定工作单元实现,在本例中是 GenerateMD5,并配置其参数。

此时,您应该能够重新运行您的任务

$ gradle clean md5

> Task :md5
Generating MD5 for einstein.txt...
Generating MD5 for feynman.txt...
Generating MD5 for hawking.txt...

BUILD SUCCESSFUL in 3s
3 actionable tasks: 3 executed

结果应该和之前一样,尽管 MD5 哈希文件可能以不同的顺序生成,因为工作单元是并行执行的。然而,这次任务运行速度快得多。这是因为 Worker API 并行执行每个文件的 MD5 计算,而不是按顺序执行。

步骤 3. 更改隔离模式

隔离模式控制 Gradle 将工作项彼此之间以及与 Gradle 运行时其余部分隔离的程度。

WorkerExecutor 上有三个控制此行为的方法

  1. noIsolation()

  2. classLoaderIsolation()

  3. processIsolation()

noIsolation() 模式是最低级别的隔离,它将阻止工作单元更改项目状态。这是最快的隔离模式,因为它设置和执行工作项所需的开销最少。但是,它将为所有工作单元使用一个共享的类加载器。这意味着每个工作单元可以通过静态类状态相互影响。这也意味着每个工作单元都使用构建脚本类路径上相同版本的库。如果您希望用户能够配置任务使用不同(但兼容)版本的 Apache Commons Codec 库运行,则需要使用不同的隔离模式。

首先,您必须将 buildSrc/build.gradle 中的依赖更改为 compileOnly。这告诉 Gradle 在构建类时应该使用此依赖,但不应该将其放入构建脚本类路径中。

buildSrc/build.gradle.kts
repositories {
    mavenCentral()
}

dependencies {
    implementation("commons-io:commons-io:2.5")
    compileOnly("commons-codec:commons-codec:1.9")
}
buildSrc/build.gradle
repositories {
    mavenCentral()
}

dependencies {
    implementation 'commons-io:commons-io:2.5'
    compileOnly 'commons-codec:commons-codec:1.9'
}

接下来,更改 CreateMD5 任务,允许用户配置他们想要使用的 codec 库版本。它会在运行时解析合适的库版本,并配置 worker 使用此版本。

classLoaderIsolation() 方法告诉 Gradle 在一个具有隔离类加载器的线程中运行此工作

buildSrc/src/main/java/CreateMD5.java
import org.gradle.api.Action;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFile;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.*;
import org.gradle.process.JavaForkOptions;
import org.gradle.workers.*;

import javax.inject.Inject;
import java.io.File;
import java.util.Set;

abstract public class CreateMD5 extends SourceTask {

    @InputFiles
    abstract public ConfigurableFileCollection getCodecClasspath(); (1)

    @OutputDirectory
    abstract public DirectoryProperty getDestinationDirectory();

    @Inject
    abstract public WorkerExecutor getWorkerExecutor();

    @TaskAction
    public void createHashes() {
        WorkQueue workQueue = getWorkerExecutor().classLoaderIsolation(workerSpec -> {
            workerSpec.getClasspath().from(getCodecClasspath()); (2)
        });

        for (File sourceFile : getSource().getFiles()) {
            Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");
            workQueue.submit(GenerateMD5.class, parameters -> {
                parameters.getSourceFile().set(sourceFile);
                parameters.getMD5File().set(md5File);
            });
        }
    }
}
1 公开一个用于 codec 库类路径的输入属性。
2 创建工作队列时,在 ClassLoaderWorkerSpec 上配置类路径。

接下来,您需要配置您的构建,使其拥有一个仓库,以便在任务执行时查找 codec 版本。我们还创建一个依赖来从这个仓库解析我们的 codec 库。

build.gradle.kts
plugins { id("base") }

repositories {
    mavenCentral() (1)
}

val codec = configurations.create("codec") { (2)
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
    }
    isVisible = false
    isCanBeConsumed = false
}

dependencies {
    codec("commons-codec:commons-codec:1.10") (3)
}

tasks.register<CreateMD5>("md5") {
    codecClasspath.from(codec) (4)
    destinationDirectory = project.layout.buildDirectory.dir("md5")
    source(project.layout.projectDirectory.file("src"))
}
build.gradle
plugins { id 'base' }

repositories {
    mavenCentral() (1)
}

configurations.create('codec') { (2)
    attributes {
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
    }
    visible = false
    canBeConsumed = false
}

dependencies {
    codec 'commons-codec:commons-codec:1.10' (3)
}

tasks.register('md5', CreateMD5) {
    codecClasspath.from(configurations.codec) (4)
    destinationDirectory = project.layout.buildDirectory.dir('md5')
    source(project.layout.projectDirectory.file('src'))
}
1 添加一个仓库来解析 codec 库 - 这可以是一个不同于用于构建 CreateMD5 任务类的仓库。
2 添加一个 配置 来解析我们的 codec 库版本。
3 配置 Apache Commons Codec 的一个备用且兼容的版本。
4 配置 md5 任务使用该配置作为其类路径。请注意,该配置直到任务执行时才会被解析。

现在,如果您运行您的任务,它应该会按预期工作,使用配置的 codec 库版本

$ gradle clean md5

> Task :md5
Generating MD5 for einstein.txt...
Generating MD5 for feynman.txt...
Generating MD5 for hawking.txt...

BUILD SUCCESSFUL in 3s
3 actionable tasks: 3 executed

步骤 4. 创建 Worker Daemon

有时,在执行工作项时需要使用更高的隔离级别。例如,外部库可能依赖于某些系统属性的设置,这可能导致工作项之间冲突。或者某个库可能与 Gradle 运行的 JDK 版本不兼容,需要使用不同版本的 JDK 运行。

Worker API 可以通过 processIsolation() 方法来实现这一点,该方法会使工作在单独的“worker daemon”中执行。这些 worker 进程是会话范围的,可以在同一构建会话中重用,但不会跨构建持久化。然而,如果系统资源不足,Gradle 将停止未使用的 worker daemon。

要使用 worker daemon,请在创建 WorkQueue 时使用 processIsolation() 方法。您可能还需要为新进程配置自定义设置

buildSrc/src/main/java/CreateMD5.java
import org.gradle.api.Action;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFile;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.*;
import org.gradle.process.JavaForkOptions;
import org.gradle.workers.*;

import javax.inject.Inject;
import java.io.File;
import java.util.Set;

abstract public class CreateMD5 extends SourceTask {

    @InputFiles
    abstract public ConfigurableFileCollection getCodecClasspath(); (1)

    @OutputDirectory
    abstract public DirectoryProperty getDestinationDirectory();

    @Inject
    abstract public WorkerExecutor getWorkerExecutor();

    @TaskAction
    public void createHashes() {
        (1)
        WorkQueue workQueue = getWorkerExecutor().processIsolation(workerSpec -> {
            workerSpec.getClasspath().from(getCodecClasspath());
            workerSpec.forkOptions(options -> {
                options.setMaxHeapSize("64m"); (2)
            });
        });

        for (File sourceFile : getSource().getFiles()) {
            Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");
            workQueue.submit(GenerateMD5.class, parameters -> {
                parameters.getSourceFile().set(sourceFile);
                parameters.getMD5File().set(md5File);
            });
        }
    }
}
1 将隔离模式更改为 PROCESS
2 为新进程设置 JavaForkOptions

现在,您应该能够运行您的任务,它会按预期工作,但使用 worker daemon 而不是其他方式

$ gradle clean md5

> Task :md5
Generating MD5 for einstein.txt...
Generating MD5 for feynman.txt...
Generating MD5 for hawking.txt...

BUILD SUCCESSFUL in 3s
3 actionable tasks: 3 executed

请注意,执行时间可能会很长。这是因为 Gradle 需要为每个 worker daemon 启动一个新进程,这开销很大。

然而,如果您第二次运行任务,您会发现它运行得快得多。这是因为在初始构建期间启动的 worker daemon 已持久化,并在后续构建期间可立即使用

$ gradle clean md5

> Task :md5
Generating MD5 for einstein.txt...
Generating MD5 for feynman.txt...
Generating MD5 for hawking.txt...

BUILD SUCCESSFUL in 1s
3 actionable tasks: 3 executed

隔离模式

Gradle 提供了三种隔离模式,可以在创建 WorkQueue 时进行配置,并通过 WorkerExecutor 上的以下方法之一指定

WorkerExecutor.noIsolation()

这表示工作应该在最小隔离的线程中运行。
例如,它将共享加载任务的类加载器。这是最快的隔离级别。

WorkerExecutor.classLoaderIsolation()

这表示工作应该在具有隔离类加载器的线程中运行。
该类加载器将包含加载工作单元实现类的类加载器的类路径,以及通过 ClassLoaderWorkerSpec.getClasspath() 添加的任何额外类路径条目。

WorkerExecutor.processIsolation()

这表示工作应该以最高隔离级别运行,即在一个单独的进程中执行工作。
该进程的类加载器将使用加载工作单元的类加载器的类路径,以及通过 ClassLoaderWorkerSpec.getClasspath() 添加的任何额外类路径条目。此外,该进程将是一个 worker daemon,它将保持活动状态,并可用于具有相同要求的未来工作项。可以使用 ProcessWorkerSpec.forkOptions(org.gradle.api.Action) 配置此进程与 Gradle JVM 不同的设置。

Worker Daemon

使用 processIsolation() 时,Gradle 将启动一个长生命周期的 worker daemon 进程,该进程可用于未来的工作项。

build.gradle.kts
// Create a WorkQueue with process isolation
val workQueue = workerExecutor.processIsolation() {
    // Configure the options for the forked process
    forkOptions {
        maxHeapSize = "512m"
        systemProperty("org.gradle.sample.showFileSize", "true")
    }
}

// Create and submit a unit of work for each file
source.forEach { file ->
    workQueue.submit(ReverseFile::class) {
        fileToReverse = file
        destinationDir = outputDir
    }
}
build.gradle
// Create a WorkQueue with process isolation
WorkQueue workQueue = workerExecutor.processIsolation() { ProcessWorkerSpec spec ->
    // Configure the options for the forked process
    forkOptions { JavaForkOptions options ->
        options.maxHeapSize = "512m"
        options.systemProperty "org.gradle.sample.showFileSize", "true"
    }
}

// Create and submit a unit of work for each file
source.each { file ->
    workQueue.submit(ReverseFile.class) { ReverseParameters parameters ->
        parameters.fileToReverse = file
        parameters.destinationDir = outputDir
    }
}

当提交 worker daemon 的工作单元时,Gradle 会首先查找是否存在兼容且空闲的 daemon。如果存在,它会将工作单元发送到空闲的 daemon,并将其标记为忙碌。如果不存在,则会启动一个新的 daemon。在评估兼容性时,Gradle 会考虑多个标准,所有这些标准都可以通过 ProcessWorkerSpec.forkOptions(org.gradle.api.Action) 进行控制。

默认情况下,worker daemon 以 512MB 的最大堆内存启动。可以通过调整 worker 的 fork 选项来更改此设置。

可执行文件

仅当 daemon 使用相同的 Java 可执行文件时才被视为兼容。

类路径

如果 daemon 的类路径包含所有请求的类路径条目,则被视为兼容。
请注意,仅当类路径与请求的类路径完全匹配时,daemon 才被视为兼容。

堆设置

如果 daemon 的堆大小设置至少与请求的一致,则被视为兼容。
换句话说,堆设置高于请求的 daemon 也被视为兼容。

JVM 参数

如果 daemon 设置了所有请求的 JVM 参数,则兼容。
请注意,如果 daemon 拥有比请求的额外 JVM 参数(除了特殊处理的参数,例如堆设置、断言、调试等),则兼容。

系统属性

如果 daemon 设置了所有请求的具有相同值的系统属性,则被视为兼容。
请注意,如果 daemon 拥有比请求的额外系统属性,则兼容。

环境变量

如果 daemon 设置了所有请求的具有相同值的环境变量,则被视为兼容。
请注意,如果 daemon 拥有比请求的更多环境变量,则兼容。

Bootstrap 类路径

如果 daemon 包含所有请求的 bootstrap 类路径条目,则被视为兼容。
请注意,如果 daemon 拥有比请求的更多 bootstrap 类路径条目,则兼容。

调试

仅当调试设置为与请求的值相同(truefalse)时,daemon 才被视为兼容。

启用断言

仅当启用断言设置为与请求的值相同(truefalse)时,daemon 才被视为兼容。

默认字符编码

仅当默认字符编码设置为与请求的值相同时,daemon 才被视为兼容。

Worker daemon 将一直运行,直到启动它们的构建 daemon 停止或系统内存变得不足。当系统内存较低时,Gradle 将停止 worker daemon 以最大程度地减少内存消耗。

关于将普通任务操作转换为使用 worker API 的分步说明可以在 开发并行任务 一节中找到。

取消和超时

为了支持取消(例如,用户使用 CTRL+C 停止构建)和任务超时,自定义任务应对其执行线程的中断做出响应。通过 worker API 提交的工作项也是如此。如果任务在 10 秒内未对中断做出响应,daemon 将关闭以释放系统资源。