C# Task.GetAwaiter and GetResult with Application.OnExit(Window.Closing)

C# Task.GetAwaiter and GetResult with Application.OnExit(Window.Closing)

이철우

C# WPF 앱이 종료할 때 어떤 System.Threading.Tasks.Task로 마무리 하려고 한다면, async/await 구문 대신에 Task.GetAwaiter()와 GetResult()를 사용해야 합니다. 종료 이벤트로 적절한 것은 Application.OnExit(또는 Window.Closing) 입니다.

Visual Studio 2022 Community를 이용하여 .Net 9 WPF 앱 프로젝트를 만듭니다.

App.xaml 파일에서 StartupUri=“MainWindow.xaml” 를 지웁니다.

// App.xaml

<Application x:Class="Study.Wpf.TestGetAwaiter.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:Study.Wpf.TestGetAwaiter">
    <Application.Resources>
    </Application.Resources>
</Application>

App.xaml.cs 에서 클래스 App을 아래처럼 바꿉니다. 여기서 Task.Delay가 마무리 작업을 대신합니다.

// App.xaml.cs

public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        AllocConsole();
        Console.WriteLine(nameof(OnStartup));

        MainWindow = new MainWindow();
        MainWindow.Show();
    }

    protected override void OnExit(ExitEventArgs e)
    {
        Task.Delay(3000).GetAwaiter().GetResult();
        Console.WriteLine(nameof(Task.Delay));
        Console.ReadLine();

        Console.WriteLine(nameof(OnExit));
        FreeConsole();
        base.OnExit(e);
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern bool AllocConsole();

    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern bool FreeConsole();
}

이것을 빌드하고 실행하고 종료하면, 3초 뒤에 'Delay’가 출력됨(마무리)을 알 수 있습니다.

Task.Delay(3000).GetAwaiter().GetResult(); 대신에 아래처럼
await Task.Delay(3000).ConfigureAwait(false); 를 활용하면 'Delay’가 출력되지 않습니다.

protected override async void OnExit(ExitEventArgs e)
{
    //Task.Delay(3000).GetAwaiter().GetResult();
    await Task.Delay(3000).ConfigureAwait(false);
    Console.WriteLine(nameof(Task.Delay));
    Console.ReadLine();

    Console.WriteLine(nameof(OnExit));
    FreeConsole();
    base.OnExit(e);
}

비동비 프로그램에서 되도록 사용하지 말라던 Task.GetAwaiter()와 GetResult()가 위 상황에서 중요한 역할을 합니다.

6개의 좋아요

await 해버리면 진입 메서드를 호출한 측에서 바로 리턴되어 버리고
이후 곧이어 앱 종료 절차가 진행되면서 이벤트 핸들러에서 await 한 거보다 먼저 앱이 종료되는 상황 같아 보이네요.

오히려 blocking 이 필요한 상황이라 이렇게 하는 게 의미 있겠네요.

생각 못했던 상황인데 요건 유용할 거 같아요.

다만 이런 부작용(?) 등을 고려하면

종료절차는 비동기를 안 하는게 정신건강이 이롭습니다.. =ㅁ=!

6개의 좋아요

적어도 SynchronizationContextDispatcher인 WPF 환경에서는 올려주신 내용이 바람직한 방향은 아닌 것 같습니다.

OnExit 함수 내에서 대기 중인 Task 안에서 또 다른 Taskawait하는 경우 데드락을 유발할 가능성이 높습니다.

    private async Task ReleaseAsync()
    {
        Console.WriteLine("D1");
        await Task.Delay(10);
        Console.WriteLine("D2"); 
    }

