Uppdatera en MSBuild-uppgift så att den fungerar i flertrådsläge

MSBuild 18.6 introducerar möjligheten att bygga parallellt i samma process. Om du vill aktivera det här läget anger du kommandoradsväxeln -mt. Tidigare versioner av MSBuild stödde parallella versioner, men byggen gjordes i separata processer. Den här ändringen påverkar hur du skapar uppgifter. Tidigare kördes aktiviteter i en separat process, men nu körs alla flertrådsaktiverade uppgifter i samma process. Även om den mesta logiken inte behöver ändras finns det vissa konstruktioner på processnivå som måste hanteras mer noggrant. Konstruktioner på processnivå omfattar den aktuella arbetskatalogen, miljövariabler och processstartinformation (ProcessStartInfo).

För att stödja dessa ändringar introducerar MSBuild 18.6 gränssnittet IMultiThreadableTask (i Microsoft.Build.Framework) och klassen TaskEnvironment. TaskEnvironment innehåller en ProjectDirectory egenskap och metoder som GetAbsolutePath(), GetEnvironmentVariable(), SetEnvironmentVariable()och GetProcessStartInfo().

Important

Multitrådat läge är för närvarande tillgängligt som en experimentell funktion. Det rekommenderas inte för produktionsanvändning just nu. Om du uppdaterar dina MSBuild-biblioteksberoenden för att använda API:erna för flertrådat läge förhindrar det implicit att dina bibliotek körs på äldre versioner av Visual Studio och MSBuild. Vi uppmuntrar tidiga användare att prova flertrådsläge och ge feedback. Skicka problem på lagringsplatsen MSBuild GitHub.

Gränssnittet IMultiThreadableTask definierar kontraktet för uppgifter som kan köras i process i flertrådade versioner:

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

För att migrera en uppgift implementerar du IMultiThreadableTask vid sidan av din befintliga basklass Task och exponerar egenskapen 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;
    // ...
}

Uppgifter som implementerar IMultiThreadableTask kan köras i process. Alla sådana uppgifter måste också ha attributet [MSBuildMultiThreadableTask], vilket är den markering som MSBuild använder för att köra uppgiften i processen. Innan du lägger till attributet kontrollerar du att aktiviteten inte har några beroenden för konstruktioner på processnivå, till exempel den aktuella arbetskatalogen eller miljön, och att koden är trådsäker. Var särskilt uppmärksam på att säkerställa trådsäker åtkomst till statiska variabler, eftersom dessa variabler delas mellan alla uppgiftsinstanser och kan kommas åt eller ändras av olika instanser av uppgiften som också körs i samma process.

Exempeluppgift: BuildCommentTask

Följande exempel AddBuildCommentTask används i hela den här artikeln för att illustrera migreringsprocessen. Den här uppgiften lägger till en byggkommentar i början av textfiler. Som standard skriver den oformaterad text. Med de valfria egenskaperna CommentPrefix och CommentSuffix kan anropare omsluta kommentaren med språkanpassad syntax (till exempel // för C#, <!-- och --> för XML, # för Python eller 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;
        }
    }
}

En projektfil kan anropa den här uppgiften för olika filtyper och skicka lämplig kommentarssyntax för var och en:

<!-- 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;" />

Den här uppgiften har fyra trådsäkerhetsproblem som måste åtgärdas för flertrådade versioner:

  1. Relativa sökvägar: File.ReadAllLines och File.WriteAllLines använd item.ItemSpec direkt, vilket kan vara en relativ sökväg. I flertrådsläge garanteras inte arbetskatalogen för processer att vara projektkatalogen.
  2. Statiskt fält: ModifiedFileCount är ett static fält som delas över alla instanser, vilket orsakar dataraser när flera versioner körs samtidigt.
  3. Miljövariabler: Det vanligaste problemet med miljövariabler i flertrådade byggen är uppgifter som ställer in miljövariabler innan de skapar en underprocess, i förväntan att underprocessen ska ärva dem. I flertrådsläge ändrar Environment.SetEnvironmentVariable() miljön på processnivå som delas av alla samtidiga byggen, så att en ändring som är avsedd för ett projekts underprocess kan spilla över till ett annat. Att läsa miljövariabler direkt i aktivitetskod (Environment.GetEnvironmentVariable()) är också i allmänhet en dålig metod. MSBuild-egenskaper är ett bättre alternativ eftersom de är loggade och spårbara.

Important

