다중 스레드 모드에서 작동하도록 MSBuild 작업 업데이트

MSBuild 18.6에는 동일한 프로세스 내에서 병렬로 빌드하는 기능이 도입되었습니다. 이 모드로 옵트인하려면 명령줄 스위치를 -mt 전달합니다. 이전 버전의 MSBuild는 병렬 빌드를 지원했지만 빌드는 별도의 프로세스에서 수행되었습니다. 이 변경 내용은 작업을 작성하는 방법에 몇 가지 영향을 줍니다. 이전에는 태스크가 별도의 프로세스에서 실행되었던 반면, 이제는 모든 다중 스레드 사용 태스크가 동일한 프로세스에서 실행됩니다. 대부분의 논리는 변경할 필요가 없지만 더 신중하게 처리해야 하는 몇 가지 프로세스 수준 구문이 있습니다. 프로세스 수준 구문에는 현재 작업 디렉터리, 환경 변수 및 프로세스 시작 정보(ProcessStartInfo)가 포함됩니다.

이러한 변경을 지원하기 위해 MSBuild 18.6에서는 IMultiThreadableTask 인터페이스(Microsoft.Build.Framework)와 TaskEnvironment 클래스를 도입했습니다. TaskEnvironment에는 ProjectDirectory 속성 및 메서드(예: GetAbsolutePath(), GetEnvironmentVariable()SetEnvironmentVariable()GetProcessStartInfo().)가 포함됩니다.

Important

다중 스레드 모드는 현재 실험적 기능으로 사용할 수 있습니다. 지금은 프로덕션용으로 사용하지 않는 것이 좋습니다. 다중 스레드 모드 API를 사용하도록 MSBuild 라이브러리 종속성을 업데이트하면 라이브러리가 이전 버전의 Visual Studio 및 MSBuild에서 실행되지 않도록 암시적으로 방지할 수 있습니다. 얼리어답터는 다중 스레드 모드를 사용해 보고 피드백을 제공하는 것이 좋습니다. MSBuild GitHub 리포지토리에서 문제를 제출합니다.

IMultiThreadableTask 인터페이스는 다중 스레드 빌드에서 프로세스 내로 실행될 수 있는 작업에 대한 규약을 정의합니다:

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

