스레딩 모델

WPF(Windows Presentation Foundation)는 개발자가 스레딩의 어려움을 겪지 않도록 설계되었습니다. 결과적으로 대부분의 WPF 개발자는 둘 이상의 스레드를 사용하는 인터페이스를 작성하지 않습니다. 다중 스레드 프로그램은 복잡하고 디버그하기 어려우므로 단일 스레드 솔루션이 있을 경우 피해야 합니다.

그러나 아무리 잘 설계되었더라도 UI 프레임워크는 모든 종류의 문제에 대해 단일 스레드 솔루션을 제공할 수 없습니다. WPF가 근접하지만, 스레드를 여러 개 사용해야만 UI(사용자 인터페이스) 응답성이나 애플리케이션 성능이 개선되는 상황이 여전히 있습니다. 일부 배경 자료를 설명한 후 이 문서에서는 이러한 상황 중 일부를 살펴보고 몇몇 하위 수준 세부 정보에 대한 설명으로 마무리 짓습니다.

참고 항목

이 토픽에서는 비동기 호출에 InvokeAsync 메서드를 사용하여 스레딩을 설명합니다. InvokeAsync 메서드는 Action 또는 Func<TResult>를 매개 변수로 사용하고 Task 속성이 있는 DispatcherOperation 또는 DispatcherOperation<TResult>를 반환합니다. DispatcherOperation 또는 연결된 Task와 함께 await 키워드를 사용할 수 있습니다. DispatcherOperation 또는 DispatcherOperation<TResult>에서 반환하는 Task를 동기식으로 기다려야 하는 경우 DispatcherOperationWait 확장 메서드를 호출하세요. Task.Wait를 호출하면 교착 상태가 발생합니다. Task를 사용하여 비동기 작업을 수행하는 방법에 대한 자세한 내용은 작업 기반 비동기 프로그래밍을 참조하세요.

동기식 호출을 수행하려면 대리자, Action 또는 Func<TResult> 매개 변수를 사용하는 오버로드도 포함된 Invoke 메서드를 사용합니다.

개요 및 디스패처

일반적으로 WPF 애플리케이션은 두 개의 스레드로 시작합니다. 하나는 렌더링 처리용이고 다른 하나는 UI 관리용입니다. 렌더링 스레드는 UI 스레드가 입력을 수신하고, 이벤트를 처리하고, 화면을 그리고, 애플리케이션 코드를 실행하는 동안 백그라운드에서 효과적으로 실행됩니다. 대부분의 애플리케이션은 여러 스레드를 사용하는 것이 가장 좋더라도 단일 UI 스레드를 사용합니다. 나중에 예제를 통해 이를 설명하겠습니다.

UI 스레드는 Dispatcher라는 개체 내부의 작업 항목을 큐에 넣습니다. Dispatcher는 우선 순위에 따라 작업 항목을 선택하고 각 작업 항목을 완료할 때까지 실행합니다. 모든 UI 스레드에는 하나 이상의 Dispatcher가 있어야 하고, 각 Dispatcher는 정확히 하나의 스레드에서 작업 항목을 실행할 수 있습니다.

반응이 빠르고 사용자에게 친숙한 애플리케이션 빌드하는 방법과 관련된 힌트는 작업 항목을 적게 유지하여 Dispatcher 처리량을 최대화하는 것입니다. 이 방법에서는 항목이 처리를 기다리며 Dispatcher 큐에 놓인 채 쓸모가 없어지지 않습니다. 입력과 응답 간의 인식할 수 있는 지연으로 인해 사용자가 불편해질 수 있습니다.

그렇다면 WPF 애플리케이션은 대규모 작업을 어떻게 처리해야 하나요? 코드에 큰 계산을 포함하거나 몇몇 원격 서버에서 데이터베이스를 쿼리해야 하면 어떻게 될까요? 일반적으로 대답은 별도의 스레드에서 큰 작업을 처리하고 UI 스레드가 Dispatcher 큐의 항목을 처리하도록 자유롭게 두는 것입니다. 큰 작업이 완료되면 표시를 위해 결과를 다시 UI 스레드에 보고할 수 있습니다.

지금까지 Windows에서는 UI 요소에 액세스하려면 이 요소를 만든 스레드를 사용해야 합니다. 즉, 일부 장기 실행 작업을 처리하는 백그라운드 스레드는 작업이 완료될 때 입력란을 업데이트할 수 없습니다. Windows에서는 이 작업을 수행하여 UI 구성 요소의 무결성을 보장합니다. 목록 상자의 콘텐츠가 그리는 동안 백그라운드 스레드를 통해 업데이트되면 목록 상자가 이상하게 표시될 수 있습니다.

WPF에는 이 조정을 적용하는 기본 제공 상호 배제 메커니즘이 있습니다. WPF의 클래스는 대부분 DispatcherObject에서 파생됩니다. 생성 시 DispatcherObject는 현재 실행 중인 스레드에 연결된 Dispatcher에 대한 참조를 저장합니다. 실제로 DispatcherObject는 자신을 만드는 스레드와 연결됩니다. 프로그램 실행 중에 DispatcherObject는 공용 VerifyAccess 메서드를 호출할 수 있습니다. VerifyAccess는 현재 스레드와 연결된 Dispatcher를 확인하고 이를 생성 중에 저장된 Dispatcher 참조와 비교합니다. 일치하지 않으면 VerifyAccess에서 예외를 throw합니다. VerifyAccessDispatcherObject에 속한 모든 메서드의 시작 부분에서 호출되어야 합니다.

