Atualizar uma tarefa do MSBuild para funcionar em modo multithread

O MSBuild 18.6 introduz a capacidade de construir em paralelo dentro do mesmo processo. Para optar por este modo, passe o -mt interruptor da linha de comandos. Versões anteriores do MSBuild suportavam compilações paralelas, mas as compilações eram feitas em processos separados. Esta alteração tem algum impacto na forma como cria tarefas. Enquanto antes, as tarefas corriam num processo separado, agora todas as tarefas com multithread funcionam no mesmo processo. Embora a maior parte da lógica não precise de mudar, existem alguns conceitos ao nível do processo que precisam de ser tratados com mais cuidado. Os construtos ao nível do processo incluem o diretório de trabalho atual, variáveis de ambiente e informação de início de processo (ProcessStartInfo).

Para suportar estas alterações, o MSBuild 18.6 introduz a interface IMultiThreadableTask (em Microsoft.Build.Framework) e a classe TaskEnvironment. TaskEnvironment inclui uma ProjectDirectory propriedade e métodos como GetAbsolutePath(), GetEnvironmentVariable(), SetEnvironmentVariable(), e GetProcessStartInfo().

Importante

O modo multithreaded está atualmente disponível como funcionalidade experimental; não é recomendado para uso em produção neste momento. Atualizar as dependências da tua biblioteca MSBuild para usar as APIs do modo multithreaded impede implicitamente que as tuas bibliotecas corram em versões mais antigas do Visual Studio e MSBuild. Incentivamos os primeiros utilizadores a experimentar o modo multithread e a fornecer feedback. Submeta problemas no repositório GitHub do MSBuild.

A interface IMultiThreadableTask define o contrato para tarefas que podem ser executadas no processo em compilações multithread:

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

Para migrar uma tarefa, implemente IMultiThreadableTask juntamente com a sua classe base existente Task e exponha a TaskEnvironment propriedade:

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

As tarefas que implementam IMultiThreadableTask podem ser executadas em processo. Todas essas tarefas devem também carregar o [MSBuildMultiThreadableTask] atributo, que é o marcador que o MSBuild usa para optar pela execução em processo da tarefa. Antes de adicionar o atributo, confirme que a tarefa não tem quaisquer dependências em construções ao nível do processo, como o diretório de trabalho atual ou o ambiente, e que o seu código é seguro para threads. Preste especial atenção para garantir o acesso seguro entre threads a variáveis estáticas, pois estas variáveis são partilhadas entre todas as instâncias da tarefa e podem ser acedidas ou modificadas por diferentes instâncias da tarefa que também estejam a correr no mesmo processo.

Tarefa de exemplo: BuildCommentTask

O exemplo AddBuildCommentTask seguinte é utilizado ao longo deste artigo para ilustrar o processo de migração. Esta tarefa adiciona um comentário de compilação no início de ficheiros de texto. Por defeito, escreve texto simples; as propriedades opcionais CommentPrefix e CommentSuffix permitem aos chamadores envolver o comentário com sintaxe adequada à linguagem (por exemplo, // para C#, <!-- e --> para XML, # para Python ou 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;
        }
    }
}

Um ficheiro de projeto pode invocar esta tarefa para diferentes tipos de ficheiros, passando a sintaxe de comentário apropriada para cada um:

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

Esta tarefa apresenta quatro questões de segurança de thread que precisam de ser resolvidas para compilações multithreaded:

  1. Caminhos relativos: File.ReadAllLines e File.WriteAllLines usar item.ItemSpec diretamente, que pode ser um caminho relativo. No modo multithread, o diretório de trabalho do processo não é necessariamente o diretório do projeto.
  2. Campo estático: ModifiedFileCount é um static campo partilhado entre todas as instâncias, que provoca corridas de dados quando várias compilações são executadas em simultâneo.
  3. Variáveis de ambiente: O problema mais comum das variáveis de ambiente em compilações multithread é o das tarefas que definem variáveis de ambiente antes de criar um processo filho, na expectativa de que o processo filho as herde. No modo multithreaded, Environment.SetEnvironmentVariable() modifica o ambiente ao nível do processo partilhado por todas as compilações concorrentes, de modo que uma alteração destinada ao processo filho de um projeto pode interferir com o de outro. Ler as variáveis do ambiente diretamente no código da tarefa (Environment.GetEnvironmentVariable()) também é geralmente uma má prática; As propriedades do MSBuild são uma alternativa melhor porque são registadas e rastreáveis.

Importante