Det flertrådade byggläget är för närvarande endast tillgängligt för CLI-versioner (dotnet build och MSBuild.exe). Visual Studio MSBuild-versioner har ännu inte stöd för flertrådad körning i processen. I Visual Studio fortsätter all körning av uppgifter att ske i en separat process. Visual Studio integrering planeras för en framtida version.

Förutsättningar

  • MSBuild 18.6 eller senare.

  • Aktivera körning av flertrådade aktiviteter med kommandoradsväxeln -mt :

    dotnet build -mt
    

    Mer information om växeln finns i -mtMSBuild-kommandoradsreferens.

Planera migreringen

Granska aktivitetskoden för följande problem:

  1. Kontrollera aktivitetskoden och identifiera eventuell användning av relativa sökvägar. Kontrollera alla indata och fil-I/O.
  2. Sök efter användning av miljövariabler.
  3. Sök efter eventuell ProcessStartInfo API-användning.
  4. Kontrollera eventuella statiska fält eller datastrukturer och använd standardmetoder för att göra dem trådsäkra.
  5. Om inget av ovanstående gäller kan du överväga att bara lägga till attributet.
  6. Överväg särskilda krav för att stödja tidigare versioner av MSBuild. Se Stöd för tidigare versioner av MSBuild.

Snabbreferens för att ersätta API:er

I följande tabell sammanfattas de .NET API:er som du bör ersätta och deras TaskEnvironment motsvarigheter:

API i .NET att undvika Nivå Ersättning
Path.GetFullPath(path) FEL Se anteckningen som följer den här tabellen
File.* med relativa sökvägar FEL Lös med TaskEnvironment.GetAbsolutePath() först
Directory.* med relativa sökvägar FEL Lös med TaskEnvironment.GetAbsolutePath() först
Environment.GetEnvironmentVariable() FEL TaskEnvironment.GetEnvironmentVariable()
Environment.SetEnvironmentVariable() FEL TaskEnvironment.SetEnvironmentVariable()
Environment.CurrentDirectory FEL TaskEnvironment.ProjectDirectory
new ProcessStartInfo() FEL TaskEnvironment.GetProcessStartInfo()
Process.Start() FEL Använd ToolTask eller TaskEnvironment.GetProcessStartInfo()
Statiska fält VARNING Använda instansfält eller trådsäkra samlingar

Anmärkning

Path.GetFullPath(path) gör två saker: den konverterar en relativ sökväg till en absolut sökväg och skapar en kanonisk form av sökvägen (matchning . och .. segment). Dessa måste hanteras separat:

  • Endast absolut sökväg: Använd TaskEnvironment.GetAbsolutePath(path). Den här metoden räcker för de flesta fil-I/O-åtgärder där du skickar sökvägen direkt till .NET API:er.
  • Kanonisk sökväg: Om du förlitar dig på det kanoniska formuläret (till exempel när du använder en sökväg som en cache- eller ordboksnyckel) kan du använda Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path)) för att få en helt löst, kanonisk absolut sökväg.

Markera uppgiften med attributet

Alla uppgifter som ingår i flertrådade byggen måste märkas med [MSBuildMultiThreadableTask]-attributet. Det här attributet är den signal MSBuild använder för att identifiera uppgifter som är säkra att köra i processen.

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

Om din uppgift redan är trådsäker och inte använder några API:er på processnivå (aktuell arbetskatalog, miljövariabler) ProcessStartInfoär attributet ensamt allt du behöver. Uppgiften fortsätter att ärva från Task (eller ToolTask) utan några andra ändringar.

Om din uppgift behöver ersätta API-anrop på processnivå (till exempel för att lösa relativa sökvägar eller läsa miljövariabler på ett säkert sätt) ska du även implementera IMultiThreadableTask. Det här gränssnittet ger din uppgift åtkomst till egenskapen TaskEnvironment . Attributet är fortfarande obligatoriskt i båda fallen. IMultiThreadableTask är ytterligare ett steg som låser TaskEnvironment upp API.

Anmärkning

MSBuild identifierar MSBuildMultiThreadableTaskAttribute endast efter namnrymd och namn och ignorerar den definierande sammansättningen. Det innebär att du kan definiera attributet själv i din egen kod (se Stöd för tidigare versioner av MSBuild) och MSBuild känner fortfarande igen det.

Anmärkning

MSBuildMultiThreadableTaskAttribute är inte ärverbar (Inherited = false). Varje aktivitetsklass måste uttryckligen deklarera attributet som multitrådbart. Att ärva från en klass som har attributet innebär inte automatiskt att den härledda klassen kan köras i flera trådar.

