Примечание.
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
MSBuild 18.6 предоставляет возможность параллельного создания в рамках одного процесса. Чтобы включить этот режим, укажите ключ командной строки -mt. Предыдущие версии MSBuild поддерживали параллельные сборки, но сборки были выполнены в отдельных процессах. Это изменение влияет на то, как вы создаете задачи. В то время как ранее задачи выполняются в отдельном процессе, теперь все многопоточные задачи выполняются в одном процессе. Хотя большая часть логики не требует изменения, существуют некоторые конструкции уровня процесса, которые необходимо тщательно обрабатывать. Конструкции уровня процесса включают текущий рабочий каталог, переменные среды и сведения о запуске процесса (ProcessStartInfo).
Для поддержки этих изменений MSBuild 18.6 представляет интерфейс IMultiThreadableTask (в Microsoft.Build.Framework) и класс TaskEnvironment.
TaskEnvironment
ProjectDirectory включает свойство и методы, такие как GetAbsolutePath(), GetEnvironmentVariable()и SetEnvironmentVariable()GetProcessStartInfo().
Important
Многопоточный режим в настоящее время доступен как экспериментальная функция; на данный момент его не рекомендуется использовать в производственной среде. Обновление зависимостей библиотеки MSBuild для использования интерфейсов API многопоточного режима неявно предотвращает работу библиотек на старых версиях Visual Studio и MSBuild. Мы рекомендуем ранним пользователям попробовать многопоточный режим и предоставить отзыв. Отправьте проблемы в репозитории MSBuild GitHub.
Интерфейс IMultiThreadableTask определяет контракт для задач, которые могут выполняться в многопоточных сборках:
// Microsoft.Build.Framework
public interface IMultiThreadableTask : ITask
{
TaskEnvironment TaskEnvironment { get; set; }
}
Чтобы перенести задачу, реализуйте IMultiThreadableTask вместе с существующим Task базовым классом и предоставьте 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;
// ...
}
Задачи, которые реализуют IMultiThreadableTask , могут выполняться в процессе. Все такие задачи также должны иметь атрибут [MSBuildMultiThreadableTask], который служит маркером, используемым MSBuild для включения задачи в выполнение в процессе. Перед добавлением атрибута убедитесь, что задача не имеет зависимостей от конструкций на уровне процесса, таких как текущий рабочий каталог или среда, и что его код является потокобезопасной. Обратите особое внимание, чтобы обеспечить потокобезопасный доступ к статическим переменным, так как эти переменные являются общими для всех экземпляров задач и могут быть доступны или изменены различными экземплярами задачи, которые также выполняются в одном процессе.
Пример задачи: BuildCommentTask
Следующий пример AddBuildCommentTask используется в этой статье для иллюстрации процесса миграции. Эта задача добавляет комментарий сборки к текстовым файлам. По умолчанию он записывает обычный текст; необязательные свойства CommentPrefix и CommentSuffix позволяют вызывающим лицам упаковывать комментарий в синтаксис, соответствующий языку (например, // для C#, <!-- и --> для XML, # для Python или 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;
}
}
}
Файл проекта может вызвать эту задачу для разных типов файлов, передав соответствующий синтаксис комментариев для каждого:
<!-- 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=" -->" />
Эта задача имеет четыре проблемы с безопасностью потока, которые необходимо устранить для многопоточных сборок:
-
Относительные пути:
File.ReadAllLinesиFile.WriteAllLinesиспользуйтеitem.ItemSpecнапрямую, что может быть относительным путем. В многопоточных режимах рабочий каталог процесса не гарантируется, что он является каталогом проекта. -
Статическое поле:
ModifiedFileCountэтоstaticполе, общее для всех экземпляров, что приводит к расам данных при одновременном выполнении нескольких сборок. -
Переменные среды. Наиболее распространенная проблема переменной среды в многопоточных сборках — это задачи, которые задают переменные среды перед тем, как создать дочерний процесс, ожидая, что дочерний объект наследует их. В многопоточном режиме
Environment.SetEnvironmentVariable()изменяет окружение процесса, которое используется всеми параллельно выполняющимися сборками, поэтому изменение, предназначенное для дочернего процесса одного проекта, может повлиять на дочерний процесс другого проекта. Чтение переменных среды непосредственно в коде задачи (Environment.GetEnvironmentVariable()) также обычно является плохой практикой; Свойства MSBuild являются лучшей альтернативой, так как они регистрируются и отслеживаются.
Important
В настоящее время режим многопоточной сборки доступен только для сборок CLI (dotnet build и MSBuild.exe) . Сборки MSBuild в Visual Studio пока не поддерживают многопоточное выполнение внутри процесса. В среде Visual Studio все задачи по-прежнему выполняются вне процесса. Интеграция с Visual Studio планируется в одном из будущих выпусков.
Prerequisites
MSBuild 18.6 или более поздней версии.
Включите многопоточное выполнение задач с помощью переключателя командной
-mtстроки:dotnet build -mt
Планирование миграции
Просмотрите код задачи для следующих проблем:
- Проверьте код задачи и определите любое использование относительных путей. Проверьте все входные и файловые операции ввода-вывода.
- Проверьте использование переменных среды.
- Проверьте наличие любого использования API
ProcessStartInfo. - Проверьте все статические поля или структуры данных и используйте стандартные методы, чтобы сделать их потокобезопасной.
- Если ни одно из указанных выше не применяется, попробуйте добавить атрибут только.
- Учитывайте особые требования для поддержки более ранних версий MSBuild. См . статью "Поддержка более ранних версий MSBuild".
Краткий справочник по замене API
В следующей таблице приведены API .NET, которые необходимо заменить, и их эквиваленты TaskEnvironment:
| API .NET, которых следует избегать | Level | Replacement |
|---|---|---|
Path.GetFullPath(path) |
ОШИБКА | См. примечание после этой таблицы |
File.* с относительными путями |
ОШИБКА | Сначала устраните проблему с помощью TaskEnvironment.GetAbsolutePath() |
Directory.* с относительными путями |
ОШИБКА | Сначала устраните проблему с помощью TaskEnvironment.GetAbsolutePath() |
Environment.GetEnvironmentVariable() |
ОШИБКА | TaskEnvironment.GetEnvironmentVariable() |
Environment.SetEnvironmentVariable() |
ОШИБКА | TaskEnvironment.SetEnvironmentVariable() |
Environment.CurrentDirectory |
ОШИБКА | TaskEnvironment.ProjectDirectory |
new ProcessStartInfo() |
ОШИБКА | TaskEnvironment.GetProcessStartInfo() |
Process.Start() |
ОШИБКА | Используйте ToolTask или TaskEnvironment.GetProcessStartInfo() |
| Статические поля | ПРЕДУПРЕЖДЕНИЕ | Используйте поля экземпляра или потокобезопасные коллекции |
Замечание
Path.GetFullPath(path) выполняет две функции: преобразует относительный путь в абсолютный и приводит путь к канонической форме (с разрешением сегментов . и ..). Их необходимо обрабатывать отдельно:
-
Только абсолютный путь: используйте
TaskEnvironment.GetAbsolutePath(path). Этот подход достаточно для большинства операций ввода-вывода файлов, где вы передаете путь непосредственно в .NET API. -
Канонический путь: если вы используете каноническую форму (например, при использовании пути в качестве кэша или ключа словаря), используйте
Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path))для получения полностью разрешенного канонического абсолютного пути.
Пометьте задачу атрибутом
Все задачи, участвующие в многопоточных сборках, должны быть помечены атрибутом [MSBuildMultiThreadableTask] . Этот атрибут является сигналом MSBuild, который используется для идентификации задач, безопасных для выполнения внутрипроцессного процесса.
[MSBuildMultiThreadableTask]
public class MyTask : Task
{
public override bool Execute()
{
// Task logic that doesn't depend on process-level state
return true;
}
}
Если задача уже является потокобезопасной и не использует API уровня процесса (текущий рабочий каталог, переменные среды), ProcessStartInfoатрибут только вам нужен. Задача продолжает наследоваться от Task (или ToolTask) без каких-либо других изменений.
Если задача должна заменить вызовы API на уровне процесса (например, для разрешения относительных путей или безопасного чтения переменных среды), также реализуйте IMultiThreadableTask. Этот интерфейс предоставляет вашей задаче доступ к свойству TaskEnvironment. Атрибут остается обязательным в обоих случаях; IMultiThreadableTask — это дополнительный шаг, который разблокирует TaskEnvironment API.
Замечание
MSBuild обнаруживает MSBuildMultiThreadableTaskAttribute только по пространству имён и имени, игнорируя сборку, в которой он определён. Это означает, что вы можете самостоятельно определить атрибут в собственном коде (см. статью "Поддержка более ранних версий MSBuild") и MSBuild по-прежнему распознает его.
Замечание
MSBuildMultiThreadableTaskAttribute не наследуется (Inherited = false). Каждый класс задач должен явно объявить атрибут, который должен быть распознан как многопоточный. Наследование от класса, имеющего этот атрибут, само по себе не делает производный класс автоматически поддерживающим многопоточность.
Инициализировать TaskEnvironment как резервный режим
При реализации IMultiThreadableTask, инициализируйте свойство TaskEnvironment значением TaskEnvironment.Fallback:
public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
MSBuild задает это свойство перед вызовом Execute() в обычной сборке. Значение по умолчанию Fallback гарантирует, что задача будет корректно работать в других сценариях размещения (например, в модульных тестах или пользовательских средствах оркестрации сборки), где MSBuild недоступен и не может задать это свойство. Без него доступ TaskEnvironment за пределами обработчика приведет к возникновению исключения со ссылкой NULL.
Если необходимо поддерживать версии MSBuild ниже 18.6, не включающие TaskEnvironment.Fallback, вместо этого инициализируйте свойство значением null и предваряйте все вызовы TaskEnvironment проверкой на null. Дополнительные варианты см. в статье "Поддержка более ранних версий MSBuild ".
Обновление путей и операций ввода-вывода файлов
Задача часто принимает входные данные, такие как списки элементов в MSBuild, которые, если они файлы, могут находиться в виде относительных путей.
Относительные пути всегда относятся к текущему рабочему каталогу процесса, но так как задача выполняется в процессе, рабочий каталог может не совпадать с тем же, что и при выполнении задачи в собственном процессе. Такие пути относятся к каталогу проекта.
TaskEnvironment включает свойство ProjectDirectory и метод GetAbsolutePath(), которые можно использовать для преобразования относительных путей в абсолютные. Кроме того, вы можете обратиться к метаданным FullPath; нет необходимости использовать относительный путь ItemSpec, а затем преобразовывать его в абсолютный.
Тип `AbsolutePath`
AbsolutePath — это неизменяемая структура в Microsoft.Build.Framework, представляющая собой проверенный абсолютный путь к файлу. Ключевые члены включают:
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);
}
Конструктор AbsolutePath проверяет, является ли предоставленный путь корневым. Вы также можете создать AbsolutePath, передав относительный путь и базовый путь. Неявное преобразование в string означает, что вы можете передать AbsolutePath напрямую в любой API, который ожидает путь типа string.
Свойство OriginalValue сохраняет исходную строку пути, как она была передана до разрешения. Это свойство полезно, если нужно сохранить относительные пути в результатах задачи или сообщениях журнала. Например, задача, которая регистрирует файлы, которые он обрабатывает, может использовать OriginalValue в сообщениях журнала, чтобы пути в выходных данных оставались относительными и читаемыми, а при этом использовать разрешенное Value (или неявное string преобразование) для фактических операций ввода-вывода файлов.
Используйте TaskEnvironment.GetAbsolutePath() для разрешения путей элементов:
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}");
Устранение конфликтов доступа к файлам в параллельных сборках
Конфликт при доступе к файлу может возникнуть, когда несколько задач выполняются параллельно и обращаются к одному и тому же файлу. Эта проблема касается как традиционной многопроцессной модели, так и более нового многопоточного режима в рамках одного процесса. В обоих случаях доступ к одному и тому же файлу можно получить одновременно, когда:
- Один и тот же файл присутствует в сборках нескольких подпроектов (например, общий файл конфигурации или связанный исходный файл).
- Задача считывает и записывает файл, который также обрабатывает другой экземпляр задачи.
Удобные методы, такие как File.ReadAllLines и File.WriteAllLines не предоставляют явный контроль над блокировкой файлов. Если возможен параллельный доступ, используйте FileStream с явным совместным использованием и блокировкой:
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);
}
Основные рекомендации по вводу-выводу файлов в многопоточных задачах:
- Используется
FileShare.Noneдля операций чтения и изменения записи. Этот параметр предотвращает чтение устаревшего содержимого другой задачи при обновлении файла. - Перехватите
IOExceptionи при необходимости повторите попытку. Если другая задача или процесс удерживает блокировку, при попытке открыть генерируется исключениеIOException. Часто уместна повторная попытка после небольшой паузы с увеличением интервала ожидания. - Избегайте одновременного хранения блокировок на нескольких файлах. Если две задачи блокируют по одному файлу, а затем каждая пытается заблокировать другой, возникает взаимоблокировка. Если необходимо работать с несколькими файлами, заблокируйте их в согласованном порядке (например, отсортированы по полному пути).
- Держите блокировки как можно короче. Откройте файл, прочитайте, измените, запишите и закройте его за одну операцию. Не удерживайте блокировку файла при выполнении несвязанной работы.
Предыдущий пример является одним из подходов. Общие рекомендации по потокобезопасному файловому вводу-выводу в .NET см. в разделах класс FileStream, перечисление FileShare и Рекомендации по использованию управляемой многопоточности.
Замечание
TaskEnvironment сам по себе не является потокобезопасным. Это имеет значение, только если ваша задача внутренне создает собственные потоки (например, использование Parallel.ForEach или Task.Run). Большинство задач этого не делают. Они реализуют Execute() линейно и позволяют MSBuild обрабатывать параллелизм между экземплярами задач. Если ваша задача создает собственные потоки, сохраните значения из TaskEnvironment в локальные переменные перед их запуском, вместо того чтобы одновременно обращаться к TaskEnvironment из нескольких потоков.
Обновление переменных среды
Замечание
Чтение переменных среды в коде задачи обычно является плохой практикой даже в однопоточных сборках. Свойства MSBuild являются лучшей альтернативой: они явно ограничены, регистрируются во время сборки и отслеживаются в журнале сборки. Если задача в настоящее время считывает переменную среды для получения входных данных, попробуйте заменить ее свойством задачи. Проект по-прежнему может получать значение из переменной среды: <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />
В этом разделе описано, как перенести существующие задачи, которые уже используют переменные среды. Если у вас есть возможность провести рефакторинг, отдавайте предпочтение свойствам и элементам.
Настройка переменных среды для дочерних процессов
Наиболее распространенная проблема переменной среды в многопоточных сборках — это задача, которая задает переменную среды, а затем создает дочерний процесс, ожидая, что дочерний объект наследует его. В многопроцессной модели Environment.SetEnvironmentVariable() безопасно изменил среду рабочего процесса для этого проекта. В многопоточном режиме процесс является общим для всех параллельно выполняемых сборок, поэтому изменение, предназначенное для дочернего процесса одного проекта, может затронуть другой проект.
Используйте TaskEnvironment.SetEnvironmentVariable() вместе с TaskEnvironment.GetProcessStartInfo() (см. вызовы API Update ProcessStart).
GetProcessStartInfo() возвращает ProcessStartInfo, предварительно заполненный рабочим каталогом проекта и его изолированной таблицей переменных среды, включая все переменные, которые вы задаёте с помощью SetEnvironmentVariable(), поэтому дочерние процессы автоматически наследуют корректную среду проекта.
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
Чтение переменных среды в существующих задачах
Если существующая задача считывает переменные среды и не сможете немедленно рефакторингировать свойства задачи, замените Environment.GetEnvironmentVariable() на TaskEnvironment.GetEnvironmentVariable(). Этот вызов метода считывает данные из таблицы переменных среды уровня проекта, а не из общих переменных среды процесса, поэтому параллельные сборки не мешают друг другу.
До (из BuildCommentTask):
string disableComments = Environment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
After:
string disableComments = TaskEnvironment.GetEnvironmentVariable("DISABLE_BUILD_COMMENTS");
Подсказка
При обновлении существующего кода, считывающего переменную среды, рекомендуется заменить шаблон свойством задачи. Например, добавьте public bool DisableComments { get; set; } в задачу и передавайте DisableComments="$(DISABLE_BUILD_COMMENTS)" из проекта. MSBuild регистрирует разрешенное значение, что делает его видимым в журнале сборки и гораздо проще диагностировать, чем скрытая переменная среды.
Обновление вызовов API ProcessStart
Как правило, если задача запускает процесс, следует использовать ToolTask, который берёт всё на себя. В случаях, когда вы обновляете задачу, которая вызывается ProcessStartInfo напрямую, используйте TaskEnvironment.GetProcessStartInfo(). Возвращает объект ProcessStartInfo, настроенный с использованием рабочего каталога проекта и таблицы его изолированного окружения. Если вы также задаете переменные среды перед запуском, используйте TaskEnvironment.SetEnvironmentVariable() сначала, как показано в предыдущем разделе.
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);
Замечание
Если ваша задача наследуется от ToolTask, информация о запуске процесса уже обрабатывается. Вам нужно обновить только задачи, которые напрямую создают ProcessStartInfo.
Обновите статические поля и структуры данных, сделав их потокобезопасными
Статические поля требуют тщательного подхода при переходе на многопоточные сборки. Даже в многопроцессной модели один процесс может собирать несколько проектов, поэтому статическое состояние является общим, но не используется одновременно.
Многопоточный режим добавляет новое измерение к этой проблеме. Теперь несколько сборок могут совместно использовать один и тот же процесс и выполнять задачи одновременно (особенно с MSBuild Server, который автоматически включен с многопоточной обработкой). Статическое поле является общим для всех экземпляров задач в процессе, не только в рамках вашей сборки, но, возможно, и между отдельными запусками сборки, выполняющимися одновременно. Например, два разработчика, работающие dotnet build одновременно на сервере сборки, или два окна терминала на одном компьютере, могут совместно использовать одно и то же статическое состояние, и теперь эти сборки получают доступ к нему одновременно.
В примере BuildCommentTask статическое поле ModifiedFileCount является общим для всех экземпляров:
Before:
private static int ModifiedFileCount = 0;
// In Execute():
ModifiedFileCount++;
Этот код имеет две проблемы. Во-первых, ++ оператор не является атомарным. При одновременном выполнении нескольких экземпляров задач два потока могут считывать одно и то же значение и записывать один и тот же добавочный результат, что приводит к потере счетчиков. Во-вторых, так как поле является статическим, оно сохраняется во всех сборках и совместно используется между параллельными сборками в одном процессе.
В следующих разделах показаны два подхода к устранению этих проблем, от самых простых до наиболее правильных.
Подход 1: Использование потокобезопасного, но общего для всего процесса API
Самое простое решение — сделать операцию инкремента атомарной:
private static int ModifiedFileCount = 0;
// In Execute():
int fileNumber = Interlocked.Increment(ref ModifiedFileCount);
Interlocked.Increment выполняет операцию чтения и добавочной записи как одну атомарную операцию, поэтому подсчеты не теряются. Этот подход решает проблему параллелизма, но счетчик по-прежнему используется во всех сборках процесса, включая последовательные сборки и одновременные сборки. Если две сборки запускаются параллельно, номера их файлов чередуются (сборка A получает #1, #3, #5; сборка B получает #2, #4, #6). Приемлемость этой ситуации зависит от того, требует ли ваша задача изоляции для каждой сборки. Для счётчика последовательной нумерации файлов, такого как ModifiedFileCount, совместное использование между сборками приводит к нарушению корректности; вместо этого используйте RegisterTaskObject (см. подход 2).
Здесь потокобезопасным, но работающим на уровне процесса эквивалентом API является InterlockedIncrement, однако в собственном коде вам потребуется найти подходящие потокобезопасные замены для всех API, которые не являются потокобезопасными. Например, если задача сохраняет состояние с помощью Dictionary, рассмотрите возможность использования ConcurrentDictionary<TKey,TValue>.
Подход 2: RegisterTaskObject для изоляции на уровне сборки
Если для вашей задачи требуется статическое состояние, совместно используемое подпроектами в рамках одного вызова сборки, но изолированное от других параллельных сборок, используйте IBuildEngine4.RegisterTaskObject с RegisteredTaskObjectLifetime.Build. MSBuild управляет временем существования объекта, созданного при первом использовании и очистке при завершении сборки. Обратите внимание, что зарегистрированные объекты должны быть потокобезопасны.
Сначала определите простой класс счетчиков, безопасный для потоков:
internal class FileCounter
{
private int _count = 0;
public int Next() => Interlocked.Increment(ref _count);
}
Затем используйте вспомогательный метод с двойной проверкой блокировки, чтобы получить или создать счетчик:
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;
}
В Execute():
FileCounter counter = GetOrCreateCounter();
// ...
int fileNumber = counter.Next();
При таком подходе каждый вызов сборки получает свой собственный FileCounter. Все подпроекты в рамках одной сборки используют общий счетчик (последовательную нумерацию), но отдельный dotnet build, выполняемый одновременно на той же машине, использует другой счетчик.
RegisteredTaskObjectLifetime.Build указывает MSBuild ограничить область действия объекта текущим вызовом сборки и удалить его после завершения сборки.
Выберите правильный подход
При принятии решения об обработке статического состояния начните с этого вопроса: безопасно ли использовать эти данные во всех сборках, которые когда-либо могут выполняться в одном процессе, включая последовательные сборки и одновременные сборки?
Рабочие процессы MSBuild сохраняются во время вызовов (по умолчанию используется повторное использование узла), а процесс MSBuild может служить нескольким сборкам решений в течение всего времени существования, а не только в одном dotnet build вызове. Не предполагайте, что процесс обрабатывает только одну сборку.
Воспользуйтесь следующими инструкциями:
- Сохраняйте статическое поле только в том случае, если кэшированные данные безопасны для доступа из нескольких потоков в разных проектах и в ходе нескольких сборок без необходимости инвалидации между сборками. Например, может подойти кэш неизменяемых данных, вычисляемых один раз на основе входных данных, которые никогда не изменяются (например, метаданных сборки, загружаемых один раз при запуске).
-
Используйте
IBuildEngine4.RegisterTaskObjectсRegisteredTaskObjectLifetime.Build, когда состояние должно быть изолировано при каждом запуске сборки (например, для счётчиков, накопителей или кэшей, которые должны сбрасываться между сборками или не должны быть доступны в параллельных сборках). Это предпочтительный подход для большинства общих изменяемых состояний. -
Используйте примитивы
System.Threading(Interlocked,ConcurrentDictionary,lock,ReaderWriterLockSlim), чтобы сделать любое сохраняемое статическое состояние потокобезопасным, но помните, что одна лишь потокобезопасность не обеспечивает изоляцию на уровне сборки. См. рекомендации по эффективной работе с управляемой многопоточностью.
Подсказка
Полный пример миграции, приведённый далее в статье, использует подход RegisterTaskObject для демонстрации изоляции на уровне сборки.
Пример завершения миграции
В следующем фрагменте кода показан полностью перенесённый AddBuildCommentTask со всеми пятью применёнными изменениями:
- Имеет атрибут
[MSBuildMultiThreadableTask], помечающий его для выполнения в процессе. - Реализует
IMultiThreadableTaskнаряду с существующим базовым классомTaskи предоставляет свойствоTaskEnvironment. - Использует
TaskEnvironment.GetAbsolutePath()для разрешения путей. - Используется
TaskEnvironment.GetEnvironmentVariable()вместоEnvironment.GetEnvironmentVariable(). - Использует
IBuildEngine4.RegisterTaskObjectвместе сRegisteredTaskObjectLifetime.Build, чтобы ограничить счётчик файлов текущим вызовом сборки, заменяя статический счётчик уровня процесса.
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;
}
}
}
Что происходит с неперенесёнными задачами
Задачи, которые не имеют атрибута [MSBuildMultiThreadableTask] или не реализуются IMultiThreadableTask , продолжают работать без каких-либо изменений. MSBuild выполняет эти задачи в дочернем TaskHost процессе, который обеспечивает ту же изоляцию на уровне процесса, что и предыдущие версии MSBuild. Этот подход замедляется из-за затрат на взаимодействие между процессами, но он полностью совместим с существующим кодом задачи. Миграция необязательна с точки зрения корректности — задачи, которые не были перенесены, по-прежнему выдают корректные результаты, но миграция повышает производительность сборки.
Поддержка более ранних версий MSBuild
Если вы обновите пользовательскую задачу, а затем распространите ее среди других, ваша задача будет поддерживать клиентов, использующих MSBuild версии 18.6 или более поздней. Для поддержки клиентов в более ранних версиях MSBuild есть три варианта.
Вариант 1. Принятие снижения производительности
Не изменяйте задачу. MSBuild выполняет задачи, не относящиеся к атрибутам TaskHost , в дочернем процессе, что является более медленным, но полностью совместимым. Этот параметр не требует изменений кода.
Вариант 2. Обслуживание отдельных реализаций
Создание отдельных сборок задач для MSBuild 18.6 и более ранних версий. Версия MSBuild 18.6+ реализует IMultiThreadableTask и использует TaskEnvironment. Более ранняя версия по-прежнему использует Task с API уровня процесса.
Вариант 3. Мост совместимости
Определите MSBuildMultiThreadableTaskAttribute самостоятельно в своей сборке задач. Поскольку MSBuild распознаёт атрибут только по пространству имён и имени (игнорируя определяющую сборку), атрибут, определённый вами, работает как в старых, так и в новых версиях MSBuild:
namespace Microsoft.Build.Framework
{
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
internal class MSBuildMultiThreadableTaskAttribute : Attribute { }
}
При запуске в MSBuild 18.6 или более поздней версии MSBuild распознает атрибут и запускает задачу в процессе. При запуске в более ранних версиях MSBuild игнорирует неизвестный атрибут и запускает задачу, как и раньше.
С этим параметром у вас не будет доступа к TaskEnvironment, поэтому вам придётся вручную выполнять всё, что он обычно делает, например преобразовывать все относительные пути в абсолютные.
Сравнение подходов
В следующей таблице сравниваются три подхода при выполнении в многопоточных режимах (-mt). В режиме без многопоточных операций все задачи выполняются вне процесса независимо от того, как они помечены.
| Подход | Обслуживание | Производительность (18.6+) | Производительность (более старая версия) | Доступ к TaskEnvironment |
|---|---|---|---|---|
| Отдельные реализации | Высокий | Полностью в процессе | Полностью вне процесса | Да (версия 18.6+ ) |
| Мост совместимости | Low | Полностью в процессе | Полностью вне процесса | Нет (только атрибут) |
| Без изменений | None | Sidecar (более медленный) | Полностью вне процесса | No |