스레드 하나만으로 UI를 수정할 수 있다면 백그라운드 스레드가 사용자와 어떻게 상호 작용할까요? 백그라운드 스레드는 UI 스레드가 대신 작업을 수행하도록 요청할 수 있습니다. 이 작업을 수행하려면 작업 항목을 UI 스레드의 Dispatcher에 등록합니다. Dispatcher 클래스는 작업 항목을 등록하기 위한 메서드(Dispatcher.InvokeAsync, Dispatcher.BeginInvokeDispatcher.Invoke)를 제공합니다. 이러한 메서드는 실행을 위한 대리자를 예약합니다. Invoke는 동기 호출입니다. 즉, UI 스레드가 실제로 대리자 실행을 완료할 때까지 반환되지 않습니다. InvokeAsyncBeginInvoke는 비동기식이며 즉시 반환됩니다.

Dispatcher는 우선 순위별로 큐에 있는 요소의 순서를 지정합니다. Dispatcher 큐에 요소를 추가할 때 10개 수준을 지정할 수 있습니다. 이러한 우선 순위는 DispatcherPriority 열거형에서 유지 관리됩니다.

장기 실행 계산이 포함된 단일 스레드 앱

대부분의 GUI(그래픽 사용자 인터페이스)는 사용자 조작에 대한 응답으로 생성된 이벤트를 기다리는 동안 많은 시간을 유휴 상태로 보냅니다. 신중하게 프로그래밍하면 UI의 응답성에 영향을 미치지 않고 이 유휴 시간을 건설적으로 사용할 수 있습니다. WPF 스레딩 모델은 입력이 UI 스레드에서 발생하는 작업을 중단하는 것을 허용하지 않습니다. 즉, 보류 중인 입력 이벤트를 쓸모가 없어지기 전에 처리하기 위해 주기적으로 Dispatcher로 돌아가야 합니다.

이 섹션의 개념을 보여 주는 샘플 앱은 GitHub에서 C# 또는 Visual Basic용으로 다운로드할 수 있습니다.

다음 예제를 참조하세요.

소수 스레딩을 보여주는 스크린샷.

이 간단한 애플리케이션은 3부터 위쪽으로 계산하여 소수를 검색합니다. 사용자가 시작 단추를 클릭하면 검색이 시작됩니다. 프로그램이 소수를 찾으면 검색 결과로 사용자 인터페이스를 업데이트합니다. 이때 사용자가 검색을 중지할 수 있습니다.

간단한 방법이지만 소수 검색이 영원히 계속될 수 있는 문제가 있습니다. 단추의 클릭 이벤트 처리기 내부에서 전체 검색을 처리한 경우 UI 스레드에는 다른 이벤트를 처리할 기회가 제공되지 않습니다. 해당 UI는 입력에 응답하거나 메시지를 처리할 수 없고, 다시 표시되지 않으며 단추 클릭에 응답하지 않습니다.

소수 검색을 별도의 스레드에서 수행할 수 있지만 이 경우 동기화 문제를 처리해야 합니다. 단일 스레드 방법을 통해 발견된 가장 큰 소수를 나열하는 레이블을 직접 업데이트할 수 있습니다.

계산 작업을 관리 가능한 청크로 분할할 경우 주기적으로 Dispatcher로 돌아가서 이벤트를 처리할 수 있습니다. WPF에 다시 표시되고 입력을 처리할 기회를 제공할 수 있습니다.

계산과 이벤트 처리 간에 처리 시간을 나누는 가장 좋은 방법은 Dispatcher에서 계산을 관리하는 것입니다. InvokeAsync 메서드를 사용하여 UI 이벤트를 가져온 동일한 큐에서 소수 검사를 예약할 수 있습니다. 예제에서는 단일 소수 검사를 한 번만 예약합니다. 소수 검사가 완료된 후 즉시 다음 검사를 예약합니다. 이 검사는 UI 이벤트가 처리된 후에만 진행됩니다.

디스패처 대기열을 보여주는 스크린샷.

Microsoft Word에서는 이 메커니즘을 사용하여 맞춤법 검사를 수행합니다. 맞춤법 검사는 UI 스레드를 사용하여 백그라운드에서 수행됩니다. 코드를 살펴보겠습니다.

다음 예제에서는 사용자 인터페이스를 만드는 XAML을 보여 줍니다.

Important

이 문서에 표시된 XAML은 C# 프로젝트에서 가져온 것입니다. Visual Basic XAML은 XAML에 대한 지원 클래스를 선언할 때 약간 다릅니다.

<Window x:Class="SDKSamples.PrimeNumber"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Prime Numbers" Width="360" Height="100">
    <StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="20" >
        <Button Content="Start"  
                Click="StartStopButton_Click"
                Name="StartStopButton"
                Margin="5,0,5,0" Padding="10,0" />
        
        <TextBlock Margin="10,0,0,0">Biggest Prime Found:</TextBlock>
        <TextBlock Name="bigPrime" Margin="4,0,0,0">3</TextBlock>
    </StackPanel>
</Window>

다음 예제에서는 코드 숨김을 보여 줍니다.

using System;
using System.Windows;
using System.Windows.Threading;

namespace SDKSamples
{
    public partial class PrimeNumber : Window
    {
        // Current number to check
        private long _num = 3;
        private bool _runCalculation = false;

        public PrimeNumber() =>
            InitializeComponent();

        private void StartStopButton_Click(object sender, RoutedEventArgs e)
        {
            _runCalculation = !_runCalculation;

            if (_runCalculation)
            {
                StartStopButton.Content = "Stop";
                StartStopButton.Dispatcher.InvokeAsync(CheckNextNumber, DispatcherPriority.SystemIdle);
            }
            else
                StartStopButton.Content = "Resume";
        }

        public void CheckNextNumber()
        {
            // Reset flag.
            _isPrime = true;

            for (long i = 3; i <= Math.Sqrt(_num); i++)
            {
                if (_num % i == 0)
                {
                    // Set not a prime flag to true.
                    _isPrime = false;
                    break;
                }
            }

            // If a prime number, update the UI text
            if (_isPrime)
                bigPrime.Text = _num.ToString();

            _num += 2;
            
            // Requeue this method on the dispatcher
            if (_runCalculation)
                StartStopButton.Dispatcher.InvokeAsync(CheckNextNumber, DispatcherPriority.SystemIdle);
        }

