Nota:
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
MSBuild 18.6 presenta la capacidad de compilar en paralelo dentro del mismo proceso. Para activar este modo, use la opción de línea de comandos -mt. Las versiones anteriores de MSBuild admitían compilaciones paralelas, pero las compilaciones se realizaron en procesos independientes. Este cambio tiene algunos impactos en la forma en que crea tareas. Mientras que anteriormente, las tareas se ejecutarían en un proceso independiente, ahora todas las tareas habilitadas para varios subprocesos se ejecutan en el mismo proceso. Aunque la mayoría de la lógica no necesita cambiar, hay algunas construcciones de nivel de proceso que deben controlarse con más cuidado. Las construcciones de nivel de proceso incluyen el directorio de trabajo actual, las variables de entorno y la información de inicio del proceso (ProcessStartInfo).
Para admitir estos cambios, MSBuild 18.6 introduce la interfaz IMultiThreadableTask (en Microsoft.Build.Framework) y la clase TaskEnvironment.
TaskEnvironment incluye una ProjectDirectory propiedad y métodos como GetAbsolutePath(), GetEnvironmentVariable(), SetEnvironmentVariable()y GetProcessStartInfo().
Important
El modo multiproceso está disponible actualmente como una característica experimental; no se recomienda para su uso en producción en este momento. La actualización de las dependencias de la biblioteca de MSBuild para usar las API de modo multiproceso impide implícitamente que las bibliotecas se ejecuten en versiones anteriores de Visual Studio y MSBuild. Animamos a los usuarios pioneros a probar el modo multiproceso y proporcionar comentarios. Envíe problemas en el repositorio MSBuild GitHub.
La IMultiThreadableTask interfaz define el contrato para las tareas que se pueden ejecutar en proceso en compilaciones multiproceso:
// Microsoft.Build.Framework
public interface IMultiThreadableTask : ITask
{
TaskEnvironment TaskEnvironment { get; set; }
}
Para migrar una tarea, implemente IMultiThreadableTask junto con la clase base existente Task y exponga la TaskEnvironment propiedad :
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;
// ...
}
Las tareas que implementan IMultiThreadableTask pueden ejecutarse en proceso. Todas esas tareas también deben incluir el atributo [MSBuildMultiThreadableTask], que es el marcador que usa MSBuild para indicar que la tarea debe ejecutarse en proceso. Antes de agregar el atributo , confirme que la tarea no tiene dependencias en construcciones de nivel de proceso, como el directorio de trabajo actual o el entorno, y que su código es seguro para subprocesos. Preste especial atención para garantizar el acceso seguro para subprocesos a variables estáticas, ya que estas variables se comparten entre todas las instancias de tarea y se puede acceder a ellas o modificarlas mediante diferentes instancias de la tarea que también se ejecutan en el mismo proceso.
Tarea de ejemplo: BuildCommentTask
En este artículo se usa el ejemplo AddBuildCommentTask siguiente para ilustrar el proceso de migración. Esta tarea antepone un comentario de compilación a los archivos de texto. De forma predeterminada, escribe texto sin formato; las propiedades opcionales CommentPrefix y CommentSuffix permiten a los llamadores ajustar el comentario en la sintaxis adecuada para el lenguaje (por ejemplo, // para C#, <!-- y --> para XML, # para Python o YAML):
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using RequiredAttribute = Microsoft.Build.Framework.RequiredAttribute;
namespace BuildCommentTask
{
public class AddBuildCommentTask : Microsoft.Build.Utilities.Task
{
private static int ModifiedFileCount = 0;
// Callers are responsible for passing only text files in TargetFiles,
// and for setting CommentPrefix/CommentSuffix to match the file type.
[Required]
public ITaskItem[] TargetFiles { get; set; }
[Required]
public string VersionNumber { get; set; }
// Optional CommentPrefix and CommentSuffix wrap the comment in
// language-appropriate syntax, e.g., "// " for C# or "# " for Python.
// Include any desired spacing in the prefix or suffix value.
public string CommentPrefix { get; set; } = "";
public string CommentSuffix { get; set; } = "";
public override bool Execute()
{
string disableComments = Environment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
if (!string.IsNullOrEmpty(disableComments))
{
Log.LogMessage(MessageImportance.Normal, "Build comments disabled via environment variable.");
return true;
}
string buildDate = DateTime.UtcNow.ToString("yyyy-MM-dd");
string commentPattern = $@"^{Regex.Escape(CommentPrefix)}\s*Build Date:.*Version:.*{Regex.Escape(CommentSuffix)}$";
foreach (var item in TargetFiles)
{
var filePath = item.ItemSpec;
try
{
string[] originalLines = File.ReadAllLines(filePath);
if (originalLines.Length > 0 && Regex.IsMatch(originalLines[0], commentPattern))
{
Log.LogMessage(MessageImportance.Low, $"Skipped (already annotated): {filePath}");
continue;
}
ModifiedFileCount++;
string comment = $"{CommentPrefix}Build Date: {buildDate}, Version: {VersionNumber}, File #: {ModifiedFileCount}{CommentSuffix}";
// Note: rewriting a file in place like this is convenient for a sample but is not
// recommended in production tasks. Prefer writing to a separate output file instead.
File.WriteAllLines(filePath, new[] { comment }.Concat(originalLines));
Log.LogMessage(MessageImportance.High, $"Added build comment to: {filePath}");
}
catch (Exception ex)
{
Log.LogError($"Failed to process {filePath}: {ex.Message}");
return false;
}
}
return true;
}
}
}
Un archivo de proyecto podría invocar esta tarea para distintos tipos de archivo, pasando la sintaxis de comentario adecuada para cada uno:
<!-- Stamp generated text files with plain text (no comment prefix) -->
<AddBuildCommentTask
TargetFiles="@(GeneratedFiles)"
VersionNumber="$(Version)" />
<!-- Stamp C# source files with // comments -->
<AddBuildCommentTask
TargetFiles="@(Compile)"
VersionNumber="$(Version)"
CommentPrefix="// " />
<!-- Stamp XML content files with <!-- --> comments -->
<AddBuildCommentTask
TargetFiles="@(Content -> WithMetadataValue('Extension', '.xml'))"
VersionNumber="$(Version)"
CommentPrefix="<!-- "
CommentSuffix=" -->" />
Esta tarea tiene cuatro problemas de seguridad para subprocesos que deben solucionarse para las compilaciones multiproceso:
-
Rutas de acceso relativas:
File.ReadAllLinesyFile.WriteAllLinesuseitem.ItemSpecdirectamente, lo que podría ser una ruta de acceso relativa. En modo multiproceso, no se garantiza que el directorio de trabajo del proceso sea el directorio del proyecto. -
Campo estático:
ModifiedFileCountes unstaticcampo compartido en todas las instancias, lo que provoca carreras de datos cuando varias compilaciones se ejecutan simultáneamente. -
Variables de entorno: el problema de la variable de entorno más común en las compilaciones multiproceso es tareas que establecen variables de entorno antes de generar un proceso secundario, esperando que el elemento secundario los herede. En el modo multihilo,
Environment.SetEnvironmentVariable()modifica el entorno de nivel de proceso compartido por todas las compilaciones concurrentes, por lo que un cambio destinado al proceso hijo de un proyecto puede propagarse al de otro. La lectura de variables de entorno directamente en el código de tarea (Environment.GetEnvironmentVariable()) también suele ser una práctica incorrecta; Las propiedades de MSBuild son una alternativa mejor porque se registran y se pueden realizar seguimientos.
Important
El modo de compilación multiproceso solo está disponible actualmente para las compilaciones de la CLI (dotnet build y MSBuild.exe). Visual Studio las compilaciones de MSBuild aún no admiten la ejecución multiproceso en proceso. En Visual Studio, la ejecución de todas las tareas se sigue realizando en un proceso independiente. Visual Studio integración está planeada para una versión futura.
Prerequisites
MSBuild 18.6 o posterior.
Habilite la ejecución de tareas multiproceso con el
-mtmodificador de línea de comandos:dotnet build -mtPara obtener más información sobre el modificador
-mt, consulte Referencia de la línea de comandos de MSBuild.
Planear la migración
Revisa el código de tu tarea para detectar los siguientes problemas:
- Compruebe el código de la tarea y detecte cualquier uso de rutas relativas. Compruebe todas las entradas y E/S de archivos.
- Compruebe si hay usos de variables de entorno.
- Compruebe si hay algún uso de la
ProcessStartInfoAPI. - Compruebe los campos estáticos o las estructuras de datos y use métodos estándar para que sean seguros para subprocesos.
- Si no se aplica ninguno de los elementos anteriores, considere la posibilidad de agregar solo el atributo .
- Tenga en cuenta los requisitos especiales para admitir versiones anteriores de MSBuild. Consulte Compatibilidad con versiones anteriores de MSBuild.
Referencia rápida para la sustitución de API
En la tabla siguiente se resumen las API de .NET que debe reemplazar y sus equivalentes de TaskEnvironment:
| API de .NET a evitar | Level | Replacement |
|---|---|---|
Path.GetFullPath(path) |
ERROR | Consulte la nota que sigue a esta tabla. |
File.* con rutas de acceso relativas |
ERROR | Resolver primero con TaskEnvironment.GetAbsolutePath() |
Directory.* con rutas de acceso relativas |
ERROR | Resuelve primero con TaskEnvironment.GetAbsolutePath() |
Environment.GetEnvironmentVariable() |
ERROR | TaskEnvironment.GetEnvironmentVariable() |
Environment.SetEnvironmentVariable() |
ERROR | TaskEnvironment.SetEnvironmentVariable() |
Environment.CurrentDirectory |
ERROR | TaskEnvironment.ProjectDirectory |
new ProcessStartInfo() |
ERROR | TaskEnvironment.GetProcessStartInfo() |
Process.Start() |
ERROR | Usar ToolTask o TaskEnvironment.GetProcessStartInfo() |
| Campos estáticos | Advertencia | Use campos de instancia o colecciones seguras para los hilos |
Note
Path.GetFullPath(path) hace dos cosas: convierte una ruta relativa en una ruta absoluta y produce una forma canónica de la ruta (resolviendo los segmentos . y ..). Estos deben controlarse por separado:
-
Solo ruta absoluta: Use
TaskEnvironment.GetAbsolutePath(path). Este enfoque es suficiente para la mayoría de las operaciones de E/S de archivos en las que se pasa la ruta de acceso directamente a las API de .NET. -
Ruta de acceso canónica: si se basa en la forma canónica (por ejemplo, al usar una ruta de acceso como una clave de caché o diccionario), use
Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path))para obtener una ruta de acceso absoluta canónica totalmente resuelta.
Marca la tarea con el atributo
Todas las tareas que participan en compilaciones multiproceso deben marcarse con el [MSBuildMultiThreadableTask] atributo . Este atributo es el indicador que usa MSBuild para identificar las tareas seguras para ejecutarse dentro del proceso.
[MSBuildMultiThreadableTask]
public class MyTask : Task
{
public override bool Execute()
{
// Task logic that doesn't depend on process-level state
return true;
}
}
Si la tarea ya es segura para subprocesos y no usa ninguna API de nivel de proceso (directorio de trabajo actual, variables de entorno, ProcessStartInfo), el atributo es todo lo que necesita. La tarea continúa heredando de Task (o ToolTask) sin ningún otro cambio.
Si la tarea sí necesita reemplazar las llamadas a la API a nivel de proceso (por ejemplo, para resolver rutas relativas o leer variables de entorno de forma segura), implemente también IMultiThreadableTask. Esta interfaz proporciona a la tarea acceso a la TaskEnvironment propiedad . El atributo sigue siendo necesario en ambos casos; IMultiThreadableTask es un paso adicional que desbloquea el TaskEnvironment API.
Note
MSBuild detecta el MSBuildMultiThreadableTaskAttribute solo por su espacio de nombres y su nombre, ignorando el ensamblado en el que se define. Esto significa que puede definir el atributo usted mismo en su propio código (consulte Compatibilidad con versiones anteriores de MSBuild) y MSBuild todavía lo reconoce.
Note
El MSBuildMultiThreadableTaskAttribute no es heredable (Inherited = false). Cada clase de tarea debe declarar explícitamente el atributo que se reconocerá como multiproceso. La herencia de una clase que tiene el atributo no hace automáticamente que la clase derivada sea multiproceso.
Inicializar TaskEnvironment como Fallback
Al implementar IMultiThreadableTask, inicialice la TaskEnvironment propiedad en TaskEnvironment.Fallback:
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
MSBuild establece esta propiedad antes de llamar a Execute() en una compilación normal. El Fallback valor predeterminado garantiza que la tarea funciona correctamente en otros escenarios de hospedaje (como pruebas unitarias o herramientas de orquestación de compilación personalizadas) donde MSBuild no está presente para establecer la propiedad. Sin él, acceder a TaskEnvironment fuera del motor provocaría una excepción de referencia nula.
Si necesita admitir versiones de MSBuild anteriores a la 18.6 que no incluyen TaskEnvironment.Fallback, inicialice la propiedad en null su lugar y proteja las TaskEnvironment llamadas con una comprobación nula. Consulte Compatibilidad con versiones anteriores de MSBuild para obtener más opciones.
Actualizar rutas de acceso y E/S de archivos
Una tarea suele aceptar entradas, como listas de elementos en MSBuild, que, si son archivos, podría estar en forma de rutas de acceso relativas.
Las rutas relativas siempre se interpretan en relación con el directorio de trabajo actual del proceso, pero, como la tarea ahora se ejecuta dentro del mismo proceso, es posible que el directorio de trabajo no sea el mismo que cuando la tarea se ejecutaba en su propio proceso. Estas rutas de acceso son relativas al directorio del proyecto. El TaskEnvironment incluye una propiedad ProjectDirectory y un método GetAbsolutePath() que puede utilizar para convertir rutas de acceso relativas en rutas de acceso absolutas. También puede acceder al metadato FullPath; no es necesario usar la ruta relativa ItemSpec y luego convertirla en absoluta.
El tipo AbsolutePath
AbsolutePath es una estructura de solo lectura en Microsoft.Build.Framework que representa una ruta de acceso de archivo absoluta validada. Los miembros clave incluyen:
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);
}
El constructor AbsolutePath comprueba que la ruta proporcionada es una ruta absoluta. También puede crear un AbsolutePath proporcionando una ruta relativa y una ruta base. La conversión implícita a string significa que puedes pasar un AbsolutePath directamente a cualquier API que espere una ruta de acceso string.
La propiedad OriginalValue conserva la cadena de ruta original tal como se pasó antes de resolverse. Esta propiedad es útil cuando necesite mantener rutas relativas en las salidas de las tareas o en los mensajes de registro. Por ejemplo, una tarea que registra qué archivos ha procesado puede usar OriginalValue en sus mensajes de registro para que las rutas de la salida sigan siendo relativas y legibles, mientras sigue usando el valor resuelto de Value (o la conversión implícita de string) para las operaciones reales de E/S de archivos.
Use TaskEnvironment.GetAbsolutePath() para resolver las rutas de acceso de los elementos:
Antes:
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));
Después:
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}");
Gestionar los conflictos de acceso a archivos en compilaciones paralelas
La contención de archivos puede producirse siempre que varias tareas se ejecuten en paralelo y accedan al mismo archivo. Esta preocupación se aplica tanto al modelo tradicional de varios procesos como al modo multiproceso más reciente en proceso. En ambos casos, se puede acceder al mismo archivo simultáneamente cuando:
- El mismo archivo aparece en varias compilaciones de subproyectos (por ejemplo, un archivo de configuración compartido o un archivo de origen vinculado).
- Una tarea lee y escribe un archivo que otra instancia de tarea también está procesando.
Métodos de conveniencia como File.ReadAllLines y File.WriteAllLines no proporcionan control explícito sobre el bloqueo de archivos. Cuando sea posible el acceso simultáneo, use FileStream con uso compartido explícito y bloqueo:
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);
}
Instrucciones clave para la E/S de archivos en tareas multiproceso:
- Use
FileShare.Nonepara operaciones de lectura-modificación-escritura. Esta configuración impide que otra tarea lea contenido obsoleto mientras actualiza el archivo. - Capture
IOExceptiony considere la posibilidad de reintentar. Cuando otra tarea o proceso contiene un bloqueo, el intento de apertura produceIOException. Un breve reintento con retroceso suele ser adecuado. - Evite mantener bloqueos en varios archivos a la vez. Si dos tareas bloquean cada una un archivo y luego intentan bloquear el otro, se produce un interbloqueo. Si debe operar en varios archivos, bloqueelos en un orden coherente (por ejemplo, ordenado por ruta de acceso completa).
- Mantenga los bloqueos tan breves como sea posible. Abra el archivo, lea, modifique, escriba y cierre en una operación. No mantenga un bloqueo de archivo mientras realiza un trabajo no relacionado.
El ejemplo anterior es un enfoque. Para obtener orientación general sobre la E/S de archivos segura para subprocesos en .NET, consulte FileStream class, FileShare enum y Procedimientos recomendados para los subprocesos administrados.
Note
TaskEnvironment no es seguro para subprocesos por sí mismo. Esto solo importa si la tarea genera internamente sus propios subprocesos (por ejemplo, mediante Parallel.ForEach o Task.Run). La mayoría de las tareas no lo hacen. Implementan Execute() linealmente y permiten que MSBuild controle el paralelismo entre instancias de tarea. Si la tarea crea sus propios subprocesos, capture los valores de TaskEnvironment en variables locales antes de crearlos, en lugar de acceder a TaskEnvironment desde varios subprocesos de forma concurrente.
Actualizar variables de entorno
Note
La lectura de variables de entorno en el código de tarea suele ser un procedimiento incorrecto, incluso en compilaciones de un solo subproceso. Las propiedades de MSBuild son una alternativa mejor: se limitan explícitamente, se registran durante la compilación y se pueden realizar seguimientos en el registro de compilación. Si la tarea lee actualmente una variable de entorno para recibir la entrada, considere la posibilidad de reemplazarla por una propiedad de tarea en su lugar. El proyecto todavía puede derivar el valor de una variable de entorno: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />.
Las instrucciones de esta sección son para migrar tareas existentes que ya se basan en variables de entorno. Si tiene la oportunidad de refactorizar, prefiera propiedades y elementos.
Configuración de variables de entorno para procesos secundarios
El problema de la variable de entorno más común en las compilaciones multiproceso es una tarea que establece una variable de entorno y, a continuación, genera un proceso secundario, esperando que el elemento secundario lo herede. En el modelo de varios procesos, Environment.SetEnvironmentVariable() modificó de forma segura el entorno de proceso de trabajo para ese proyecto. En modo multiproceso, el proceso se comparte en todas las compilaciones simultáneas, por lo que un cambio destinado al proceso secundario de un proyecto puede filtrarse a otro.
Use TaskEnvironment.SetEnvironmentVariable() junto con TaskEnvironment.GetProcessStartInfo() (consulte Update ProcessStart API calls).
GetProcessStartInfo() devuelve un elemento ProcessStartInfo rellenado previamente con el directorio de trabajo del proyecto y su tabla de entorno aislada, incluidas las variables establecidas con SetEnvironmentVariable(), por lo que los procesos secundarios heredan automáticamente el entorno correcto con ámbito de proyecto.
Antes:
Environment.SetEnvironmentVariable("TOOL_OUTPUT_DIR", outputDir);
var startInfo = new ProcessStartInfo("mytool.exe") { UseShellExecute = false };
Process.Start(startInfo); // inherits the modified process-level environment
Después:
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
Lectura de variables de entorno en tareas existentes
Si su tarea actual lee variables de entorno y no puede refactorizarla de inmediato para usar propiedades de tarea, sustituya Environment.GetEnvironmentVariable() por TaskEnvironment.GetEnvironmentVariable(). Esta llamada al método lee datos de la tabla de entorno del ámbito del proyecto en lugar del entorno de proceso compartido, por lo que las compilaciones simultáneas no interfieren entre sí.
Antes (desde BuildCommentTask):
string disableComments = Environment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
Después:
string disableComments = TaskEnvironment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
Sugerencia
Al actualizar el código existente que lee una variable de entorno, considere la posibilidad de reemplazar el patrón por una propiedad de tarea. Por ejemplo, exponga public bool DisableComments { get; set; } en la tarea y deje que el proyecto pase DisableComments="$(DISABLE_BUILD_COMMENTS)". MSBuild registra el valor resuelto, lo que hace que sea visible en el registro de compilación y mucho más fácil de diagnosticar que una variable de entorno oculta leída.
Actualizar las llamadas a la API ProcessStart
Normalmente, si una tarea inicia un proceso, debe usar ToolTask, que controla todo para usted. En los casos en que esté actualizando una tarea que invoca directamente a ProcessStartInfo, use TaskEnvironment.GetProcessStartInfo(). Esto devuelve un ProcessStartInfo configurado con el directorio de trabajo del proyecto y su tabla de entorno aislada. Si también va a establecer variables de entorno antes de iniciar la aplicación, use TaskEnvironment.SetEnvironmentVariable() primero, como se indica en la sección anterior.
Antes:
var startInfo = new ProcessStartInfo("mytool.exe")
{
WorkingDirectory = ".",
UseShellExecute = false
};
Process.Start(startInfo);
Después:
ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo();
startInfo.FileName = "mytool.exe";
startInfo.UseShellExecute = false;
Process.Start(startInfo);
Note
Si la tarea hereda de ToolTask, la información de inicio del proceso ya se gestiona automáticamente. Solo necesita actualizar las tareas que crean ProcessStartInfo directamente.
Actualizar campos estáticos y estructuras de datos para que sean seguros para subprocesos
Los campos estáticos requieren un tratamiento cuidadoso al migrar a compilaciones multiproceso. Incluso en el modelo de varios procesos, un único proceso puede compilar varios proyectos, por lo que el estado estático se comparte, simplemente no simultáneamente.
El modo multiproceso agrega una nueva dimensión a este problema. Varias compilaciones ahora pueden compartir el mismo proceso y ejecutar tareas simultáneamente (especialmente con MSBuild Server, que se habilita automáticamente con multithreading). Un campo estático se comparte entre todas las instancias de tarea del proceso, no solo dentro de su compilación, sino también, potencialmente, entre distintas invocaciones de compilación que se ejecutan simultáneamente. Por ejemplo, dos desarrolladores que ejecutan dotnet build al mismo tiempo en un servidor de compilación, o dos ventanas de terminal en la misma máquina ejecutándolo simultáneamente, podrían compartir el mismo estado estático y entonces esas compilaciones accederían a él al mismo tiempo.
En el BuildCommentTask ejemplo, el campo ModifiedFileCount estático se comparte en todas las instancias:
Antes:
private static int ModifiedFileCount = 0;
// In Execute():
ModifiedFileCount++;
Este código tiene dos problemas. En primer lugar, el ++ operador no es atómico. Cuando varias instancias de tarea se ejecutan simultáneamente, dos subprocesos pueden leer el mismo valor y escribir el mismo resultado incrementado, lo que provoca la pérdida de recuentos. En segundo lugar, dado que el campo es estático, se conserva entre compilaciones y se comparte entre compilaciones simultáneas en el mismo proceso.
En las secciones siguientes se muestran dos enfoques para solucionar estos problemas, desde el más sencillo hasta el más correcto.
Enfoque 1: Uso de una API segura para subprocesos, pero para todo el proceso
La corrección más sencilla es hacer que el incremento sea atómico:
private static int ModifiedFileCount = 0;
// In Execute():
int fileNumber = Interlocked.Increment(ref ModifiedFileCount);
Interlocked.Increment realiza la lectura-incremento-escritura como una sola operación atómica, por lo que no se pierde ningún recuento. Este enfoque resuelve el problema de simultaneidad, pero el contador todavía se comparte en todas las compilaciones del proceso, incluidas compilaciones consecutivas y compilaciones simultáneas. Si dos compilaciones se ejecutan simultáneamente, sus números de archivo intercalan (la compilación A obtiene #1, #3, #5; La compilación B obtiene #2, #4, #6). Que esta situación sea aceptable depende de si su tarea requiere aislamiento por compilación. Para un contador secuencial de numeración de archivos como ModifiedFileCount, compartirlo entre compilaciones plantea un problema de corrección; use RegisterTaskObject en su lugar (consulte el método 2).
Aquí, el equivalente seguro para hilos de una API de ámbito de proceso es InterlockedIncrement, pero en tu propio código tendrías que encontrar alternativas seguras para hilos para cualquier API que no lo sea. Por ejemplo, si la tarea conserva el estado mediante un(a) Dictionary, considere usar ConcurrentDictionary<TKey,TValue>.
Enfoque 2: RegisterTaskObject para el aislamiento de ámbito de compilación
Si la tarea necesita un estado estático que se comparte entre subproyectos dentro de una sola invocación de compilación, pero aislada de otras compilaciones simultáneas, use IBuildEngine4.RegisterTaskObject con RegisteredTaskObjectLifetime.Build. MSBuild administra la duración del objeto, que se crea en el primer uso y se limpia cuando finaliza la compilación. Tenga en cuenta que los objetos registrados deben ser seguros para hilos.
En primer lugar, defina una clase de contador simple segura para hilos:
internal class FileCounter
{
private int _count = 0;
public int Next() => Interlocked.Increment(ref _count);
}
A continuación, utilice un método auxiliar con bloqueo de doble comprobación para obtener o crear el 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;
}
En Execute():
FileCounter counter = GetOrCreateCounter();
// ...
int fileNumber = counter.Next();
Con este enfoque, cada invocación de compilación obtiene su propio FileCounter. Todos los subproyectos de la misma compilación comparten el contador (numeración secuencial), pero una ejecución independiente dotnet build al mismo tiempo en la misma máquina obtiene un contador diferente.
RegisteredTaskObjectLifetime.Build indica a MSBuild que deba definir el ámbito del objeto a la invocación de compilación actual y limpiarlo cuando finalice la compilación.
Elegir el enfoque correcto
Al decidir cómo controlar el estado estático, empiece por esta pregunta: ¿Es seguro compartir estos datos en todas las compilaciones que podrían ejecutarse en el mismo proceso, incluidas las compilaciones consecutivas y las compilaciones simultáneas?
Los procesos de trabajo de MSBuild persisten entre invocaciones (la reutilización de nodos está activada de forma predeterminada) y un proceso de MSBuild puede servir potencialmente varias compilaciones de solución durante su duración, no solo dentro de una sola dotnet build llamada. No suponga que un proceso solo controla una compilación.
Utilice estas directrices:
- Conserve el campo estático solo si los datos almacenados en caché son seguros para acceder desde varios subprocesos entre distintos proyectos y en varias compilaciones sin necesidad de invalidación entre compilaciones. Por ejemplo, una caché de datos inmutables calculados una vez a partir de entradas que nunca cambian (como metadatos de ensamblado cargados una vez al inicio) podría calificarse.
-
Usa
IBuildEngine4.RegisterTaskObjectconRegisteredTaskObjectLifetime.Buildcuando el estado deba aislarse en cada invocación de compilación (por ejemplo, contadores, acumuladores o memorias caché que deban restablecerse entre compilaciones o no se filtren entre compilaciones simultáneas). Este es el enfoque preferido para la mayoría del estado mutable compartido. -
Utilice primitivas de
System.Threading(Interlocked,ConcurrentDictionary,lock,ReaderWriterLockSlim) para que cualquier estado estático retenido sea seguro para subprocesos, pero recuerde que la seguridad de los subprocesos por sí sola no proporciona aislamiento a nivel de compilación. Consulte Procedimientos recomendados para subprocesos administrados.
Sugerencia
En el ejemplo completo de migración que aparece más adelante en este artículo, se utiliza el enfoque de RegisterTaskObject para demostrar el aislamiento con ámbito de compilación.
Ejemplo de migración completa
El código siguiente muestra la migración AddBuildCommentTask completa con los cinco cambios aplicados:
- Tiene el
[MSBuildMultiThreadableTask]atributo , lo que lo marca para la ejecución en proceso. - Implementa
IMultiThreadableTaskjunto con la clase base existenteTask, y expone la propiedadTaskEnvironment. - Usa
TaskEnvironment.GetAbsolutePath()para la resolución de rutas. - Usa
TaskEnvironment.GetEnvironmentVariable()en lugar deEnvironment.GetEnvironmentVariable(). - Usa
IBuildEngine4.RegisterTaskObjectconRegisteredTaskObjectLifetime.Buildpara limitar el contador de archivos a la invocación de compilación actual, reemplazando el contador estático de todo el proceso.
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;
}
}
}
¿Qué ocurre con las tareas no migradas?
Las tareas que no tienen el [MSBuildMultiThreadableTask] atributo o que no implementan IMultiThreadableTask siguen funcionando sin cambios. MSBuild ejecuta estas tareas en un proceso subsidiaria TaskHost , que proporciona el mismo aislamiento de nivel de proceso que las versiones anteriores de MSBuild. Este enfoque es más lento debido a la sobrecarga de la comunicación entre procesos, pero es totalmente compatible con el código de tarea existente. La migración es opcional para la corrección( las tareas no migradas siguen produciendo resultados correctos, pero la migración mejora el rendimiento de la compilación.
Compatibilidad con versiones anteriores de MSBuild
Si actualiza la tarea personalizada y luego la distribuye a otros, su tarea es compatible con clientes que usan MSBuild 18.6 o versiones posteriores. Para dar soporte a los clientes que usan versiones anteriores de MSBuild, hay tres opciones.
Opción 1: Aceptar un rendimiento reducido
No realice ningún cambio en la tarea. MSBuild ejecuta tareas sin atributos en un proceso subsidiario TaskHost, que es más lento pero plenamente compatible. Esta opción no requiere ningún cambio de código.
Opción 2: Mantener implementaciones independientes
Compile ensamblados de tareas independientes para MSBuild 18.6 y versiones anteriores. La versión de MSBuild 18.6+ implementa IMultiThreadableTask y usa TaskEnvironment. La versión anterior sigue usando Task con las API de nivel de proceso.
Opción 3: Puente de compatibilidad
Defina usted mismo el MSBuildMultiThreadableTaskAttribute en el ensamblado de tareas. Dado que MSBuild detecta el atributo únicamente por el espacio de nombres y el nombre (ignorando el ensamblado en el que se define), el atributo que haya definido usted mismo funciona tanto en las versiones antiguas como en las nuevas de MSBuild:
namespace Microsoft.Build.Framework
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
internal class MSBuildMultiThreadableTaskAttribute : Attribute { }
}
Cuando se ejecuta en MSBuild 18.6 o posterior, MSBuild reconoce el atributo y ejecuta la tarea en proceso. Cuando se ejecuta en versiones anteriores, MSBuild omite el atributo desconocido y ejecuta la tarea como antes.
Con esta opción, no tienes acceso a TaskEnvironment, por lo que tendrás que gestionar manualmente todo lo que este gestiona, como convertir todas tus rutas relativas en rutas absolutas.
Comparación de enfoques
En la tabla siguiente se comparan los tres enfoques al ejecutarse en modo multiproceso (-mt). En modo no multiproceso, todas las tareas se ejecutan fuera de proceso, independientemente de cómo estén marcadas.
| Approach | Maintenance | Rendimiento (18.6+) | Rendimiento (anterior) | Acceso a TaskEnvironment |
|---|---|---|---|---|
| Implementaciones independientes | Alto | Completo en proceso | Totalmente fuera de proceso | Sí (versión 18.6 o superior) |
| Puente de compatibilidad | Bajo | Totalmente en proceso | Totalmente fuera de proceso | No (solo atributo) |
| Sin cambios | Ninguno | Sidecar (más lento) | Completamente fuera de proceso | No |