Aktualizowanie zadania MSBuild do pracy w trybie wielowątkowym

Program MSBuild 18.6 wprowadza możliwość równoległego kompilowania w ramach tego samego procesu. Aby włączyć ten tryb, podaj przełącznik wiersza polecenia -mt. Poprzednie wersje programu MSBuild obsługiwały kompilacje równoległe, ale kompilacje były wykonywane w osobnych procesach. Ta zmiana ma pewien wpływ na sposób tworzenia zadań. O ile wcześniej zadania były uruchamiane w osobnym procesie, teraz wszystkie zadania obsługujące wielowątkowość są uruchamiane w tym samym procesie. Chociaż większość logiki nie musi się zmieniać, istnieją pewne konstrukcje na poziomie procesu, które muszą być bardziej starannie obsługiwane. Konstrukcje na poziomie procesu obejmują bieżący katalog roboczy, zmienne środowiskowe i informacje o rozpoczęciu procesu (ProcessStartInfo).

W celu obsługi tych zmian program MSBuild 18.6 wprowadza interfejs IMultiThreadableTask (w Microsoft.Build.Framework) i klasę TaskEnvironment. TaskEnvironment ProjectDirectory zawiera właściwość i metody, takie jak GetAbsolutePath(), GetEnvironmentVariable(), SetEnvironmentVariable()i GetProcessStartInfo().

Ważna

Tryb wielowątkowy jest obecnie dostępny jako funkcja eksperymentalna; obecnie nie jest zalecany do zastosowań produkcyjnych. Aktualizowanie zależności biblioteki MSBuild w celu używania interfejsów API trybu wielowątkowego niejawnie uniemożliwia uruchamianie bibliotek w starszych wersjach Visual Studio i MSBuild. Zachęcamy wczesnych użytkowników do wypróbowania trybu wielowątkowego i przekazywania opinii. Prześlij problemy w repozytorium MSBuild GitHub.

Interfejs IMultiThreadableTask definiuje wymagania dotyczące zadań, które mogą być uruchamiane w ramach procesu w kompilacjach wielowątkowych:

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

Aby zmigrować zadanie, zaimplementuj IMultiThreadableTask obok istniejącej klasy bazowej Task i udostępnij właściwość 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;
    // ...
}

Zadania implementujące IMultiThreadableTask mogą być uruchamiane w procesie. Wszystkie takie zadania muszą również mieć atrybut [MSBuildMultiThreadableTask], czyli znacznik używany przez MSBuild do włączenia zadania do wykonywania w procesie. Przed dodaniem atrybutu upewnij się, że zadanie nie ma żadnych zależności od konstrukcji na poziomie procesu, takich jak bieżący katalog roboczy lub środowisko, i że jego kod jest bezpieczny wątkowo. Należy zwrócić szczególną uwagę na zapewnienie bezpiecznego wątkowo dostępu do zmiennych statycznych, ponieważ te zmienne są współużytkowane przez wszystkie wystąpienia zadań i mogą być dostępne lub modyfikowane przez różne wystąpienia zadania, które są również uruchomione w tym samym procesie.

Przykładowe zadanie: BuildCommentTask

Poniższy przykład AddBuildCommentTask jest używany w tym artykule w celu zilustrowania procesu migracji. To zadanie poprzedza komentarz kompilacji do plików tekstowych. Domyślnie zapisuje zwykły tekst; opcjonalne właściwości CommentPrefix i CommentSuffix pozwalają wywołującym ujmować komentarz w składni odpowiedniej dla danego języka (na przykład // dla języka C#, <!-- i --> dla XML, # dla języka Python lub 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;
        }
    }
}

Plik projektu może wywołać to zadanie dla różnych typów plików, przekazując odpowiednią składnię komentarza dla każdego z nich:

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

