비동기 메서드를 활성 상태로 유지

시작하고 잊어버리는 작업은 시작하기 쉽고 유실되기 쉽습니다. 비동기 작업을 시작하고 반환 Task된 작업을 삭제하면 완료, 취소 및 실패에 대한 가시성이 손실됩니다.

비동기 코드의 대부분의 수명 버그는 컴파일러 버그가 아닌 소유권 버그입니다. async 상태 기계와 Task 상태는 계속작업이 컨티뉴에이션을 통해 접근 가능한 동안 유지됩니다. 앱이 더 이상 해당 작업을 추적하지 않을 때 문제가 발생합니다.

화재 및 잊어버리기로 인해 수명 버그가 발생하는 이유

백그라운드 작업을 추적하지 않고 시작하면 다음 세 가지 위험이 발생합니다.

  • 작업이 실패할 수 있으며 아무도 예외를 관찰하지 않습니다.
  • 프로세스가 완료되기 전에 프로세스 또는 호스트를 종료할 수 있습니다.
  • 이 작업은 제어하려는 개체 또는 범위보다 오래 지속될 수 있습니다.

작업이 실제로 선택 사항이며 실패가 허용되는 경우에만 fire-and-forget을 사용합니다.

백그라운드 작업을 명시적으로 추적

이 샘플에서는 실행 중인 작업의 스레드로부터 안전한 사전을 보유하는 사용자 지정 헬퍼 클래스인 BackgroundTaskTracker를 정의합니다. 호출할 때 Track 작업이 완료되면 사전에서 작업을 제거하고 오류를 기록하는 ContinueWith 연속 작업을 등록합니다. DrainAsync를 호출하면, 사전에 있는 모든 작업에서 Task.WhenAll을 호출하고, 그 결과 작업을 반환합니다.

public sealed class BackgroundTaskTracker
{
    private readonly ConcurrentDictionary<int, Task> _inFlight = new();

    public void Track(Task operationTask, string name)
    {
        int id = operationTask.Id;
        _inFlight[id] = operationTask;

        _ = operationTask.ContinueWith(completedTask =>
        {
            _inFlight.TryRemove(id, out _);

            if (completedTask.IsFaulted)
            {
                Console.WriteLine($"{name} failed: {completedTask.Exception?.GetBaseException().Message}");
            }
        }, TaskScheduler.Default);
    }

    public Task DrainAsync()
    {
        Task[] snapshot = _inFlight.Values.ToArray();
        return snapshot.Length == 0 ? Task.CompletedTask : Task.WhenAll(snapshot);
    }
}
Public NotInheritable Class BackgroundTaskTracker
    Private ReadOnly _inFlight As New ConcurrentDictionary(Of Integer, Task)()

    Public Sub Track(operationTask As Task, name As String)
        Dim id As Integer = operationTask.Id
        _inFlight(id) = operationTask

        Dim continuationTask As Task = operationTask.ContinueWith(Sub(completedTask)
                                                                      Dim removedTask As Task = Nothing
                                                                      _inFlight.TryRemove(id, removedTask)

                                                                      If completedTask.IsFaulted Then
                                                                          Console.WriteLine($"{name} failed: {completedTask.Exception.GetBaseException().Message}")
                                                                      End If
                                                                  End Sub,
                                                                  TaskScheduler.Default)
    End Sub

    Public Function DrainAsync() As Task
        Dim snapshot As Task() = _inFlight.Values.ToArray()

        If snapshot.Length = 0 Then
            Return Task.CompletedTask
        End If

        Return Task.WhenAll(snapshot)
    End Function
End Class

BackgroundTaskTracker 를 사용하여 백그라운드 작업을 시작하고, 관찰하며, 드레이닝하는 다음 예제를 봅니다.

public static class FireAndForgetFix
{
    public static async Task RunAsync(BackgroundTaskTracker tracker)
    {
        Task backgroundTask = Task.Run(async () =>
        {
            await Task.Delay(100);
            throw new InvalidOperationException("Background operation failed.");
        });

        tracker.Track(backgroundTask, "Cache refresh");

        try
        {
            await tracker.DrainAsync();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Drain observed failure: {ex.GetBaseException().Message}");
        }
    }
}
Public Module FireAndForgetFix
    Public Async Function RunAsync(tracker As BackgroundTaskTracker) As Task
        Dim backgroundTask As Task = Task.Run(Async Function()
                                                  Await Task.Delay(100)
                                                  Throw New InvalidOperationException("Background operation failed.")
                                              End Function)

        tracker.Track(backgroundTask, "Cache refresh")

        Try
            Await tracker.DrainAsync()
        Catch ex As Exception
            Console.WriteLine($"Drain observed failure: {ex.GetBaseException().Message}")
        End Try
    End Function
End Module

이렇게 생각할 수 있습니다: 단지 시작한 작업만 기다리고 있다면 DrainAsync , 왜 await backgroundTask를 직접 호출하고 추적기를 완전히 건너뛰지 않을까요? 단일 메서드에서 단일 작업을 수행할 수 있습니다. 추적기는 구성 요소의 수명 동안 여러 위치에서 작업을 시작할 때 유용합니다. 각 호출자는 자신의 작업을 공유 추적기에 전달하고, 종료 시점에 한 DrainAsync 번의 호출로 시작된 작업의 수나 누가 시작했는지를 알지 못한 채 모든 작업이 완료되기를 기다립니다. 또한 추적기는 일관된 예외 관찰 정책을 준수합니다. 등록된 모든 태스크는 동일한 실패 로깅 연속성을 가지므로, 어떤 코드 경로에서 작업이 시작되었는지를 불문하고 예외가 누락되지 않도록 보장합니다.