작업을 마이그레이션하려면 기존 Task 기본 클래스와 함께 IMultiThreadableTask를 구현하고 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 는 마이그레이션 프로세스를 설명하기 위해 이 문서 전체에서 사용됩니다. 이 작업은 텍스트 파일 앞에 빌드 주석을 추가합니다. 기본적으로 일반 텍스트를 씁니다. 선택적 CommentPrefixCommentSuffix 속성을 사용하면 호출자가 언어에 적합한 구문으로 주석을 래핑할 수 있습니다(예: C#의 경우 //, XML의 경우 <!-- 및 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="&lt;!-- "
    CommentSuffix=" --&gt;" />

이 작업에는 다중 스레드 빌드에 대해 해결해야 하는 네 가지 스레드 안전 문제가 있습니다.

  1. 상대 경로: File.ReadAllLinesFile.WriteAllLines는 상대 경로일 수 있는 item.ItemSpec를 직접 사용합니다. 다중 스레드 모드에서는 프로세스 작업 디렉터리가 프로젝트 디렉터리로 보장되지 않습니다.
  2. 정적 필드: ModifiedFileCountstatic 모든 인스턴스에서 공유되는 필드로, 여러 빌드가 동시에 실행될 때 데이터 경합이 발생합니다.
  3. 환경 변수: 다중 스레드 빌드에서 가장 일반적인 환경 변수 문제는 자식 프로세스를 생성하기 전에 환경 변수를 설정 하여 자식이 상속할 것으로 예상하는 작업입니다. 다중 스레드 모드 Environment.SetEnvironmentVariable() 에서는 모든 동시 빌드에서 공유하는 프로세스 수준 환경을 수정하므로 한 프로젝트의 자식 프로세스에 대한 변경 내용이 다른 프로젝트의 프로세스로 출혈될 수 있습니다. 작업 코드(Environment.GetEnvironmentVariable())에서 직접 환경 변수를 읽는 것도 일반적으로 좋지 않습니다. MSBuild 속성은 기록되고 추적할 수 있으므로 더 나은 대안입니다.

Important

다중 스레드 빌드 모드는 현재 CLI(dotnet buildMSBuild.exe) 빌드에만 사용할 수 있습니다. Visual Studio MSBuild 빌드는 아직 프로세스 내 다중 스레드 실행을 지원하지 않습니다. Visual Studio에서는 모든 작업 실행이 계속 프로세스 외부에서 실행됩니다. Visual Studio 통합은 향후 릴리스에 대해 계획되어 있습니다.

필수 조건

  • MSBuild 18.6 이상.

  • -mt 명령줄 스위치를 사용하여 다중 스레드 작업 실행을 사용하도록 설정합니다:

    dotnet build -mt
    

    스위치에 대한 -mt 자세한 내용은 MSBuild 명령줄 참조를 참조하세요.

마이그레이션 계획

작업 코드를 검토하여 다음 문제를 확인합니다.

  1. 작업 코드를 확인하고 상대 경로의 사용량을 식별합니다. 모든 입력 및 파일 I/O를 확인합니다.
  2. 환경 변수의 용도를 확인합니다.
  3. ProcessStartInfo API 사용 여부를 확인합니다.
  4. 정적 필드 또는 데이터 구조를 확인하고 표준 메서드를 사용하여 스레드로부터 안전하게 만듭니다.
  5. 위의 항목이 적용되지 않는 경우 특성만 추가하는 것이 좋습니다.
  6. 이전 버전의 MSBuild를 지원하기 위한 특별한 요구 사항을 고려합니다. 이전 버전의 MSBuild 지원을 참조하세요.

API 대체 빠른 참조 가이드

다음 표에는 대체해야 하는 .NET API와 그에 해당하는 TaskEnvironment 대체 항목이 요약되어 있습니다.

피해야 할 .NET API Level 교체
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에 직접 전달하는 대부분의 파일 I/O 작업에 충분합니다.
  • 정식 경로: 정식 형식(예: 경로를 캐시 또는 사전 키로 사용하는 경우)을 사용하는 경우 완전히 확인된 정식 절대 경로를 가져오는 데 사용합니다 Path.GetFullPath(TaskEnvironment.GetAbsolutePath(path)) .

작업을 특성으로 표시

다중 스레드 빌드에 참여하는 모든 작업은 특성으로 [MSBuildMultiThreadableTask] 표시되어야 합니다. 이 특성은 MSBuild가 In-Process를 실행해도 안전한 작업을 식별하는 데 사용하는 신호입니다.

[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 속성에 액세스할 수 있도록 합니다. 두 경우 모두 이 속성은 여전히 필요하며, IMultiThreadableTaskTaskEnvironment API를 잠금 해제하는 추가 단계입니다.

메모

MSBuild는 MSBuildMultiThreadableTaskAttribute를 네임스페이스와 이름으로만 식별하며, 이를 정의한 어셈블리는 무시합니다. 즉, 고유한 코드에서 직접 특성을 정의할 수 있으며( 이전 버전의 MSBuild 지원 참조) MSBuild는 여전히 해당 특성을 인식합니다.

메모

상속 MSBuildMultiThreadableTaskAttribute 할 수 없습니다(Inherited = false). 각 작업 클래스는 다중 스레드 가능으로 인식되도록 특성을 명시적으로 선언해야 합니다. 특성이 있는 클래스에서 상속해도 파생 클래스를 다중 스레드할 수 있는 것은 아닙니다.

TaskEnvironment를 Fallback으로 초기화

IMultiThreadableTask을(를) 구현할 때 TaskEnvironment 속성을 TaskEnvironment.Fallback(으)로 초기화합니다:

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

MSBuild는 일반 빌드에서 호출 Execute() 하기 전에 이 속성을 설정합니다. 기본값은 Fallback MSBuild가 속성을 설정하기 위해 존재하지 않는 다른 호스팅 시나리오(예: 단위 테스트 또는 사용자 지정 빌드 오케스트레이션 도구)에서 작업이 올바르게 작동하도록 합니다. 이것이 없으면 엔진 외부에서 TaskEnvironment에 접근할 때 null 참조 예외가 발생합니다.

TaskEnvironment.Fallback이 포함되지 않은 18.6 이전 버전의 MSBuild를 지원해야 하는 경우에는 속성을 대신 null으로 초기화하고, 모든 TaskEnvironment 호출은 null 검사로 보호하세요. 자세한 옵션은 이전 버전의 MSBuild 지원을 참조하세요.

경로 및 파일 I/O 업데이트

태스크는 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로의 암시적 변환은 string 경로를 기대하는 모든 API에 AbsolutePath을 직접 전달할 수 있음을 의미합니다.

이 속성은 OriginalValue 원래 경로 문자열이 해결 전에 전달된 대로 유지됩니다. 이 속성은 작업 출력 또는 로그 메시지에 상대 경로를 유지해야 하는 경우에 유용합니다. 예를 들어, 어떤 파일을 처리했는지 기록하는 태스크는 출력의 경로가 상대 경로로 유지되고 읽기 쉽도록 로그 메시지에서 OriginalValue를 사용할 수 있으며, 실제 파일 I/O에는 해석된 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}");

병렬 빌드에서 파일 경합 처리

여러 작업이 병렬로 실행되고 동일한 파일에 액세스할 때마다 파일 경합이 발생할 수 있습니다. 이 문제는 기존의 다중 프로세스 모델과 최신 In-Process 다중 스레드 모드 모두에 적용됩니다. 두 경우 모두 다음 경우에 동일한 파일에 동시에 액세스할 수 있습니다.

  • 동일한 파일이 여러 하위 프로젝트 빌드(예: 공유 구성 파일 또는 연결된 원본 파일)에 나타납니다.
  • 태스크는 다른 작업 인스턴스도 처리 중인 파일을 읽고 씁니다.

File.ReadAllLinesFile.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);
}

