更新 MSBuild 任务以在多线程模式下工作

MSBuild 18.6 引入了在同一进程中并行生成的功能。 若要选择加入此模式,请传递 -mt 命令行开关。 以前版本的 MSBuild 支持并行生成,但生成是在单独的进程中完成的。 此更改对创作任务的方式有一些影响。 而以前,任务将在单独的进程中运行,现在所有启用了多线程的任务都在同一进程中运行。 虽然大多数逻辑不需要更改,但需要更仔细地处理一些进程级构造。 进程级构造包括当前工作目录、环境变量和进程启动信息(ProcessStartInfo)。

为了支持这些更改,MSBuild 18.6 引入了 IMultiThreadableTask 接口(Microsoft.Build.Framework)和 TaskEnvironment 类。 TaskEnvironment ProjectDirectory包括属性和方法,例如GetAbsolutePath()GetEnvironmentVariable()SetEnvironmentVariable()GetProcessStartInfo()

重要

多线程模式当前可用作实验性功能;目前不建议将其用于生产用途。 更新 MSBuild 库依赖项以使用多线程模式 API 隐式阻止库在较旧版本的 Visual Studio 和 MSBuild 上运行。 我们鼓励早期采用者尝试多线程模式,并提供反馈。 在 MSBuild GitHub 存储库提交问题。

IMultiThreadableTask 接口定义了可在多线程生成过程中于进程内运行的任务所需遵循的约定:

// Microsoft.Build.Framework
public interface IMultiThreadableTask : ITask
{
    TaskEnvironment TaskEnvironment { get; set; }
}

若要迁移任务,请与现有IMultiThreadableTask基类一起实现Task并公开TaskEnvironment属性:

public class MyTask : Task, IMultiThreadableTask
{
    // Initialize to Fallback so the task works safely outside the MSBuild engine (for example, in unit tests).
    public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
    // ...
}

实现 IMultiThreadableTask 的任务可以在进程内运行。 所有这些任务还必须携带 [MSBuildMultiThreadableTask] 属性,这是 MSBuild 用于选择任务进入进程内执行的标记。 在添加属性之前,请确认任务对进程级构造(如当前工作目录或环境)没有任何依赖关系,并且其代码是线程安全的。 特别注意确保对静态变量的线程安全访问,因为这些变量在所有任务实例之间共享,并且可由同时在同一进程中运行的不同任务实例进行访问或修改。

示例任务:BuildCommentTask