        private bool _isPrime = false;
    }
}
Imports System.Windows.Threading

Public Class PrimeNumber
    ' Current number to check
    Private _num As Long = 3
    Private _runCalculation As Boolean = False

    Private Sub StartStopButton_Click(sender As Object, e As RoutedEventArgs)
        _runCalculation = Not _runCalculation

        If _runCalculation Then
            StartStopButton.Content = "Stop"
            StartStopButton.Dispatcher.InvokeAsync(AddressOf CheckNextNumber, DispatcherPriority.SystemIdle)
        Else
            StartStopButton.Content = "Resume"
        End If

    End Sub

    Public Sub CheckNextNumber()
        ' Reset flag.
        _isPrime = True

        For i As Long = 3 To Math.Sqrt(_num)
            If (_num Mod i = 0) Then

                ' Set Not a prime flag to true.
                _isPrime = False
                Exit For
            End If
        Next

        ' If a prime number, update the UI text
        If _isPrime Then
            bigPrime.Text = _num.ToString()
        End If

        _num += 2

        ' Requeue this method on the dispatcher
        If (_runCalculation) Then
            StartStopButton.Dispatcher.InvokeAsync(AddressOf CheckNextNumber, DispatcherPriority.SystemIdle)
        End If
    End Sub

    Private _isPrime As Boolean
End Class

StartStopButton_Click 처리기는 Button에서 텍스트를 업데이트할 뿐 아니라 Dispatcher 큐에 대리자를 추가하여 첫 번째 소수 검사를 예약합니다. 이벤트 처리기가 작업을 완료한 후 Dispatcher가 이 대리자를 실행하도록 선택하는 경우도 있습니다.

앞서 언급했듯이, InvokeAsync는 대리자 실행을 예약하는 데 사용되는 Dispatcher 멤버입니다. 이 예제에서는 SystemIdle 우선 순위를 선택합니다. Dispatcher는 처리할 중요한 이벤트가 없을 경우에만 이 대리자를 실행합니다. UI 응답성이 숫자 검사보다 더 중요합니다. 또한 숫자 검사 루틴을 표현하는 새 대리자를 전달합니다.

public void CheckNextNumber()
{
    // Reset flag.
    _isPrime = true;

    for (long i = 3; i <= Math.Sqrt(_num); i++)
    {
        if (_num % i == 0)
        {
            // Set not a prime flag to true.
            _isPrime = false;
            break;
        }
    }

    // If a prime number, update the UI text
    if (_isPrime)
        bigPrime.Text = _num.ToString();

    _num += 2;
    
    // Requeue this method on the dispatcher
    if (_runCalculation)
        StartStopButton.Dispatcher.InvokeAsync(CheckNextNumber, DispatcherPriority.SystemIdle);
}

private bool _isPrime = false;
Public Sub CheckNextNumber()
    ' Reset flag.
    _isPrime = True

    For i As Long = 3 To Math.Sqrt(_num)
        If (_num Mod i = 0) Then

            ' Set Not a prime flag to true.
            _isPrime = False
            Exit For
        End If
    Next

    ' If a prime number, update the UI text
    If _isPrime Then
        bigPrime.Text = _num.ToString()
    End If

    _num += 2

    ' Requeue this method on the dispatcher
    If (_runCalculation) Then
        StartStopButton.Dispatcher.InvokeAsync(AddressOf CheckNextNumber, DispatcherPriority.SystemIdle)
    End If
End Sub

Private _isPrime As Boolean

이 메서드는 다음 홀수가 소수인지 확인합니다. 소수인 경우 이 메서드는 bigPrimeTextBlock을 직접 업데이트하여 검색 결과를 반영합니다. 컨트롤을 만드는 데 사용된 같은 스레드에서 계산이 수행되므로 이 작업을 수행할 수 있습니다. 계산에 별도의 스레드를 사용하도록 선택한 경우에는 더 복잡한 동기화 메커니즘을 사용하고 UI 스레드에서 업데이트를 실행해야 합니다. 이 상황은 다음에 살펴보겠습니다.

여러 Windows, 여러 스레드

일부 WPF 애플리케이션에는 여러 개의 최상위 창이 필요합니다. 하나의 스레드/디스패처 조합으로 여러 창을 관리하는 것은 완벽하게 허용되지만 때로는 여러 스레드가 더 나은 작업을 수행하는 경우도 있습니다. 특히 창 중 하나가 스레드를 독점할 가능성이 있는 경우 여러 스레드를 사용하는 것이 좋습니다.

Windows 탐색기가 이 방식으로 작동합니다. 새로운 각 탐색기 창은 원래 프로세스에 속하지만 독립 스레드의 제어를 기반으로 만들어집니다. 네트워크 리소스를 찾을 때와 같이 Explorer가 응답하지 않는 경우에도 다른 Explorer 창은 계속 응답하고 사용할 수 있습니다.

다음 예를 통해 이 개념을 보여줄 수 있습니다.

4번 복제된 WPF 창의 스크린샷. 창 중 3개는 동일한 스레드를 사용하고 있고 나머지 2개는 다른 스레드에 있음을 나타냅니다.

이 이미지의 상위 3개 창은 동일한 스레드 식별자를 공유합니다. 1. 다른 두 창에는 9와 4라는 서로 다른 스레드 식별자가 있습니다. 각 창의 오른쪽 상단에 자홍색 회전 ️문자 ‼️ 모양이 있습니다.

이 예에는 회전하는 ‼️ 문자 모양이 있는 창, 일시 중지 단추, 그리고 현재 스레드나 새 스레드 아래에 새 창을 만드는 두 개의 다른 단추가 포함되어 있습니다. ‼️ 문자 모양은 스레드를 5초 동안 일시 중지하는 일시 중지 단추를 누를 때까지 계속 회전합니다. 창 하단에 스레드 식별자가 표시됩니다.