다중 스레드 작업의 파일 I/O에 대한 주요 지침:

  • 읽기-수정-쓰기 작업에 사용합니다 FileShare.None . 이 설정은 파일을 업데이트하는 동안 다른 작업이 부실 콘텐츠를 읽는 것을 방지합니다.
  • catch IOException 하고 다시 시도하는 것이 좋습니다. 다른 작업 또는 프로세스가 잠금을 보유하고 있으면 열기를 시도할 때 IOException가 발생합니다. 백오프를 사용한 짧은 재시도는 종종 적절합니다.
  • 한 번에 여러 파일에 잠금을 유지하지 마세요. 두 작업이 각각 한 파일을 잠그고 다른 작업을 잠그려고 하면 교착 상태가 발생합니다. 여러 파일에서 작업해야 하는 경우 일관된 순서로 잠급니다(예: 전체 경로로 정렬).
  • 잠금을 가능한 한 짧게 유지합니다. 한 작업에서 파일을 열고, 읽고, 수정하고, 쓰고, 닫습니다. 관련 없는 작업을 수행하는 동안 파일 잠금을 유지하지 마세요.

앞의 예제는 한 가지 방법입니다. .NET의 스레드로부터 안전한 파일 I/O에 대한 일반적인 지침은 FileStream 클래스, FileShare 열거형관리 스레딩 모범 사례를 참조하세요.

메모