To zadanie ma cztery problemy związane z bezpieczeństwem wielowątkowym, które należy rozwiązać w przypadku kompilacji wielowątkowych:

  1. Ścieżki względne: File.ReadAllLines i File.WriteAllLines używają bezpośrednio item.ItemSpec, który może być ścieżką względną. W trybie wielowątkowym nie ma gwarancji, że katalog roboczy procesu będzie katalogiem projektu.
  2. Pole statyczne: ModifiedFileCount to static pole współdzielone przez wszystkie instancje, co powoduje wyścigi danych, gdy wiele kompilacji jest uruchamianych współbieżnie.
  3. Zmienne środowiskowe: Najczęstszym problemem ze zmiennymi środowiskowymi w kompilacjach wielowątkowych są zadania, które ustawiają zmienne środowiskowe przed uruchomieniem procesu podrzędnego, oczekując, że proces potomny je odziedziczy. W trybie wielowątkowym Environment.SetEnvironmentVariable() modyfikuje środowisko na poziomie procesu, współdzielone przez wszystkie równoległe kompilacje, przez co zmiana przeznaczona dla procesu potomnego jednego projektu może przeniknąć do procesu potomnego innego projektu. Odczytywanie zmiennych środowiskowych bezpośrednio w kodzie zadania (Environment.GetEnvironmentVariable()) jest również ogólnie złym rozwiązaniem; Właściwości programu MSBuild są lepszym rozwiązaniem, ponieważ są rejestrowane i możliwe do śledzenia.

Ważna

Wielowątkowy tryb kompilacji jest obecnie dostępny tylko dla kompilacji uruchamianych z poziomu interfejsu wiersza polecenia (dotnet build i MSBuild.exe). Kompilacje MSBuild w programie Visual Studio nie obsługują jeszcze wykonywania wielowątkowego w tym samym procesie. W programie Visual Studio wykonywanie wszystkich zadań nadal odbywa się poza procesem. Integracja z Visual Studio jest planowana w jednej z przyszłych wersji.

Prerequisites

  • MSBuild w wersji 18.6 lub nowszej.

  • Włącz wykonywanie wielowątkowego zadania za pomocą przełącznika -mt wiersza polecenia:

    dotnet build -mt
    

    Aby uzyskać więcej informacji na temat przełącznika -mt , zobacz dokumentacja wiersza polecenia programu MSBuild.

Zaplanuj migrację

Przejrzyj kod zadania pod kątem następujących problemów:

  1. Sprawdź kod zadania i zidentyfikuj wszelkie użycie ścieżek względnych. Sprawdź wszystkie operacje we/wy danych wejściowych i plików.
  2. Sprawdź, czy nie ma żadnych zastosowań zmiennych środowiskowych.
  3. Sprawdź, czy użyto interfejsu API ProcessStartInfo.
  4. Sprawdź wszystkie pola statyczne lub struktury danych i użyj standardowych metod, aby były bezpieczne wątkowo.
  5. Jeśli żaden z powyższych elementów nie ma zastosowania, rozważ dodanie tylko atrybutu.
  6. Rozważ specjalne wymagania dotyczące obsługi wcześniejszych wersji programu MSBuild. Zobacz Obsługa wcześniejszych wersji programu MSBuild.

Szybka dokumentacja dotycząca zastępowania interfejsu API

Poniższa tabela podsumowuje interfejsy API platformy .NET, które należy zastąpić, oraz ich odpowiedniki TaskEnvironment:

Interfejs API platformy .NET, którego należy unikać Level Replacement
Path.GetFullPath(path) ERROR Zobacz notatkę poniżej tej tabeli
File.* z użyciem ścieżek względnych ERROR Najpierw rozwiąż za pomocą TaskEnvironment.GetAbsolutePath()
Directory.* ze ścieżkami względnymi ERROR Rozwiąż najpierw za pomocą 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 Użyj ToolTask lub TaskEnvironment.GetProcessStartInfo()
Pola statyczne OSTRZEŻENIE Używaj pól instancji lub kolekcji bezpiecznych dla wątków

Note

Path.GetFullPath(path) wykonuje dwie rzeczy: przekształca ścieżkę względną w ścieżkę bezwzględną i tworzy kanoniczną postać ścieżki (rozwiązując segmenty . i ..). Należy je obsłużyć oddzielnie:

  • Tylko ścieżka bezwzględna: Użyj TaskEnvironment.GetAbsolutePath(path). Takie podejście jest wystarczające w przypadku większości operacji we/wy plików, w których przekazujesz ścieżkę bezpośrednio do interfejsów API .NET.
  • Ścieżka kanoniczna: jeśli korzystasz z formularza kanonicznego (na przykład w przypadku używania ścieżki jako pamięci podręcznej lub klucza słownika), użyj polecenia Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path)) , aby uzyskać w pełni rozpoznaną, kanoniczną ścieżkę bezwzględną.

