다중 스레딩은 Windows Forms 앱의 성능을 향상시킬 수 있지만 Windows Forms 컨트롤에 대한 액세스는 기본적으로 스레드로부터 안전하지 않습니다. 다중 스레딩은 심각하고 복잡한 버그에 코드를 노출할 수 있습니다. 컨트롤을 조작하는 두 개 이상의 스레드가 강제로 컨트롤을 일관성 없는 상태로 만들고 경합 상태, 교착 상태, 중단 또는 중지로 이어질 수 있습니다. 앱에서 다중 스레딩을 구현할 때는 스레드로부터 안전한 방식으로 스레드 간 컨트롤을 호출해야 합니다. 자세한 내용은 관리형 스레딩 모범 사례를 참조하세요.
해당 컨트롤을 만들지 않은 스레드에서 Windows Forms 컨트롤을 안전하게 호출하는 방법에는 두 가지가 있습니다. System.Windows.Forms.Control.Invoke 메서드를 사용하여 주 스레드에서 만든 대리자를 호출하여 컨트롤을 호출합니다. 또는 System.ComponentModel.BackgroundWorker를 구현하고 이벤트 기반 모델을 사용하여 백그라운드 스레드에서 수행된 작업을 결과에 대한 보고와 분리합니다.
안전하지 않은 스레드 간 호출
컨트롤을 만들지 않은 스레드에서 직접 컨트롤을 호출하는 것은 안전하지 않습니다. 다음 코드 조각은 System.Windows.Forms.TextBox 컨트롤에 대한 안전하지 않은 호출을 보여 줍니다. Button1_Click 이벤트 처리기는 주 스레드의 WriteTextUnsafe 속성을 직접 설정하는 새로운 TextBox.Text 스레드를 만듭니다.
private void button2_Click(object sender, EventArgs e)
{
WriteTextUnsafe("Writing message #1 (UI THREAD)");
_ = Task.Run(() => WriteTextUnsafe("Writing message #2 (OTHER THREAD)"));
}
private void WriteTextUnsafe(string text) =>
textBox1.Text += $"{Environment.NewLine}{text}";
Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
WriteTextUnsafe("Writing message #1 (UI THREAD)")
Task.Run(Sub() WriteTextUnsafe("Writing message #2 (OTHER THREAD)"))
End Sub
Private Sub WriteTextUnsafe(text As String)
TextBox1.Text += $"{Environment.NewLine}{text}"
End Sub
Visual Studio 디버거는 메시지와 함께 스레드 간 작업이 유효하지 않음을 InvalidOperationException 발생시켜 이러한 안전하지 않은 스레드 호출을 검색합니다. 만든 스레드가 아닌 다른 스레드에서 액세스한 컨트롤입니다. Visual InvalidOperationException Studio 디버깅 중에 안전하지 않은 스레드 간 호출에 대해 항상 발생하며 앱 런타임에 발생할 수 있습니다. 문제를 해결해야 하지만 Control.CheckForIllegalCrossThreadCalls 속성을 false로 설정하여 예외를 사용하지 않도록 설정할 수 있습니다.
안전한 스레드 간 호출
Windows Forms 애플리케이션은 다른 모든 Windows UI 프레임워크와 유사한 엄격한 계약과 유사한 프레임워크를 따릅니다. 모든 컨트롤을 만들고 동일한 스레드에서 액세스해야 합니다. 이는 Windows에서 시스템 메시지를 배달하기 위해 애플리케이션이 단일 전용 스레드를 제공해야 하기 때문에 중요합니다. Windows Window Manager는 키 누르기, 마우스 클릭 또는 창 크기 조정과 같은 애플리케이션 창에 대한 상호 작용을 감지할 때마다 해당 정보를 UI를 만들고 관리하는 스레드로 라우팅하고 실행 가능한 이벤트로 바꿉니다. 이 스레드를 UI 스레드라고합니다.
다른 스레드에서 실행되는 코드는 UI 스레드에서 만들고 관리하는 컨트롤에 액세스할 수 없으므로 Windows Forms는 다음 코드 예제와 같이 다른 스레드에서 이러한 컨트롤을 안전하게 사용하는 방법을 제공합니다.
예: Control.InvokeAsync 사용(.NET 9 이상)
Control.InvokeAsync UI 스레드에 비동기 친화적인 마샬링을 제공하는 메서드(.NET 9 이상)입니다.
-
System.Windows.Forms.Control.Invoke 메서드는 주 스레드에서 대리자를 호출하여 컨트롤을 호출합니다.
-
System.ComponentModel.BackgroundWorker 구성 요소는 이벤트 기반 모델을 제공합니다.
예: Control.InvokeAsync 사용(.NET 9 이상)
.NET 9부터 Windows Forms는 UI 스레드에 비동기 친화적인 마샬링을 제공하는 메서드를 포함합니다 InvokeAsync . 이 메서드는 비동기 이벤트 처리기에 유용하며 많은 일반적인 교착 상태 시나리오를 제거합니다.
비고
Control.InvokeAsync 는 .NET 9 이상에서만 사용할 수 있습니다. .NET Framework에서는 지원되지 않습니다.
차이점 이해: Invoke 및 InvokeAsync
Control.Invoke(보내기 - 차단):
- 대리자를 UI 스레드의 메시지 큐에 동기적으로 보냅니다.
- 호출 스레드는 UI 스레드가 대리자를 처리할 때까지 기다립니다.
- 메시지 큐에 마샬링된 대리자가 메시지 도착(교착 상태)을 기다리는 동안 UI가 중지될 수 있습니다.
- UI 스레드에 표시할 준비가 된 경우(예: 단추 사용 안 됨 또는 컨트롤의 텍스트 설정) 유용합니다.
Control.InvokeAsync(게시 - 비차단):
- 호출이 완료되는 것을 기다리는 대신 대리자를 UI 스레드의 메시지 큐에 비동기적으로 게시합니다.
- 호출 스레드를 차단하지 않고 즉시 반환합니다.
-
Task완료 대기할 수 있는 값을 반환합니다. - 비동기 시나리오에 적합하며 UI 스레드 병목 현상을 방지합니다.
InvokeAsync의 장점
Control.InvokeAsync 에는 이전 Control.Invoke 메서드에 비해 몇 가지 이점이 있습니다. 대기할 수 있는 값을 Task 반환하여 비동기 및 대기 코드에서 잘 작동합니다. 또한 비동기 코드를 동기 호출 호출과 혼합할 때 발생할 수 있는 일반적인 교착 상태 문제를 방지합니다. 달리 Control.Invoke메서드는 InvokeAsync 호출 스레드를 차단하지 않으므로 앱의 응답성이 유지됩니다.
이 메서드는 필요한 CancellationToken경우 작업을 취소할 수 있도록 취소를 지원합니다. 또한 예외를 제대로 처리하여 오류를 적절하게 처리할 수 있도록 코드에 다시 전달합니다. .NET 9에는 메서드를 올바르게 사용하는 데 도움이 되는 컴파일러 경고(WFO2001)가 포함되어 있습니다.
비동기 이벤트 처리기 및 모범 사례에 대한 포괄적인 지침은 이벤트 개요를 참조하세요.
올바른 InvokeAsync 오버로드 선택
Control.InvokeAsync 에서는 다양한 시나리오에 대한 4가지 오버로드를 제공합니다.
| 오버 로드 | 사용 사례 | Example |
|---|---|---|
InvokeAsync(Action) |
동기화 작업, 반환 값 없음 | 컨트롤 속성을 업데이트합니다. |
InvokeAsync<T>(Func<T>) |
반환 값을 사용하여 작업을 동기화합니다. | 제어 상태를 가져옵니다. |
InvokeAsync(Func<CancellationToken, ValueTask>) |
비동기 작업, 반환 값 없음.* | 장기 실행 UI 업데이트입니다. |
InvokeAsync<T>(Func<CancellationToken, ValueTask<T>>) |
반환 값이 있는 비동기 작업입니다.* | 결과를 사용하여 비동기 데이터 페치 |
*Visual Basic은 대기를 ValueTask지원하지 않습니다.
다음 예제에서는 백그라운드 스레드에서 컨트롤을 안전하게 업데이트하는 방법을 InvokeAsync 보여 줍니다.
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Perform background work
await Task.Run(async () =>
{
for (int i = 0; i <= 100; i += 10)
{
// Simulate work
await Task.Delay(100);
// Create local variable to avoid closure issues
int currentProgress = i;
// Update UI safely from background thread
await loggingTextBox.InvokeAsync(() =>
{
loggingTextBox.Text = $"Progress: {currentProgress}%";
});
}
});
loggingTextBox.Text = "Operation completed!";
}
finally
{
button1.Enabled = true;
}
}
Private Async Sub Button1_Click(sender As Object, e As EventArgs) Handles button1.Click
button1.Enabled = False
Try
' Perform background work
Await Task.Run(Async Function()
For i As Integer = 0 To 100 Step 10
' Simulate work
Await Task.Delay(100)
' Create local variable to avoid closure issues
Dim currentProgress As Integer = i
' Update UI safely from background thread
Await loggingTextBox.InvokeAsync(Sub()
loggingTextBox.Text = $"Progress: {currentProgress}%"
End Sub)
Next
End Function)
' Update UI after completion
Await loggingTextBox.InvokeAsync(Sub()
loggingTextBox.Text = "Operation completed!"
End Sub)
Finally
button1.Enabled = True
End Try
End Sub
UI 스레드에서 실행해야 하는 비동기 작업의 경우 비동기 오버로드를 사용합니다.
private async void button2_Click(object sender, EventArgs e)
{
button2.Enabled = false;
try
{
loggingTextBox.Text = "Starting operation...";
// Dispatch and run on a new thread, but wait for tasks to finish
// Exceptions are rethrown here, because await is used
await Task.WhenAll(Task.Run(SomeApiCallAsync),
Task.Run(SomeApiCallAsync),
Task.Run(SomeApiCallAsync));
// Dispatch and run on a new thread, but don't wait for task to finish
// Exceptions are not rethrown here, because await is not used
_ = Task.Run(SomeApiCallAsync);
}
catch (OperationCanceledException)
{
loggingTextBox.Text += "Operation canceled.";
}
catch (Exception ex)
{
loggingTextBox.Text += $"Error: {ex.Message}";
}
finally
{
button2.Enabled = true;
}
}
private async Task SomeApiCallAsync()
{
using var client = new HttpClient();
// Simulate random network delay
await Task.Delay(Random.Shared.Next(500, 2500));
// Do I/O asynchronously
string result = await client.GetStringAsync("https://github.com/dotnet/docs/raw/refs/heads/main/README.md");
// Marshal back to UI thread
await this.InvokeAsync(async (cancelToken) =>
{
loggingTextBox.Text += $"{Environment.NewLine}Operation finished at: {DateTime.Now:HH:mm:ss.fff}";
});
// Do more async I/O ...
}
Private Async Sub Button2_Click(sender As Object, e As EventArgs) Handles button2.Click
button2.Enabled = False
Try
loggingTextBox.Text = "Starting operation..."
' Dispatch and run on a new thread, but wait for tasks to finish
' Exceptions are rethrown here, because await is used
Await Task.WhenAll(Task.Run(AddressOf SomeApiCallAsync),
Task.Run(AddressOf SomeApiCallAsync),
Task.Run(AddressOf SomeApiCallAsync))
' Dispatch and run on a new thread, but don't wait for task to finish
' Exceptions are not rethrown here, because await is not used
Call Task.Run(AddressOf SomeApiCallAsync)
Catch ex As OperationCanceledException
loggingTextBox.Text += "Operation canceled."
Catch ex As Exception
loggingTextBox.Text += $"Error: {ex.Message}"
Finally
button2.Enabled = True
End Try
End Sub
Private Async Function SomeApiCallAsync() As Task
Using client As New HttpClient()
' Simulate random network delay
Await Task.Delay(Random.Shared.Next(500, 2500))
' Do I/O asynchronously
Dim result As String = Await client.GetStringAsync("https://github.com/dotnet/docs/raw/refs/heads/main/README.md")
' Marshal back to UI thread
' Extra work here in VB to handle ValueTask conversion
Await Me.InvokeAsync(DirectCast(
Async Function(cancelToken As CancellationToken) As Task
loggingTextBox.Text &= $"{Environment.NewLine}Operation finished at: {DateTime.Now:HH:mm:ss.fff}"
End Function,
Func(Of CancellationToken, Task)).AsValueTask() 'Extension method to convert Task
)
' Do more Async I/O ...
End Using
End Function
예: Control.Invoke 메서드 사용
다음 예제에서는 Windows Forms 컨트롤에 대한 스레드로부터 안전한 호출을 보장하는 패턴을 보여 줍니다. System.Windows.Forms.Control.InvokeRequired 속성을 쿼리하여 컨트롤이 생성한 스레드 ID를 호출하는 스레드 ID와 비교합니다. 다른 경우 Control.Invoke 메서드를 호출해야 합니다.
WriteTextSafe를 통해 TextBox 컨트롤의 Text 속성을 새 값으로 설정할 수 있습니다. 메서드는 InvokeRequired를 쿼리합니다. InvokeRequired가 true를 반환하는 경우 WriteTextSafe는 재귀적으로 자신을 호출하여 이 메서드를 Invoke 메서드에 대한 대리자로 전달합니다. InvokeRequired가 false를 반환하는 경우 WriteTextSafe는 TextBox.Text를 직접 설정합니다. Button1_Click 이벤트 처리기는 새 스레드를 만들고 WriteTextSafe 메서드를 실행합니다.
private void button1_Click(object sender, EventArgs e)
{
WriteTextSafe("Writing message #1");
_ = Task.Run(() => WriteTextSafe("Writing message #2"));
}
public void WriteTextSafe(string text)
{
if (textBox1.InvokeRequired)
textBox1.Invoke(() => WriteTextSafe($"{text} (NON-UI THREAD)"));
else
textBox1.Text += $"{Environment.NewLine}{text}";
}
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
WriteTextSafe("Writing message #1")
Task.Run(Sub() WriteTextSafe("Writing message #2"))
End Sub
Private Sub WriteTextSafe(text As String)
If (TextBox1.InvokeRequired) Then
TextBox1.Invoke(Sub()
WriteTextSafe($"{text} (NON-UI THREAD)")
End Sub)
Else
TextBox1.Text += $"{Environment.NewLine}{text}"
End If
End Sub
차이점에 대한 Invoke 자세한 내용은 InvokeAsync를 참조하세요.
예: BackgroundWorker 사용
컨트롤 또는 폼에 대한 액세스가 주 스레드(UI 스레드) System.ComponentModel.BackgroundWorker 에서만 수행되도록 보장하면서 다중 스레딩 시나리오를 구현하는 쉬운 방법은 이벤트 기반 모델을 사용하는 구성 요소를 사용하는 것입니다. 백그라운드 스레드는 주 스레드와 상호 작용하지 않는 BackgroundWorker.DoWork 이벤트를 발생시킵니다. 주 스레드는 주 스레드의 컨트롤을 호출할 수 있는 BackgroundWorker.ProgressChanged 및 BackgroundWorker.RunWorkerCompleted 이벤트 처리기를 실행합니다.
중요합니다
BackgroundWorker 구성 요소는 더 이상 Windows Forms 애플리케이션의 비동기 시나리오에 권장되는 방법이 아닙니다. 이전 버전과의 호환성을 위해 이 구성 요소를 계속 지원하지만 UI 스레드에서 다른 스레드로의 프로세서 워크로드 오프로드만 해결합니다. 파일 I/O 또는 프로세서가 적극적으로 작동하지 않을 수 있는 네트워크 작업과 같은 다른 비동기 시나리오는 처리하지 않습니다.
최신 비동기 프로그래밍의 경우 대신 메서드를 async 사용합니다await. 프로세서 집약적 작업을 명시적으로 오프로드해야 하는 경우 새 작업을 만들고 시작하는 데 사용합니다 Task.Run . 그러면 다른 비동기 작업처럼 대기할 수 있습니다. 자세한 내용은 예제: Control.InvokeAsync(.NET 9 이상) 및 스레드 간 작업 및 이벤트 사용을 참조하세요.
BackgroundWorker를 사용하여 스레드로부터 안전한 호출을 하려면 DoWork 이벤트를 처리합니다. 백그라운드 작업자가 상태를 보고하는 데 사용하는 이벤트에는 ProgressChanged 및 RunWorkerCompleted의 두 가지가 있습니다. 이벤트는 ProgressChanged 주 스레드에 상태 업데이트를 전달하는 데 사용되며 RunWorkerCompleted , 이벤트는 백그라운드 작업자가 완료되었음을 알리는 데 사용됩니다. 백그라운드 스레드를 시작하려면 BackgroundWorker.RunWorkerAsync를 호출합니다.
이 예제는 DoWork 이벤트에서 0에서 10까지 세며, 세는 사이에 1초 동안 잠시 멈춥니다. ProgressChanged 이벤트 처리기를 사용하여 기본 스레드에 다시 숫자를 보고하고 TextBox 컨트롤의 Text 속성을 설정합니다. ProgressChanged 이벤트를 사용하려면 BackgroundWorker.WorkerReportsProgress 속성을 true로 설정해야 합니다.
private void button1_Click(object sender, EventArgs e)
{
if (!backgroundWorker1.IsBusy)
backgroundWorker1.RunWorkerAsync(); // Not awaitable
}
private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
int counter = 0;
int max = 10;
while (counter <= max)
{
backgroundWorker1.ReportProgress(0, counter.ToString());
System.Threading.Thread.Sleep(1000);
counter++;
}
}
private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e) =>
textBox1.Text = (string)e.UserState;
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
If (Not BackgroundWorker1.IsBusy) Then
BackgroundWorker1.RunWorkerAsync() ' Not awaitable
End If
End Sub
Private Sub BackgroundWorker1_DoWork(sender As Object, e As ComponentModel.DoWorkEventArgs) Handles BackgroundWorker1.DoWork
Dim counter = 0
Dim max = 10
While counter <= max
BackgroundWorker1.ReportProgress(0, counter.ToString())
System.Threading.Thread.Sleep(1000)
counter += 1
End While
End Sub
Private Sub BackgroundWorker1_ProgressChanged(sender As Object, e As ComponentModel.ProgressChangedEventArgs) Handles BackgroundWorker1.ProgressChanged
TextBox1.Text = e.UserState
End Sub
.NET Desktop feedback