Initiera TaskEnvironment till reservläge

När du implementerar IMultiThreadableTaskinitierar du TaskEnvironment egenskapen till TaskEnvironment.Fallback:

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

MSBuild anger den här egenskapen innan den anropas Execute() i en normal version. Standardinställningen Fallback säkerställer att uppgiften fungerar korrekt i andra värdscenarier (till exempel enhetstester eller verktyg för anpassad build-orkestrering) där MSBuild inte finns för att ange egenskapen. Utan den skulle åtkomst till TaskEnvironment utanför motorn utlösa ett nullreferensfel.

Om du behöver stöd för MSBuild-versioner tidigare än 18.6 som inte innehåller TaskEnvironment.Fallback, initierar du egenskapen till null i stället och skyddar alla TaskEnvironment anrop med en null-kontroll. Fler alternativ finns i Stöd för tidigare versioner av MSBuild .

Uppdatera sökvägar och fil-I/O

En uppgift tar ofta emot indata, till exempel objektlistor i MSBuild, vilka, om de är filer, kan anges som relativa sökvägar.

Relativa sökvägar är alltid relativa till processens aktuella arbetskatalog, men eftersom aktiviteten nu körs inom processen kanske arbetskatalogen inte är densamma som den var när aktiviteten kördes i sin egen process. Sådana sökvägar är relativa till projektkatalogen. TaskEnvironment innehåller egenskapen ProjectDirectory och metoden GetAbsolutePath() som du kan använda för att omvandla relativa sökvägar till absoluta sökvägar. Du kan också komma åt FullPath metadatum. Du behöver inte använda den ItemSpec relativa sökvägen och sedan absolutisera den.

AbsolutePath-typen

AbsolutePath är en skrivskyddad struktur i Microsoft.Build.Framework som representerar en validerad absolut filsökväg. Viktiga medlemmar är:

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);
}

Konstruktorn AbsolutePath verifierar att den angivna sökvägen är rotad. Du kan också skapa en AbsolutePath genom att ange en relativ sökväg och en bassökväg. Den implicita konverteringen till string innebär att du kan skicka en AbsolutePath direkt till alla API:er som förväntar sig en string-sökväg.

Egenskapen OriginalValue bevarar den ursprungliga sökvägssträngen som den skickades i före upplösningen. Den här egenskapen är användbar när du behöver behålla relativa sökvägar i aktivitetsutdata eller loggmeddelanden. En uppgift som loggar vilka filer som bearbetas kan till exempel använda OriginalValue i sina loggmeddelanden så att sökvägarna i utdata förblir relativa och läsbara, medan de fortfarande använder den lösta Value (eller implicita string konverteringen) för faktisk fil-I/O.

Använd TaskEnvironment.GetAbsolutePath() för att lösa objektsökvägar:

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}");

Hantera filkonflikter i parallella byggen

Filkonkurration kan uppstå när flera uppgifter körs parallellt och får åtkomst till samma fil. Det här problemet gäller både den traditionella multiprocessmodellen och det nyare flertrådsläget i processen. I båda fallen kan samma fil nås samtidigt när:

  • Samma fil visas i flera delprojektversioner (till exempel en delad konfigurationsfil eller en länkad källfil).
  • En uppgift läser och skriver en fil som en annan uppgiftsinstans också bearbetar.

Bekvämlighetsmetoder som File.ReadAllLines och File.WriteAllLines ger inte explicit kontroll över fillåsning. När samtidig åtkomst är möjlig kan du använda FileStream med explicit delning och låsning:

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);
}

Viktiga riktlinjer för fil-I/O i flertrådade uppgifter:

  • Använd FileShare.None vid läs-ändra-skrivåtgärder. Den här inställningen förhindrar att en annan uppgift läser inaktuellt innehåll när du uppdaterar filen.
  • Fånga IOException och överväg att försöka igen. När en annan uppgift eller process innehar ett lås utlöser ditt försök att öppna IOException. Ett kort återförsök med backoff är ofta lämpligt.
  • Undvik att hålla lås på flera filer samtidigt. Om två processer vardera låser en fil och sedan försöker låsa den andra filen uppstår ett dödläge. Om du måste arbeta med flera filer låser du dem i en konsekvent ordning (till exempel sorterat efter fullständig sökväg).
  • Håll låsen så korta som möjligt. Öppna filen, läs, ändra, skriva och stäng i en åtgärd. Håll inte ett fillås när du utför orelaterat arbete.