Oznaczanie zadania za pomocą atrybutu

Wszystkie zadania, które biorą udział w kompilacji wielowątkowej, muszą być oznaczone atrybutem [MSBuildMultiThreadableTask]. Ten atrybut jest sygnałem MSBuild używanym do identyfikowania zadań, które są bezpieczne do uruchamiania w procesie.

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

Jeśli zadanie jest już bezpieczne dla wątków i nie używa żadnych interfejsów API dotyczących poziomu procesu (bieżący katalog roboczy, zmienne środowiskowe, ProcessStartInfo), sam atrybut w zupełności wystarczy. Zadanie nadal dziedziczy po Task (lub ToolTask) bez żadnych innych zmian.

Jeśli zadanie wymaga zastąpienia wywołań interfejsu API na poziomie procesu (na przykład w celu bezpiecznego rozpoznawania ścieżek względnych lub odczytywania zmiennych środowiskowych), zaimplementuj również element IMultiThreadableTask. Ten interfejs zapewnia zadaniu dostęp do właściwości TaskEnvironment. Atrybut pozostaje wymagany w obu przypadkach; IMultiThreadableTask to dodatkowy krok, który odblokowuje TaskEnvironment API.

Note

MSBuild wykrywa element MSBuildMultiThreadableTaskAttribute wyłącznie na podstawie przestrzeni nazw i nazwy, ignorując zestaw definiujący. Oznacza to, że atrybut można zdefiniować samodzielnie we własnym kodzie (zobacz Obsługa wcześniejszych wersji programu MSBuild) i program MSBuild nadal go rozpoznaje.

Note

Element MSBuildMultiThreadableTaskAttribute jest nie dziedziczony (Inherited = false). Każda klasa zadań musi jawnie zadeklarować atrybut, który ma być rozpoznawany jako wielowątkowy. Dziedziczenie po klasie, która ma ten atrybut, nie powoduje automatycznie, że klasa pochodna jest przystosowana do pracy wielowątkowej.

Inicjowanie zadaniaŚrodowisko do powrotu

Podczas implementacji IMultiThreadableTask zainicjuj właściwość TaskEnvironment wartością TaskEnvironment.Fallback:

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

Program MSBuild ustawia tę właściwość przed wywołaniem Execute() w normalnej kompilacji. Ustawienie Fallback domyślne gwarantuje, że zadanie działa poprawnie w innych scenariuszach hostingu (takich jak testy jednostkowe lub niestandardowe narzędzia orkiestracji kompilacji), w których program MSBuild nie jest obecny w celu ustawienia właściwości. Bez tego próba uzyskania dostępu do TaskEnvironment spoza silnika spowodowałaby wyjątek odwołania do obiektu o wartości null.

Jeśli musisz obsługiwać wersje MSBuild starsze niż 18.6, które nie zawierają TaskEnvironment.Fallback, zamiast tego zainicjuj właściwość wartością null i zabezpiecz wszystkie wywołania TaskEnvironment sprawdzeniem wartości null. Aby uzyskać więcej opcji , zobacz Obsługa wcześniejszych wersji programu MSBuild .

Aktualizowanie ścieżek i operacji wejścia/wyjścia na plikach

Zadanie często akceptuje dane wejściowe, takie jak listy elementów w programie MSBuild, które jeśli są plikami, mogą być w postaci ścieżek względnych.

Ścieżki względne są zawsze określane względem bieżącego katalogu roboczego procesu, ale ponieważ zadanie jest teraz wykonywane w obrębie tego samego procesu, katalog roboczy może nie być taki sam, jak wtedy, gdy zadanie było uruchamiane we własnym procesie. Takie ścieżki są względne względem katalogu projektu. Element TaskEnvironment zawiera właściwość ProjectDirectory i metodę GetAbsolutePath(), których można użyć do przekształcania ścieżek względnych w ścieżki bezwzględne. Możesz również uzyskać dostęp do metadanych FullPath; nie ma potrzeby używać ścieżki względnej ItemSpec, a następnie przekształcać jej do postaci bezwzględnej.