O modo de compilação multithreaded está atualmente disponível apenas para compilações CLI (dotnet build e MSBuild.exe). As compilações MSBuild do Visual Studio ainda não suportam execução multithread em processo. No Visual Studio, a execução de todas as tarefas continua a ser executada fora do processo. A integração com o Visual Studio está planeada para um lançamento futuro.

Pré-requisitos

  • MSBuild 18.6 ou posterior.

  • Ative a execução de tarefas em multithreading com o comutador de linha de comandos -mt:

    dotnet build -mt
    

    Para mais informações sobre a opção -mt, consulte a referência da linha de comandos do MSBuild.

Planear a migração

Revise o seu código de tarefa para as seguintes questões:

  1. Verifique o código da tarefa e identifique qualquer utilização de caminhos relativos. Verifique todas as entradas e entradas/saídas de ficheiros.
  2. Verifique se existem utilizações das variáveis de ambiente.
  3. Verifique se há algum ProcessStartInfo uso da API.
  4. Verifique todos os campos estáticos ou estruturas de dados e use métodos convencionais para os tornar seguros em contexto multithread.
  5. Se nenhuma das opções acima se aplicar, considere adicionar apenas o atributo.
  6. Considere requisitos especiais para suportar versões anteriores do MSBuild. Veja Suporte a versões anteriores do MSBuild.

Referência rápida para substituição de API

A tabela seguinte resume as APIs .NET que deve substituir e os seus equivalentes TaskEnvironment:

API .NET para evitar Nível Substituição
Path.GetFullPath(path) ERROR Ver nota a seguir a esta tabela
File.* com caminhos relativos ERROR Resolva primeiro com TaskEnvironment.GetAbsolutePath()
Directory.* com caminhos relativos ERROR Resolva primeiro com 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 Utilize ToolTask ou TaskEnvironment.GetProcessStartInfo()
Campos estáticos ADVERTÊNCIA Utilizar campos de instância ou coleções thread-safe

Observação

Path.GetFullPath(path) faz duas coisas: converte um caminho relativo num caminho absoluto e produz uma forma canónica do caminho (resolução . e .. segmentos). Estes devem ser tratados separadamente:

  • Apenas caminho absoluto: Use TaskEnvironment.GetAbsolutePath(path). Esta abordagem é suficiente para a maioria das operações de I/O de ficheiros, em que o caminho é passado diretamente às APIs do .NET.
  • Caminho canónico: Se confiar na forma canónica (por exemplo, ao usar um caminho como cache ou chave de dicionário), use Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path)) para obter um caminho absoluto canónico totalmente resolvido.

Marque a tarefa com o atributo

Todas as tarefas que participam em builds multithreaded devem ser marcadas com o [MSBuildMultiThreadableTask] atributo. Este atributo é o sinal que o MSBuild utiliza para identificar tarefas que são seguras para executar em processo.

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

Se a tua tarefa já for thread-safe e não usar APIs ao nível do processo (diretório de trabalho atual, variáveis de ambiente, ProcessStartInfo), o atributo por si só é suficiente. A tarefa continua a herdar de Task (ou ToolTask) sem quaisquer outras alterações.

Se a sua tarefa precisar de substituir chamadas de API ao nível do processo (por exemplo, para resolver caminhos relativos ou ler variáveis de ambiente de forma segura), implemente IMultiThreadableTasktambém . Esta interface permite à sua tarefa aceder à propriedade TaskEnvironment. O atributo continua a ser necessário em ambos os casos; IMultiThreadableTask é um passo adicional que desbloqueia o TaskEnvironment API.

Observação

O MSBuild deteta o MSBuildMultiThreadableTaskAttribute apenas pelo espaço de nomes e pelo nome, ignorando a assemblagem que o define. Isto significa que podes definir o atributo tu próprio no teu próprio código (ver Suporte a versões anteriores do MSBuild) e o MSBuild ainda o reconhece.

Observação

O MSBuildMultiThreadableTaskAttribute é não herdável (Inherited = false). Cada classe de tarefa deve declarar explicitamente que o atributo será reconhecido como multithreadable. Herdar de uma classe que tem o atributo não torna automaticamente a classe derivada multithreadável.

Inicializar o TaskEnvironment como Reserva

Ao implementar IMultiThreadableTask, inicialize a TaskEnvironment propriedade para TaskEnvironment.Fallback:

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