일시 중지 단추를 누르면 동일한 스레드 아래의 모든 창이 응답하지 않게 됩니다. 다른 스레드 아래의 모든 창은 계속해서 정상적으로 작동합니다.

다음 예에서는 창에 대한 XAML입니다.

<Window x:Class="SDKSamples.MultiWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Thread Hosted Window" Width="360" Height="180" SizeToContent="Height" ResizeMode="NoResize" Loaded="Window_Loaded">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <TextBlock HorizontalAlignment="Right" Margin="30,0" Text="‼️" FontSize="50" FontWeight="ExtraBold"
                   Foreground="Magenta" RenderTransformOrigin="0.5,0.5" Name="RotatedTextBlock">
            <TextBlock.RenderTransform>
                <RotateTransform Angle="0" />
            </TextBlock.RenderTransform>
            <TextBlock.Triggers>
                <EventTrigger RoutedEvent="Loaded">
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimation Storyboard.TargetName="RotatedTextBlock"
                                Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
                                From="0" To="360" Duration="0:0:5" RepeatBehavior="Forever" />
                        </Storyboard>
                    </BeginStoryboard>
                </EventTrigger>
            </TextBlock.Triggers>
        </TextBlock>

        <StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="20" >
            <Button Content="Pause" Click="PauseButton_Click" Margin="5,0" Padding="10,0" />
            <TextBlock Margin="5,0,0,0" Text="<-- Pause for 5 seconds" />
        </StackPanel>

        <StackPanel Grid.Row="1" Margin="10">
            <Button Content="Create 'Same Thread' Window" Click="SameThreadWindow_Click" />
            <Button Content="Create 'New Thread' Window" Click="NewThreadWindow_Click" Margin="0,10,0,0" />
        </StackPanel>

        <StatusBar Grid.Row="2" VerticalAlignment="Bottom">
            <StatusBarItem Content="Thread ID" Name="ThreadStatusItem" />
        </StatusBar>

    </Grid>
</Window>

다음 예제에서는 코드 숨김을 보여 줍니다.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;

namespace SDKSamples
{
    public partial class MultiWindow : Window
    {
        public MultiWindow() =>
            InitializeComponent();

        private void Window_Loaded(object sender, RoutedEventArgs e) =>
            ThreadStatusItem.Content = $"Thread ID: {Thread.CurrentThread.ManagedThreadId}";

        private void PauseButton_Click(object sender, RoutedEventArgs e) =>
            Task.Delay(TimeSpan.FromSeconds(5)).Wait();

        private void SameThreadWindow_Click(object sender, RoutedEventArgs e) =>
            new MultiWindow().Show();

        private void NewThreadWindow_Click(object sender, RoutedEventArgs e)
        {
            Thread newWindowThread = new Thread(ThreadStartingPoint);
            newWindowThread.SetApartmentState(ApartmentState.STA);
            newWindowThread.IsBackground = true;
            newWindowThread.Start();
        }

        private void ThreadStartingPoint()
        {
            new MultiWindow().Show();

            System.Windows.Threading.Dispatcher.Run();
        }
    }
}
Imports System.Threading

Public Class MultiWindow
    Private Sub Window_Loaded(sender As Object, e As RoutedEventArgs)
        ThreadStatusItem.Content = $"Thread ID: {Thread.CurrentThread.ManagedThreadId}"
    End Sub

    Private Sub PauseButton_Click(sender As Object, e As RoutedEventArgs)
        Task.Delay(TimeSpan.FromSeconds(5)).Wait()
    End Sub

    Private Sub SameThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim window As New MultiWindow()
        window.Show()
    End Sub

    Private Sub NewThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim newWindowThread = New Thread(AddressOf ThreadStartingPoint)
        newWindowThread.SetApartmentState(ApartmentState.STA)
        newWindowThread.IsBackground = True
        newWindowThread.Start()
    End Sub

    Private Sub ThreadStartingPoint()
        Dim window As New MultiWindow()
        window.Show()

        System.Windows.Threading.Dispatcher.Run()
    End Sub
End Class

다음은 주의할 몇 가지 세부 정보입니다.

  • Task.Delay(TimeSpan) 작업은 일시 중지 단추를 누를 때 현재 스레드가 5초 동안 일시 중지되도록 하는 데 사용됩니다.

    private void PauseButton_Click(object sender, RoutedEventArgs e) =>
        Task.Delay(TimeSpan.FromSeconds(5)).Wait();
    
    Private Sub PauseButton_Click(sender As Object, e As RoutedEventArgs)
        Task.Delay(TimeSpan.FromSeconds(5)).Wait()
    End Sub
    
  • SameThreadWindow_Click 이벤트 처리기는 현재 스레드 아래에 즉시 새 창을 표시합니다. NewThreadWindow_Click 이벤트 처리기는 다음 글머리 기호에 설명된 대로 ThreadStartingPoint 메서드 실행을 시작하는 새 스레드를 만듭니다. 그러면 새 창이 표시됩니다.

    private void SameThreadWindow_Click(object sender, RoutedEventArgs e) =>
        new MultiWindow().Show();
    
    private void NewThreadWindow_Click(object sender, RoutedEventArgs e)
    {
        Thread newWindowThread = new Thread(ThreadStartingPoint);
        newWindowThread.SetApartmentState(ApartmentState.STA);
        newWindowThread.IsBackground = true;
        newWindowThread.Start();
    }
    
    Private Sub SameThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim window As New MultiWindow()
        window.Show()
    End Sub
    
    Private Sub NewThreadWindow_Click(sender As Object, e As RoutedEventArgs)
        Dim newWindowThread = New Thread(AddressOf ThreadStartingPoint)
        newWindowThread.SetApartmentState(ApartmentState.STA)
        newWindowThread.IsBackground = True
        newWindowThread.Start()
    End Sub
    
  • ThreadStartingPoint 메서드는 새 스레드의 시작점입니다. 새 창은 이 스레드의 제어 하에 만들어집니다. WPF는 자동으로 새 System.Windows.Threading.Dispatcher를 만들어 새 스레드를 관리합니다. 창을 작동시키려면 System.Windows.Threading.Dispatcher를 시작하면 됩니다.

    private void ThreadStartingPoint()
    {
        new MultiWindow().Show();
    
        System.Windows.Threading.Dispatcher.Run();
    }
    
    Private Sub ThreadStartingPoint()
        Dim window As New MultiWindow()
        window.Show()
    
        System.Windows.Threading.Dispatcher.Run()
    End Sub
    