Typ AbsolutePath

AbsolutePath jest strukturą tylko do odczytu w Microsoft.Build.Framework, która reprezentuje zweryfikowaną bezwzględną ścieżkę pliku. Kluczowi członkowie obejmują:

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

Konstruktor sprawdza, czy podana AbsolutePath ścieżka jest zakorzeniona. Można również utworzyć obiekt AbsolutePath , podając ścieżkę względną i ścieżkę podstawową. Niejawna konwersja do string oznacza, że można przekazać AbsolutePath bezpośrednio do dowolnego interfejsu API, który oczekuje ścieżki string.

Właściwość OriginalValue zachowuje oryginalny ciąg znaków ścieżki w postaci, w jakiej został przekazany przed rozstrzygnięciem. Ta właściwość jest przydatna, gdy trzeba zachować ścieżki względne w danych wyjściowych zadań lub komunikatach dziennika. Na przykład zadanie, które zapisuje w dzienniku, które pliki przetworzyło, może używać OriginalValue w swoich komunikatach dziennika, tak aby ścieżki w danych wyjściowych pozostały względne i czytelne, a jednocześnie do faktycznych operacji wejścia/wyjścia na plikach używać ustalonego Value (lub niejawnej konwersji string).

Użyj TaskEnvironment.GetAbsolutePath(), aby rozwiązać ścieżki elementów:

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

Obsługa rywalizacji o pliki w równoległych kompilacjach

Rywalizacja o pliki może wystąpić, gdy wiele zadań jest uruchamianych równolegle i uzyskuje dostęp do tego samego pliku. Dotyczy to zarówno tradycyjnego modelu wieloprocesowego, jak i nowszego trybu wielowątkowego. W obu przypadkach ten sam plik może być uzyskiwany współbieżnie, gdy:

  • Ten sam plik jest wyświetlany w wielu kompilacjach podprojektów (na przykład udostępniony plik konfiguracji lub połączony plik źródłowy).
  • Zadanie odczytuje i zapisuje plik, który jest również przetwarzany przez inne wystąpienie zadania.

Metody wygodne, takie jak File.ReadAllLines i File.WriteAllLines nie zapewniają jawnej kontroli nad blokowaniem plików. Jeśli dostęp współbieżny jest możliwy, użyj funkcji FileStream jawnego udostępniania i blokowania:

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

Najważniejsze wskazówki dotyczące we/wy plików w zadaniach wielowątkowych:

  • Użyj FileShare.None do operacji odczyt-modyfikacja-zapis. To ustawienie uniemożliwia innym zadaniu odczytywanie nieaktualnej zawartości podczas aktualizowania pliku.
  • Przechwyć IOException i rozważ ponowienie próby. Gdy inne zadanie lub proces utrzymuje blokadę, próba otwarcia powoduje zgłoszenie IOException. Krótkie ponowienie próby z wycofywaniem jest często odpowiednie.
  • Unikaj trzymania blokad na wielu plikach jednocześnie. Jeśli dwa zadania blokują jeden plik, a następnie spróbuj zablokować drugi, otrzymasz zakleszczenie. Jeśli musisz pracować z wieloma plikami, zablokuj je w spójnej kolejności (na przykład posortowane według pełnej ścieżki).
  • Utrzymuj blokady tak krótko, jak to możliwe. Otwórz plik, odczytaj, zmodyfikuj, zapisz i zamknij w jednej operacji. Nie przechowuj blokady pliku podczas wykonywania niepowiązanej pracy.

Powyższy przykład to jedno podejście. Ogólne wskazówki dotyczące bezpiecznych wątkowo operacji wejścia/wyjścia na plikach w środowisku .NET można znaleźć w tematach FileStream class, FileShare enum i Managed threading best practices.

Note

