지난 번 쓰레드의 개념 (1)에 이어 설명합니다.
2. 쓰레드의 개념(2)
쓰레드의 우선 순위에 따라 쓰레드가 다른 쓰레드와 비교해서 상대적으로 얼마나 CPU 시간을 취득할 것인지를 결정합니다. 쓰레드 우선 순위에는 아래의 값 중 하나를 지정합니다.
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
이 값은 복수의 쓰레드가 동시에 실행되고 있는 경우에만 의미를 갖습니다.
using (Process p = Process.GetCurrentProcess()) p.PriorityClass = ProcessPriorityClass.High;
ProcessPriorityClass.High는 의미 그대로 가장 최우선 순위를 의미합니다. 프로세스의 우선 순위를 실시간으로 지정한다는 것은 다른 프로세스에 대해서 일절 CPU 시간을 할당하지 않겠다는 것을 OS에게 지시하게 됩니다. 만약 프로그램이 잘못되어 무한 루프에 빠져버리는 경우 OS 조차도 움직일 수 없는 상태로 빠져 키보드 버튼을 눌러 프로그램을 종료시키는 것 조차도 할 수 없게 됩니다. 이러한 이유 때문에 실시간으로 어플리케이션을 동작시키기 위해서는 보통 High로 설정하는 것이 가장 좋은 방법입니다.
try-catch-finally 로는 다른 쓰레드에서 발생한 예외를 Catch 할 수 없습니다.
public static void Main() { try { new Thread (Go).Start(); } catch (Exception ex) { // 캐치 할 수 없음! Console.WriteLine ("Exception!"); } } static void Go() { throw null; } // Throws a NullReferenceException
이 예제에서 try-catch 블럭은 전혀 의미를 갖지 못합니다. 개별 쓰레드는 개별적으로 독립된 실행 경로에서 실행된다는 것을 떠올린 다면 이와 같은 사실을 이해할 수 있다고 생각합니다.
해결책으로서는 Go 메서드를 내부에 예외의 catch 문을 옮기는 것입니다.
public static void Main() { new Thread (Go).Start(); } static void Go() { try { // ... throw null; // The NullReferenceException will get caught below // ... } catch (Exception ex) { // 예외를 Catch하여 로그의 기록과 다른 쓰레드에 예외를 전달한다 } }
제품화된 어플리케이션에서는 메인 쓰레드에 대해서 실행하는 것처럼 생성된 쓰레드의 실행 메서드 전체에 대해서 예외처리를 적절히 구현할 필요가 있습니다. Catch되지 못한 예외는 어플리케이션의 강제종료를 불러 일으킵니다.
어플리케이션 전체의 예외 핸들링을 하는 이벤트로 WPF와 WindowsForm에서는
- Application.DispatcherUnhandledException
- Application.ThreadException
가 있습니다. 이것들의 이벤트는 메인 쓰레드에서의 예외만 핸들할 수 있습니다. 워커 쓰레드에서의 예외는 자기 자신이 핸들하여 적절히 처리를 할 필요가 있습니다.
AppDomain.CurrentDomain.UnhandledException에서는 모든 쓰레드에서 발생한 예외를 핸들할 수 있습니다. 하지만 이 이벤트에서는 어플리케이션의 Shutdown을 막기 위한 방법은 제공되지 않습니다.
하지만 몇가지 경우에는 예외를 핸들링할 필요가 없는 경우도 있습니다. 왜냐하면 .NET Framework가 묵시적으로 처리를 해주고 있기 때문입니다. 자세한 것은 나중에 해설할 예정입니다.
- 비동기 대리자
- BackgroundWorker
- Task Parallel Library (conditions apply)
어떠한 상황에서도 쓰레드를 스타트 할 때에는 그 쓰레드 전용의 스택 메모리의 확보등으로 수 백 마이크로 초 정도의 CPU 시간이 소비됩니다. 방침으로는 쓰레드는 1MB의 메모리를 스택에 확보합니다. 쓰레드 풀은 한번 생성한 쓰레드를 재이용하여 공유하는 것으로 오버 헤드를 회피합니다. 멀티 코어 프로세서를 활용하여 병렬로 처리를 진행할 경우 등에 쓰레드 풀의 구조는 효과적입니다.
쓰레드 풀은 동시에 실행하는 쓰레드의 총 갯수가 증가하지 않도록 조절하는 역활도 갖고 있습니다. 동시 실행하는 쓰레드의 갯수가 너무 증가하게 되면 OS의 쓰레드 관리의 부하가 너무 커지게 되어 CPU 캐쉬의 동작이 비효율적으로 되어버립니다. 쓰레드 풀에서는 동시 실행되고 있는 쓰레드 수의 한계에 달하면 새롭게 실행되는 작업은 큐에 들어가 기존의 실행중인 작업 중 하나가 종료될 때까지 대기하게 됩니다.
쓰레드 풀은 다양한 곳에서 내부적으로 사용되고 있습니다.
- Task Parallel Library의 내부
- ThreadPool.QueueUserWorkItem 메서드의 호출
- Asynchronous delegate의 내부
- BackgroundWorker의 내부
- WCF,Remoting,ASP.NET,ASMX Web Service
- System.Timers.Timer,System.Threading.Timer
- Async로 종료하는 메서드. 예를 들면 WebClient 클래스의 메서드(Event-based asynchronous pattern)
- Begin으로 시작하는 메서드의 대부분(Asynchronous programming pattern)
- PLINQ
Task Parallel Library와 PLINQ에서는 쓰레드 풀을 의식하지 않고 멀티 쓰레드 프로그래밍을 간단히 실행할 수 있어서 상당히 강력합니다. 자세한 내용은 상위 레벨의 쓰레드 처리에서 설명합니다. 간결히 Task 클래스에 대해서 설명을 하자면 Task 클래스를 사용한다는 것은 쓰레드 풀 상에서 Delegate를 실행한다고 하는 것과 같습니다.
쓰레드 풀의 쓰레드를 사용할 때 몇가지 주의해야할 점이 있습니다.
- 쓰레드 풀의 쓰레드에는 이름을 설정할 수 없습니다. 결과로서 디버그가 어려워질 경우가 있습니다.
- 쓰레드는 모두 백그라운드 쓰레드로서 실행됩니다.
- 쓰레드를 블럭하면 ThreadPool.SetMinThreads를 호출하지 않는 한 빠른 단계로 지연이 발생하게 됩니다.
쓰레드 우선 순위에 관해서는 자유롭게 변경할 수 있습니다. 쓰레드의 실행이 종료되어 쓰레드 풀로 돌아가는 타이밍으로 우선 순위는 전부 Normal로 자동적으로 복원됩니다.
현재 실행되고 있는 쓰레드가 쓰레드 풀의 쓰레드인지 아닌지를 확인하기 위해서는 Thread.CurrentThread.IsThreadPoolThread의 값을 취득하면 확인할 수 있습니다.
Task 클래스를 사용하는 것으로 쓰레드 풀의 기능을 간단히 이용할 수 있습니다. Task 클래스는 .NET 4.0 부터 도입된 클래스입니다. .NET4.0보다 이전의 버전에서의 멀티 쓰레드에 상세히 아신다면 Task 클래스는 ThreadPool.QueueUserWorkItem을 이용하는 것과 같은 동작을 하며, Task<TResult> 는 asynchronous delegates과 같은 동작을 합니다. 새로운 Task 클래스를 사용하는 것으로 보다 빠르고 편리하고 유연하게 가능하게 됩니다. Task 클래스를 사용하기 위해서는 Task.Factory.StartNew 메서드에 delegate를 넘기는 것으로 사용할 수 있습니다.
static void Main() // The Task class is in System.Threading.Tasks { Task.Factory.StartNew (Go); } static void Go() { Console.WriteLine ("Hello from the thread pool!"); }
Task.Factory.StartNew 메서드는 Task 객체를 반환합니다. 이 Task 객체를 사용하여 실행 상태를 감시할 수 있습니다. 예를 들면 Wait 메서드를 호출하면 클래스의 실행이 완료될 떄 까지 대기할 수 있습니다.
쓰레드 내부에서 Catch하지 못한 예외는 호스트 쓰레드에서 Wait 메서드가 호출되는 시점에서 다시 Throw 됩니다. Wait 메서드를 호출하지 않는 경우에는 보통의 쓰레드와 같이 어플리케이션이 종료되게 됩니다.
Task<TResult> 클래스는 Task 클래스의 서브 클래스입니다. 이 클래스를 사용하면 태스크의 실행 종료 후에 반환 값을 취득하는 것이 가능합니다. 아래의 예제에서는 Task<TResult> 클래스를 사용하여 WEB 페이지를 다운로드 하고 있습니다.
static void Main() { // Start the task executing: Task<string> task = Task.Factory.StartNew<string> ( () => DownloadString ("http://www.linqpad.net") ); // 병렬로 다른 처리를 실행 RunSomeOtherMethod(); // Result 프로퍼티로부터 실행 결과의 반환값을 취득합니다. // 실행이 완료되지 않은 경우 실행이 완료될 때 까지 대기합니다. string result = task.Result; } static string DownloadString (string uri) { using (var wc = new System.Net.WebClient()) return wc.DownloadString (uri); }
StartNew 메서드에 String을 지정하면 Result 프로퍼티는 묵시적으로 String 형이 됩니다. 쓰레드 내부에서 Catch 되지 못한 예외는 Result 프로퍼티를 읽으려는 시점에서 AggregateException으로 랩핑되어 다시 Throw 됩니다. 하지만 Result 프로퍼티를 읽거나 Wait 메서드의 호출을 실행하지 않는 경우 어플리케이션은 강제적으로 종료됩니다.
Task Parallel Library는 그 외에도 많은 기능을 제공하고 있습니다. 특히 멀티 코어의 효과적인 활용이 가능하도록 최적화 되어 있습니다. 자세한 내용은 상위 쓰레드 처리에서 설명합니다.
.NET4.0 보다 이전 버전을 사용하고 있는 경우, Task Parallel Library를 이용할 수 없습니다. 그래서 다른 방법으로 쓰레드 풀을 이용할 필요가 있습니다. ThreadPool.QueueUserWorkItem이나 Asynchronous delegate 중 하나를 사용해야 합니다. 두 개의 차이점은 Asynchronous delegate는 반환값을 받으면 모든 예외를 호출 지점으로 보내 주는 부분이 다릅니다.
QueueUserWorkItem
QueueUserWorkItem을 사용하려면 쓰레드 풀에서 실행하고 싶은 처리를 delegate로 이 메서드에 넘겨야 합니다.
static void Main() { ThreadPool.QueueUserWorkItem (Go); ThreadPool.QueueUserWorkItem (Go, 123); Console.ReadLine(); } static void Go (object data) // data will be null with the first call. { Console.WriteLine ("Hello from the thread pool! " + data); }
Hello from the thread pool! Hello from the thread pool! 123
WaitCallback delegate의 정의에 맞추기 위해 Go 메서드는 data라고 하는 object 형의 인수를 1개 갖는 메서드가 됩니다. 이것은 메서드에 데이터를 넘기기 위해 편리한 방법입니다. Task와는 달리 QueueUserWorkItem은 이후 실행을 위해 오브젝트를 반환할 방법이 없습니다. 또한 예외 처리를 적절히 구현하지 않아 처리하지 못한 예외가 발생할 경우 프로그램이 자동적으로 종료됩니다.
비동기 Delegate
QueueUserWorkItem은 반환 값을 취득하는 방법은 제공하지 않습니다. Asynchronous delegate는 이 문제를 해결합니다. 각가지 타입의 복수의 인수를 넘기거나, 반환 값을 취득하는 것이 가능합니다. 앞서 말하면 미처리의 예외를 자동적으로 호출한 쓰레드가 EndInvoke를 호출한 타이밍에서 다시 재 Throw 합니다. 그래서 명시적으로 예외의 Catch를 하지 않아도 됩니다.
비동기 Delegate 와 비동기 Method는 차이가 있다는 사실에 주의하시기 바랍니다. (비동기 메서드는 Begin 또는 End로 시작하는 메서드입니다. 예를 들면 File.BeginRead/File.ReadEnd 등이 있습니다)
비동기 메서드는 외견상으로는 상당히 비슷하게 생겼지만, 다른 난해한 문제를 해결하기 위해 존재하고 있습니다.
비동기 Delegate의 시작 방법은 아래와 같습니다.
- 병렬처리를 하고 싶은 메서드의 delegate를 Func 클래스 등으로 인스턴스 화 합니다.
- BeginInvoke를 호출하여 반환 값의 IAsyncResult를 변수로 확보합니다. BeginInvoke를 호출하면 곧 바로 호출한 곳으로 처리가 반환됩니다. 쓰레드 풀의 쓰레드가 처리를 실행하고 있는 동안 다른 처리를 실행하는 것이 가능합니다.
- 반환 값이 필요한 경우 생성된 delegate의 EndInvoke에 미리 확보해 둔 IAsyncResult를 넘겨 호출합니다.
static void Main() { Func<string, int> method = Work; IAsyncResult cookie = method.BeginInvoke ("test", null, null); // // ... 병렬로 무언가의 처리를 실행 // int result = method.EndInvoke (cookie); Console.WriteLine ("String length is: " + result); } static int Work (string s) { return s.Length; }
EndInvoke는 세가지의 일을 실행합니다. 첫 번째는 비동기에서 실행한 메서드가 아직 완료하지 않은 경우 완료할 때 까지 대기하는 것이 가능합니다. 두 번째는 반환값을 취득하여 이용하는 것이 가능합니다. 세 번째는 워커 쓰레드에서 발생된 예외를 자동적으로 호출하는 쓰레드의 지점에서 다시 Throw를 하여 받게 됩니다.
비동기 Delegate 가 반환 값을 갖고 있지 않는 경우에서도 EndInvoke를 호출하는 코드를 의무적으로 작성하는 편이 좋습니다. EndInvoke를 호출하지 않더라도 컴파일러에 나쁜 일을 하는 것은 아닙니다. 다만 EndInvoke를 호출하지 않는 경우 워커 쓰레드에서 예외가 발생하더라도 해당 예외를 Catch를 하지 못하기 때문에 주의가 필요합니다.
BeginInvoke를 호출할 때 Callback 메서드를 지정하는 것도 가능합니다. 지정한 메서드의 인수는 IAsyncResult 타입이며 이곳에서 지정한 메서드는 처리가 완료한 후에 호출합니다. 메인 쓰레드는 호출한 비동기 처리 이후를 신경쓸 필요없이 처리를 실행하면서도, 비동기 처리의 완료 후에 더 몇가지 처리를 실행하고 싶을 때에 이 구조를 활용할 수 있습니다.
쓰레드 풀은 1개의 쓰레드를 가진 상태에서 초기화됩니다. Task가 등록된 최대값을 넘게 되면 풀 관리자가 새로운 쓰레드를 투입하여 병렬로 처리를 실행할 수 있도록 쓰레드의 수가 조절됩니다. 쓰레드가 사용될 필요가 없을 정도로 충분한 시간이 경과하게 되면 풀 관리자는 처리효율을 유지하기 위해 자동적으로 여러개의 쓰레드를 파기하여 풀 내에 있는 쓰레드 수를 조절합니다.
풀이 새로운 쓰레드를 생성할 수 있는 한계를 넘어설 것 같다면 쓰레드의 수를 ThreadPool.SetMaxThreads 에서 지정할 수 있습니다. 기본값은 다음과 같습니다.
- .NET4.0(32bit) 1023
- .NET4.0(64bit) 32768
- .NET3.5 250/1Core
- .NET2.0 25/1Core
최소값의 기본 값은 1개의 프로세서 당 1개의 쓰레드가 준비됩니다. 이에 따라 CPU를 풀로 활용하여 처리를 할 수 있습니다. 하지만 서버 환경의 경우 (ASP.NET과 IIS와 같은) 이 값은 좀 더 크게 50이라던지 큰 값으로 설정하는 경우도 있습니다.
쓰레드의 갯수의 최소값은 어떤식으로 조절되는가?
쓰레드 수의 최소값을 X로 설정하더라도 쓰레드가 X개로 즉시 생성되는 것은 아닙니다. 쓰레드는 필요에 의한 타이밍에 생성됩니다. 정확히 말하자면 풀 매니져는 쓰레드의 생성 요구가 있는 시점에서 쓰레드를 생성하게 됩니다. 쓰레드 풀이 쓰레드가 필요하게 된 시점에서 쓰레드를 생성하지 않아 지연이 발생한다고 한다면 어떤 이유에서 일까요?
이것은 상당히 단시간에 끝나는 처리가 일시적으로 많이 발생할 때에 많은 쓰레드가 할당된 나머지 어플리케이션의 메모리 사용량 증대가 발생하는 것을 방지하기 위해서입니다. 보다 구체적으로 해설하자면 4 코어의 머신에서 클라이언트 어플리케이션을 실행하는 40개의 업무를 한번에 실행하게 됩니다. 만약 각 업무가 10밀리 초의 계산을 수행하기 위해 4개의 코어에 처리가 배정된다고 가정한다면 모든 처리가 완료되기 까지 100밀리 초가 걸리게 됩니다. 이상적으로는 이것들의 40개의 업무가 정확히 4개의 쓰레드에서 실행되는 것이 가장 좋습니다.
- 4 개보다 더 작은 쓰레드 수의 경우, 코어를 최대한 사용하지 않게 됩니다.
- 4 개보다 더 많은 쓰레드 수의 경우, 메모리의 낭비와 불필요한 쓰레드의 생성을 위해 CPU 시간을 낭비하게 됩니다.
ThreadPool.SetMinThreads (50, 50);
메서드의 두번쩨 인수에서 설정하고 있는 값은 I/O 완료 포트에서 응답 대기가 가능한 쓰레드 수를 지정 가능합니다. 이 값은 APM에 의해서 사용됩니다. 기본값을 1 코어 당 1이 됩니다.
역자:
길고긴 쓰레드의 개념의 이야기가 끝났습니다. 드디어 다음 장으로 넘어가게 됩니다.
다음 장은 2. 동기처리의 기초로 설명하겠습니다.
'Programming > Thread' 카테고리의 다른 글
Threading in C# 4.락 (Lock) (0) | 2016.10.30 |
---|---|
Threading in C# 3.동기화 처리의 기초 (0) | 2016.10.27 |
Threading in C# 1.쓰레드의 개념(1) (0) | 2016.10.21 |