O MSBuild define esta propriedade antes de chamar Execute() numa compilação normal. O Fallback padrão garante que a tarefa funciona corretamente noutros cenários de alojamento (como testes unitários ou ferramentas de orquestração personalizadas) onde o MSBuild não está presente para definir a propriedade. Sem isso, aceder TaskEnvironment fora do motor lançaria uma exceção de referência nula.

Se precisares de suportar versões do MSBuild anteriores à 18.6 que não incluam TaskEnvironment.Fallback, inicializa a propriedade em null vez disso e protege todas as TaskEnvironment chamadas com uma verificação de null. Consulte Suporte a versões anteriores do MSBuild para mais opções.

Rotas de atualização e I/O de ficheiros

Uma tarefa frequentemente aceita entradas, como listas de itens no MSBuild, que, se forem ficheiros, podem ser sob a forma de caminhos relativos.

Os caminhos relativos são sempre relativos ao diretório de trabalho atual do processo, mas como a tarefa agora é executada em processo, o diretório de trabalho pode não ser o mesmo que era quando a tarefa foi executada no seu próprio processo. Esses caminhos são relativos ao diretório do projeto. TaskEnvironment inclui a propriedade ProjectDirectory e o método GetAbsolutePath() que pode utilizar para converter caminhos relativos em caminhos absolutos. Também podes aceder ao FullPath metadado; não é necessário usar o caminho relativo ItemSpec e depois convertê-lo num caminho absoluto.

O tipo AbsolutePath

AbsolutePath é uma estrutura somente de leitura em Microsoft.Build.Framework que representa um caminho de ficheiro absoluto validado. Os membros-chave incluem:

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

O AbsolutePath construtor valida que o caminho fornecido está enraizado. Também podes construir um AbsolutePath fornecendo um caminho relativo e um caminho base. A conversão implícita para string significa que podes passar um AbsolutePath diretamente para qualquer API que exija um caminho string.

A OriginalValue propriedade preserva a cadeia de caminho original tal como foi passada antes da resolução. Esta propriedade é útil quando é necessário manter caminhos relativos nas saídas de tarefas ou nas mensagens de registo. Por exemplo, uma tarefa que regista que ficheiros processou pode usar OriginalValue nas suas mensagens de registo para que os caminhos na saída permaneçam relativos e legíveis, enquanto continua a utilizar o Value resolvido (ou a conversão implícita string) para as operações reais de E/S sobre ficheiros.

Usar TaskEnvironment.GetAbsolutePath() para resolver caminhos de itens:

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

Gerir a contenção de acesso a ficheiros em compilações paralelas

A contenção de ficheiros pode ocorrer sempre que várias tarefas correm em paralelo e acedem ao mesmo ficheiro. Esta preocupação aplica-se tanto ao modelo multiprocesso tradicional como ao modo multithreaded em processo mais recente. Em ambos os casos, o mesmo ficheiro pode ser acedido simultaneamente quando:

  • O mesmo ficheiro aparece em múltiplas compilações de subprojetos (por exemplo, um ficheiro de configuração partilhado ou um ficheiro fonte ligado).
  • Uma tarefa lê e escreve um ficheiro que outra instância de tarefa também está a processar.

Métodos de conveniência como File.ReadAllLines e File.WriteAllLines não dão controlo explícito sobre o bloqueio de ficheiros. Quando o acesso concorrente for possível, use FileStream com partilha e bloqueio explícitos:

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

Diretrizes-chave para I/O de ficheiros em tarefas multithreaded:

  • Use FileShare.None para operações de leitura-modificação-escrita. Esta definição impede que outra tarefa leia conteúdo obsoleto enquanto está a atualizar o ficheiro.
  • Apanha IOException e considere tentar novamente. Quando outra tarefa ou processo mantém um bloqueio, a tua tentativa de abertura lança IOException. Uma nova tentativa após um breve intervalo de espera é muitas vezes adequada.
  • Evita segurar fechaduras em vários ficheiros ao mesmo tempo. Se duas tarefas bloquearem um ficheiro cada uma e depois tentarem bloquear o outro, fica um deadlock. Se tiver de operar em vários ficheiros, bloqueie-os numa ordem consistente (por exemplo, ordenados por caminho completo).
  • Mantém as fechaduras o mais curtas possível. Abrir o ficheiro, ler, modificar, escrever e fechar numa só operação. Não mantenha um bloqueio de ficheiro enquanto realiza tarefas não relacionadas.

O exemplo anterior é uma abordagem. Para orientações gerais sobre I/O de ficheiros thread-safe em .NET, veja FileStream class, FileShare enum e Managed threading best practices.

Observação