TaskEnvironment sam w sobie nie jest bezpieczny dla wątków. Ma to znaczenie tylko wtedy, gdy zadanie wewnątrz tworzy własne wątki (na przykład za pomocą Parallel.ForEach lub Task.Run). Większość zadań tego nie robi. Implementują Execute() liniowo i pozwalają narzędziu MSBuild zarządzać równoległością między wystąpieniami zadań. Jeśli zadanie rzeczywiście tworzy własne wątki, zapisz wartości z TaskEnvironment w zmiennych lokalnych przed ich uruchomieniem, zamiast odwoływać się równocześnie do TaskEnvironment z wielu wątków.

Aktualizowanie zmiennych środowiskowych

Note

Odczytywanie zmiennych środowiskowych w kodzie zadania jest zwykle złym rozwiązaniem, nawet w kompilacjach jednowątkowych. Właściwości programu MSBuild są lepszym rozwiązaniem: są jawnie ograniczone, rejestrowane podczas kompilacji i możliwe do śledzenia w dzienniku kompilacji. Jeśli zadanie aktualnie odczytuje zmienną środowiskową do odbierania danych wejściowych, rozważ zastąpienie jej właściwością zadania. Projekt nadal może uzyskać wartość ze zmiennej środowiskowej: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />.

Wskazówki zawarte w tej sekcji dotyczą migrowania istniejących zadań, które już opierają się na zmiennych środowiskowych. Jeśli masz możliwość refaktoryzacji, preferuj właściwości i elementy.

Ustawianie zmiennych środowiskowych dla procesów podrzędnych

Najczęstszym problemem dotyczącym zmiennych środowiskowych w kompilacjach wielowątkowych jest zadanie, które ustawia zmienną środowiskową, a następnie uruchamia proces podrzędny, oczekując, że proces podrzędny ją odziedziczy. W modelu wieloprocesowym Environment.SetEnvironmentVariable() bezpiecznie zmodyfikował środowisko procesu roboczego dla tego projektu. W trybie wielowątkowym proces jest współużytkowany we wszystkich współbieżnych kompilacjach, więc zmiana przeznaczona dla procesu podrzędnego jednego projektu może wyciekać do innego.