Föregående exempel är en metod. Allmän vägledning om trådsäker fil-I/O i .NET finns i FileStream-klass, FileShare enum och Hanterad trådmetod.

Anmärkning

TaskEnvironment är inte trådsäkert. Detta spelar bara roll om din uppgift internt startar egna trådar (till exempel med Parallel.ForEach eller Task.Run). De flesta uppgifter gör inte det. De implementerar Execute() linjärt och låter MSBuild hantera parallellitet mellan uppgiftsinstanser. Om din uppgift skapar egna trådar bör du lagra värden från TaskEnvironment i lokala variabler innan du startar dem, i stället för att komma åt TaskEnvironment från flera trådar samtidigt.

Uppdatera miljövariabler

Anmärkning

Att läsa miljövariabler i aktivitetskod är vanligtvis en dålig metod, även i entrådade versioner. MSBuild-egenskaper är ett bättre alternativ: de är uttryckligen begränsade, loggade under bygget och kan spåras i byggloggen. Om din uppgift för närvarande läser en miljövariabel för att ta emot indata kan du överväga att ersätta den med en aktivitetsegenskap i stället. Projektet kan fortfarande härleda värdet från en miljövariabel: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />.

Vägledningen i det här avsnittet är för migrering av befintliga uppgifter som redan är beroende av miljövariabler. Om du har möjlighet att omstrukturera föredrar du egenskaper och objekt.

Ställa in miljövariabler för underprocesser

Det vanligaste problemet med miljövariabler i flertrådade byggen är en aktivitet som ställer in en miljövariabel och sedan startar en underprocess, i förväntan att underprocessen ska ärva den. I multiprocessmodellen Environment.SetEnvironmentVariable() ändrade du arbetsprocessmiljön för projektet på ett säkert sätt. I flertrådsläge delas processen mellan alla samtidiga versioner, så en ändring som är avsedd för ett projekts underordnade process kan läcka till en annan.

Använd TaskEnvironment.SetEnvironmentVariable() tillsammans med TaskEnvironment.GetProcessStartInfo() (se Uppdatera ProcessStart API-anrop). GetProcessStartInfo() returnerar en ProcessStartInfo som är förifylld med projektets arbetskatalog och dess isolerade miljötabell, inklusive alla variabler som du ställer in med SetEnvironmentVariable(), så att underprocesser automatiskt ärver rätt projektavgränsade miljö.

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

Läsa miljövariabler i befintliga uppgifter

Om din befintliga uppgift läser miljövariabler och du inte omedelbart kan omstrukturera till aktivitetsegenskaper ersätter Environment.GetEnvironmentVariable() du med TaskEnvironment.GetEnvironmentVariable(). Det här metodanropet läser från miljötabellen med projektomfattning i stället för den delade processmiljön, så samtidiga versioner stör inte varandra.

Före (från BuildCommentTask):

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

After:

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

Tips/Råd

När du uppdaterar befintlig kod som läser en miljövariabel bör du överväga att ersätta mönstret med en aktivitetsegenskap. Du kan till exempel exponera public bool DisableComments { get; set; } för aktiviteten och låta projektet skicka DisableComments="$(DISABLE_BUILD_COMMENTS)". MSBuild loggar det lösta värdet, vilket gör det synligt i byggloggen och mycket enklare att diagnostisera än att läsa en dold miljövariabel.

Uppdatera ProcessStart API-anrop

Om en uppgift startar en process bör du vanligtvis använda ToolTask, som hanterar allt åt dig. Om du uppdaterar en uppgift som anropar ProcessStartInfo direkt använder du TaskEnvironment.GetProcessStartInfo(). Detta returnerar en ProcessStartInfo som har konfigurerats med projektets arbetskatalog och tabellen för dess isolerade miljö. Om du också ställer in miljövariabler innan du startar använder du TaskEnvironment.SetEnvironmentVariable() först, som du ser i föregående avsnitt.

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

Anmärkning

Om din uppgift ärver från ToolTask, hanteras processstartinformationen redan automatiskt. Du behöver bara uppdatera uppgifter som skapar ProcessStartInfo direkt.

Uppdatera statiska fält och datastrukturer så att de är trådsäkra

Statiska fält kräver noggrann behandling när du migrerar till flertrådade versioner. Även i modellen med flera processer kan en enda process skapa flera projekt, så det statiska tillståndet delas, men inte samtidigt.

