C#에서 Thread와 Parallel.ForEach 안전하게 중단하는 방법
멀티스레딩 환경에서 작업을 안전하게 중단하는 것은 중요한 과제입니다. 특히 C#에서 Thread.Abort()가 .NET 5 이상에서 지원되지 않으면서, 안전한 중단 방법에 대한 이해가 더욱 중요해졌습니다.
TL;DR: Thread.Abort() 대신 CancellationToken이나 플래그 패턴을 사용하여 Thread와 Parallel.ForEach를 안전하게 중단할 수 있습니다. 이 방법들은 리소스 누수와 데이터 손상을 방지하며 .NET의 표준 취소 패턴을 따릅니다.
이 글은 2016년도에 C#(.NET Framework 4.5 기반) 프로젝트를 진행하면서 작성했던 멀티스레드 중단 처리 포스트의 업데이트 버전입니다. .NET 5부터 Thread.Abort()가 공식적으로 obsolete되면서 기존 내용이 현재 환경에 맞지 않게 되어, .NET Framework 4.8부터 최신 .NET까지 모든 환경에서 사용할 수 있는 안전한 중단 방법들을 정리했습니다.
Thread.Abort()를 사용하면 안 되는 이유
Thread.Abort()는 여러 심각한 문제를 야기할 수 있습니다:
- .NET 5 이상에서 지원 중단: 더 이상 사용할 수 없는 방법입니다
- 리소스 누수: 파일 핸들, 데이터베이스 연결 등이 제대로 해제되지 않을 수 있습니다
- 데이터 손상: 작업 중간에 강제 종료되어 일관성이 깨질 수 있습니다
- 예측 불가능한 동작: ThreadAbortException이 예상치 못한 곳에서 발생할 수 있습니다
Thread.Abort()는 .NET 5 이상에서 지원되지 않으며, 이전 버전에서도 안전하지 않습니다. 절대 사용하지 마세요.
Thread 안전하게 중단하기
1. 플래그 패턴 사용
가장 간단한 방법은 boolean 플래그를 사용하는 것입니다:
public class SafeThread
{
private volatile bool _stopRequested = false;
private Thread _thread;
public void Start()
{
_thread = new Thread(DoWork);
_thread.Start();
}
public void Stop()
{
_stopRequested = true;
_thread?.Join(); // 스레드가 완전히 종료될 때까지 대기
}
private void DoWork()
{
while (!_stopRequested)
{
// 실제 작업 수행
ProcessItem();
// 주기적으로 중단 요청 확인
if (_stopRequested)
break;
Thread.Sleep(100); // 예시: 100ms 대기
}
Console.WriteLine("스레드가 안전하게 종료되었습니다.");
}
private void ProcessItem()
{
// 실제 작업 로직
Console.WriteLine($"작업 처리 중... (Thread ID: {Thread.CurrentThread.ManagedThreadId})");
}
}
2. CancellationToken 사용 (권장)
.NET의 표준 취소 패턴인 CancellationToken을 사용하는 방법입니다 (.NET Framework 4.0 이상):
public class CancellableThread
{
private Thread _thread;
private CancellationTokenSource _cancellationTokenSource;
public void Start()
{
_cancellationTokenSource = new CancellationTokenSource();
_thread = new Thread(() => DoWork(_cancellationTokenSource.Token));
_thread.Start();
}
public void Stop()
{
_cancellationTokenSource?.Cancel();
_thread?.Join();
_cancellationTokenSource?.Dispose();
}
private void DoWork(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
ProcessItem();
// 취소 요청 확인 및 예외 발생
cancellationToken.ThrowIfCancellationRequested();
// 취소 가능한 대기 (.NET Framework 4.8에서는 Thread.Sleep 사용)
if (cancellationToken.WaitHandle.WaitOne(100))
{
break; // 취소 요청됨
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
finally
{
Console.WriteLine("스레드가 안전하게 종료되었습니다.");
}
}
private void ProcessItem()
{
Console.WriteLine($"작업 처리 중... (Thread ID: {Thread.CurrentThread.ManagedThreadId})");
}
}
Parallel.ForEach 안전하게 중단하기
Parallel.ForEach는 ParallelOptions를 통해 CancellationToken을 지원합니다 (.NET Framework 4.0 이상):
public class ParallelProcessor
{
public async Task ProcessItemsAsync(IEnumerable<int> items)
{
var cancellationTokenSource = new CancellationTokenSource();
// 5초 후 자동 취소 (예시)
cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(5));
var parallelOptions = new ParallelOptions
{
CancellationToken = cancellationTokenSource.Token,
MaxDegreeOfParallelism = Environment.ProcessorCount
};
try
{
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
// 각 반복에서 취소 요청 확인
parallelOptions.CancellationToken.ThrowIfCancellationRequested();
ProcessSingleItem(item, parallelOptions.CancellationToken);
});
Console.WriteLine("모든 작업이 완료되었습니다.");
}
catch (OperationCanceledException)
{
Console.WriteLine("병렬 작업이 취소되었습니다.");
}
finally
{
cancellationTokenSource.Dispose();
}
}
private void ProcessSingleItem(int item, CancellationToken cancellationToken)
{
// 긴 작업 시뮬레이션
for (int i = 0; i < 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(100); // 실제 작업 시뮬레이션
Console.WriteLine($"Item {item}, Step {i + 1}/10 (Thread: {Thread.CurrentThread.ManagedThreadId})");
}
}
}
외부에서 수동으로 취소하기
사용자 입력이나 다른 조건에 따라 수동으로 취소하는 예시입니다:
public class ManualCancellationExample
{
private CancellationTokenSource _cancellationTokenSource;
public async Task StartProcessingAsync()
{
_cancellationTokenSource = new CancellationTokenSource();
var items = Enumerable.Range(1, 1000);
var parallelOptions = new ParallelOptions
{
CancellationToken = _cancellationTokenSource.Token,
MaxDegreeOfParallelism = 4
};
try
{
await Task.Run(() =>
{
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
parallelOptions.CancellationToken.ThrowIfCancellationRequested();
// 무거운 작업 시뮬레이션
Thread.Sleep(200);
Console.WriteLine($"처리 완료: {item}");
});
});
}
catch (OperationCanceledException)
{
Console.WriteLine("사용자에 의해 작업이 취소되었습니다.");
}
}
public void CancelProcessing()
{
_cancellationTokenSource?.Cancel();
Console.WriteLine("취소 요청이 전송되었습니다.");
}
}
// 사용 예시
class Program
{
static async Task Main(string[] args)
{
var processor = new ManualCancellationExample();
// 백그라운드에서 처리 시작
var processingTask = processor.StartProcessingAsync();
Console.WriteLine("Enter 키를 누르면 작업을 취소합니다...");
Console.ReadLine();
// 사용자 입력으로 취소
processor.CancelProcessing();
await processingTask;
Console.WriteLine("프로그램이 종료되었습니다.");
}
}
.NET Framework 4.8 호환 버전
.NET Framework 4.8에서는 일부 최신 API가 없어서 약간 다른 접근이 필요합니다:
public class NetFramework48Thread
{
private Thread _thread;
private CancellationTokenSource _cancellationTokenSource;
public void Start()
{
_cancellationTokenSource = new CancellationTokenSource();
_thread = new Thread(() => DoWork(_cancellationTokenSource.Token));
_thread.Start();
}
public void Stop()
{
_cancellationTokenSource?.Cancel();
_thread?.Join();
_cancellationTokenSource?.Dispose();
}
private void DoWork(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
ProcessItem();
// 취소 요청 확인
cancellationToken.ThrowIfCancellationRequested();
// .NET Framework 4.8에서 취소 가능한 대기
// Task.Delay 대신 WaitHandle 사용
if (cancellationToken.WaitHandle.WaitOne(100))
{
break; // 취소 요청됨
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
finally
{
Console.WriteLine("스레드가 안전하게 종료되었습니다.");
}
}
private void ProcessItem()
{
Console.WriteLine($"작업 처리 중... (Thread ID: {Thread.CurrentThread.ManagedThreadId})");
}
}
.NET Framework 4.8에서 Parallel.ForEach 사용
public class NetFramework48ParallelProcessor
{
public void ProcessItems(IEnumerable<int> items)
{
var cancellationTokenSource = new CancellationTokenSource();
// 5초 후 자동 취소
var timer = new System.Threading.Timer(_ => cancellationTokenSource.Cancel(),
null, 5000, Timeout.Infinite);
var parallelOptions = new ParallelOptions
{
CancellationToken = cancellationTokenSource.Token,
MaxDegreeOfParallelism = Environment.ProcessorCount
};
try
{
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
parallelOptions.CancellationToken.ThrowIfCancellationRequested();
ProcessSingleItem(item, parallelOptions.CancellationToken);
});
Console.WriteLine("모든 작업이 완료되었습니다.");
}
catch (OperationCanceledException)
{
Console.WriteLine("병렬 작업이 취소되었습니다.");
}
finally
{
timer?.Dispose();
cancellationTokenSource.Dispose();
}
}
private void ProcessSingleItem(int item, CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(100);
Console.WriteLine($"Item {item}, Step {i + 1}/10 (Thread: {Thread.CurrentThread.ManagedThreadId})");
}
}
}
.NET Framework 4.8에서 Task 기반 접근
.NET Framework 4.8에서는 Task를 사용한 접근도 가능합니다:
public class NetFramework48TaskProcessor
{
private CancellationTokenSource _cancellationTokenSource;
public async Task StartProcessingAsync()
{
_cancellationTokenSource = new CancellationTokenSource();
var items = Enumerable.Range(1, 1000);
try
{
// .NET Framework 4.8에서는 Task.Run 대신 Task.Factory.StartNew 사용 권장
await Task.Factory.StartNew(() =>
{
var parallelOptions = new ParallelOptions
{
CancellationToken = _cancellationTokenSource.Token,
MaxDegreeOfParallelism = 4
};
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
parallelOptions.CancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(200);
Console.WriteLine($"처리 완료: {item}");
});
}, _cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("사용자에 의해 작업이 취소되었습니다.");
}
}
public void CancelProcessing()
{
_cancellationTokenSource?.Cancel();
Console.WriteLine("취소 요청이 전송되었습니다.");
}
}
Thread와 Parallel.ForEach 조합하기
별도 스레드에서 Parallel.ForEach를 실행하고 전체를 안전하게 중단하는 방법입니다:
public class ThreadedParallelProcessor
{
private Thread _workerThread;
private CancellationTokenSource _cancellationTokenSource;
public void Start(IEnumerable<int> items)
{
_cancellationTokenSource = new CancellationTokenSource();
_workerThread = new Thread(() => ProcessInBackground(items, _cancellationTokenSource.Token));
_workerThread.Start();
}
public void Stop()
{
_cancellationTokenSource?.Cancel();
_workerThread?.Join(TimeSpan.FromSeconds(10)); // 최대 10초 대기
_cancellationTokenSource?.Dispose();
}
private void ProcessInBackground(IEnumerable<int> items, CancellationToken cancellationToken)
{
try
{
var parallelOptions = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Environment.ProcessorCount / 2
};
Parallel.ForEach(items, parallelOptions, (item, loopState) =>
{
cancellationToken.ThrowIfCancellationRequested();
// 실제 작업 처리
ProcessItem(item, cancellationToken);
});
Console.WriteLine("백그라운드 처리가 완료되었습니다.");
}
catch (OperationCanceledException)
{
Console.WriteLine("백그라운드 작업이 취소되었습니다.");
}
}
private void ProcessItem(int item, CancellationToken cancellationToken)
{
// 긴 작업 중간중간 취소 확인
for (int i = 0; i < 5; i++)
{
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(100);
}
Console.WriteLine($"Item {item} 처리 완료");
}
}
.NET Framework vs .NET 5+ 차이점
API 차이점 요약
기능 | .NET Framework 4.8 | .NET 5+ |
---|---|---|
CancellationToken | ✅ 지원 | ✅ 지원 |
Task.Delay with CancellationToken | ❌ 제한적 | ✅ 완전 지원 |
Task.Run | ❌ 없음 | ✅ 지원 |
CancellationTokenSource.CancelAfter | ❌ 없음 | ✅ 지원 |
Thread.Abort | ⚠️ 사용 가능하지만 위험 | ❌ Obsolete |
.NET Framework 4.8 권장 패턴
public class RecommendedNetFramework48Pattern
{
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly ManualResetEventSlim _completedEvent;
public RecommendedNetFramework48Pattern()
{
_cancellationTokenSource = new CancellationTokenSource();
_completedEvent = new ManualResetEventSlim(false);
}
public void StartWork()
{
Task.Factory.StartNew(() => DoWork(_cancellationTokenSource.Token),
_cancellationTokenSource.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
}
public void StopWork(int timeoutMs = 5000)
{
_cancellationTokenSource.Cancel();
if (!_completedEvent.Wait(timeoutMs))
{
Console.WriteLine("작업이 제한 시간 내에 완료되지 않았습니다.");
}
}
private void DoWork(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
// 실제 작업 수행
ProcessWorkItem();
// 주기적 취소 확인 (.NET Framework 4.8 호환)
if (cancellationToken.WaitHandle.WaitOne(100))
{
break;
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("작업이 취소되었습니다.");
}
finally
{
_completedEvent.Set();
Console.WriteLine("작업이 완료되었습니다.");
}
}
private void ProcessWorkItem()
{
// 실제 작업 로직
Thread.Sleep(50);
Console.WriteLine($"작업 처리 중... (Thread: {Thread.CurrentThread.ManagedThreadId})");
}
public void Dispose()
{
_cancellationTokenSource?.Dispose();
_completedEvent?.Dispose();
}
}
성능 고려사항
취소 확인 빈도
취소 확인을 너무 자주 하면 성능에 영향을 줄 수 있습니다:
private void OptimizedWork(CancellationToken cancellationToken)
{
const int checkInterval = 100; // 100번 작업마다 한 번씩 확인
int counter = 0;
while (true)
{
// 실제 작업
DoSomeWork();
// 주기적으로만 취소 확인
if (++counter % checkInterval == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
}
}
리소스 정리
취소 시에도 리소스가 제대로 정리되도록 해야 합니다:
private void SafeResourceHandling(CancellationToken cancellationToken)
{
using var fileStream = new FileStream("data.txt", FileMode.Open);
using var reader = new StreamReader(fileStream);
try
{
while (!reader.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
var line = reader.ReadLine();
ProcessLine(line);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("파일 처리가 취소되었습니다.");
// using 문에 의해 자동으로 리소스 정리됨
}
}
결론
C#에서 Thread와 Parallel.ForEach를 안전하게 중단하는 핵심은 다음과 같습니다:
- Thread.Abort() 사용 금지: .NET 5 이상에서 지원되지 않으며, .NET Framework에서도 안전하지 않습니다
- CancellationToken 활용: .NET Framework 4.0부터 지원되는 표준 취소 패턴으로 가장 권장되는 방법입니다
- 플래그 패턴: 간단한 시나리오에서 사용할 수 있는 대안입니다
- 프레임워크별 차이점 고려: .NET Framework 4.8과 .NET 5+ 간의 API 차이를 이해해야 합니다
- 적절한 취소 확인: 성능과 반응성의 균형을 맞춰야 합니다
- 리소스 정리: 취소 시에도 모든 리소스가 안전하게 해제되도록 해야 합니다
이러한 패턴들을 사용하면 리소스 누수나 데이터 손상 없이 멀티스레딩 작업을 안전하게 제어할 수 있습니다. 특히 장시간 실행되는 백그라운드 작업이나 사용자가 중단할 수 있는 작업에서는 반드시 이런 안전한 중단 메커니즘을 구현해야 합니다.
참고 자료
공식 문서
- Thread.Abort Method - Microsoft Docs - Thread.Abort() 메서드 공식 문서 및 지원 중단 안내
- CancellationToken Struct - Microsoft Docs - CancellationToken 구조체 공식 문서
- Parallel.ForEach Method - Microsoft Docs - Parallel.ForEach 메서드 공식 문서
- Cancellation in Managed Threads - Microsoft Docs - 관리되는 스레드에서의 취소 패턴 가이드
관련 기술 문서
- Task-based Asynchronous Pattern (TAP) - Microsoft Docs - 비동기 프로그래밍 패턴
- Parallel Programming in .NET - Microsoft Docs - .NET 병렬 프로그래밍 가이드
- Best Practices for Managed Threading - Microsoft Docs - 관리되는 스레딩 모범 사례
추가 학습 자료
- Stephen Cleary's Blog - Async/Await Best Practices - 비동기 코드 모범 사례
- Eric Lippert's Blog - Cancellation - 취소 패턴에 대한 심화 설명