本文中使用了以下示例 AddBuildCommentTask 来说明迁移过程。 此任务会在文本文件开头添加构建注释。 默认情况下,它会写入纯文本;可选的 CommentPrefixCommentSuffix 属性允许调用方用适合相应语言的语法将注释包裹起来(例如,C# 使用 //,XML 使用 <!---->,Python 或 YAML 使用 #):

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using RequiredAttribute = Microsoft.Build.Framework.RequiredAttribute;

namespace BuildCommentTask
{
    public class AddBuildCommentTask : Microsoft.Build.Utilities.Task
    {
        private static int ModifiedFileCount = 0;

        // Callers are responsible for passing only text files in TargetFiles,
        // and for setting CommentPrefix/CommentSuffix to match the file type.
        [Required]
        public ITaskItem[] TargetFiles { get; set; }

        [Required]
        public string VersionNumber { get; set; }

        // Optional CommentPrefix and CommentSuffix wrap the comment in
        // language-appropriate syntax, e.g., "// " for C# or "# " for Python.
        // Include any desired spacing in the prefix or suffix value.
        public string CommentPrefix { get; set; } = "";
        public string CommentSuffix { get; set; } = "";

        public override bool Execute()
        {
            string disableComments = Environment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
            if (!string.IsNullOrEmpty(disableComments))
            {
                Log.LogMessage(MessageImportance.Normal, "Build comments disabled via environment variable.");
                return true;
            }

            string buildDate = DateTime.UtcNow.ToString("yyyy-MM-dd");
            string commentPattern = $@"^{Regex.Escape(CommentPrefix)}\s*Build Date:.*Version:.*{Regex.Escape(CommentSuffix)}$";

            foreach (var item in TargetFiles)
            {
                var filePath = item.ItemSpec;
                try
                {
                    string[] originalLines = File.ReadAllLines(filePath);

                    if (originalLines.Length > 0 && Regex.IsMatch(originalLines[0], commentPattern))
                    {
                        Log.LogMessage(MessageImportance.Low, $"Skipped (already annotated): {filePath}");
                        continue;
                    }

                    ModifiedFileCount++;
                    string comment = $"{CommentPrefix}Build Date: {buildDate}, Version: {VersionNumber}, File #: {ModifiedFileCount}{CommentSuffix}";
                    // Note: rewriting a file in place like this is convenient for a sample but is not
                    // recommended in production tasks. Prefer writing to a separate output file instead.
                    File.WriteAllLines(filePath, new[] { comment }.Concat(originalLines));
                    Log.LogMessage(MessageImportance.High, $"Added build comment to: {filePath}");
                }
                catch (Exception ex)
                {
                    Log.LogError($"Failed to process {filePath}: {ex.Message}");
                    return false;
                }
            }
            return true;
        }
    }
}

项目文件可能会针对不同的文件类型调用此任务,为每个文件类型传递相应的注释语法:

<!-- Stamp generated text files with plain text (no comment prefix) -->
<AddBuildCommentTask
    TargetFiles="@(GeneratedFiles)"
    VersionNumber="$(Version)" />

<!-- Stamp C# source files with // comments -->
<AddBuildCommentTask
    TargetFiles="@(Compile)"
    VersionNumber="$(Version)"
    CommentPrefix="// " />

<!-- Stamp XML content files with <!-- --> comments -->
<AddBuildCommentTask
    TargetFiles="@(Content -> WithMetadataValue('Extension', '.xml'))"
    VersionNumber="$(Version)"
    CommentPrefix="&lt;!-- "
    CommentSuffix=" --&gt;" />

此任务有四个线程安全问题,需要针对多线程生成解决:

  1. 相对路径File.ReadAllLinesFile.WriteAllLines 直接使用 item.ItemSpec,而这可能是相对路径。 在多线程模式下,进程工作目录不保证为项目目录。
  2. 静态字段ModifiedFileCount 是一个由所有实例共享的 static 字段,因此在多个构建并发运行时会导致数据争用。
  3. 环境变量:在多线程构建中,最常见的环境变量问题是某些任务在生成子进程之前设置环境变量,并期望子进程继承这些变量。 在多线程模式下,Environment.SetEnvironmentVariable() 会修改所有并发构建共享的进程级环境,因此,原本针对某个项目子进程的更改可能会影响到另一个项目的子进程。 直接在任务代码(Environment.GetEnvironmentVariable())中读取环境变量通常是一种不良做法:MSBuild 属性是更好的替代方法,因为它们是记录和可跟踪的。

重要

多线程生成模式目前仅适用于 CLI (dotnet buildMSBuild.exe) 生成。 Visual Studio MSBuild 生成尚不支持进程内多线程执行。 在 Visual Studio 中,所有任务执行都会继续在进程外运行。 计划在未来版本中提供 Visual Studio 集成。

Prerequisites

  • MSBuild 18.6 或更高版本。

  • 使用 -mt 命令行开关启用多线程任务执行:

    dotnet build -mt
    

    有关 -mt 开关的详细信息,请参阅 MSBuild 命令行参考

计划迁移

检查您的任务代码是否存在以下问题:

  1. 检查任务代码,并识别其中是否使用了相对路径。 检查所有输入和文件输入/输出。
  2. 检查是否使用了任何环境变量。
  3. 检查是否有任何 ProcessStartInfo API 使用情况。
  4. 检查任何静态字段或数据结构,并使用标准方法使其线程安全。
  5. 如果上述任何内容均不适用,请考虑仅添加属性。
  6. 考虑支持早期版本的 MSBuild 的特别要求。 请参阅 MSBuild 的早期版本支持

API 替换快速参考

下表总结了应替换的.NET API 及其TaskEnvironment等效项:

应避免使用的 .NET API Level 替换
Path.GetFullPath(path) 错误 请参阅此表后面的说明
File.* 具有相对路径 错误 先使用 TaskEnvironment.GetAbsolutePath() 进行解析
Directory.* 具有相对路径 错误 先使用 TaskEnvironment.GetAbsolutePath() 解析
Environment.GetEnvironmentVariable() 错误 TaskEnvironment.GetEnvironmentVariable()
Environment.SetEnvironmentVariable() 错误 TaskEnvironment.SetEnvironmentVariable()
Environment.CurrentDirectory 错误 TaskEnvironment.ProjectDirectory
new ProcessStartInfo() 错误 TaskEnvironment.GetProcessStartInfo()
Process.Start() 错误 使用 ToolTaskTaskEnvironment.GetProcessStartInfo()
静态字段 警告 使用实例字段或线程安全集合

注释

Path.GetFullPath(path)完成两项操作:它将相对路径转换为绝对路径,并生成路径的规范形式(解析 ... 段)。 需要单独处理这些操作:

  • 仅限绝对路径:使用 TaskEnvironment.GetAbsolutePath(path)。 此方法足以用于将路径直接传递到.NET API 的大多数文件 I/O 操作。
  • 规范路径:如果你依赖于规范形式(例如,使用路径作为缓存或字典键时),则用于 Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path)) 获取完全解析的规范绝对路径。