위의 코드는 ConfigureAwait(false) 없이 await가 컨텍스트를 캡처한 상태로 진입했기 때문에 Dispatcher가 다음 파트를 실행할 수 있을 때까지 대기하게 됩니다.

    protected override void OnExit(ExitEventArgs e)
    {
        ReleaseAsync().GetAwaiter().GetResult();
        ...

올려주신 방식대로라면 DispatcherOnExit 내부에서 대기하고 있으므로 D2 파트를 실행할 수 없게 되며, 결과적으로 데드락 상태에 빠지게 됩니다.

WPF에서 비동기 종료 로직을 수행하는 경우에는 ShutdownModeOnExplicitShutdown 또는 OnMainWindowClose로 설정한 뒤,
아래와 같이 메인 윈도우가 닫힐 때 모든 비동기 작업을 완료한 후 애플리케이션이 종료되도록 구성하는 방식이 가장 안전하다고 생각합니다.

OnMainWindowClose 일 경우

public partial class MainWindow : Window
{
    private bool _isClosing;

    protected override async void OnClosing(CancelEventArgs e)
    {
        if (_isClosing)
            return;

        e.Cancel = true; // await 수행전 Cancel 설정
        _isClosing = true;

        try
        {
            await CleanupAsync();
        }
        catch (Exception ex) { } // 오류 처리
        finally
        {
            Close(); // 다시 호출
        }
    }

    private async Task CleanupAsync()
    {
        // 비동기 종료 작업 호출
        await SomeTask;
    }
}

OnExplicitShutdown 일 경우

        ...
        MainWindow.Closed += Handler_MainWindowClosed;
    }
    
    private async void Handler_MainWindowClosed(object? sender, EventArgs e)
    {
        try
        {
            await CleanupAsync();
        }
        catch (Exception ex) { } // 오류 처리
        finally
        {
            Application.Current.Shutdown();
        }
    }

    private async Task CleanupAsync()
    {
        // 비동기 종료 작업 호출
        await SomeTask;
    }
}
5개의 좋아요

주의할 점은

  1. 프로젝트 속성의 Output type : Windows Application
  2. ShutdownMode는 OnExplicitShutdown가 아닐 때.
    [AllocConsole();과 FreeConsole(); 를 사용하여 출력]
  3. 관심사 분리 : 클래스 Application에서 시작, 종료 작업을 수행.

자연스런 마무리 작업(Task)을 수행하는 것입니다.

그리고 Window.Closing 이벤트를 쓰려면, 아래와 같이 합니다.

    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);
            AllocConsole();

            Console.WriteLine(nameof(OnStartup));

            MainWindow = new MainWindow();
            MainWindow.Closing += MainWindow_Closing;
            MainWindow.Show();
        }

        private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
        {
            MainWindow.Closing -= MainWindow_Closing;

            Task.Delay(3000).GetAwaiter().GetResult();
            Console.WriteLine(nameof(Task.Delay));
        }

        protected override void OnExit(ExitEventArgs e)
        {
            //Task.Delay(3000).GetAwaiter().GetResult();
            //Console.WriteLine(nameof(Task.Delay));
            Console.ReadLine();

            Console.WriteLine(nameof(OnExit));
            FreeConsole();
            base.OnExit(e);
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern bool AllocConsole();

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern bool FreeConsole();
    }
1개의 좋아요

al6uiz 님이 올려주신대로 SynchronizationContext Main Thread Dispatcher 에 대한 컨텍스트 캡쳐가 일어날 경우에는 문제가 생길 수 있어, 댓글을 남깁니다.
await SomethingAsync().ConfigureAwait(false)SomethingAsync().GetAwaiter().GetResult() 양쪽다 OnExit 와 같이 Main Thread 로 블로킹을 할 경우, 문제의 소지가 있습니다.

대표적으로 아래와 같이 Main Thread에서 DbContext 에 대해 수행했을때 터지는데요.

public void OnButtonClick()
{
    var users = _dbContext.Users.ToListAsync().GetAwaiter().GetResult();
}

Main Thread 에서 블로킹하며, Task 에 대해 해결할 수 있는 방법은 제가 아는 한, 아래와 같이 Task 로 한번더 감싸는 방법을 사용하거나, 동기식으로 처리해야합니다.

protected override void OnExit(ExitEventArgs e)
{
    Task.Run(async () =>
    {
        await SomethingAsync();
    }).Wait();
    
    base.OnExit(e);
}

근데 이렇게 처리하는 방법은 async await 를 사용하는 이점을 버리게 됩니다. async awiat 를 최대한 활용하기 위해, al6uiz 님이 설명하시는 대로 Closing 에서 일단 종료 절차를 cancel 후, 정리를 완료하고, 종료 절차를 수행하는 방법이 대안으로 보입니다.

1개의 좋아요