Użyj TaskEnvironment.SetEnvironmentVariable() razem z TaskEnvironment.GetProcessStartInfo() (zobacz Update ProcessStart API calls (Aktualizowanie wywołań interfejsu API ProcessStart). GetProcessStartInfo() zwraca obiekt ProcessStartInfo, wstępnie wypełniony katalogiem roboczym projektu i jego izolowaną tabelą środowiskową, w tym wszystkimi zmiennymi ustawionymi za pomocą SetEnvironmentVariable(), dzięki czemu procesy podrzędne automatycznie dziedziczą prawidłowe środowisko właściwe dla projektu.

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

Odczytywanie zmiennych środowiskowych w istniejących zadaniach

Jeśli istniejące zadanie odczytuje zmienne środowiskowe i nie można od razu refaktoryzować właściwości zadania, zastąp zmienną Environment.GetEnvironmentVariable()TaskEnvironment.GetEnvironmentVariable(). To wywołanie metody odczytuje dane z tabeli zmiennych środowiskowych przypisanej do projektu, a nie ze współdzielonego środowiska procesu, więc równoległe kompilacje nie zakłócają się wzajemnie.

Przed (od BuildCommentTask):

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

After:

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

Wskazówka

Podczas aktualizowania istniejącego kodu, który odczytuje zmienną środowiskową, rozważ zastąpienie wzorca właściwością zadania. Na przykład udostępnij public bool DisableComments { get; set; } w zadaniu i pozwól projektowi przekazać DisableComments="$(DISABLE_BUILD_COMMENTS)". Program MSBuild rejestruje rozpoznaną wartość, co sprawia, że jest ona widoczna w dzienniku kompilacji i znacznie łatwiejsza do zdiagnozowania niż odczyt ukrytej zmiennej środowiskowej.

Aktualizowanie wywołań interfejsu API ProcessStart

Zazwyczaj jeśli zadanie uruchamia proces, należy użyć polecenia ToolTask, który obsługuje wszystko za Ciebie. Jeśli aktualizujesz zadanie, które bezpośrednio wywołuje ProcessStartInfo, użyj TaskEnvironment.GetProcessStartInfo(). Zwraca to element ProcessStartInfo skonfigurowany z katalogiem roboczym projektu i jego izolowaną tabelą środowisk. Jeśli ustawiasz również zmienne środowiskowe przed uruchomieniem, najpierw użyj polecenia TaskEnvironment.SetEnvironmentVariable() , jak pokazano w poprzedniej sekcji.

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

Jeśli zadanie dziedziczy po ToolTask, informacje o uruchamianiu procesu są już obsługiwane. Wystarczy zaktualizować zadania, które bezpośrednio tworzą ProcessStartInfo.

Aktualizowanie pól statycznych i struktur danych w celu zapewnienia bezpieczeństwa wątkowego

Pola statyczne wymagają ostrożnego podejścia podczas migracji do kompilacji wielowątkowych. Nawet w modelu wieloprocesowym pojedynczy proces może kompilować wiele projektów, więc stan statyczny jest współdzielony, ale nie jednocześnie.

Tryb wielowątkowy dodaje nowy wymiar do tego problemu. Wiele kompilacji może teraz współużytkować ten sam proces i uruchamiać zadania współbieżnie (zwłaszcza w przypadku programu MSBuild Server, który jest automatycznie włączony z wielowątkiem). Pole statyczne jest współużytkowane przez wszystkie wystąpienia zadań w procesie, nie tylko w ramach kompilacji, ale potencjalnie między oddzielnymi wywołaniami kompilacji uruchomionymi współbieżnie. Na przykład dwóch deweloperów uruchamiających dotnet build w tym samym czasie na serwerze kompilacji albo dwa okna terminala na tym samym komputerze mogą współdzielić ten sam stan statyczny, a w takiej sytuacji te kompilacje uzyskują do niego dostęp jednocześnie.

W tym przykładzie BuildCommentTask pole ModifiedFileCount statyczne jest współużytkowane we wszystkich wystąpieniach:

Before:

private static int ModifiedFileCount = 0;

// In Execute():
ModifiedFileCount++;

Ten kod ma dwa problemy. Najpierw operator ++ nie jest atomowy. Gdy wiele instancji zadań działa równocześnie, dwa wątki mogą odczytać tę samą wartość i oba zapisać tę samą zwiększoną wartość, co prowadzi do utraty zliczeń. Po drugie, ponieważ pole jest statyczne, zachowuje swoją wartość między kompilacjami i jest współdzielone między współbieżnymi kompilacjami w tym samym procesie.

W poniższych sekcjach przedstawiono dwa podejścia do rozwiązywania tych problemów— od najprostszego do najbardziej poprawnego.

Podejście 1: Użyj bezpiecznego dla wątków, ale obejmującego cały proces interfejsu API

Najprostszą poprawką jest uczynienie inkrementacji atomową:

private static int ModifiedFileCount = 0;

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

Interlocked.Increment wykonuje operację odczytu, zwiększenia i zapisu jako pojedynczą operację atomową, więc żadne zliczenia nie są tracone. To podejście rozwiązuje problem współbieżności, ale licznik jest nadal współdzielony przez wszystkie kompilacje w ramach procesu, w tym kolejne kompilacje i kompilacje współbieżne. Jeśli dwie kompilacje są uruchomione równocześnie, numery ich plików przeplatają się (kompilacja A otrzymuje #1, #3, #5; kompilacja B otrzymuje #2, #4, #6). To, czy taka sytuacja jest dopuszczalna, zależy od tego, czy zadanie wymaga izolacji poszczególnych kompilacji. W przypadku licznika sekwencyjnego numerowania plików, takiego jak ModifiedFileCount, współdzielenie między kompilacjami stanowi problem z punktu widzenia poprawności; zamiast tego użyj RegisterTaskObject (zobacz podejście 2).

Tutaj odpowiadającym mu interfejsem API, który jest bezpieczny wątkowo, ale obejmuje cały proces, jest InterlockedIncrement, ale we własnym kodzie trzeba znaleźć odpowiednie, bezpieczne wątkowo zamienniki dla wszystkich interfejsów API, które nie są bezpieczne wątkowo. Jeśli na przykład zadanie utrwala stan przy użyciu Dictionary, rozważ użycie ConcurrentDictionary<TKey,TValue>.

Podejście 2: RegisterTaskObject do izolacji na poziomie kompilacji

Jeśli zadanie wymaga stanu statycznego współdzielonego między podprojektami w ramach pojedynczego uruchomienia kompilacji, ale odizolowanego od innych współbieżnych kompilacji, użyj IBuildEngine4.RegisterTaskObject z RegisteredTaskObjectLifetime.Build. Program MSBuild zarządza okresem istnienia obiektu, który jest tworzony przy pierwszym użyciu i czyszczony po zakończeniu kompilacji. Należy pamiętać, że zarejestrowane obiekty muszą być bezpieczne wątkowo.

Najpierw zdefiniuj prostą klasę licznika bezpiecznego wątkowo:

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

Następnie użyj metody pomocniczej z dwukrotnie sprawdzonym blokowaniem, aby uzyskać lub utworzyć licznik:

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

W pliku Execute():

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

Dzięki temu podejściu każde wywołanie kompilacji otrzymuje własną wartość FileCounter. Wszystkie podprojekty w ramach tej samej kompilacji współdzielą licznik (numerację sekwencyjną), ale oddzielne uruchomienie dotnet build działające w tym samym czasie na tej samej maszynie otrzymuje inny licznik. RegisteredTaskObjectLifetime.Build powoduje, że program MSBuild ogranicza zakres obiektu do bieżącego wywołania kompilacji i usuwa go po zakończeniu kompilacji.

Wybieranie odpowiedniego podejścia

Podejmując decyzję o sposobie obsługi stanu statycznego, zacznij od tego pytania: czy te dane są bezpieczne do udostępniania we wszystkich kompilacjach, które kiedykolwiek mogą być uruchamiane w tym samym procesie, w tym kolejnych kompilacji i współbieżnych kompilacji?

Procesy robocze programu MSBuild pozostają uruchomione między wywołaniami (ponowne użycie węzła jest domyślnie włączone), a proces MSBuild może potencjalnie obsługiwać wiele kompilacji rozwiązań w całym okresie swojego działania, nie tylko podczas pojedynczego wywołania dotnet build. Nie zakładaj, że proces obsługuje tylko jedną kompilację.

Skorzystaj z następujących wytycznych:

  • Zachowaj pole statyczne tylko wtedy, gdy do danych w pamięci podręcznej można bezpiecznie uzyskiwać dostęp z wielu wątków, w różnych projektach i w wielu kompilacjach, bez konieczności ich unieważniania między kompilacjami. Na przykład pamięć podręczna niezmiennych danych obliczanych jednokrotnie na podstawie danych wejściowych, które nigdy się nie zmieniają (na przykład metadanych zestawu załadowanych raz podczas uruchamiania), może się do tego nadawać.
  • Użyj IBuildEngine4.RegisterTaskObject z RegisteredTaskObjectLifetime.Build, gdy stan musi być odizolowany dla każdego wywołania kompilacji (na przykład liczniki, akumulatory lub pamięci podręczne, które powinny być resetowane między kompilacjami albo nie przenikać między współbieżnymi kompilacjami). Jest to preferowane podejście dla najbardziej współużytkowanego stanu modyfikowalnego.
  • Użyj System.Threading prymitywów (Interlocked, ConcurrentDictionary, lock, ReaderWriterLockSlim), aby zapewnić bezpieczeństwo wątkowe wszelkim utrzymywanym statycznym stanom, ale pamiętaj, że samo bezpieczeństwo wątkowe nie zapewnia izolacji na poziomie kompilacji. Zobacz Najlepsze rozwiązania dotyczące zarządzanych wątków.

Wskazówka

Kompletny przykład migracji w dalszej części tego artykułu wykorzystuje podejście RegisterTaskObject, aby zademonstrować izolację ograniczoną do kompilacji.

Kompletny przykład migracji

Poniższy kod przedstawia w pełni zmigrowany AddBuildCommentTask ze wszystkimi pięcioma zastosowanymi zmianami:

  1. Ma atrybut [MSBuildMultiThreadableTask], który oznacza go do wykonania w ramach procesu.
  2. Implementuje IMultiThreadableTask obok istniejącej klasy bazowej Task i udostępnia właściwość TaskEnvironment.
  3. Używa TaskEnvironment.GetAbsolutePath() metody rozpoznawania ścieżki.
  4. Używa TaskEnvironment.GetEnvironmentVariable() zamiast Environment.GetEnvironmentVariable().
  5. Używa IBuildEngine4.RegisterTaskObject wraz z RegisteredTaskObjectLifetime.Build do ograniczenia zakresu licznika plików do bieżącego wywołania kompilacji, zastępując statyczny licznik w skali całego procesu.
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;
        }
    }
}