使用属性标记任务

参与多线程生成的所有任务都必须使用 [MSBuildMultiThreadableTask] 属性进行标记。 此属性是 MSBuild 用来标识安全运行进程内的任务的信号。

[MSBuildMultiThreadableTask]
public class MyTask : Task
{
    public override bool Execute()
    {
        // Task logic that doesn't depend on process-level state
        return true;
    }
}

如果你的任务本身已经是线程安全的,并且不使用任何进程级 API(当前工作目录、环境变量、ProcessStartInfo),那么仅该属性即可。 该任务将继续继承自 Task (或 ToolTask), 没有任何其他更改。

如果任务确实需要替换进程级 API 调用(例如,若要安全地解析相对路径或读取环境变量),也实现 IMultiThreadableTask。 此接口使您的任务能够访问 TaskEnvironment 属性。 在这两种情况下,该属性仍是必需的;IMultiThreadableTask 是用于解锁 TaskEnvironment API 的额外步骤。

注释

MSBuild 仅按命名空间和名称检测 MSBuildMultiThreadableTaskAttribute ,忽略定义程序集。 这意味着可以在自己的代码中自行定义属性(请参阅 MSBuild 的早期版本),MSBuild 仍可识别它。

注释

MSBuildMultiThreadableTaskAttribute 是不可继承的(Inherited = false)。 每个任务类都必须显式声明该属性,使其被视为可多线程。 从具有特性的类继承不会自动使派生类多线程化。

将 TaskEnvironment 初始化为后备模式

实现 IMultiThreadableTask时,将 TaskEnvironment 属性初始化为 TaskEnvironment.Fallback

public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

MSBuild 在正常生成中调用 Execute() 之前设置此属性。 Fallback 默认值可确保任务在其他托管场景中(例如单元测试或自定义构建流程编排工具)正常工作,因为在这些场景中,MSBuild 不存在,因此无法设置该属性。 如果没有它,在引擎外部访问 TaskEnvironment 将引发空引用异常。

如果需要支持早于 18.6 且不包含 TaskEnvironment.Fallback 的 MSBuild 版本,请改为将该属性初始化为 null,并对任何 TaskEnvironment 调用先进行 null 检查。 有关更多选项,请参阅支持旧版 MSBuild

更新路径和文件输入/输出

任务通常接受输入,例如 MSBuild 中的项列表(如果它们是文件),可能采用相对路径的形式。

相对路径始终相对于进程的当前工作目录,但由于任务现在正在进程内执行,因此工作目录可能与任务在其自己的进程中运行时不同。 此类路径是相对于项目目录的。 TaskEnvironment 包含一个 ProjectDirectory 属性和一个 GetAbsolutePath() 方法,你可以使用它们将相对路径解析为绝对路径。 您还可以访问 FullPath 元数据项;无需使用 ItemSpec 相对路径,然后再将其转换为绝对路径。

AbsolutePath 类型