TaskEnvironment em si não é seguro para fios. Isto só importa se a tua tarefa gerar internamente os seus próprios threads (por exemplo, usando Parallel.ForEach ou Task.Run). A maioria das tarefas não faz isto. Implementam Execute() de forma linear e permitem que o MSBuild trate do paralelismo entre instâncias de tarefas. Se a sua tarefa criar os seus próprios threads, capture valores de TaskEnvironment em variáveis locais antes de os gerar, em vez de aceder TaskEnvironment a múltiplos threads em simultâneo.

Atualizar variáveis de ambiente

Observação

Ler variáveis de ambiente no código das tarefas é geralmente uma má prática, mesmo em compilações de uma única thread. As propriedades do MSBuild são uma alternativa melhor: são explicitamente definidas no âmbito, registadas durante a compilação e rastreáveis no registo de compilação. Se a sua tarefa lê atualmente uma variável de ambiente para receber entrada, considere substituí-la por uma propriedade de tarefa. O projeto ainda pode derivar o valor a partir de uma variável de ambiente: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />.

A orientação nesta secção é para migrar tarefas existentes que já dependem de variáveis do ambiente. Se tiver oportunidade de refatorar, prefira propriedades e itens.

Definição de variáveis de ambiente para processos filhos

O problema mais comum de variáveis de ambiente em builds multithreaded é uma tarefa que define uma variável de ambiente e depois gera um processo filho, esperando que o filho o herde. No modelo multiprocesso, Environment.SetEnvironmentVariable() modificou de forma segura o ambiente do processo de trabalho para esse projeto. No modo multithread, o processo é partilhado entre todas as compilações concorrentes, pelo que uma alteração destinada ao processo filho de um projeto pode infiltrar-se noutro.

Usar TaskEnvironment.SetEnvironmentVariable() em conjunto com TaskEnvironment.GetProcessStartInfo() (ver Atualizar chamadas API ProcessStart). GetProcessStartInfo() devolve um ProcessStartInfo pré-preenchido com o diretório de trabalho do projeto e a sua tabela de ambiente isolado, incluindo quaisquer variáveis definidas com SetEnvironmentVariable(), de modo que os processos filhos herdam automaticamente o ambiente correto, com âmbito de projeto.

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

Leitura de variáveis de ambiente em tarefas existentes

Se a sua tarefa atual lê variáveis de ambiente e não conseguir refatorar imediatamente para as propriedades da tarefa, substitua Environment.GetEnvironmentVariable() por TaskEnvironment.GetEnvironmentVariable(). Esta chamada de método lê da tabela de ambiente no âmbito do projeto, em vez do ambiente de processo partilhado, pelo que compilações em simultâneo não interferem entre si.

Antes (de BuildCommentTask):

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

After:

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

Sugestão

Ao atualizar código existente que lê uma variável de ambiente, considere substituir o padrão por uma propriedade de tarefa. Por exemplo, expor public bool DisableComments { get; set; } na tarefa e permitir que o projeto passe DisableComments="$(DISABLE_BUILD_COMMENTS)". O MSBuild regista o valor resolvido, tornando-o visível no registo de compilação e muito mais fácil de diagnosticar do que uma variável oculta do ambiente.

Atualizar chamadas à API ProcessStart

Normalmente, se uma tarefa iniciar um processo, deve utilizar ToolTask, que trata de tudo automaticamente. Nos casos em que estiver a atualizar uma tarefa que chama ProcessStartInfo diretamente, use TaskEnvironment.GetProcessStartInfo(). Isto retorna uma ProcessStartInfo configuração com o diretório de trabalho do projeto e a sua tabela de ambiente isolada. Se também estiveres a definir variáveis de ambiente antes de lançar, usa TaskEnvironment.SetEnvironmentVariable() primeiro, como mostrado na secção anterior.

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

Observação

Se a sua tarefa herda de ToolTask, as informações de arranque do processo já são tratadas automaticamente. Só precisas de atualizar tarefas que criam ProcessStartInfo diretamente.

Atualizar campos estáticos e estruturas de dados para serem seguros em contexto multithread

Os campos estáticos exigem um tratamento cuidadoso quando migrar para compilações multithread. Mesmo no modelo multi-processo, um único processo pode construir vários projetos, por isso o estado estático é partilhado, só que não em simultâneo.