추적된 패턴의 세 가지 주요 구성 요소는 다음과 같습니다.

  • 변수에 작업을 할당합니다 . 참조를 유지하는 것이 추적을 backgroundTask 가능하게 합니다. 참조할 수 없는 작업은 드레이닝하거나 관찰할 수 없는 작업입니다.
  • 추적기에 등록tracker.Track 실패를 기록하는 연속 작업을 연결하고 현재 처리 중인 작업 집합에 작업을 추가합니다. 예외가 조용히 사라지지 않고, 백그라운드 작업에서 발생한 예외는 해당 연속 작업을 통해 표시됩니다.
  • 종료 시 드레이닝 - tracker.DrainAsync 실행 중인 모든 항목이 대기합니다. 구성 요소 또는 프로세스가 종료되기 전에 호출하여 진행 중인 작업이 비행 중 중단되지 않음을 보장합니다.

추적되지 않은 화재 및 잊기의 결과

반환된 Task을 추적하지 않고 삭제하면 조용한 실패가 발생합니다.

public static class FireAndForgetPitfall
{
    public static async Task RunAsync()
    {
        _ = Task.Run(async () =>
        {
            await Task.Delay(100);
            throw new InvalidOperationException("Background operation failed.");
        });

        await Task.Delay(150);
        Console.WriteLine("Caller finished without observing background completion.");
    }
}
Public Module FireAndForgetPitfall
    Public Async Function RunAsync() As Task
        Dim discardedTask As Task = Task.Run(Async Function()
                                                 Await Task.Delay(100)
                                                 Throw New InvalidOperationException("Background operation failed.")
                                             End Function)

        Await Task.Delay(150)
        Console.WriteLine("Caller finished without observing background completion.")
    End Function
End Module

다음 세 가지 문제는 작업을 중단하는 것에 대한 결과로 발생합니다.

  • 조용한 예외 - 백그라운드 작업의 결과는 관찰되지 않습니다. 런타임은 종료 시 비결정적으로 UnobservedTaskException로 이동시키며, 이로 인해 정상적으로 처리하기에는 너무 늦어집니다.
  • 종료 조정 없음 - 호출자가 작업을 완료할 때까지 기다리지 않고 계속해서 종료합니다. 수명이 짧은 프로세스 또는 종료 시간 제한이 있는 호스트에서 백그라운드 작업이 취소되거나 완전히 손실됩니다.
  • 가시성 없음 - 작업에 대한 참조가 없으면 작업이 성공했는지, 실패했는지, 아직 실행 중인지 확인할 수 없습니다.

추적되지 않은 fire-and-forget은 다음 세 가지 조건이 모두 유지되는 경우에만 허용됩니다. 작업은 진정으로 선택 사항이며, 오류는 무시해도 안전하며, 예상된 프로세스 수명 내에 작업이 잘 완료됩니다. 중요하지 않은 원격 분석 ping 로깅은 이러한 조건이 모두 유지될 수 있는 한 가지 예입니다.

소유권 명시적 유지

다음 소유권 모델 중 하나를 사용합니다.

  • Task를 반환하고 호출자에게 대기할 것을 요구합니다.
  • 전용 소유자 서비스에서 백그라운드 작업을 추적합니다.
  • 호스트가 수명을 소유할 수 있도록 호스트 관리 백그라운드 추상화 사용

호출자가 반환된 후에도 작업을 계속해야 하는 경우 소유권을 명시적으로 이전합니다. 예를 들어 오류를 기록하고 종료에 참여하는 추적기에게 작업을 전달합니다.

백그라운드 작업에서 예외를 노출합니다.

삭제된 작업은 종료 및 관찰되지 않은 예외 처리가 발생할 때까지 자동으로 실패할 수 있습니다. 해당 타이밍은 비결정적이며 정상적인 요청 또는 워크플로 처리에 너무 늦습니다.

백그라운드 작업을 큐에 넣을 때 관찰 로직을 연결합니다. 최소한 지속되는 과정에서 실패를 로그로 기록합니다. 대기하는 모든 작업이 동일한 정책을 가져오도록 중앙 집중식 추적기를 선호합니다.

예외 전파 세부 정보는 작업 예외 처리를 참조하세요.

취소 및 종료 조정

백그라운드 작업을 앱 또는 작업 수명을 나타내는 취소 토큰에 연결합니다. 종료 중:

  1. 새 작업 수락을 중지합니다.
  2. 신호 취소.
  3. 정해진 시간 제한으로 추적 작업을 기다립니다.
  4. 불완전한 작업을 기록합니다.

이 흐름은 종료를 예측 가능하게 유지하고 부분 쓰기 또는 분리된 작업을 방지합니다.

GC가 완료되기 전에 비동기 메서드를 수집할 수 있나요?

런타임은 비동기 상태 머신을 활성 상태로 유지하면서 연속 작업에서 계속 참조합니다. 일반적으로 상태 컴퓨터 자체의 가비지 수집에 대한 비행 중 비동기 작업이 손실되지 않습니다.

반환된 작업의 소유권을 잃거나, 필요한 리소스를 일찍 삭제하거나, 프로세스가 완료되기 전에 종료되도록 하면 여전히 정확성이 손실될 수 있습니다. 작업 소유권 및 조정된 종료에 집중합니다.

참고하십시오