AbsolutePath是表示已验证的绝对文件路径的 Microsoft.Build.Framework中的只读结构。 关键成员包括:

public readonly struct AbsolutePath : IEquatable<AbsolutePath>
{
    public string Value { get; }
    public string OriginalValue { get; }
    public AbsolutePath(string path);  // Validates Path.IsPathRooted
    public AbsolutePath(string path, AbsolutePath basePath);
    public static implicit operator string(AbsolutePath path);
}

AbsolutePath 构造函数会验证所提供的路径是否为根路径。 还可以通过提供相对路径和基路径来构造一个 AbsolutePath。 到 string 的隐式转换意味着,你可以将 AbsolutePath 直接传递给任何需要 string 路径的 API。

OriginalValue 属性保留原始路径字符串,因为它在解析之前传入。 如果需要在任务输出或日志消息中保留相对路径,此属性非常有用。 例如,某个任务如果会记录它处理过哪些文件,可能会在其日志消息中使用 OriginalValue,以便输出中的路径保持为相对路径并且便于阅读;而在实际的文件 I/O 操作中,仍会使用已解析后的 Value(或隐式的 string 转换)。

使用 TaskEnvironment.GetAbsolutePath() 来解析项路径:

之前:

var filePath = item.ItemSpec;
string[] originalLines = File.ReadAllLines(filePath);
// Note: rewriting a file in place like this is convenient for a sample but is not
// recommended in production tasks. Prefer writing to a separate output file instead.
File.WriteAllLines(filePath, new[] { comment }.Concat(originalLines));

之后:

AbsolutePath filePath = TaskEnvironment.GetAbsolutePath(item.ItemSpec);
string[] originalLines = File.ReadAllLines(filePath);  // AbsolutePath converts to string implicitly
// Note: rewriting a file in place like this is convenient for a sample but is not
// recommended in production tasks. Prefer writing to a separate output file instead.
File.WriteAllLines(filePath, new[] { comment }.Concat(originalLines));
// Use filePath.OriginalValue in log messages to preserve the relative path as written by the user
Log.LogMessage(MessageImportance.High, $"Added build comment to: {filePath.OriginalValue}");

处理并行构建中的文件争用

每当多个任务并行运行并访问同一文件时,都可能发生文件争用。 这一问题适用于传统的多进程模型和较新的进程内多线程模式。 在这两种情况下,在以下情况下,可能会同时访问同一文件:

  • 同一文件显示在多个子项目生成中(例如,共享配置文件或链接源文件)。
  • 任务读取并写入另一个任务实例正在处理的文件。

方便的方法,例如 File.ReadAllLinesFile.WriteAllLines 不提供对文件锁定的显式控制。 如果可能发生并发访问,请使用 FileStream,并进行显式共享和锁定:

using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
    // FileShare.None ensures exclusive access; other attempts
    // to open this file will throw IOException until the stream
    // is disposed.
    using var reader = new StreamReader(stream);
    string content = reader.ReadToEnd();

    stream.SetLength(0); // Truncate before rewriting.
    stream.Position = 0;

    using var writer = new StreamWriter(stream);
    writer.WriteLine(comment);
    writer.Write(content);
}

多线程任务中文件 I/O 的关键准则:

  • 使用 FileShare.None 执行读取-修改-写入操作。 此设置可防止另一个任务在更新文件时读取到过时内容。
  • 捕获 IOException 并尝试重试。 当另一个任务或进程持有锁时,你的打开尝试会抛出 IOException。 采用退避策略的短暂重试通常是合适的。
  • 避免同时锁定多个文件。 如果两个任务各自锁定一个文件,然后再尝试锁定另一个文件,就会发生死锁。 如果必须对多个文件进行操作,请按一致的顺序锁定它们(例如,按完整路径排序)。
  • 尽可能缩短持锁时间。 在一个操作中打开文件、读取、修改、写入和关闭。 执行不相关的工作时不要保留文件锁。

前面的示例是一种方法。 有关 .NET 中线程安全文件 I/O 的通用指导,请参阅 FileStream 类FileShare 枚举以及 托管线程处理最佳做法

注释