Co się stanie z niezmigrowanymi zadaniami

Zadania, które nie mają atrybutu [MSBuildMultiThreadableTask] lub nie implementują IMultiThreadableTask , kontynuują pracę bez żadnych zmian. Program MSBuild uruchamia te zadania w procesie zależnym TaskHost , który zapewnia tę samą izolację na poziomie procesu co wcześniejsze wersje programu MSBuild. Takie podejście jest wolniejsze ze względu na obciążenie komunikacji między procesami, ale jest w pełni zgodne z istniejącym kodem zadania. Migracja jest opcjonalna dla poprawności — zadania niemigrowane nadal generują poprawne wyniki, ale migracja zwiększa wydajność kompilacji.

Obsługa wcześniejszych wersji programu MSBuild

Jeśli zaktualizujesz swoje zadanie niestandardowe, a następnie rozpowszechnisz je innym, Twoje zadanie obsługuje klientów korzystających z programu MSBuild 18.6 lub nowszego. Aby obsługiwać klientów we wcześniejszych wersjach programu MSBuild, masz trzy opcje.

Opcja 1. Akceptowanie obniżonej wydajności

Nie wprowadzaj żadnych zmian w zadaniu. Program MSBuild uruchamia zadania bez przypisanych atrybutów w pomocniczym procesie TaskHost, który jest wolniejszy, ale w pełni zgodny. Ta opcja nie wymaga żadnych zmian w kodzie.

