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; }
}
若要遷移任務,請在現有的 Task 基底類別旁一併實作 IMultiThreadableTask,並公開 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 的 <!-- 和 -->,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="<!-- "
CommentSuffix=" -->" />
此任務有四個執行緒安全問題需要針對多執行緒建置處理:
-
相對路徑:
File.ReadAllLines和File.WriteAllLines直接使用item.ItemSpec,其可能是相對路徑。 在多執行緒模式下,程序工作目錄不一定是專案目錄。 -
靜態欄位:
ModifiedFileCount是static所有實例共用的欄位,當多個建置同時執行時會引發資料競賽。 -
環境變數:多執行緒建置中最常見的環境變數問題是任務在產生子程序前 設定 環境變數,期望子程序繼承。 在多執行緒模式下,
Environment.SetEnvironmentVariable()會修改所有並行建置共用的行程層級環境,因此原本要套用於某個專案子行程的變更,可能會影響到另一個專案的子行程。 直接讀取任務程式碼Environment.GetEnvironmentVariable()中的環境變數()通常也是一個不良做法;MSBuild 的屬性是更好的選擇,因為它們有記錄且可追蹤。
這很重要
多執行緒建置模式目前僅適用於 CLI(dotnet build 和 MSBuild.exe)建置。 Visual Studio MSBuild 的建置版本尚未支援多執行緒的執行過程。 在 Visual Studio 中,所有工作執行都會持續在處理序外執行。 Visual Studio 整合計畫於未來版本推出。
Prerequisites
MSBuild 18.6 或更新版本。
使用命令列交換器啟用多執行緒任務執行
-mt:dotnet build -mt欲了解更多交換器資訊
-mt,請參閱 MSBuild 命令列參考資料。
規劃移轉
請檢視你的任務程式碼,注意以下問題:
- 檢查任務程式碼,並找出是否有使用相對路徑。 檢查所有輸入和檔案輸入/輸出。
- 檢查是否有使用環境變數。
- 檢查是否有
ProcessStartInfoAPI 使用情況。 - 檢查任何靜態欄位或資料結構,並使用標準方法使其執行緒安全。
- 如果以上都不適用,請考慮只新增屬性。
- 考慮支援早期版本 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() |
錯誤 | 使用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,忽略其定義所在的組件。 這表示你可以自己在程式碼中定義屬性(參見 Support 早期版本的 MSBuild),而 MSBuild 仍然能辨識它。
Note
該 MSBuildMultiThreadableTaskAttribute 是不可遺傳的(Inherited = false)。 每個任務類別必須明確宣告要被識別的屬性為多執行緒。 從具有該屬性的類別繼承,並不會自動使衍生類別成為可多執行緒。
將 TaskEnvironment 初始化為 Fallback
實作 IMultiThreadableTask時,將屬性 TaskEnvironment 初始化為 TaskEnvironment.Fallback:
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
MSBuild 會在呼叫 Execute() 正常建置前設定這個屬性。 預設值確保 Fallback 任務在其他主機情境(如單元測試或自訂編譯工具)中能正常運作,且這些場景沒有 MSBuild 來設定該屬性。 沒有它,在引擎外部存取 TaskEnvironment 會拋出空參考例外。
如果你需要支援 18.6 之前且不包含 TaskEnvironment.Fallback的 MSBuild 版本,請將屬性初始化為 , null 並用空檢查來保護所有 TaskEnvironment 呼叫。 更多選項請參閱 支援 MSBuild 早期版本 。
更新路徑與檔案輸入/輸出
任務通常接受輸入,例如MSBuild中的項目清單,若是檔案,則可能以相對路徑形式呈現。
相對路徑總是相對於程序目前的工作目錄,但因為任務現在是在程序中執行,工作目錄可能不會與任務在自身程序中執行時相同。 這些路徑皆相對於專案目錄。
TaskEnvironment 包含 ProjectDirectory 屬性和 GetAbsolutePath() 方法,可用來將相對路徑解析為絕對路徑。 你也可以存取 FullPath 元資料;不需要先用 ItemSpec 相對路徑再絕對化。
絕對路徑類型
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 ,使輸出路徑保持相對且可讀,同時仍使用已解析 Value (或隱含 string 轉換)來執行實際檔案輸入輸出。
用 TaskEnvironment.GetAbsolutePath() 來解決物品路徑:
Before:
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));
After:
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.ReadAllLines 和 File.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);
}
多執行緒任務中檔案輸入輸出的關鍵指引:
- 使用
FileShare.None進行讀取-修改-寫入作業。 這個設定可以防止其他任務在更新檔案時讀取過時的內容。 - 抓住
IOException機會,考慮再試一次。 當其他任務或程序鎖定時,你的開啟嘗試會拋IOException出 。 通常適合在短暫延遲後重試。 - 避免同時鎖定多個檔案。 如果兩個任務各自鎖定一個檔案,然後再嘗試鎖定另一個,就會陷入死結。 如果你必須對多個檔案進行操作,請將它們鎖定在一致的順序(例如依全路徑排序)。
- 鎖頭盡量縮短。 打開檔案、讀取、修改、寫入並關閉,一次操作完成。 做無關工作時不要按檔案鎖。
前述範例就是一種方法。 關於.NET中執行緒安全的檔案輸入輸出的一般指引,請參見 FileStream 類別、FileShare enum,以及 管理執行緒最佳實務。
Note
TaskEnvironment 本身不是執行緒安全的。 這只有在你的任務內部自行生成執行緒(例如使用 Parallel.ForEach 或 Task.Run)時才重要。 大多數任務不會這樣做。 它們以線性方式實作 Execute(),並讓 MSBuild 處理各個任務執行個體之間的平行處理。 如果你的任務確實會建立自己的執行緒,請先將 TaskEnvironment 中的值擷取到區域變數,再建立這些執行緒,而不要讓多個執行緒同時存取 TaskEnvironment。
更新環境變數
Note
即使在單執行緒建置中,讀取任務程式碼中的環境變數通常是不好的做法。 MSBuild 屬性是更好的替代方案:它們明確有範圍限制,建置時會記錄,且在建置日誌中可追蹤。 如果你的任務目前讀取環境變數來接收輸入,考慮用任務屬性來取代它。 專案仍可從環境變數推導出值: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />。
本節的指引是針對已依賴環境變數的既有任務遷移。 如果有機會進行重構,請優先使用屬性和項目。
為子程序設定環境變數
多執行緒建置中最常見的環境變數問題是某個 任務設定環境 變數後,會產生子程序,期望子程序繼承該程序。 在多程序模型中,Environment.SetEnvironmentVariable() 已安全地修改了該專案的工作程序環境。 在多執行緒模式下,程序會被所有並行建置共享,因此原本針對一個專案子程序的變更可能會洩漏到另一個。
TaskEnvironment.SetEnvironmentVariable() 與 TaskEnvironment.GetProcessStartInfo() 一起使用(請參閱 更新 ProcessStart API 呼叫)。
GetProcessStartInfo() 回傳一個已預先填入專案工作目錄及其隔離環境表的 ProcessStartInfo,其中包含你用 SetEnvironmentVariable() 設定的任何變數,讓子行程自動繼承正確的專案專屬環境。
Before:
Environment.SetEnvironmentVariable("TOOL_OUTPUT_DIR", outputDir);
var startInfo = new ProcessStartInfo("mytool.exe") { UseShellExecute = false };
Process.Start(startInfo); // inherits the modified process-level environment
After:
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");
After:
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() ,如前一節所示。
Before:
var startInfo = new ProcessStartInfo("mytool.exe")
{
WorkingDirectory = ".",
UseShellExecute = false
};
Process.Start(startInfo);
After:
ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo();
startInfo.FileName = "mytool.exe";
startInfo.UseShellExecute = false;
Process.Start(startInfo);
Note
如果你的任務繼承自 ToolTask,程序啟動資訊已為你處理好。 你只需要更新直接產生 ProcessStartInfo 的任務。
更新靜態欄位與資料結構以達到執行緒安全
遷移到多執行緒建置時,靜態欄位需要謹慎處理。 即使在多程序模型中,單一程序也能建構多個專案,因此靜態狀態是共享的,只是不會同時進行。
多執行緒模式為這個問題增添了新的層次。 多個建置現在可以共享同一程序並同時執行任務(尤其是 MSBuild Server,因為它會自動啟用多執行緒)。 靜態欄位會在整個過程中的所有任務實例間共享,不僅在你的建置內,甚至可能同時在不同的建置呼叫間共享。 例如,兩個同時在建置伺服器上執行 dotnet build 的開發者,或同一台機器上的兩個終端視窗,可能會共享相同的靜態狀態,而這些建置者同時存取該狀態。
在範例 BuildCommentTask 中,靜態欄位 ModifiedFileCount 在所有實例間共享:
Before:
private static int ModifiedFileCount = 0;
// In Execute():
ModifiedFileCount++;
這套程式碼有兩個問題。 首先, ++ 操作員不是原子的。 當多個任務實例同時執行時,兩個執行緒可能會讀取相同的值,並同時寫入相同的遞增結果,導致計數遺失。 其次,由於欄位是靜態的,它會跨建置持續存在,並且在同一過程中的並行建置間共享。
以下章節將展示兩種解決這些問題的方法,從最簡單到最正確。
方法一:使用執行緒安全但適用於整個程序的 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 (見方法二)。
在這裡,執行緒安全但整個程序的 API 對應是 InterlockedIncrement,但在你自己的程式碼中,你需要找到適合的執行緒安全替代方案,來替代任何不執行緒安全的 API。 例如,如果您的任務使用 Dictionary 來持久保存狀態,請考慮使用 ConcurrentDictionary<TKey,TValue>。
方法 2:RegisterTaskObject 用於建置範圍隔離
如果你的任務需要靜態狀態,且在單一建置調用中跨子專案共享,但與其他並行建置隔離,請使用 IBuildEngine4.RegisterTaskObject 。RegisteredTaskObjectLifetime.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.RegisterTaskObject搭配RegisteredTaskObjectLifetime.Build(例如計數器、累加器或快取,這些項目應在建置之間重設,或不應洩漏到並行建置之間)。 這是大多數可變共享狀態的首選方法。 -
使用
System.Threading原語 (Interlocked,ConcurrentDictionary,lock,ReaderWriterLockSlim)來使任何保留的靜態狀態都具備執行緒安全,但請記住,僅靠執行緒安全並不能提供建置層級的隔離。 請參閱 管理執行緒最佳實務。
小提示
本文後面的完整遷移範例使用此 RegisterTaskObject 方法來展示建置範圍隔離。
完整遷移範例
以下程式碼顯示已完成完整遷移且已套用所有五項變更的 AddBuildCommentTask:
- 具有
[MSBuildMultiThreadableTask]屬性,將其標記為在處理序內執行。 - 實作
IMultiThreadableTask,並與現有的Task基底類別一同使用,同時公開TaskEnvironment屬性。 - 使用
TaskEnvironment.GetAbsolutePath()進行路徑解析。 - 用法
TaskEnvironment.GetEnvironmentVariable()代替Environment.GetEnvironmentVariable()。 - 使用
IBuildEngine4.RegisterTaskObject搭配RegisteredTaskObjectLifetime.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 18.6 或更新版本的客戶端。 要支援早期版本 MSBuild 的客戶端,你有三個選擇。
選項一:接受效能下降
不要更改你的任務。 MSBuild 在一個子程序中執行 TaskHost 非歸屬任務,雖然速度較慢,但完全相容。 此選項不需更改程式碼。
選項二:維持獨立實作
為 MSBuild 18.6+ 及更早版本建立獨立的任務組件。 MSBuild 18.6+ 版本實作了 IMultiThreadableTask,並使用 TaskEnvironment。 早期版本仍使用 Task 程序層級 API。
選項三:相容性橋接
在你的任務組件中自行定義 MSBuildMultiThreadableTaskAttribute。 由於 MSBuild 僅透過命名空間和名稱偵測屬性(忽略定義的組合語言),你的自定義屬性在舊版和新版 MSBuild 中都能運作:
namespace Microsoft.Build.Framework
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
internal class MSBuildMultiThreadableTaskAttribute : Attribute { }
}
在 MSBuild 18.6 或更新版本上執行時,MSBuild 會辨識該屬性並執行該任務。 在較早版本上執行時,MSBuild 會忽略未知屬性,並照舊執行任務。
使用這個選項時,你無法存取 TaskEnvironment,因此你必須手動處理它處理的所有資料,例如將所有相對路徑轉換成絕對路徑。
方法比較
下表比較了在多執行緒模式下運行時的三種方法(-mt)。 在非多執行緒模式下,所有任務無論如何標記都會在程序外執行。
| 方法 | Maintenance | 效能 (18.6+) | 性能(較舊) | 任務環境存取 |
|---|---|---|---|---|
| 獨立實作 | 高 | 完整處理中 | 完全在行程外執行 | 是(18.6+ 版本) |
| 相容性橋接 | 低 | 完整進行中 | 完全在處理序外執行 | 否(僅限屬性) |
| 沒有變化 | None | 邊車(較慢) | 完全程序外 | No |