TaskEnvironment 本身不是线程安全的。 仅当任务在内部生成自己的线程(例如,使用 Parallel.ForEachTask.Run) 时才重要。 大多数任务不执行此操作。 它们以线性方式实现 Execute() ,并允许 MSBuild 跨任务实例处理并行度。 如果任务确实创建了自己的线程,请在生成这些线程之前从 TaskEnvironment 本地变量中捕获值,而不是同时从多个线程进行访问 TaskEnvironment

更新环境变量

注释

在任务代码中读取环境变量通常是一种不良做法,即使在单线程生成中也是如此。 MSBuild 属性是更好的替代方法:它们是显式限定范围、在生成期间记录的,并在生成日志中可跟踪。 如果任务当前读取环境变量来接收输入,请考虑改为将其替换为任务属性。 项目仍可以从环境变量派生值: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />

本节中的指导适用于迁移已经依赖环境变量的现有任务。 如果有机会重构,首选属性和项。

为子进程设置环境变量

多线程生成中最常见的环境变量问题是一个 任务,该任务设置 环境变量,然后生成子进程,期望子进程继承它。 在多进程模型中, Environment.SetEnvironmentVariable() 安全地修改该项目的工作进程环境。 在多线程模式下,进程会在所有并发构建之间共享,因此,原本只针对某个项目子进程的更改可能会波及到另一个项目。

TaskEnvironment.SetEnvironmentVariable()TaskEnvironment.GetProcessStartInfo() 一起使用(请参阅 更新 ProcessStart API 调用)。 GetProcessStartInfo() 返回一个 ProcessStartInfo,其中已预先填入项目的工作目录及其独立的环境变量表,包括你通过 SetEnvironmentVariable() 设置的任何变量,因此子进程会自动继承正确的项目级环境。

之前:

Environment.SetEnvironmentVariable("TOOL_OUTPUT_DIR", outputDir);
var startInfo = new ProcessStartInfo("mytool.exe") { UseShellExecute = false };
Process.Start(startInfo);  // inherits the modified process-level environment

之后:

TaskEnvironment.SetEnvironmentVariable("TOOL_OUTPUT_DIR", outputDir);
ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo();
startInfo.FileName = "mytool.exe";
startInfo.UseShellExecute = false;
Process.Start(startInfo);  // inherits the project-scoped environment

读取现有任务中的环境变量

如果现有任务会读取环境变量,并且你无法立即将其重构为使用任务属性,请将 Environment.GetEnvironmentVariable() 替换为 TaskEnvironment.GetEnvironmentVariable()。 此方法调用从项目作用域的环境表而非共享的进程环境中读取,因此并发构建不会相互干扰。

之前 (发件人 BuildCommentTask):

string disableComments = Environment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");

后:

string disableComments = TaskEnvironment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");

小窍门

更新读取环境变量的现有代码时,请考虑将模式替换为任务属性。 例如,公开 public bool DisableComments { get; set; } 任务并让项目传递 DisableComments="$(DISABLE_BUILD_COMMENTS)"。 MSBuild 记录解析的值,使其在生成日志中可见,并且比读取隐藏的环境变量更容易诊断。

更新 ProcessStart API 调用

通常情况下,如果某个任务会启动一个进程,你应当使用 ToolTask,它会为你处理好一切。 如果要更新直接调用 ProcessStartInfo 的任务,请使用 TaskEnvironment.GetProcessStartInfo()。 这将返回一个已使用项目的工作目录及其隔离环境表进行配置的 ProcessStartInfo。 如果在启动前还设置环境变量,请先使用 TaskEnvironment.SetEnvironmentVariable() ,如上一部分所示。

之前:

var startInfo = new ProcessStartInfo("mytool.exe")
{
    WorkingDirectory = ".",
    UseShellExecute = false
};
Process.Start(startInfo);

后:

ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo();
startInfo.FileName = "mytool.exe";
startInfo.UseShellExecute = false;
Process.Start(startInfo);

注释

如果你的任务继承自 ToolTask,则系统已为你处理好进程启动信息。 只需更新直接创建 ProcessStartInfo 的任务。

将静态字段和数据结构更新为线程安全