이 섹션의 개념을 보여 주는 샘플 앱은 GitHub에서 C# 또는 Visual Basic용으로 다운로드할 수 있습니다.

Task.Run으로 차단 작업 처리

그래픽 애플리케이션에서 차단 작업을 처리하는 것은 어려울 수 있습니다. 애플리케이션이 정지된 것처럼 보이기 때문에 이벤트 처리기에서 차단 메서드를 호출하고 싶지 않습니다. 이전 예에서는 자체 스레드에 새 창을 만들어 각 창이 서로 독립적으로 실행되도록 했습니다. System.Windows.Threading.Dispatcher를 사용하여 새 스레드를 만들 수 있지만 작업이 완료된 후 새 스레드를 기본 UI 스레드와 동기화하기가 어려워집니다. 새 스레드는 UI를 직접 수정할 수 없으므로 Dispatcher.InvokeAsync, Dispatcher.BeginInvoke 또는 Dispatcher.Invoke를 사용하여 UI 스레드의 Dispatcher에 대리자를 삽입해야 합니다. 결국 이러한 대리자는 UI 요소를 수정할 수 있는 권한을 가지고 실행됩니다.

결과를 동기화하는 동안 새 스레드에서 코드를 실행하는 더 쉬운 방법인 TAP(작업 기반 비동기 패턴)가 있습니다. 이는 비동기 작업을 나타내는 데 사용하는 System.Threading.Tasks 네임스페이스의 TaskTask<TResult> 형식을 기준으로 합니다. TAP은 단일 메서드를 사용하여 비동기 작업의 시작과 완료를 나타냅니다. 이 패턴에는 몇 가지 이점이 있습니다.

  • Task의 호출자는 코드를 비동기적으로 또는 동기적으로 실행하도록 선택할 수 있습니다.
  • 진행률은 Task에서 보고될 수 있습니다.
  • 호출 코드는 실행을 일시 중단하고 작업 결과를 기다릴 수 있습니다.

Task.Run 예

이 예제에서는 날씨 예보를 검색하는 원격 프로시저 호출을 모방합니다. 단추를 클릭하면 데이터 페치가 진행 중임을 나타내도록 UI가 업데이트되고 일기 예보 페치를 모방하는 작업이 시작됩니다. 작업이 시작되면 작업이 완료될 때까지 단추 이벤트 처리기 코드가 일시 중단됩니다. 작업이 완료된 후에도 이벤트 처리기 코드가 계속 실행됩니다. 코드는 일시 중단되며 나머지 UI 스레드를 차단하지 않습니다. WPF의 동기화 컨텍스트는 WPF가 계속 실행될 수 있도록 코드 일시 중단을 처리합니다.

예 앱의 워크플로를 보여 주는 다이어그램.

예 앱의 워크플로를 보여 주는 다이어그램. 앱에는 "예측 페치"라는 텍스트가 포함된 단일 단추가 있습니다. 단추를 누른 후 앱의 다음 단계를 가리키는 화살표가 있습니다. 이는 앱이 데이터를 가져오는 중임을 나타내는 앱 중앙에 배치된 시계 이미지입니다. 잠시 후 앱은 데이터 결과에 따라 태양 또는 비구름 이미지를 반환합니다.

이 섹션의 개념을 보여 주는 샘플 앱은 GitHub에서 C# 또는 Visual Basic용으로 다운로드할 수 있습니다. 이 예의 XAML은 상당히 크기 때문에 이 문서에서는 제공되지 않습니다. 이전 GitHub 링크를 사용하여 XAML을 찾습니다. XAML은 단일 단추를 사용하여 날씨를 가져옵니다.

XAML에 대한 코드 숨김을 고려합니다.

using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Threading.Tasks;

namespace SDKSamples
{
    public partial class Weather : Window
    {
        public Weather() =>
            InitializeComponent();

        private async void FetchButton_Click(object sender, RoutedEventArgs e)
        {
            // Change the status image and start the rotation animation.
            fetchButton.IsEnabled = false;
            fetchButton.Content = "Contacting Server";
            weatherText.Text = "";
            ((Storyboard)Resources["HideWeatherImageStoryboard"]).Begin(this);

            // Asynchronously fetch the weather forecast on a different thread and pause this code.
            string weather = await Task.Run(FetchWeatherFromServerAsync);

            // After async data returns, process it...
            // Set the weather image
            if (weather == "sunny")
                weatherIndicatorImage.Source = (ImageSource)Resources["SunnyImageSource"];

            else if (weather == "rainy")
                weatherIndicatorImage.Source = (ImageSource)Resources["RainingImageSource"];

            //Stop clock animation
            ((Storyboard)Resources["ShowClockFaceStoryboard"]).Stop(ClockImage);
            ((Storyboard)Resources["HideClockFaceStoryboard"]).Begin(ClockImage);
            
            //Update UI text
            fetchButton.IsEnabled = true;
            fetchButton.Content = "Fetch Forecast";
            weatherText.Text = weather;
        }