O modo multithreading acrescenta uma nova dimensão a este problema. Múltiplas compilações podem agora partilhar o mesmo processo e executar tarefas em simultâneo (especialmente com o MSBuild Server, que é automaticamente ativado com multithreading). Um campo estático é partilhado entre todas as instâncias da tarefa no processo, não apenas na tua compilação, mas potencialmente entre invocações de compilação separadas em execução em simultâneo. Por exemplo, dois programadores a correr dotnet build ao mesmo tempo num servidor de compilação, ou duas janelas de terminal na mesma máquina, podem partilhar o mesmo estado estático, e agora essas compilações acedem-no ao mesmo tempo.

No BuildCommentTask exemplo, o campo ModifiedFileCount estático é partilhado por todas as instâncias:

Before:

private static int ModifiedFileCount = 0;

// In Execute():
ModifiedFileCount++;

Este código tem dois problemas. Primeiro, o ++ operador não é atómico. Quando múltiplas instâncias de tarefas são executadas em simultâneo, dois threads podem ler o mesmo valor e ambos escrever o mesmo resultado incrementado, causando perda de contas. Em segundo lugar, como o campo é estático, mantém-se entre compilações e é partilhado entre compilações concorrentes no mesmo processo.

As secções seguintes mostram duas abordagens para resolver estes problemas, da mais simples à mais correta.

Abordagem 1: Utilizar uma API thread-safe, mas que abrange todo o processo

A solução mais simples é tornar o incremento atómico:

private static int ModifiedFileCount = 0;

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

Interlocked.Increment realiza a operação de leitura-incremento-escrita como uma única operação atómica, pelo que não se perde qualquer contagem. Esta abordagem resolve o problema da concorrência, mas o contador continua a ser partilhado por todas as compilações do processo, incluindo compilações consecutivas e compilações concorrentes. Se duas builds correrem em simultâneo, os seus números de ficheiro intercalam-se (a Build A recebe #1, #3, #5; A Build B recebe #2, #4, #6). Se esta situação é aceitável depende de a sua tarefa exigir isolamento por construção. Para um contador de numeração sequencial de ficheiros como ModifiedFileCount, a partilha entre compilações constitui um problema de correção; utilize antes RegisterTaskObject (ver a abordagem 2).

Aqui, o equivalente a thread-safe, mas para toda a API, é InterlockedIncrement, mas no seu próprio código, terá de encontrar substitutos thread-safe apropriados para quaisquer APIs que não sejam thread-safe. Por exemplo, se a sua tarefa mantém o estado usando um Dictionary, considere usar ConcurrentDictionary<TKey,TValue>.

Abordagem 2: RegisterTaskObject para isolamento no âmbito da compilação

Se a sua tarefa precisar de um estado estático que seja partilhado entre subprojetos numa única invocação de compilação, mas isolado de outras compilações concorrentes, use IBuildEngine4.RegisterTaskObject com RegisteredTaskObjectLifetime.Build. O MSBuild gere a vida útil do objeto, que é criado à primeira utilização e limpo quando a compilação termina. Tenha em atenção que os objetos registados têm de ser seguros para execução em múltiplas threads.

Primeiro, defina uma classe contadora simples e segura para threads:

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

Depois, utiliza um método auxiliar com bloqueio de dupla verificação para obter ou criar o contador:

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

Em Execute():

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

Com esta abordagem, cada invocação de build recebe o seu próprio FileCounter. Todos os subprojetos dentro da mesma build partilham o contador (numeração sequencial), mas um subprojeto dotnet build a correr ao mesmo tempo na mesma máquina recebe um contador diferente. RegisteredTaskObjectLifetime.Build indica ao MSBuild para limitar o objeto à invocação atual da construção e limpá-lo quando a construção terminar.

Escolha a abordagem certa

Ao decidir como lidar com o estado estático, comece por esta questão: estes dados são seguros para partilhar entre todas as compilações que possam correr no mesmo processo, incluindo compilações consecutivas e concorrentes?

Os processos de trabalho do MSBuild persistem entre invocações (a reutilização de nós está ativada por defeito), e um processo do MSBuild pode potencialmente ser utilizado em várias compilações de soluções ao longo da sua vida útil, não apenas numa única chamada a dotnet build. Não presuma que um processo gere apenas uma build.

Siga estas diretrizes:

  • Mantenha o campo estático apenas se for seguro aceder aos dados em cache a partir de múltiplas threads em diferentes projetos e em múltiplas compilações, sem exigir invalidação entre compilações. Por exemplo, um cache de dados imutáveis calculado uma vez a partir de entradas que nunca mudam (como metadados de montagem carregados uma vez no arranque) pode qualificar-se.
  • Utilize IBuildEngine4.RegisterTaskObject com RegisteredTaskObjectLifetime.Build quando o estado tiver de ser isolado em cada invocação de build (por exemplo, contadores, acumuladores ou caches que devem ser reinicializados entre builds ou não ser partilhados entre builds concorrentes). Esta é a abordagem preferida para a maioria dos estados mutáveis partilhados.
  • Utilize System.Threading primitivas (Interlocked, ConcurrentDictionary, lock, ReaderWriterLockSlim) para tornar qualquer estado estático mantido seguro para threads, mas lembre-se de que a segurança dos threads, por si só, não proporciona isolamento ao nível da compilação. Consulte as melhores práticas de threading gerido.

Sugestão

O exemplo completo de migração mais adiante neste artigo utiliza esta RegisterTaskObject abordagem para demonstrar o isolamento com escopo de construção.

Exemplo completo de migração

O código seguinte mostra a migração AddBuildCommentTask completa com todas as cinco alterações aplicadas:

  1. Tem o atributo [MSBuildMultiThreadableTask], indicando que deve ser executado no processo.
  2. Implementa IMultiThreadableTask juntamente com a classe base existente Task e expõe a TaskEnvironment propriedade.
  3. Utiliza TaskEnvironment.GetAbsolutePath() para a resolução de caminhos.
  4. Utiliza TaskEnvironment.GetEnvironmentVariable() em vez de Environment.GetEnvironmentVariable().
  5. Usa IBuildEngine4.RegisterTaskObject com RegisteredTaskObjectLifetime.Build para limitar o contador de ficheiros à invocação atual da compilação, substituindo o contador estático global ao 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;
        }
    }
}