Multitrådat läge lägger till en ny dimension i det här problemet. Flera versioner kan nu dela samma process och köra uppgifter samtidigt (särskilt med MSBuild Server, som aktiveras automatiskt med multitrådning). Ett statiskt fält delas över alla aktivitetsinstanser i processen, inte bara i din version, utan potentiellt över separata bygganrop som körs samtidigt. Till exempel kan två utvecklare som kör dotnet build samtidigt på en byggserver, eller två terminalfönster på samma maskin, dela samma statiska tillstånd, och då kan dessa byggen komma åt det samtidigt.

I exemplet BuildCommentTask delas det statiska fältet ModifiedFileCount mellan alla instanser:

Before:

private static int ModifiedFileCount = 0;

// In Execute():
ModifiedFileCount++;

Den här koden har två problem. För det första är operatorn ++ inte atomisk. När flera aktivitetsinstanser körs samtidigt kan två trådar läsa samma värde och båda skriva samma inkrementerade resultat, vilket orsakar förlorade antal. För det andra, eftersom fältet är statiskt, bevaras det mellan versioner och delas mellan samtidiga versioner i samma process.

Följande avsnitt visar två metoder för att åtgärda dessa problem, från enklast till mest korrekta.

Metod 1: Använd ett trådsäkert, men processomfattande API

Den enklaste korrigeringen är att göra inkrementet atomisk:

private static int ModifiedFileCount = 0;

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

Interlocked.Increment utför läs-, inkrementerings- och skrivoperationen som en enda atomisk operation, så att inga ökningar går förlorade. Den här metoden löser samtidighetsproblemet, men räknaren delas fortfarande över alla versioner i processen, inklusive på varandra följande versioner och samtidiga versioner. Om två byggen körs samtidigt varvas deras filnummer (Build A får #1, #3, #5; Build B får #2, #4, #6). Om den här situationen är acceptabel beror på om din uppgift kräver isolering per bygge. För en sekventiell filnumreringsräknare som ModifiedFileCountär delning mellan versioner ett problem med korrekthet. Använd RegisterTaskObject i stället (se Metod 2).

Här är InterlockedIncrementden trådsäkra, men processomfattande API-motsvarigheten , men i din egen kod skulle du behöva hitta lämpliga trådsäkra ersättningar för alla API:er som inte är trådsäkra. Om din uppgift till exempel bevarar tillståndet med hjälp av en Dictionarykan du överväga att använda ConcurrentDictionary<TKey,TValue>.

Metod 2: RegisterTaskObject för isolering med byggomfattning

Om din uppgift behöver statiskt tillstånd som delas mellan underprojekt i ett enda bygganrop men som är isolerat från andra samtidiga versioner använder du IBuildEngine4.RegisterTaskObject med RegisteredTaskObjectLifetime.Build. MSBuild hanterar livslängden för objektet, som skapas vid första användningen och rensas när bygget slutar. Observera att de registrerade objekten måste vara trådsäkra.

Definiera först en enkel trådsäker räknarklass:

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

Använd sedan en hjälpmetod med dubbelkontrollerad låsning för att hämta eller skapa räknaren:

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;
}

I Execute():

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

Med den här metoden får varje build-anrop sin egen FileCounter. Alla underprojekt i samma bygge delar räknaren (sekventiell numrering), men en separat dotnet build som körs samtidigt på samma dator får en annan räknare. RegisteredTaskObjectLifetime.Build instruerar MSBuild att begränsa objektet till det aktuella bygganropet och rensa det när bygget slutar.

Välj rätt metod

När du bestämmer dig för hur du ska hantera statiskt tillstånd börjar du med den här frågan: är dessa data säkra att dela över alla versioner som någonsin kan köras i samma process, inklusive på varandra följande versioner och samtidiga versioner?

MSBuild-arbetsprocesser bevaras mellan anrop (nodåteranvändning är aktiverat som standard) och en MSBuild-process kan potentiellt hantera flera lösningsversioner under dess livslängd, inte bara inom ett enda dotnet build anrop. Anta inte att en process endast hanterar en version.