迁移到多线程生成时,静态字段需要仔细处理。 即使在多进程模型中,单个进程也可以生成多个项目,因此静态状态不会同时共享。

多线程模式为此问题添加新维度。 现在,多个构建可以共享同一进程,并发运行任务(尤其是在使用 MSBuild Server 时,它会在启用多线程时自动启用)。 静态字段会在该进程中的所有任务实例之间共享,不仅在你的构建内部共享,甚至还可能在并发运行的不同构建调用之间共享。 例如,两个开发人员在同一台生成服务器上同时运行 dotnet build ,或在同一台计算机上的两个终端窗口上运行,可能会共享相同的静态状态,现在这些生成会同时访问它。

在此 BuildCommentTask 示例中,静态字段 ModifiedFileCount 在所有实例之间共享:

之前:

private static int ModifiedFileCount = 0;

// In Execute():
ModifiedFileCount++;

此代码有两个问题。 首先, ++ 运算符不是原子的。 当多个任务实例并发运行时,两个线程可以读取相同的值,并写入相同的递增结果,从而导致计数丢失。 其次,由于该字段是静态的,因此它会在多次构建之间持续存在,并且会在同一进程中的并发构建之间共享。

以下各节演示了两种解决这些问题的方法,从最简单的到最正确。

方法 1:使用线程安全但作用于整个进程的 API

最简单的修复方法是使递增操作成为原子操作:

private static int ModifiedFileCount = 0;

// In Execute():
int fileNumber = Interlocked.Increment(ref ModifiedFileCount);