O que acontece às tarefas não migradas

As tarefas que não têm o [MSBuildMultiThreadableTask] atributo ou que não implementam IMultiThreadableTask continuam a funcionar sem quaisquer alterações. O MSBuild executa estas tarefas num processo subsidiário TaskHost , que proporciona o mesmo isolamento ao nível do processo que as versões anteriores do MSBuild. Esta abordagem é mais lenta devido à sobrecarga da comunicação entre processos, mas é totalmente compatível com o código de tarefa existente. A migração é opcional para garantir a correção — tarefas não migradas continuam a produzir resultados corretos — mas a migração melhora o desempenho da compilação.

Suporte a versões anteriores do MSBuild

Se atualizar a sua tarefa personalizada e depois a distribuir a outros, a sua tarefa suporta clientes que usam o MSBuild 18.6 ou versões posteriores. Para suportar clientes em versões anteriores do MSBuild, tens três opções.

Opção 1: Aceitar desempenho reduzido

Não faça alterações à sua tarefa. O MSBuild executa tarefas não atribuídas num processo subsidiário TaskHost , que é mais lento mas totalmente compatível. Esta opção não requer alterações de código.

Opção 2: Manter implementações separadas

Construa assemblies de tarefas separadas para MSBuild 18.6+ e versões anteriores. A versão MSBuild 18.6+ implementa IMultiThreadableTask e utiliza TaskEnvironment. A versão anterior continua a utilizar Task com APIs ao nível do processo.

Opção 3: Ponte de compatibilidade

Defina você mesmo o MSBuildMultiThreadableTaskAttribute na sua assemblagem de tarefas. Como o MSBuild deteta o atributo apenas pelo namespace e nome (ignorando a assembly definidora), o seu atributo autodefinido funciona tanto nas versões antigas como nas novas do MSBuild:

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

Ao correr no MSBuild 18.6 ou posterior, o MSBuild reconhece o atributo e executa a tarefa em processo. Ao correr em versões anteriores, o MSBuild ignora o atributo desconhecido e executa a tarefa como antes.

Com esta opção, não tens acesso a TaskEnvironment, por isso terás de gerir manualmente tudo o que ele gere, como converter todos os teus caminhos relativos em caminhos absolutos.

Comparação de abordagens

A tabela seguinte compara as três abordagens ao correr em modo multithreaded (-mt). Em modo não multithread, todas as tarefas correm fora de processo independentemente da forma como são marcadas.

Abordagem Maintenance Desempenho (18,6+) Desempenho (mais antigo) Acesso a TaskEnvironment
Implementações separadas Alto Totalmente em processo Completamente fora do processo Sim (versão 18.6+)
Ponte de compatibilidade Baixo Em processamento Completamente fora do processo Não (apenas atributo)
Sem alterações None Sidecar (mais lento) Completamente fora do processo No