Använd dessa riktlinjer:

  • Behåll endast det statiska fältet om cachelagrade data är säkra att komma åt från flera trådar i olika projekt och i flera versioner utan att kräva ogiltighet mellan versioner. Till exempel kan en cache med oföränderliga data som beräknas en gång från indata som aldrig ändras (till exempel sammansättningsmetadata som läses in en gång vid start) kvalificeras.
  • Använd IBuildEngine4.RegisterTaskObject med RegisteredTaskObjectLifetime.Build när tillståndet måste isoleras per build-anrop (till exempel räknare, ackumulatorer eller cacheminnen som ska återställas mellan versioner eller inte läcka mellan samtidiga versioner). Det här är den bästa metoden för det mest delade föränderliga tillståndet.
  • Använd System.Threading primitiver (Interlocked, ConcurrentDictionary, lock, ReaderWriterLockSlim) för att göra alla kvarhållna statiska tillstånd trådsäkra, men kom ihåg att enbart trådsäkerhet inte ger isolering på byggnivå. Se Metodtips för hanterad trådning.

Tips/Råd

Det fullständiga migreringsexemplet längre fram i den här artikeln använder metoden RegisterTaskObject för att demonstrera build-begränsad isolering.

Exempel på fullständig migrering

Följande kod visar den fullständigt migrerade AddBuildCommentTask med alla fem ändringar som tillämpas:

  1. Har attributet [MSBuildMultiThreadableTask], som markerar det för körning i processen.
  2. Implementerar IMultiThreadableTask tillsammans med den befintliga Task basklassen TaskEnvironment och exponerar egenskapen.
  3. Använder TaskEnvironment.GetAbsolutePath() för sökvägsupplösning.
  4. Använder TaskEnvironment.GetEnvironmentVariable() i stället för Environment.GetEnvironmentVariable().
  5. Använder IBuildEngine4.RegisterTaskObject med RegisteredTaskObjectLifetime.Build för att begränsa filräknaren till det aktuella bygganropet och ersätta den processomfattande statiska räknaren.
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;
        }
    }
}

Vad händer med icke-migrerade uppgifter

Uppgifter som inte har attributet [MSBuildMultiThreadableTask] eller som inte implementerar IMultiThreadableTask fortsätter att fungera utan ändringar. MSBuild kör dessa uppgifter i en underordnad TaskHost process, vilket ger samma isolering på processnivå som tidigare versioner av MSBuild. Den här metoden är långsammare på grund av omkostnaderna för kommunikation mellan processer, men den är helt kompatibel med befintlig aktivitetskod. Migrering är valfritt för att få korrekta resultat – uppgifter som inte har migrerats ger fortfarande korrekta resultat – men migrering förbättrar buildprestandan.

Stöd för tidigare versioner av MSBuild

Om du uppdaterar din anpassade uppgift och sedan distribuerar den till andra stöder din uppgift klienter med HJÄLP av MSBuild 18.6 eller senare. För att stödja klienter i tidigare versioner av MSBuild har du tre alternativ.

Alternativ 1: Acceptera lägre prestanda

Gör inga ändringar i uppgiften. MSBuild kör icke-tillskrivna uppgifter i en underordnad TaskHost process, vilket är långsammare men helt kompatibelt. Det här alternativet kräver inga kodändringar.

Alternativ 2: Underhålla separata implementeringar

Skapa separata uppgiftssammansättningar för MSBuild 18.6+ och tidigare versioner. MSBuild 18.6+-versionen implementerar IMultiThreadableTask och använder TaskEnvironment. Den tidigare versionen fortsätter att använda Task med API:er på processnivå.

Alternativ 3: Kompatibilitetsbrygga

Definiera MSBuildMultiThreadableTaskAttribute själv i aktivitetssammansättningen. Eftersom MSBuild identifierar attributet endast efter namnområde och namn (ignorerar den definierande sammansättningen) fungerar ditt självdefinierade attribut i både gamla och nya versioner av MSBuild:

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

Vid körning på MSBuild 18.6 eller senare identifierar MSBuild attributet och kör uppgiften i processen. När du kör på tidigare versioner ignorerar MSBuild det okända attributet och kör uppgiften som tidigare.

Med det här alternativet har du inte åtkomst till TaskEnvironment, så du måste hantera allt som hanteras manuellt, till exempel konvertera alla dina relativa sökvägar till absoluta sökvägar.

Jämförelse av metoder

I följande tabell jämförs de tre metoderna när de körs i flertrådat läge (-mt). I läge utan multitrådning körs alla uppgifter utanför processen, oavsett hur de markeras.

Metod Maintenance Prestanda (18,6+) Prestanda (äldre) TaskEnvironment-åtkomst
Separata implementeringar Högt Fullständig process Helt utanför processen Ja (version 18.6+ )
Kompatibilitetsbrygga Lågt Fullständig process Fullständig out-of-process Nej (endast för attribut)
Inga ändringar None Sidovagn (långsammare) Helt utanför processen No