Opcja 2. Obsługa oddzielnych implementacji

Twórz oddzielne zestawy zadań dla programu MSBuild w wersji 18.6 lub nowszej. MSBuild w wersji 18.6 lub nowszej implementuje IMultiThreadableTask i używa TaskEnvironment. Wcześniejsza wersja nadal używa Task z interfejsami API na poziomie procesu.

Opcja 3. Mostek zgodności

Zdefiniuj MSBuildMultiThreadableTaskAttribute samodzielnie w zespole zadań. Ponieważ program MSBuild wykrywa atrybut według przestrzeni nazw i tylko nazwy (ignorując zestaw definiujący), atrybut zdefiniowany samodzielnie działa zarówno w starych, jak i nowych wersjach programu MSBuild:

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

W przypadku uruchamiania w programie MSBuild 18.6 lub nowszym program MSBuild rozpoznaje atrybut i uruchamia zadanie w procesie. W przypadku uruchamiania we wcześniejszych wersjach program MSBuild ignoruje nieznany atrybut i uruchamia zadanie tak jak poprzednio.

Przy tej opcji nie masz dostępu do TaskEnvironment, więc wszystko, czym on się zajmuje, trzeba będzie obsłużyć ręcznie, na przykład konwertowanie wszystkich ścieżek względnych na bezwzględne.

Porównanie podejść

W poniższej tabeli porównano trzy podejścia podczas uruchamiania w trybie wielowątkowym (-mt). W trybie jednowątkowym wszystkie zadania są uruchamiane poza procesem, niezależnie od tego, jak są oznaczone.

Metoda Maintenance Wydajność (18.6+) Wydajność (starsza) Dostęp do aplikacji TaskEnvironment
Oddzielne implementacje High W trakcie realizacji W pełni poza procesem Tak (wersja 18.6 lub nowsza)
Pomost zgodności Low W toku W pełni poza procesem Nie (tylko atrybut)
Brak zmian Żadne Sidecar (wolniejsza) W pełni poza procesem No