        private async Task<string> FetchWeatherFromServerAsync()
        {
            // Simulate the delay from network access
            await Task.Delay(TimeSpan.FromSeconds(4));

            // Tried and true method for weather forecasting - random numbers
            Random rand = new Random();

            if (rand.Next(2) == 0)
                return "rainy";
            
            else
                return "sunny";
        }

        private void HideClockFaceStoryboard_Completed(object sender, EventArgs args) =>
            ((Storyboard)Resources["ShowWeatherImageStoryboard"]).Begin(ClockImage);

        private void HideWeatherImageStoryboard_Completed(object sender, EventArgs args) =>
            ((Storyboard)Resources["ShowClockFaceStoryboard"]).Begin(ClockImage, true);
    }
}
Imports System.Windows.Media.Animation

Public Class Weather

    Private Async Sub FetchButton_Click(sender As Object, e As RoutedEventArgs)

        ' Change the status image and start the rotation animation.
        fetchButton.IsEnabled = False
        fetchButton.Content = "Contacting Server"
        weatherText.Text = ""
        DirectCast(Resources("HideWeatherImageStoryboard"), Storyboard).Begin(Me)

        ' Asynchronously fetch the weather forecast on a different thread and pause this code.
        Dim weatherType As String = Await Task.Run(AddressOf FetchWeatherFromServerAsync)

        ' After async data returns, process it...
        ' Set the weather image
        If weatherType = "sunny" Then
            weatherIndicatorImage.Source = DirectCast(Resources("SunnyImageSource"), ImageSource)

        ElseIf weatherType = "rainy" Then
            weatherIndicatorImage.Source = DirectCast(Resources("RainingImageSource"), ImageSource)

        End If

        ' Stop clock animation
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Stop(ClockImage)
        DirectCast(Resources("HideClockFaceStoryboard"), Storyboard).Begin(ClockImage)

        ' Update UI text
        fetchButton.IsEnabled = True
        fetchButton.Content = "Fetch Forecast"
        weatherText.Text = weatherType
    End Sub

    Private Async Function FetchWeatherFromServerAsync() As Task(Of String)

        ' Simulate the delay from network access
        Await Task.Delay(TimeSpan.FromSeconds(4))

        ' Tried and true method for weather forecasting - random numbers
        Dim rand As New Random()

        If rand.Next(2) = 0 Then
            Return "rainy"
        Else
            Return "sunny"
        End If

    End Function

    Private Sub HideClockFaceStoryboard_Completed(sender As Object, e As EventArgs)
        DirectCast(Resources("ShowWeatherImageStoryboard"), Storyboard).Begin(ClockImage)
    End Sub

    Private Sub HideWeatherImageStoryboard_Completed(sender As Object, e As EventArgs)
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Begin(ClockImage, True)
    End Sub
End Class