TaskEnvironment 자체는 스레드로부터 안전하지 않습니다. 이는 태스크가 내부적으로 자체 스레드(예: 사용 Parallel.ForEach 또는 Task.Run)를 생성하는 경우에만 중요합니다. 대부분의 작업은 이 작업을 수행하지 않습니다. Execute()를 선형적으로 구현하고, 작업 인스턴스 전반에 걸친 병렬 처리는 MSBuild가 담당하도록 합니다. 태스크가 자체 스레드를 생성하는 경우, 여러 스레드에서 TaskEnvironment에 동시에 액세스하는 대신 스레드를 생성하기 전에 TaskEnvironment의 값을 지역 변수에 캡처하세요.

환경 변수 업데이트

메모

작업 코드에서 환경 변수를 읽는 것은 일반적으로 단일 스레드 빌드에서도 좋지 않은 방법입니다. MSBuild 속성은 더 나은 대안입니다. 명시적으로 범위가 지정되고, 빌드 중에 기록되며, 빌드 로그에서 추적할 수 있습니다. 현재 작업에서 입력을 받기 위해 환경 변수를 읽는 경우 대신 작업 속성으로 바꾸는 것이 좋습니다. 프로젝트는 여전히 환경 변수 <AddBuildCommentTask DisableComments="$(DISABLE_BUILD_COMMENTS)" ... />에서 값을 파생시킬 수 있습니다.

이 섹션의 지침은 환경 변수에 이미 의존하는 기존 작업을 마이그레이션하기 위한 것입니다. 리팩터링할 기회가 있는 경우 속성 및 항목을 선호합니다.

자식 프로세스에 대한 환경 변수 설정

다중 스레드 빌드에서 가장 일반적인 환경 변수 문제는 환경 변수를 설정한 다음 자식 프로세스를 생성하여 자식이 상속할 것으로 예상하는 작업입니다. 다중 프로세스 모델 Environment.SetEnvironmentVariable() 에서 해당 프로젝트의 작업자 프로세스 환경을 안전하게 수정했습니다. 다중 스레드 모드에서 프로세스는 모든 동시 빌드에서 공유되므로 한 프로젝트의 자식 프로세스에 대한 변경 내용이 다른 프로젝트로 누출될 수 있습니다.

TaskEnvironment.SetEnvironmentVariable()와(과) TaskEnvironment.GetProcessStartInfo()를 함께 사용합니다(ProcessStart API 호출 업데이트 참조). GetProcessStartInfo()는 프로젝트의 작업 디렉터리와 격리된 환경 테이블(여기에는 SetEnvironmentVariable()로 설정한 변수도 포함됨)로 미리 채워진 ProcessStartInfo를 반환하므로, 자식 프로세스는 프로젝트 범위의 올바른 환경을 자동으로 상속합니다.

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()로 바꾸세요. 이 메서드 호출은 공유 프로세스 환경이 아닌 프로젝트 범위 환경 테이블에서 읽기 때문에 동시 빌드가 서로 간섭하지 않습니다.

이전 (from 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는 확인된 값을 기록하여 빌드 로그에 표시되고 숨겨진 환경 변수 읽기보다 진단하기가 훨씬 쉽습니다.

ProcessStart API 호출 업데이트

일반적으로 작업이 프로세스를 시작하는 경우 모든 것을 처리해 주는 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.RegisterTaskObjectRegisteredTaskObjectLifetime.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, , ,InterlockedConcurrentDictionary)을 ReaderWriterLockSlim 유지된 정적 상태를 스레드로부터 안전하게 만들지만 스레드 보안만으로는 빌드 수준 격리를 제공하지 않습니다. 관리되는 스레드 사용 모범 사례를 참조하세요.

팁 (조언)

이 문서의 후반부에 나오는 전체 마이그레이션 예제에서는 빌드 범위의 격리를 보여 주기 위해 RegisterTaskObject 방식을 사용합니다.

