マルチスレッド モードで動作するように MSBuild タスクを更新する

MSBuild 18.6 では、同じプロセス内で並列にビルドする機能が導入されています。 このモードにオプトインするには、 -mt コマンド ライン スイッチを渡します。 以前のバージョンの MSBuild では並列ビルドがサポートされていましたが、ビルドは別々のプロセスで実行されていました。 この変更は、タスクの作成方法にいくつかの影響を与えます。 以前は、タスクは別のプロセスで実行されていたのに対し、マルチスレッド対応のすべてのタスクは同じプロセスで実行されるようになりました。 ほとんどのロジックは変更する必要はありませんが、より慎重に処理する必要があるプロセス レベルのコンストラクトがいくつかあります。 プロセス レベルのコンストラクトには、現在の作業ディレクトリ、環境変数、プロセス開始情報 (ProcessStartInfo) が含まれます。

これらの変更をサポートするために、MSBuild 18.6 では、IMultiThreadableTask インターフェイス (Microsoft.Build.Framework) と TaskEnvironment クラスが導入されています。 TaskEnvironmentには、ProjectDirectoryGetAbsolutePath()GetEnvironmentVariable()SetEnvironmentVariable()などのGetProcessStartInfo()プロパティとメソッドが含まれています。

Important

マルチスレッド モードは現在、試験的な機能として使用できます。現時点では、運用環境での使用はお勧めしません。 マルチスレッド モード API を使用するように MSBuild ライブラリの依存関係を更新すると、古いバージョンの 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 は、移行プロセスを説明するためにこの記事全体で使用します。 このタスクは、テキスト ファイルの先頭にビルド コメントを追加します。 既定では、プレーン テキストが書き込まれます。省略可能な CommentPrefix プロパティと CommentSuffix プロパティを使用すると、呼び出し元は言語に適した構文でコメントをラップできます (たとえば、C# の場合は //、XML の場合は <!--、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;" />

このタスクには、マルチスレッド ビルドで対処する必要がある 4 つのスレッド セーフの問題があります。

  1. 相対パス: File.ReadAllLinesFile.WriteAllLinesitem.ItemSpecを直接使用します。これは相対パスである可能性があります。 マルチスレッド モードでは、プロセス作業ディレクトリがプロジェクト ディレクトリであるとは限りません。
  2. 静的フィールド: ModifiedFileCount は、すべてのインスタンスで共有される static フィールドです。これにより、複数のビルドが同時に実行されるとデータ 競合が発生します。
  3. 環境変数: マルチスレッド ビルドで最も一般的な環境変数の問題は、子プロセスを生成する前に環境変数を 設定 し、子プロセスを継承することを期待するタスクです。 マルチスレッド モードでは、 Environment.SetEnvironmentVariable() は、すべての同時実行ビルドによって共有されるプロセス レベルの環境を変更するため、あるプロジェクトの子プロセスを対象とした変更が別のプロジェクトにブリードされる可能性があります。 タスク コード (Environment.GetEnvironmentVariable()) で環境変数を直接読み取ることも一般的には不適切な方法です。MSBuild プロパティはログに記録され、トレース可能であるため、代わりに MSBuild プロパティを使用することをお勧めします。

Important

マルチスレッド ビルド モードは現在、CLI (dotnet build および MSBuild.exe) ビルドでのみ使用できます。 Visual Studio MSBuild ビルドでは、プロセス内でのマルチスレッド実行はまだサポートされていません。 Visual Studioでは、すべてのタスクの実行が引き続きプロセスを使い果たします。 Visual Studio統合は、将来のリリースで予定されています。

Prerequisites

移行の計画

タスクコードに次の問題がないか確認してください。

  1. タスク コードを確認し、相対パスの使用法を特定します。 すべての入力とファイル I/O を確認します。
  2. 環境変数の使用を確認します。
  3. ProcessStartInfo API の使用状況を確認します。
  4. 静的フィールドまたはデータ構造を確認し、標準メソッドを使用してスレッド セーフにします。
  5. 上記のいずれも適用されない場合は、属性のみを追加することを検討してください。
  6. 以前のバージョンの MSBuild をサポートするための特別な要件を検討してください。 以前のバージョンの MSBuild のサポートを参照してください。

API 置換のクイック リファレンス

次の表は、置き換える必要がある.NET API とそのTaskEnvironmentの対応する API をまとめたものです。

避けるべき .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() エラー ToolTaskまたはTaskEnvironment.GetProcessStartInfo()を使用する
静的フィールド 警告: インスタンス フィールドまたはスレッド セーフ コレクションを使用する

Note

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 を利用可能にするための追加の手順です。

Note

MSBuild は、定義アセンブリを無視して、名前空間と名前によってのみ MSBuildMultiThreadableTaskAttribute を検出します。 つまり、独自のコードで属性を自分で定義できます ( 以前のバージョンの MSBuild のサポートを参照)、MSBuild では引き続きそれを認識します。

Note

MSBuildMultiThreadableTaskAttributeは継承できません (Inherited = false)。 各タスク クラスは、マルチスレッドとして認識されるように属性を明示的に宣言する必要があります。 属性を持つクラスから継承しても、派生クラスは自動的にマルチスレッド化されません。

TaskEnvironment をフォールバックに初期化する

IMultiThreadableTaskを実装する場合は、TaskEnvironment プロパティを初期化してTaskEnvironment.Fallbackします。

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

MSBuild は、通常のビルドで Execute() を呼び出す前に、このプロパティを設定します。 Fallback既定では、プロパティを設定するために MSBuild が存在しない他のホスティング シナリオ (単体テストやカスタム ビルド オーケストレーション ツールなど) でタスクが正しく動作することが保証されます。 これがないと、エンジン外から TaskEnvironment にアクセスした際に、null 参照例外がスローされます。

TaskEnvironment.Fallbackを含まない 18.6 より前の MSBuild バージョンをサポートする必要がある場合は、代わりにnullするようにプロパティを初期化し、null チェックを使用してTaskEnvironment呼び出しを保護します。 その他のオプションについては、 以前のバージョンの MSBuild のサポート を参照してください。

パスとファイル I/O を更新する

タスクは多くの場合、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 パスを必要とする任意の API にstringを直接渡すことができることを意味します。

OriginalValue プロパティは、解決前に渡された元のパス文字列を保持します。 このプロパティは、タスク出力またはログ メッセージに相対パスを保持する必要がある場合に便利です。 たとえば、処理したファイルをログに記録するタスクでは、ログ メッセージに OriginalValue を使用して、出力内のパスを相対的で読み取り可能なままにしながら、解決された Value (または暗黙的な string 変換) を実際のファイル I/O に使用できます。

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 がスローされます。 多くの場合、バックオフによる短い再試行が適切です。
  • 一度に複数のファイルに対してロックを保持しないようにします。 2 つのタスクがそれぞれ 1 つのファイルをロックし、もう一方のファイルをロックしようとすると、デッドロックが発生します。 複数のファイルを扱う必要がある場合は、常に同じ順序(たとえばフルパス順)でロックしてください。
  • ロックはできるだけ短くします。 ファイルを開き、1 回の操作で読み取り、変更、書き込み、閉じます。 関連のない作業を行っている間は、ファイル ロックを保持しないでください。

上記の例は 1 つの方法です。 .NETのスレッド セーフ なファイル I/O に関する一般的なガイダンスについては、FileStream クラスFileShare 列挙型、および Managed スレッド処理のベスト プラクティスを参照してください。

Note

TaskEnvironment 自体はスレッド セーフではありません。 これは、タスクが内部的に (たとえば、 Parallel.ForEachTask.Runを使用して) 独自のスレッドを生成する場合にのみ重要です。 ほとんどのタスクではこれを行いません。 Execute()を直線的に実装し、MSBuild でタスク インスタンス間の並列処理を処理できるようにします。 タスクが独自のスレッドを作成する場合は、複数のスレッドからTaskEnvironmentに同時にアクセスするのではなく、TaskEnvironmentからローカル変数に値をキャプチャしてから生成します。

環境変数を更新する

Note

一般に、タスク コードでの環境変数の読み取りは、シングル スレッド ビルドでも不適切な方法です。 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()に置き換えます。 このメソッド呼び出しは、共有プロセス環境ではなく、プロジェクト スコープの環境テーブルから読み取りを行うため、同時実行ビルドが相互に干渉することはありません。

Before ( 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);

Note

タスクが ToolTaskから継承されている場合、プロセスの開始情報は既に自動的に処理されます。 ProcessStartInfoを直接作成するタスクのみを更新する必要があります。

静的フィールドとデータ構造をスレッド セーフに更新する

マルチスレッド ビルドに移行する場合、静的フィールドには慎重な処理が必要です。 マルチプロセス モデルでも、1 つのプロセスで複数のプロジェクトをビルドできるため、静的な状態は同時に共有されません。

マルチスレッド モードでは、この問題に新しいディメンションが追加されます。 複数のビルドで同じプロセスを共有し、タスクを同時に実行できるようになりました (特に MSBuild Server では、マルチスレッドで自動的に有効になります)。 静的フィールドは、ビルド内だけでなく、同時に実行される個別のビルド呼び出し間で、プロセス内のすべてのタスク インスタンスで共有されます。 たとえば、ビルド サーバーで同時に dotnet build を実行している 2 人の開発者、または同じコンピューター上の 2 つのターミナル ウィンドウが同じ静的状態を共有している可能性があり、それらのビルドは同時にアクセスします。

BuildCommentTaskの例では、静的フィールドModifiedFileCountはすべてのインスタンスで共有されます。

以前は:

private static int ModifiedFileCount = 0;

// In Execute():
ModifiedFileCount++;

このコードには 2 つの問題があります。 まず、 ++ 演算子はアトミックではありません。 複数のタスク インスタンスが同時に実行されると、2 つのスレッドが同じ値を読み取り、両方とも同じインクリメントされた結果を書き込むことができるので、カウントが失われます。 2 つ目は、フィールドが静的であるため、ビルド間で保持され、同じプロセス内の同時実行ビルド間で共有されます。

次のセクションでは、これらの問題を修正するための 2 つの方法を、最も簡単なものから最も正確なものまで示します。

方法 1: スレッド セーフなプロセス全体の API を使用する

最も簡単な修正は、インクリメントをアトミックにすることです。

private static int ModifiedFileCount = 0;

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

Interlocked.Increment は、読み取り/インクリメント/書き込みを 1 つのアトミック操作として実行するため、カウントは失われません。 この方法ではコンカレンシーの問題が解決されますが、カウンターは、連続するビルドや同時実行ビルドを含め、プロセス内のすべてのビルドで共有されます。 2 つのビルドが同時に実行される場合、ファイル番号はインターリーブされます (ビルド A は #1、#3、#5 を取得します。ビルド B は #2、#4、#6) を取得します。 この状況が許容できるかどうかは、タスクでビルドごとの分離が必要かどうかによって異なります。 ModifiedFileCountのような順次ファイル番号カウンターの場合、クロスビルド共有は正確性の問題です。代わりにRegisterTaskObjectを使用します (アプローチ 2 を参照)。

ここでは、スレッド セーフですが、プロセス全体の API に相当するものは InterlockedIncrementですが、独自のコードでは、スレッド セーフではない API に対して適切なスレッド セーフな代替手段を見つける必要があります。 たとえば、タスクが Dictionaryを使用して状態を保持する場合は、 ConcurrentDictionary<TKey,TValue>の使用を検討してください。

アプローチ 2: RegisterTaskObject ビルド スコープの分離用

1 つのビルド呼び出し内でサブプロジェクト間で共有されるが、他の同時実行ビルドから分離された静的な状態がタスクに必要な場合は、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 プロセスは、1 つの dotnet build 呼び出し内だけでなく、その有効期間にわたって複数のソリューション ビルドを処理できる可能性があります。 プロセスが 1 つのビルドのみを処理するとは想定しないでください。

次のガイドラインを使用してください:

  • キャッシュされたデータが異なるプロジェクト間および複数のビルド間で複数のスレッドから安全にアクセスできる場合にのみ、ビルド間で無効化を必要としない場合にのみ、静的フィールドを保持します。 たとえば、変化しない入力から一度だけ計算される不変データのキャッシュ(起動時に一度だけ読み込まれるアセンブリのメタデータなど)は、これに該当する場合があります。
  • ビルド呼び出しごとに状態を分離する必要がある場合 (カウンター、アキュムレータ、キャッシュなど、ビルド間でリセットする必要がある場合や、同時実行ビルド間でリークしない場合など)、IBuildEngine4.RegisterTaskObjectRegisteredTaskObjectLifetime.Buildを使用します。 これは、ほとんどの共有変更可能な状態に推奨されるアプローチです。
  • System.Threadingプリミティブ (InterlockedConcurrentDictionarylockReaderWriterLockSlim) を使用して、保持されている静的状態をスレッド セーフにしますが、スレッド セーフだけではビルド レベルの分離は提供されないことに注意してください。 マネージド スレッドのベスト プラクティスを参照してください。

ヒント

この記事の後半の完全な移行例では、 RegisterTaskObject アプローチを使用して、ビルド スコープの分離を示します。

完全な移行の例

次のコードは、5 つの変更がすべて適用された完全に移行された 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 は、以前のバージョンの MSBuild と同じプロセス レベルの分離を提供する、子会社の TaskHost プロセスでこれらのタスクを実行します。 プロセス間通信のオーバーヘッドのため、この方法は遅くなりますが、既存のタスク コードと完全に互換性があります。 移行は、正確性のために省略可能であり、移行されていないタスクでは正しい結果が得られますが、移行するとビルドのパフォーマンスが向上します。

以前のバージョンの MSBuild をサポートする

カスタム タスクを更新して他のユーザーに配布する場合、タスクは MSBuild 18.6 以降を使用するクライアントをサポートします。 以前のバージョンの MSBuild でクライアントをサポートするには、3 つのオプションがあります。

オプション 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) で実行するときの 3 つの方法を比較しています。 非マルチスレッド モードでは、マーク方法に関係なく、すべてのタスクがプロセス外で実行されます。

Approach Maintenance パフォーマンス(18.6+) パフォーマンス(旧) TaskEnvironment アクセス
個別の実装 完全なプロセス内処理 完全なアウトプロセス はい (18.6 以降のバージョン)
互換性ブリッジ 完全にプロセス内で実行 完全なアウトプロセス いいえ (属性のみ)
変更なし None サイドカー (低速) 完全なプロセス外 No