다음은 주의할 몇 가지 세부 정보입니다.

  • 단추 이벤트 처리기

    private async void FetchButton_Click(object sender, RoutedEventArgs e)
    {
        // Change the status image and start the rotation animation.
        fetchButton.IsEnabled = false;
        fetchButton.Content = "Contacting Server";
        weatherText.Text = "";
        ((Storyboard)Resources["HideWeatherImageStoryboard"]).Begin(this);
    
        // Asynchronously fetch the weather forecast on a different thread and pause this code.
        string weather = await Task.Run(FetchWeatherFromServerAsync);
    
        // After async data returns, process it...
        // Set the weather image
        if (weather == "sunny")
            weatherIndicatorImage.Source = (ImageSource)Resources["SunnyImageSource"];
    
        else if (weather == "rainy")
            weatherIndicatorImage.Source = (ImageSource)Resources["RainingImageSource"];
    
        //Stop clock animation
        ((Storyboard)Resources["ShowClockFaceStoryboard"]).Stop(ClockImage);
        ((Storyboard)Resources["HideClockFaceStoryboard"]).Begin(ClockImage);
        
        //Update UI text
        fetchButton.IsEnabled = true;
        fetchButton.Content = "Fetch Forecast";
        weatherText.Text = weather;
    }
    
    Private Async Sub FetchButton_Click(sender As Object, e As RoutedEventArgs)
    
        ' Change the status image and start the rotation animation.
        fetchButton.IsEnabled = False
        fetchButton.Content = "Contacting Server"
        weatherText.Text = ""
        DirectCast(Resources("HideWeatherImageStoryboard"), Storyboard).Begin(Me)
    
        ' Asynchronously fetch the weather forecast on a different thread and pause this code.
        Dim weatherType As String = Await Task.Run(AddressOf FetchWeatherFromServerAsync)
    
        ' After async data returns, process it...
        ' Set the weather image
        If weatherType = "sunny" Then
            weatherIndicatorImage.Source = DirectCast(Resources("SunnyImageSource"), ImageSource)
    
        ElseIf weatherType = "rainy" Then
            weatherIndicatorImage.Source = DirectCast(Resources("RainingImageSource"), ImageSource)
    
        End If
    
        ' Stop clock animation
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Stop(ClockImage)
        DirectCast(Resources("HideClockFaceStoryboard"), Storyboard).Begin(ClockImage)
    
        ' Update UI text
        fetchButton.IsEnabled = True
        fetchButton.Content = "Fetch Forecast"
        weatherText.Text = weatherType
    End Sub
    

    이벤트 처리기는 async(또는 Visual Basic의 경우 Async)으로 선언되었습니다. "비동기" 메서드를 사용하면 FetchWeatherFromServerAsync와 같은 대기 중인 메서드가 호출될 때 코드를 일시 중단할 수 있습니다. 이는 await(또는 Visual Basic의 경우 Await) 키워드로 지정됩니다. FetchWeatherFromServerAsync가 완료될 때까지 단추의 처리기 코드가 일시 중단되고 제어권이 호출자에게 반환됩니다. 이는 동기 메서드가 메서드의 모든 작업이 완료될 때까지 기다린 후 제어가 호출자에게 반환된다는 점을 제외하면 동기 메서드와 유사합니다.

    대기된 메서드는 단추 처리기가 있는 UI 스레드인 현재 메서드의 스레딩 컨텍스트를 활용합니다. 즉, await FetchWeatherFromServerAsync();(또는 Visual Basic의 경우 Await FetchWeatherFromServerAsync())을 호출하면 FetchWeatherFromServerAsync의 코드가 UI 스레드에서 실행되지만 디스패처에서는 실행되지 않습니다. 이는 장기 실행 계산이 포함된 단일 스레드 앱 예의 작동 방식과 유사합니다. 그러나 await Task.Run이 사용된다는 점에 유의해야 합니다. 그러면 현재 스레드 대신 지정된 작업에 대한 스레드 풀에 새 스레드가 만들어집니다. 따라서 FetchWeatherFromServerAsync는 자체 스레드에서 실행됩니다.

  • 날씨 페치

    private async Task<string> FetchWeatherFromServerAsync()
    {
        // Simulate the delay from network access
        await Task.Delay(TimeSpan.FromSeconds(4));
    
        // Tried and true method for weather forecasting - random numbers
        Random rand = new Random();
    
        if (rand.Next(2) == 0)
            return "rainy";
        
        else
            return "sunny";
    }
    
    Private Async Function FetchWeatherFromServerAsync() As Task(Of String)
    
        ' Simulate the delay from network access
        Await Task.Delay(TimeSpan.FromSeconds(4))
    
        ' Tried and true method for weather forecasting - random numbers
        Dim rand As New Random()
    
        If rand.Next(2) = 0 Then
            Return "rainy"
        Else
            Return "sunny"
        End If
    
    End Function
    

    예제를 간단하게 유지하기 위해 이 예제에는 실제로 네트워킹 코드가 없습니다. 대신에 4초 동안 새 스레드를 절전 모드로 전환하여 네트워크 액세스 지연을 시뮬레이트합니다. 이 때 원래 UI 스레드는 여전히 실행 중이며 UI 이벤트에 응답하는 반면, 새 스레드가 완료될 때까지 단추의 이벤트 처리기는 일시 중지됩니다. 이를 보여 주기 위해 애니메이션을 실행 상태로 두었으며 창 크기를 조정할 수 있습니다. UI 스레드가 일시 중지되거나 지연되면 애니메이션이 표시되지 않으며 창과 상호 작용할 수 없습니다.

    Task.Delay가 완료되고 일기 예보를 임의로 선택하면 날씨 상태가 호출자에게 반환됩니다.

  • UI 업데이트

    private async void FetchButton_Click(object sender, RoutedEventArgs e)
    {
        // Change the status image and start the rotation animation.
        fetchButton.IsEnabled = false;
        fetchButton.Content = "Contacting Server";
        weatherText.Text = "";
        ((Storyboard)Resources["HideWeatherImageStoryboard"]).Begin(this);
    
        // Asynchronously fetch the weather forecast on a different thread and pause this code.
        string weather = await Task.Run(FetchWeatherFromServerAsync);
    
        // After async data returns, process it...
        // Set the weather image
        if (weather == "sunny")
            weatherIndicatorImage.Source = (ImageSource)Resources["SunnyImageSource"];
    
        else if (weather == "rainy")
            weatherIndicatorImage.Source = (ImageSource)Resources["RainingImageSource"];
    
        //Stop clock animation
        ((Storyboard)Resources["ShowClockFaceStoryboard"]).Stop(ClockImage);
        ((Storyboard)Resources["HideClockFaceStoryboard"]).Begin(ClockImage);
        
        //Update UI text
        fetchButton.IsEnabled = true;
        fetchButton.Content = "Fetch Forecast";
        weatherText.Text = weather;
    }
    
    Private Async Sub FetchButton_Click(sender As Object, e As RoutedEventArgs)
    
        ' Change the status image and start the rotation animation.
        fetchButton.IsEnabled = False
        fetchButton.Content = "Contacting Server"
        weatherText.Text = ""
        DirectCast(Resources("HideWeatherImageStoryboard"), Storyboard).Begin(Me)
    
        ' Asynchronously fetch the weather forecast on a different thread and pause this code.
        Dim weatherType As String = Await Task.Run(AddressOf FetchWeatherFromServerAsync)
    
        ' After async data returns, process it...
        ' Set the weather image
        If weatherType = "sunny" Then
            weatherIndicatorImage.Source = DirectCast(Resources("SunnyImageSource"), ImageSource)
    
        ElseIf weatherType = "rainy" Then
            weatherIndicatorImage.Source = DirectCast(Resources("RainingImageSource"), ImageSource)
    
        End If
    
        ' Stop clock animation
        DirectCast(Resources("ShowClockFaceStoryboard"), Storyboard).Stop(ClockImage)
        DirectCast(Resources("HideClockFaceStoryboard"), Storyboard).Begin(ClockImage)
    
        ' Update UI text
        fetchButton.IsEnabled = True
        fetchButton.Content = "Fetch Forecast"
        weatherText.Text = weatherType
    End Sub
    

    작업이 완료되고 UI 스레드에 시간이 생기면 단추의 이벤트 처리기인 Task.Run의 호출자가 다시 시작됩니다. 나머지 메서드에서는 클록 애니메이션을 중지하고 날씨를 설명하는 이미지를 선택합니다. 이 이미지를 표시하고 "예측 페치" 단추를 사용하도록 설정합니다.

이 섹션의 개념을 보여 주는 샘플 앱은 GitHub에서 C# 또는 Visual Basic용으로 다운로드할 수 있습니다.

기술 세부 정보 및 주의 사항

다음 섹션에서는 다중 스레딩과 관련하여 발생할 수 있는 몇 가지 세부 사항과 주의 사항에 대해 설명합니다.

중첩 펌핑

