Nota
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare ad accedere o modificare le directory.
L'accesso a questa pagina richiede l'autorizzazione. È possibile provare a modificare le directory.
MSBuild 18.6 introduce la funzionalità di compilazione in parallelo all'interno dello stesso processo. Per attivare questa modalità, passare l'opzione da riga di comando -mt. Le versioni precedenti di MSBuild supportavano compilazioni parallele, ma le compilazioni venivano eseguite in processi separati. Questa modifica ha un certo impatto sul modo in cui si creano le attività. Mentre in precedenza, le attività verrebbero eseguite in un processo separato, ora tutte le attività abilitate per il multithread vengono eseguite nello stesso processo. Anche se la maggior parte della logica non deve cambiare, esistono alcuni costrutti a livello di processo che devono essere gestiti con maggiore attenzione. I costrutti a livello di processo includono la directory di lavoro corrente, le variabili di ambiente e le informazioni di avvio del processo (ProcessStartInfo).
Per supportare queste modifiche, MSBuild 18.6 introduce l'interfaccia IMultiThreadableTask (in Microsoft.Build.Framework) e la classe TaskEnvironment.
TaskEnvironment include una ProjectDirectory proprietà e metodi come GetAbsolutePath(), GetEnvironmentVariable(), SetEnvironmentVariable()e GetProcessStartInfo().
Importante
La modalità multithreading è attualmente disponibile come funzionalità sperimentale; non è consigliabile per l'uso in produzione in questo momento. L'aggiornamento delle dipendenze della libreria MSBuild per l'uso delle API in modalità multithreading impedisce in modo implicito l'esecuzione delle librerie nelle versioni precedenti di Visual Studio e MSBuild. Invitiamo gli early adopter a provare la modalità multithreading e fornire feedback. Inviare problemi nel repository MSBuild GitHub.
L'interfaccia IMultiThreadableTask definisce il contratto per le attività che possono essere eseguite in-process nelle compilazioni multithreading:
// Microsoft.Build.Framework
public interface IMultiThreadableTask : ITask
{
TaskEnvironment TaskEnvironment { get; set; }
}
Per eseguire la migrazione di un'attività, implementare IMultiThreadableTask insieme alla classe di base esistente Task ed esporre la TaskEnvironment proprietà :
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;
// ...
}
Le attività che implementano IMultiThreadableTask possono essere eseguite nel processo. Tutte queste attività devono inoltre includere l'attributo [MSBuildMultiThreadableTask], ovvero il marcatore usato da MSBuild per abilitare l'esecuzione in-process dell'attività. Prima di aggiungere l'attributo, verificare che l'attività non abbia dipendenze da costrutti a livello di processo come la directory di lavoro corrente o l'ambiente e che il codice sia thread-safe. Prestare particolare attenzione a garantire l'accesso thread-safe alle variabili statiche, poiché queste variabili vengono condivise tra tutte le istanze dell'attività e potrebbero essere accessibili o modificate da istanze diverse dell'attività in esecuzione nello stesso processo.
Attività di esempio: BuildCommentTask
L'esempio AddBuildCommentTask seguente viene usato in questo articolo per illustrare il processo di migrazione. Questa attività antepone un commento di compilazione ai file di testo. Per impostazione predefinita, scrive testo normale; le proprietà facoltative CommentPrefix e CommentSuffix consentono ai chiamanti di eseguire il wrapping del commento nella sintassi appropriata per il linguaggio, ad esempio // per C#, <!-- e --> per XML, # per Python o 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;
}
}
}
Un file di progetto potrebbe richiamare questa attività per tipi di file diversi, passando la sintassi di commento appropriata per ognuno di essi:
<!-- 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=" -->" />
Questa attività presenta quattro problemi di thread-safety che devono essere risolti per le compilazioni multithreading:
-
Percorsi relativi:
File.ReadAllLineseFile.WriteAllLinesusareitem.ItemSpecdirettamente, che potrebbe essere un percorso relativo. In modalità multithreading, la directory di lavoro del processo non è necessariamente la directory del progetto. -
Campo statico:
ModifiedFileCountè un campostaticcondiviso tra tutte le istanze, il che causa condizioni di competizione sui dati quando più build vengono eseguite contemporaneamente. -
Variabili di ambiente: il problema più comune delle variabili di ambiente nelle compilazioni multithreading è le attività che impostano le variabili di ambiente prima di generare un processo figlio, prevedendo che l'elemento figlio li erediti. In modalità multithreading,
Environment.SetEnvironmentVariable()modifica l'ambiente a livello di processo condiviso da tutte le build concorrenti, quindi una modifica destinata al processo figlio di un progetto può propagarsi anche a quello di un altro. La lettura delle variabili di ambiente direttamente nel codice dell'attività (Environment.GetEnvironmentVariable()) è in genere una procedura non valida; Le proprietà di MSBuild sono un'alternativa migliore perché sono registrate e tracciabili.
Importante
La modalità di compilazione multithreading è attualmente disponibile solo per le compilazioni dell'interfaccia della riga di comando (dotnet build e MSBuild.exe). Le compilazioni di MSBuild in Visual Studio non supportano ancora l'esecuzione multithread in-process. In Visual Studio, l'esecuzione di tutte le attività continua a essere eseguita fuori processo. L'integrazione con Visual Studio è prevista per una versione futura.
Prerequisites
MSBuild 18.6 o versione successiva.
Abilitare l'esecuzione di attività multithreading con l'opzione della
-mtriga di comando:dotnet build -mtPer altre informazioni sull'opzione
-mt, vedere Riferimento della riga di comando di MSBuild.
Pianificare la migrazione
Esamina il codice dell’attività per individuare i seguenti problemi:
- Controllare il codice dell'attività e identificare qualsiasi utilizzo dei percorsi relativi. Controllare tutte le operazioni di I/O di input e file.
- Verificare la presenza di eventuali usi delle variabili di ambiente.
- Verifica l'eventuale utilizzo dell'API
ProcessStartInfo. - Controllare i campi statici o le strutture di dati e usare metodi standard per renderli thread-safe.
- Se nessuno dei precedenti si applica, è consigliabile aggiungere solo l'attributo .
- Prendere in considerazione requisiti speciali per supportare le versioni precedenti di MSBuild. Vedere Supportare le versioni precedenti di MSBuild.
Informazioni di riferimento rapido sulla sostituzione dell'API
La tabella seguente riepiloga le API .NET da sostituire e i relativi equivalenti TaskEnvironment:
| .NET API da evitare | Livello | Sostituzione |
|---|---|---|
Path.GetFullPath(path) |
ERROR | Vedere la nota seguente a questa tabella |
File.* con percorsi relativi |
ERROR | Risolvi prima con TaskEnvironment.GetAbsolutePath() |
Directory.* con percorsi relativi |
ERROR | Risolvere prima con TaskEnvironment.GetAbsolutePath() |
Environment.GetEnvironmentVariable() |
ERROR | TaskEnvironment.GetEnvironmentVariable() |
Environment.SetEnvironmentVariable() |
ERROR | TaskEnvironment.SetEnvironmentVariable() |
Environment.CurrentDirectory |
ERROR | TaskEnvironment.ProjectDirectory |
new ProcessStartInfo() |
ERROR | TaskEnvironment.GetProcessStartInfo() |
Process.Start() |
ERROR | Usare ToolTask o TaskEnvironment.GetProcessStartInfo() |
| Campi statici | AVVERTIMENTO | Usare campi di istanza o raccolte thread-safe |
Note
Path.GetFullPath(path) esegue due operazioni: converte un percorso relativo in un percorso assoluto e produce una forma canonica del percorso (risoluzione . e .. segmenti). Questi devono essere gestiti separatamente:
-
Solo percorso assoluto: usare
TaskEnvironment.GetAbsolutePath(path). Questo approccio è sufficiente per la maggior parte delle operazioni di I/O dei file in cui si passa il percorso direttamente alle API di .NET. -
Percorso canonico: se ci si basa sul formato canonico (ad esempio, quando si usa un percorso come cache o chiave del dizionario), usare
Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path))per ottenere un percorso assoluto canonico completamente risolto.
Contrassegnare l'attività con l'attributo
Tutte le attività che partecipano a compilazioni multithreading devono essere contrassegnate con l'attributo [MSBuildMultiThreadableTask] . Questo attributo è il segnale usato da MSBuild per identificare le attività che sono sicure per l'esecuzione in-process.
[MSBuildMultiThreadableTask]
public class MyTask : Task
{
public override bool Execute()
{
// Task logic that doesn't depend on process-level state
return true;
}
}
Se l'attività è già thread-safe e non usa API a livello di processo (directory di lavoro corrente, variabili di ambiente, ProcessStartInfo), l'attributo da solo è sufficiente. L'attività continua a ereditare da Task (o ToolTask) senza altre modifiche.
Se l'attività richiede di sostituire le chiamate alle API a livello di processo (ad esempio, per risolvere i percorsi relativi o leggere in modo sicuro le variabili di ambiente), implementa anche IMultiThreadableTask. Questa interfaccia consente alla tua attività di accedere alla proprietà TaskEnvironment. L'attributo rimane obbligatorio in entrambi i casi; IMultiThreadableTask è un passaggio aggiuntivo che sblocca il TaskEnvironment API.
Note
MSBuild rileva MSBuildMultiThreadableTaskAttribute solo in base allo spazio dei nomi e al nome, ignorando l'assembly in cui è definito. Ciò significa che è possibile definire l'attributo manualmente nel proprio codice (vedere Supportare le versioni precedenti di MSBuild) e MSBuild lo riconosce ancora.
Note
Il MSBuildMultiThreadableTaskAttribute non è ereditabile (Inherited = false). Ogni classe di attività deve dichiarare esplicitamente l'attributo affinché sia riconosciuto come multithread. Derivare da una classe che dispone dell'attributo non rende automaticamente multithread la classe derivata.
Inizializza TaskEnvironment in modalità di riserva
Quando si implementa IMultiThreadableTask, inizializzare la TaskEnvironment proprietà in TaskEnvironment.Fallback:
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
MSBuild imposta questa proprietà prima di chiamare Execute() in una compilazione normale. L'impostazione Fallback predefinita garantisce che l'attività funzioni correttamente in altri scenari di hosting, ad esempio unit test o strumenti di orchestrazione di compilazione personalizzati, in cui MSBuild non è presente per impostare la proprietà. Senza di esso, l'accesso a TaskEnvironment al di fuori del motore genererebbe un'eccezione di riferimento null.
Se è necessario supportare le versioni di MSBuild precedenti alla 18.6 che non includono TaskEnvironment.Fallback, inizializzare invece la proprietà in null e proteggere le TaskEnvironment chiamate con un controllo Null. Per altre opzioni, vedere Supportare le versioni precedenti di MSBuild .
Aggiornare i percorsi e l'I/O dei file
Un'attività spesso accetta input, ad esempio elenchi di elementi in MSBuild, che se sono file, potrebbe essere sotto forma di percorsi relativi.
I percorsi relativi sono sempre riferiti alla directory di lavoro corrente del processo, ma poiché l'attività ora viene eseguita all'interno dello stesso processo, la directory di lavoro potrebbe non essere più la stessa di quando l'attività veniva eseguita nel proprio processo. Questi percorsi sono relativi alla directory del progetto.
TaskEnvironment include una ProjectDirectory proprietà e un GetAbsolutePath() metodo che è possibile utilizzare per risolvere i percorsi relativi ai percorsi assoluti. È anche possibile accedere al metadato FullPath; non è necessario utilizzare il percorso relativo ItemSpec e quindi renderlo assoluto.
Il tipo AbsolutePath
AbsolutePath è uno struct readonly in Microsoft.Build.Framework che rappresenta un percorso di file assoluto convalidato. I membri chiave includono:
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);
}
Il AbsolutePath costruttore convalida che il percorso specificato sia rooted. È anche possibile costruire un oggetto AbsolutePath specificando un percorso relativo e un percorso di base. La conversione implicita a string significa che è possibile passare direttamente un AbsolutePath a qualsiasi API che si aspetta un percorso string.
La proprietà OriginalValue conserva la stringa di percorso originale così come è stata passata prima della risoluzione. Questa proprietà è utile quando è necessario mantenere i percorsi relativi negli output delle attività o nei messaggi di log. Ad esempio, un'attività che registra quali file ha elaborato può usare OriginalValue nei messaggi di log, in modo che i percorsi nell'output rimangano relativi e leggibili, pur continuando a usare il Value risolto (o la conversione implicita string) per le effettive operazioni di I/O sui file.
Usare TaskEnvironment.GetAbsolutePath() per risolvere i percorsi degli elementi:
Prima:
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));
Dopo:
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}");
Gestire la contesa dei file nelle compilazioni parallele
La contesa dei file può verificarsi ogni volta che più attività vengono eseguite in parallelo e accedono allo stesso file. Questo problema riguarda sia il modello multiprocesso tradizionale che la modalità multithreading più recente. In entrambi i casi, è possibile accedere allo stesso file contemporaneamente quando:
- Lo stesso file viene visualizzato in più compilazioni di sottoprogetti, ad esempio un file di configurazione condiviso o un file di origine collegato.
- Un'attività legge e scrive un file che viene elaborato anche da un'altra istanza dell'attività.
Metodi pratici come File.ReadAllLines e File.WriteAllLines non forniscono un controllo esplicito sul blocco dei file. Quando è possibile l'accesso simultaneo, usare FileStream con condivisione e blocco espliciti:
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);
}
Linee guida chiave per l'I/O dei file nelle attività multithreading:
- Usare
FileShare.Noneper le operazioni di lettura-modifica/scrittura. Questa impostazione impedisce a un'altra attività di leggere contenuto non aggiornato durante l'aggiornamento del file. - Prendere
IOExceptionin considerazione la ripetizione dei tentativi. Quando un'altra attività o processo contiene un blocco, il tentativo di apertura generaIOException. Un breve tentativo con backoff è spesso appropriato. - Evitare di bloccare più file contemporaneamente. Se due attività bloccano un file e quindi provano a bloccare l'altro, si ottiene un deadlock. Se è necessario operare su più file, bloccarli in un ordine coerente, ad esempio ordinato in base al percorso completo.
- Mantenere i blocchi il più breve possibile. Aprire il file, leggere, modificare, scrivere e chiudere in un'unica operazione. Non conservare un blocco di file durante l'esecuzione di operazioni non correlate.
L'esempio precedente è un approccio. Per indicazioni generali sull'I/O dei file thread-safe in .NET, vedere ClasseFileStream, FileShare enum e Managed threading best practices.
Note
TaskEnvironment di per sé non è thread-safe. Questo vale solo se l'attività genera internamente i propri thread (ad esempio, usando Parallel.ForEach o Task.Run). La maggior parte delle attività non esegue questa operazione. Implementano Execute() in modo lineare e consentono a MSBuild di gestire il parallelismo tra le istanze dell'attività. Se l'attività crea effettivamente thread propri, acquisite i valori da TaskEnvironment in variabili locali prima di avviarli, anziché accedere a TaskEnvironment contemporaneamente da più thread.
Aggiornare le variabili di ambiente
Note
La lettura delle variabili di ambiente nel codice dell'attività è in genere una procedura non valida, anche nelle compilazioni a thread singolo. Le proprietà di MSBuild sono un'alternativa migliore: sono con ambito esplicito, registrate durante la compilazione e tracciabili nel log di compilazione. Se l'attività legge attualmente una variabile di ambiente per ricevere l'input, è consigliabile sostituirla con una proprietà dell'attività. Il progetto può comunque derivare il valore da una variabile di ambiente: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />.
Le indicazioni contenute in questa sezione sono relative alla migrazione di attività esistenti che si basano già sulle variabili di ambiente. Se si ha l’opportunità di procedere con il refactoring, è preferibile usare proprietà ed elementi.
Impostazione delle variabili di ambiente per i processi figlio
Il problema più comune delle variabili di ambiente nelle compilazioni multithreading è un'attività che imposta una variabile di ambiente e quindi genera un processo figlio, prevedendo che l'elemento figlio lo erediti. Nel modello multiprocesso, Environment.SetEnvironmentVariable() ha modificato in modo sicuro l'ambiente del processo worker per quel progetto. In modalità multithreading, il processo viene condiviso tra tutte le compilazioni simultanee, quindi una modifica destinata al processo figlio di un progetto può trapelare in un'altra.
Usare TaskEnvironment.SetEnvironmentVariable() insieme a TaskEnvironment.GetProcessStartInfo() (vedi Aggiornare le chiamate API ProcessStart).
GetProcessStartInfo() restituisce un ProcessStartInfo precompilato con la directory di lavoro del progetto e la tabella delle variabili del relativo ambiente isolato, incluse tutte le variabili impostate con SetEnvironmentVariable(), così che i processi figli ereditino automaticamente l'ambiente corretto, limitato al progetto.
Prima:
Environment.SetEnvironmentVariable("TOOL_OUTPUT_DIR", outputDir);
var startInfo = new ProcessStartInfo("mytool.exe") { UseShellExecute = false };
Process.Start(startInfo); // inherits the modified process-level environment
Dopo:
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
Lettura delle variabili di ambiente nelle attività esistenti
Se l'attività esistente legge le variabili di ambiente e non è possibile effettuare immediatamente il refactoring nelle proprietà dell'attività, sostituire Environment.GetEnvironmentVariable() con TaskEnvironment.GetEnvironmentVariable(). Questa chiamata al metodo legge i dati dalla tabella delle variabili d'ambiente relativa al progetto anziché dall'ambiente di processo condiviso, quindi le build concorrenti non interferiscano l'una con l'altra.
Precedente (da BuildCommentTask):
string disableComments = Environment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
Dopo:
string disableComments = TaskEnvironment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
Suggerimento
Quando si aggiorna il codice esistente che legge una variabile di ambiente, è consigliabile sostituire il modello con una proprietà dell'attività. Ad esempio, esporre public bool DisableComments { get; set; } nel task e consentire al progetto di passare DisableComments="$(DISABLE_BUILD_COMMENTS)". MSBuild registra il valore risolto, rendendolo visibile nel log di compilazione e molto più semplice da diagnosticare rispetto a una variabile di ambiente nascosta letta.
Aggiornare le chiamate API ProcessStart
In genere, se un'attività avvia un processo, è consigliabile usare ToolTask, che gestisce tutti gli elementi per l'utente. Nei casi in cui si sta aggiornando un'attività che chiama ProcessStartInfo direttamente, usare TaskEnvironment.GetProcessStartInfo(). Restituisce un oggetto ProcessStartInfo configurato con la directory di lavoro del progetto e la tabella del relativo ambiente isolato. Se si impostano anche le variabili di ambiente prima dell'avvio, usare TaskEnvironment.SetEnvironmentVariable() prima di tutto, come illustrato nella sezione precedente.
Prima:
var startInfo = new ProcessStartInfo("mytool.exe")
{
WorkingDirectory = ".",
UseShellExecute = false
};
Process.Start(startInfo);
Dopo:
ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo();
startInfo.FileName = "mytool.exe";
startInfo.UseShellExecute = false;
Process.Start(startInfo);
Note
Se l'attività eredita da ToolTask, le informazioni di avvio del processo sono già gestite automaticamente. È sufficiente aggiornare le attività che creano ProcessStartInfo direttamente.
Aggiornare i campi statici e le strutture di dati in modo che siano thread-safe
I campi statici richiedono un trattamento accurato quando si esegue la migrazione a compilazioni multithreading. Anche nel modello multiprocesso, un singolo processo può compilare più progetti, quindi lo stato statico viene condiviso, non solo contemporaneamente.
La modalità multithreading aggiunge una nuova dimensione a questo problema. Più compilazioni possono ora condividere lo stesso processo ed eseguire attività contemporaneamente (in particolare con MSBuild Server, che viene abilitato automaticamente con il multithreading). Un campo statico viene condiviso tra tutte le istanze di attività nel processo, non solo all'interno della compilazione, ma potenzialmente tra chiamate di compilazione separate in esecuzione simultaneamente. Ad esempio, due sviluppatori che eseguono dotnet build contemporaneamente su un server di build, oppure due finestre di terminale sulla stessa macchina, potrebbero condividere lo stesso stato statico e quindi tali build vi accedono contemporaneamente.
Nell'esempio BuildCommentTask il campo ModifiedFileCount statico viene condiviso tra tutte le istanze:
Prima:
private static int ModifiedFileCount = 0;
// In Execute():
ModifiedFileCount++;
Questo codice presenta due problemi. In primo luogo, l'operatore ++ non è atomico. Quando più istanze di attività vengono eseguite contemporaneamente, due thread possono leggere lo stesso valore e entrambi scrivono lo stesso risultato incrementato, causando conteggi persi. In secondo luogo, poiché il campo è statico, viene mantenuto tra compilazioni e viene condiviso tra compilazioni simultanee nello stesso processo.
Le sezioni seguenti illustrano due approcci per risolvere questi problemi, dal più semplice al più corretto.
Approccio 1: usare un'API thread-safe, ma valida per l'intero processo
La correzione più semplice consiste nel rendere atomico l'incremento:
private static int ModifiedFileCount = 0;
// In Execute():
int fileNumber = Interlocked.Increment(ref ModifiedFileCount);
Interlocked.Increment esegue l'operazione di lettura-incremento-scrittura come singola operazione atomica, quindi non viene perso alcun conteggio. Questo approccio risolve il problema di concorrenza, ma il contatore è ancora condiviso tra tutte le compilazioni del processo, incluse le compilazioni consecutive e le compilazioni simultanee. Se due compilazioni vengono eseguite contemporaneamente, i relativi numeri di file si interleave (la compilazione A ottiene #1, #3, #5; La build B ottiene #2, #4, #6). Se questa situazione sia accettabile dipende dal fatto che la tua operazione richieda l'isolamento per ogni build. Per un contatore di numerazione di file sequenziale, ad esempio ModifiedFileCount, la condivisione tra compilazioni è un problema di correttezza. Usare RegisterTaskObject invece (vedere Approccio 2).
Qui, l'equivalente dell'API thread-safe ma estesa all'intero processo è InterlockedIncrement, ma nel vostro codice dovreste trovare sostituti thread-safe appropriati per tutte le API che non lo sono. Ad esempio, se l'attività mantiene lo stato usando , Dictionaryè consigliabile usare ConcurrentDictionary<TKey,TValue>.
Approccio 2: RegisterTaskObject per l'isolamento con ambito di build
Se l'attività necessita di uno stato statico condiviso tra progetti secondari all'interno di una singola chiamata di compilazione ma isolato da altre compilazioni simultanee, usare IBuildEngine4.RegisterTaskObject con RegisteredTaskObjectLifetime.Build. MSBuild gestisce la durata dell'oggetto, creato al primo utilizzo e pulito al termine della compilazione. Si noti che gli oggetti registrati devono essere thread-safe.
Per prima cosa, definire una semplice classe di contatori thread-safe:
internal class FileCounter
{
private int _count = 0;
public int Next() => Interlocked.Increment(ref _count);
}
Usa quindi un metodo di supporto con blocco a doppio controllo per ottenere o creare il contatore:
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;
}
In Execute():
FileCounter counter = GetOrCreateCounter();
// ...
int fileNumber = counter.Next();
Con questo approccio, ogni chiamata di compilazione ottiene il proprio FileCounter. Tutti i sottoproi progetti all'interno della stessa compilazione condividono il contatore (numerazione sequenziale), ma un'esecuzione separata dotnet build nello stesso computer ottiene un contatore diverso.
RegisteredTaskObjectLifetime.Build indica a MSBuild di definire l'ambito dell'oggetto alla chiamata di compilazione corrente e di pulirla al termine della compilazione.
Scegliere l'approccio corretto
Quando si decide come gestire lo stato statico, iniziare da questa domanda: questi dati sono sicuri da condividere in tutte le compilazioni che potrebbero mai essere eseguite nello stesso processo, incluse le compilazioni consecutive e le compilazioni simultanee?
I processi di lavoro MSBuild vengono mantenuti tra le chiamate (il riutilizzo dei nodi è attivo per impostazione predefinita) e un processo MSBuild può potenzialmente servire più compilazioni di soluzioni per tutta la durata, non solo all'interno di una singola dotnet build chiamata. Non presupporre che un processo gestisca una sola compilazione.
Usa queste linee guida:
- Mantenere il campo statico solo se i dati memorizzati nella cache sono sicuri per l'accesso da più thread in progetti diversi e in più compilazioni senza richiedere invalidazione tra compilazioni. Ad esempio, una cache di dati non modificabili calcolati una volta dagli input che non cambiano mai (ad esempio, i metadati dell'assembly caricati una volta all'avvio) potrebbero qualificarsi.
-
Utilizzare
IBuildEngine4.RegisterTaskObjectconRegisteredTaskObjectLifetime.Buildquando lo stato deve essere isolato per ogni invocazione della compilazione (ad esempio, contatori, accumulatori o cache che devono essere reimpostati tra una compilazione e l’altra oppure non essere condivisi tra compilazioni simultanee). Questo è l'approccio preferito per la maggior parte dello stato modificabile condiviso. -
Usa
System.Threadingi costrutti primitivi (Interlocked,ConcurrentDictionary,lock,ReaderWriterLockSlim) per rendere thread-safe qualsiasi stato statico mantenuto, ma ricorda che la sola thread-safety non fornisce l'isolamento a livello di build. Vedere Procedure consigliate per il threading gestito.
Suggerimento
L'esempio completo di migrazione riportato più avanti in questo articolo usa l'approccio RegisterTaskObject per illustrare l'isolamento con ambito di compilazione.
Esempio di migrazione completa
Il codice seguente mostra la migrazione AddBuildCommentTask completa con tutte e cinque le modifiche applicate:
- Ha l'attributo
[MSBuildMultiThreadableTask], che lo contrassegna per l'esecuzione nel processo. - Implementa
IMultiThreadableTaskinsieme alla classe base esistenteTasked espone laTaskEnvironmentproprietà . - Utilizza
TaskEnvironment.GetAbsolutePath()per la risoluzione del percorso. -
TaskEnvironment.GetEnvironmentVariable()Usa anzichéEnvironment.GetEnvironmentVariable(). -
IBuildEngine4.RegisterTaskObjectUsa conRegisteredTaskObjectLifetime.Buildper definire l'ambito del contatore di file nella chiamata di compilazione corrente, sostituendo il contatore statico a livello di processo.
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;
}
}
}
Cosa accade alle attività non migrate
Le attività che non hanno l'attributo [MSBuildMultiThreadableTask] o non implementano IMultiThreadableTask continuano a funzionare senza modifiche. MSBuild esegue queste attività in un processo sussidiario TaskHost , che fornisce lo stesso isolamento a livello di processo delle versioni precedenti di MSBuild. Questo approccio è più lento a causa del sovraccarico della comunicazione tra processi, ma è completamente compatibile con il codice attività esistente. La migrazione è facoltativa per la correttezza, ovvero le attività non migrate producono comunque risultati corretti, ma la migrazione migliora le prestazioni di compilazione.
Supportare versioni precedenti di MSBuild
Se si aggiorna l'attività personalizzata e la si distribuisce ad altri utenti, l'attività supporta i client che usano MSBuild 18.6 o versione successiva. Per supportare i client nelle versioni precedenti di MSBuild, sono disponibili tre opzioni.
Opzione 1: Accettare prestazioni ridotte
Non apportare modifiche all'attività. MSBuild esegue attività non con attributi in un processo sussidiario TaskHost , che è più lento ma completamente compatibile. Questa opzione non richiede modifiche al codice.
Opzione 2: Gestire implementazioni separate
Compila assembly di task separate per MSBuild 18.6+ e versioni precedenti. La versione di MSBuild 18.6+ implementa IMultiThreadableTask e usa TaskEnvironment. La versione precedente continua a utilizzare Task con le API a livello di processo.
Opzione 3: Bridge di compatibilità
Definisci autonomamente MSBuildMultiThreadableTaskAttribute nell'assembly dell'attività. Poiché MSBuild rileva solo l'attributo in base allo spazio dei nomi e al nome (ignorando l'assembly di definizione), l'attributo self-defined funziona sia nelle versioni precedenti che in quelle nuove di MSBuild:
namespace Microsoft.Build.Framework
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
internal class MSBuildMultiThreadableTaskAttribute : Attribute { }
}
Quando si esegue in MSBuild 18.6 o versione successiva, MSBuild riconosce l'attributo ed esegue l'attività in-process. Durante l'esecuzione nelle versioni precedenti, MSBuild ignora l'attributo sconosciuto ed esegue l'attività come prima.
Con questa opzione, non si ha accesso a TaskEnvironment, quindi sarà necessario gestire manualmente tutti gli elementi gestiti, ad esempio la conversione di tutti i percorsi relativi in percorsi assoluti.
Confronto tra approcci
Nella tabella seguente vengono confrontati i tre approcci in esecuzione in modalità multithreading (-mt). In modalità senza multithreading, tutte le attività vengono eseguite fuori processo indipendentemente da come sono contrassegnate.
| Approccio | Maintenance | Prestazioni (18.6+) | Prestazioni (precedenti) | Accesso a TaskEnvironment |
|---|---|---|---|---|
| Implementazioni separate | Alto | Interamente in corso | Completamente fuori processo | Sì (versione 18.6+) |
| Ponte di compatibilità | Basso | Processo completo | Completamente esterno al processo | No (solo attributo) |
| Nessuna modifica | None | Sidecar (più lento) | Completamente fuori processo | No |