Interlocked.Increment 将读取递增写入作为单个原子操作执行,因此不会丢失计数。 这种方法解决了并发问题,但该计数器仍然在该进程中的所有构建之间共享,包括连续构建和并发构建。 如果两个构建同时运行,它们的文件编号会交错排列(构建 A 获得 #1、#3、#5;构建 B 获得 #2、#4、#6)。 这种情况是否可以接受,取决于你的任务是否需要针对每次构建进行隔离。 对于像 ModifiedFileCount 这样的顺序文件编号计数器,在不同构建之间共享会导致正确性问题;请改用 RegisterTaskObject(参见方法 2)。

这里,与之对应的、线程安全但作用于整个进程的 API 是 InterlockedIncrement;但在你自己的代码中,你需要为任何非线程安全的 API 找到合适的线程安全替代方案。 例如,如果您的任务使用Dictionary来持久保存状态,请考虑使用ConcurrentDictionary<TKey,TValue>

方法 2:RegisterTaskObject 用于实现构建范围内的隔离

如果任务需要在一次构建调用中跨子项目共享静态状态,同时与其他并发构建相互隔离,请将 IBuildEngine4.RegisterTaskObjectRegisteredTaskObjectLifetime.Build 结合使用。 MSBuild 管理对象的生存期,该对象在首次使用时创建,并在生成结束时清理。 请注意,已注册的对象必须是线程安全的。

首先,定义简单的线程安全计数器类:

internal class FileCounter
{
    private int _count = 0;
    public int Next() => Interlocked.Increment(ref _count);
}

然后使用采用双重检查锁定的辅助方法来获取或创建计数器:

private static readonly object s_counterLock = new();

private FileCounter GetOrCreateCounter()
{
    const string key = "BuildCommentTask.FileCounter";

    var counter = BuildEngine4.GetRegisteredTaskObject(
        key, RegisteredTaskObjectLifetime.Build) as FileCounter;

    if (counter == null)
    {
        lock (s_counterLock)
        {
            counter = BuildEngine4.GetRegisteredTaskObject(
                key, RegisteredTaskObjectLifetime.Build) as FileCounter;

            if (counter == null)
            {
                counter = new FileCounter();
                BuildEngine4.RegisterTaskObject(
                    key, counter,
                    RegisteredTaskObjectLifetime.Build,
                    allowEarlyCollection: false);
            }
        }
    }
    return counter;
}

Execute()中:

FileCounter counter = GetOrCreateCounter();
// ...
int fileNumber = counter.Next();

使用此方法时,每个构建调用都将获得各自的 FileCounter。 同一生成中的所有子项目共享计数器(顺序编号),但在同一台计算机上同时运行的独立 dotnet build 子项目会获得不同的计数器。 RegisteredTaskObjectLifetime.Build 告知 MSBuild 将对象范围限定为当前生成调用,并在生成结束时将其清理。

选择正确的方法

在决定如何处理静态状态时,先从这个问题开始:这些数据是否可以在所有可能于同一进程中运行的构建之间安全共享,包括连续构建和并发构建?

MSBuild 工作进程会在多次调用之间持续存在(默认启用节点重用),而且一个 MSBuild 进程在其整个生命周期内可能为多个解决方案构建提供服务,而不仅仅是在单次 dotnet build 调用期间。 不要假设一个进程只处理一次构建。

使用以下准则:

  • 仅当缓存的数据能够在不同项目和多次构建中的多个线程之间安全访问,且无需在构建之间使缓存失效时,才保留静态字段。 例如,基于始终不变的输入计算得出的一次性不可变数据缓存(例如在启动时加载一次的程序集元数据)可能属于这类情况。
  • 在每次构建调用都必须隔离状态时,请将 IBuildEngine4.RegisterTaskObjectRegisteredTaskObjectLifetime.Build 一起使用(例如,计数器、累加器或缓存应在不同构建之间重置,或不应在并发构建之间泄漏)。 这是大多数共享可变状态的首选方法。
  • 使用System.Threading基元Interlocked、、ConcurrentDictionarylockReaderWriterLockSlim使任何保留的静态状态线程安全,但请记住,仅线程安全不提供生成级隔离。 请参阅 托管线程最佳做法

小窍门

本文后文中的完整迁移示例使用 RegisterTaskObject 方法来展示构建范围内的隔离。

完整迁移示例

以下代码展示了已完成全部迁移并应用了全部五项更改的 AddBuildCommentTask

  1. 具有属性 [MSBuildMultiThreadableTask] ,将其标记为进程内执行。
  2. IMultiThreadableTask与现有Task基类一起实现并公开TaskEnvironment属性。
  3. 使用 TaskEnvironment.GetAbsolutePath() 进行路径解析。
  4. 使用 TaskEnvironment.GetEnvironmentVariable() 而不是 Environment.GetEnvironmentVariable()
  5. 使用IBuildEngine4.RegisterTaskObjectRegisteredTaskObjectLifetime.Build将文件计数器的作用范围限定到当前构建调用,替换原先整个进程范围内的静态计数器。
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;

namespace BuildCommentTask
{
    internal class FileCounter
    {
        private int _count = 0;
        public int Next() => Interlocked.Increment(ref _count);
    }

    [MSBuildMultiThreadableTask]
    public class AddBuildCommentTask : Task, IMultiThreadableTask
    {
        private static readonly object s_counterLock = new();

        public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;

        // Callers are responsible for passing only text files in TargetFiles,
        // and for setting CommentPrefix/CommentSuffix to match the file type.
        [Required]
        public ITaskItem[] TargetFiles { get; set; }

        [Required]
        public string VersionNumber { get; set; }

        // Optional CommentPrefix and CommentSuffix wrap the comment in
        // language-appropriate syntax, e.g., "// " for C# or "# " for Python.
        // Include any desired spacing in the prefix or suffix value.
        public string CommentPrefix { get; set; } = "";
        public string CommentSuffix { get; set; } = "";

        public override bool Execute()
        {
            string disableComments = TaskEnvironment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
            if (!string.IsNullOrEmpty(disableComments))
            {
                Log.LogMessage(MessageImportance.Normal, "Build comments disabled via environment variable.");
                return true;
            }

            FileCounter counter = GetOrCreateCounter();

            string buildDate = DateTime.UtcNow.ToString("yyyy-MM-dd");
            string commentPattern = $@"^{Regex.Escape(CommentPrefix)}\s*Build Date:.*Version:.*{Regex.Escape(CommentSuffix)}$";

            foreach (var item in TargetFiles)
            {
                AbsolutePath filePath = TaskEnvironment.GetAbsolutePath(item.ItemSpec);

                try
                {
                    string[] originalLines = File.ReadAllLines(filePath);

                    if (originalLines.Length > 0 && Regex.IsMatch(originalLines[0], commentPattern))
                    {
                        Log.LogMessage(MessageImportance.Low, $"Skipped (already annotated): {filePath}");
                        continue;
                    }

                    int fileNumber = counter.Next();
                    string comment = $"{CommentPrefix}Build Date: {buildDate}, Version: {VersionNumber}, File #: {fileNumber}{CommentSuffix}";
                    // Note: rewriting a file in place like this is convenient for a sample but is not
                    // recommended in production tasks. Prefer writing to a separate output file instead.
                    File.WriteAllLines(filePath, new[] { comment }.Concat(originalLines));
                    Log.LogMessage(MessageImportance.High, $"Added build comment to: {filePath}");
                }
                catch (Exception ex)
                {
                    Log.LogError($"Failed to process {filePath}: {ex.Message}");
                    return false;
                }
            }
            return true;
        }

        private FileCounter GetOrCreateCounter()
        {
            const string key = "BuildCommentTask.FileCounter";

            var counter = BuildEngine4.GetRegisteredTaskObject(
                key, RegisteredTaskObjectLifetime.Build) as FileCounter;

            if (counter == null)
            {
                lock (s_counterLock)
                {
                    counter = BuildEngine4.GetRegisteredTaskObject(
                        key, RegisteredTaskObjectLifetime.Build) as FileCounter;

                    if (counter == null)
                    {
                        counter = new FileCounter();
                        BuildEngine4.RegisterTaskObject(
                            key, counter,
                            RegisteredTaskObjectLifetime.Build,
                            allowEarlyCollection: false);
                    }
                }
            }
            return counter;
        }
    }
}

未迁移的任务会怎样

没有 [MSBuildMultiThreadableTask] 属性或未实现 IMultiThreadableTask 的任务在没有任何更改的情况下继续工作。 MSBuild 在辅助 TaskHost 进程中运行这些任务,该进程提供与早期版本的 MSBuild 相同的进程级隔离。 由于进程间通信的开销,此方法速度较慢,但它与现有任务代码完全兼容。 迁移对于正确性(未迁移的任务仍然产生正确的结果)是可选的,但迁移会提高生成性能。

支持早期版本的 MSBuild

如果更新自定义任务,然后将其分发给其他人,则任务支持使用 MSBuild 18.6 或更高版本的客户端。 若要支持早期版本的 MSBuild 上的客户端,有三个选项。

选项 1:接受性能降低

对任务不进行任何更改。 MSBuild 在子公司 TaskHost 进程中运行非特性化任务,该任务速度较慢,但完全兼容。 此选项不需要更改代码。

选项 2:维护单独的实现

为 MSBuild 18.6 及更早版本生成单独的任务程序集。 MSBuild 18.6+ 版本实现 IMultiThreadableTask 和使用 TaskEnvironment。 早期版本继续与进程级 API 一起使用 Task

选项 3:兼容桥接

请在任务程序集中自行定义 MSBuildMultiThreadableTaskAttribute。 由于 MSBuild 仅按命名空间和名称检测属性(忽略定义程序集),因此自定义属性适用于旧版和新版本的 MSBuild:

namespace Microsoft.Build.Framework
{
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
    internal class MSBuildMultiThreadableTaskAttribute : Attribute { }
}

在 MSBuild 18.6 或更高版本上运行时,MSBuild 识别属性并运行进程内的任务。 在早期版本上运行时,MSBuild 会忽略未知属性,并像以前一样运行任务。

使用此选项时,你无权访问 TaskEnvironment,因此必须手动处理它处理的所有内容,例如将所有相对路径转换为绝对路径。

方法比较

下表比较了在多线程模式下运行时的三种方法(-mt)。 在非多线程模式下,所有任务都在进程外运行,无论它们被如何标记。

Approach Maintenance 性能(18.6+) 性能(较旧) TaskEnvironment 访问
单独的实现 High 完全进程内 完全采用进程外模式 是(18.6+ 版本)
兼容性桥接 Low 完整进程内模式 完全采用进程外方式 否(仅限属性)
无更改 None Sidecar (较慢) 完全采用进程外模式