마이그레이션 완료 예제

다음 코드는 5가지 변경 내용이 모두 적용된 완전히 마이그레이션된 AddBuildCommentTask 것을 보여 있습니다.

  1. 프로세스 내 실행으로 표시하는 [MSBuildMultiThreadableTask] 특성이 있습니다.
  2. 기존 Task 기본 클래스와 함께 IMultiThreadableTask를 구현하고, TaskEnvironment 속성을 노출합니다.
  3. TaskEnvironment.GetAbsolutePath()을(를) 경로 확인에 사용합니다.
  4. Environment.GetEnvironmentVariable() 대신 TaskEnvironment.GetEnvironmentVariable()를 사용합니다.
  5. IBuildEngine4.RegisterTaskObjectRegisteredTaskObjectLifetime.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는 이전 버전의 MSBuild와 동일한 프로세스 수준 격리를 제공하는 자회사 TaskHost 프로세스에서 이러한 작업을 실행합니다. 이 방법은 프로세스 간 통신의 오버헤드로 인해 느리지만 기존 작업 코드와 완전히 호환됩니다. 마이그레이션은 정확성에 대한 선택 사항입니다. 마이그레이션되지 않은 작업은 여전히 올바른 결과를 생성하지만 마이그레이션하면 빌드 성능이 향상됩니다.

이전 버전의 MSBuild 지원

사용자 지정 작업을 업데이트한 다음 다른 사용자에게 배포하는 경우 작업은 MSBuild 18.6 이상을 사용하여 클라이언트를 지원합니다. 이전 버전의 MSBuild에서 클라이언트를 지원하려면 세 가지 옵션이 있습니다.

옵션 1: 성능 저하 허용

작업을 변경하지 않습니다. MSBuild는 자회사 프로세스에서 TaskHost 특성이 없는 작업을 실행하며 속도가 느리지만 완전히 호환됩니다. 이 옵션을 사용하려면 코드를 변경할 필요가 없습니다.

옵션 2: 별도의 구현 유지 관리

MSBuild 18.6 이상 및 이전 버전에 대한 별도의 작업 어셈블리를 빌드합니다. MSBuild 18.6 이상 버전은 IMultiThreadableTask를 구현하고 TaskEnvironment를 사용합니다. 이전 버전은 프로세스 수준 API와 함께 계속 사용됩니다 Task .

옵션 3: 호환성 브리지

작업 어셈블리 내에서 MSBuildMultiThreadableTaskAttribute를 직접 정의하세요. MSBuild는 네임스페이스 및 이름으로만 특성을 검색하므로(정의 어셈블리를 무시함) 자체 정의 특성은 이전 버전과 새 버전의 MSBuild 모두에서 작동합니다.

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

MSBuild 18.6 이상에서 실행하는 경우 MSBuild는 특성을 인식하고 태스크를 In-process로 실행합니다. 이전 버전에서 실행하는 경우 MSBuild는 알 수 없는 특성을 무시하고 이전과 같이 작업을 실행합니다.

이 옵션을 사용하면 액세스할 수 TaskEnvironment없으므로 모든 상대 경로를 절대 경로로 변환하는 등 처리하는 모든 항목을 수동으로 처리해야 합니다.

접근 방식 비교

다음 표에서는 다중 스레드 모드(-mt)에서 실행할 때의 세 가지 방법을 비교합니다. 다중 스레드가 아닌 모드에서는 표시된 방식에 관계없이 모든 태스크가 out-of-process로 실행됩니다.

접근 Maintenance 성능(18.6 이상) 성능(이전 버전) TaskEnvironment 접근
별도의 구현 높음 전체 진행 중 완전한 프로세스 외부 예(18.6 이상 버전)
호환성 브리지 낮은 전체 진행 중 완전한 프로세스 외부 아니요(특성 전용)
변경 사항 없음 None 사이드카 (느림) 완전 별도 프로세스 No