UI 스레드를 완전히 잠글 수 없는 경우가 있습니다. MessageBox 클래스의 Show 메서드를 살펴보겠습니다. Show는 사용자가 [확인] 단추를 클릭할 때까지 반환하지 않습니다. 하지만 상호 작용하기 위해 메시지 루프를 포함해야 하는 창을 만듭니다. 사용자가 [확인]을 클릭할 때까지 기다리고 있는 동안 원래 애플리케이션 창은 사용자 입력에 반응하지 않습니다. 하지만 이 창은 그리기 메시지를 계속 처리합니다. 원래 창은 숨겨졌다 표시될 때 자동으로 재배치됩니다.

OK 단추와 함께 MessageBox를 보여주는 스크린샷

일부 스레드는 메시지 상자 창을 처리해야 합니다. WPF에서는 메시지 상자 창인 경우에만 새 스레드를 만들 수 있지만 이 스레드는 원래 창에서 사용되지 않는 요소를 그릴 수 없습니다(상호 배제에 대한 이전 설명 참조). 대신 WPF는 중첩 메시지 처리 시스템을 사용합니다. Dispatcher 클래스에는 애플리케이션의 현재 실행 지점을 저장하고 나서 새 메시지 루프를 시작하는 PushFrame이라는 특수 메서드가 포함됩니다. 중첩 메시지 루프가 완료되면 원래 PushFrame 호출 후에 실행이 다시 시작됩니다.

이 경우 PushFrameMessageBox.Show 호출 시 프로그램 컨텍스트를 유지 관리하고, 새 메시지 루프를 시작하여 백그라운드 창을 다시 칠하고 메시지 상자 창에 대한 입력을 처리합니다. 사용자가 [확인]을 클릭하고 팝업 창을 지우면 중첩 루프가 존재하고 Show 호출 후에 컨트롤이 다시 시작됩니다.

부실 라우트된 이벤트

WPF의 라우트된 이벤트 시스템은 이벤트가 발생할 때 전체 트리에 알립니다.

<Canvas MouseLeftButtonDown="handler1" 
        Width="100"
        Height="100"
        >
  <Ellipse Width="50"
            Height="50"
            Fill="Blue" 
            Canvas.Left="30"
            Canvas.Top="50" 
            MouseLeftButtonDown="handler2"
            />
</Canvas>

왼쪽 마우스 단추로 타원을 누르면 handler2가 실행됩니다. handler2가 완료된 후 이벤트가 Canvas 개체로 전달됩니다. 이 개체는 handler1을 사용하여 이벤트를 처리합니다. handler2가 이벤트 개체를 명시적으로 처리됨으로 표시하지 않을 경우에만 이 동작이 수행됩니다.

handler2는 이 이벤트를 처리하는 데 상당한 시간이 걸릴 수 있습니다. handler2PushFrame을 사용하여 몇 시간 동안 반환하지 않는 중첩 메시지 루프를 시작할 수 있습니다. 이 메시지 루프가 완료될 때 handler2가 이벤트를 처리됨으로 표시하지 않으면 이벤트는 매우 오래된 이벤트이더라도 트리 위쪽으로 전달됩니다.

재입력 및 잠금

CLR(공용 언어 런타임)의 잠금 메커니즘은 상상하는 것처럼 동작하지 않습니다. 잠금을 요청할 때 스레드가 작업을 완전히 중단할 것으로 예상할 수 있습니다. 실제로 스레드는 우선 순위가 높은 메시지를 계속 수신하고 처리합니다. 이를 통해 교착 상태를 방지하고 인터페이스가 최소한으로 응답할 수 있지만 미묘한 버그가 발생할 수 있습니다. 대부분의 시간에는 이 사실을 알 필요가 없지만 드물게 발생하는 상황(대개 Win32 창 메시지 또는 COM STA 구성 요소가 포함됨)에서는 이 사실을 알 가치가 있습니다.

개발자는 두 개 이상의 스레드가 UI에 액세스하지 않는다는 가정 하에 작업하기 때문에 대부분의 인터페이스는 빌드 시 스레드 안전을 고려하지 않습니다. 이 경우 해당 단일 스레드는 예기치 않은 시점에 환경을 변경할 수 있으므로 DispatcherObject 상호 배제 메커니즘을 해결해야 하는 부작용이 발생합니다. 다음 의사 코드를 살펴보겠습니다.

스레딩 다시 표시를 보여주는 다이어그램.

대부분의 경우에는 작업이 제대로 진행되지만 WPF에서 예기치 않은 재입력으로 인해 실제로 문제가 발생할 수 있는 경우가 있습니다. 따라서 중요한 순간에 WPF는 DisableProcessing을 호출하여 일반적인 CLR 잠금이 아닌 WPF 재입력 없는 잠금을 사용하도록 해당 스레드에 대한 잠금 명령을 변경합니다.

그러면 CLR 팀이 이 동작을 선택하는 이유는 무엇일까요? 이 팀은 COM STA 개체 및 종료 스레드를 사용해야 했습니다. 개체가 가비지 수집될 경우 Finalize 메서드는 UI 스레드가 아닌 전용 종료자 스레드에서 실행됩니다. 또한 여기에도 문제가 있습니다. UI 스레드에서 만들어진 COM STA 개체는 UI 스레드에서만 삭제될 수 있기 때문입니다. CLR은 BeginInvoke와 동일한 작업을 수행합니다(이 예에서는 Win32의 SendMessage 사용). 하지만 UI 스레드가 사용 중이면 종료자 스레드가 중단되고 COM STA 개체를 삭제할 수 없으므로 심각한 메모리 누수가 발생합니다. 따라서 CLR 팀은 자체적인 방식으로 잠금을 적용하는 어려운 호출을 실행했습니다.

WPF에 대한 작업은 메모리 누수를 다시 일으키지 않고 예기치 않은 재입력을 피하는 것입니다. 이것이 어디에서도 재입력을 차단하지 않는 이유입니다.

참고 항목