"쓰레드? 그건 왜 쓰는데? 백그라운드 프로세스로 여러 개를 동시에 처리하면 되잖아.."라고 얘기하곤 한다. 그래서 이번에는 실제 코드와는 관계없이 쓰레드의 기본 개념에 대해서 설명하고자 한다. 물론 여기서 소개하는 기본 개념은 공통 언어 런타임(이하 CLR, Common Language Runtime), 윈도우, 유닉스와는 전혀 관계가 없다. 이들 운영체제에서 쓰레드를 관리하는 것에 대한 기본을 설명하고자 하는 것이다. 그러니 윈도우에서 이렇게 쓰레드를 처리한다라고 생각하지 말 것을 당부한다. 쓰레드 스케줄링 윈도우 2000의 작업 관리자에서 현재 수행중인 프로세스와 쓰레드의 숫자를 볼 수 있다. 동시에 "저만큼이 과연 실행될 수 있을까? 지금 내 PC에는 CPU가 한 개 뿐인데?" 라는 의문을 가질 것이다. 그리고 가장 많은 설명을 들었던 것이 시분할(Time Sharing)에 의해서 가능하다고 한다. 참고: 정확히 말하면 NT 운영체제는 시분할이 아닌 타임 슬라이싱(Time Slicing)을 사용한다. 이에 대해서는 나중에 논의하겠다. 반면에 Solaris와 같은 운영체제는 시분할을 사용한다. 시분할이라는 것은 CPU의 실행시간을 100이라 보았을 때 10으로 10개를 나누어서 10개의 쓰레드를 차례대로 교환하면서 실행하는 것을 말한다. 현재 사용하는 대부분의 운영체제는 32bit 운영체제이고 따라서 4GB의 어드레싱 영역을 얻을 수가 있다. 각각의 4G 영역을 2G씩 나누어서 상위 2G에는 시스템 코드, DLL, 프로세스 간 공유되는 데이터와 코드가 위치하고, 하위 2G에는 프로그램을 실행하는 프로세스, 응용 프로그램의 바이너리 이미지(실행파일), 응용 프로그램 공유 라이브러리(DLL)의 코드와 데이터가 위치한다. A 프로세스를 잠시 중지하고 B 쓰레드를 실행하는 것과 같이 프로세스와 프로세스 사이에 변환하는 것은 프로세스 문맥 교환(Process Context Exchange)이라 한다. 마찬가지로 하나의 프로세스안에 있는 여러 개의 쓰레드가 서로 실행되는 것을 쓰레드 문맥 교환(Thread Context Switching)이라고 한다. 이와 같이 프로세스와 쓰레드간에 문맥 교환을 어떻게 처리하는 가를 담당하는 것이 멀티태스킹 운영체제의 스케줄러다. 스케줄러는 어떤 쓰레드를 다음에 동작시킬 것인지를 결정한 뒤에 선택한 쓰레드를 동작시켜서 동시에 여러 가지 일을 처리하도록 한다. 즉, 운영체제 스케줄러의 최대 목표는 CPU를 최대한 활용하여 PC의 성능을 최대한 활용하는 것이다. 우선 순위 우선 순위에는 두 가지가 있다. 하나는 프로세스간에 우선 순위를 결정하는 프로세스 우선 순위가 있고, 하나의 프로세스내에 있는 쓰레드간의 우선 순위를 결정하는 쓰레드 우선 순위가 있다. 대부분의 운영체제에서는 우선 순위를 결정하는데 라운드 로빈(round-robin) 스케줄링 알고리즘을 포함하고 있다. 쓰레드는 FIFO(First-In First-Out) 구조의 큐에 들어가며, 스케줄러는 가장 우선 순위가 높은 쓰레드를 차례대로 수행한다. 스케줄러는 이 큐의 쓰레드를 차례대로 수행하면서 점점 낮은 순위의 큐로 이동한다. 만약, 새로 생성된 쓰레드가 현재 스케줄러가 있는 큐보다 우선 순위가 높은 큐에 들어가게 되면, 스케줄러는 우선 순위가 높은 큐로 이동하여 쓰레드를 수행하게 된다. 쓰레드의 우선 순위는 숫자로 표시하며, 이 숫자값은 운영체제마다 다르다. 윈도우 2000의 작업 관리자에서 해당 프로세스에서 마우스 오른쪽 버튼을 클릭하면 다음과 같이 우선 순위를 설정하는 것을 볼 수 있다.
그림에서 볼 수 있는 것처럼 윈도우 2000 서버는 실시간, 높음, 보통 초과, 보통, 보통 미만, 낮음과 같은 프로세스 우선 순위를 설정할 수 있다. 윈도우의 우선 순위는 32를 기준으로 설정한다. 이 숫자값이 높으면 높을수록 우선 순위가 높다. 다음 표를 참고하자.
우선 순위 | 숫자값 | 플래그 | C# 플래그 |
실시간(Time Critical) | 32 | THREAD_PRIORITY_TIME_CRITICAL | |
실시간(Real Time) | 24 | REALTIME_PRIORITY_CLASS | |
최상(Highest) | THREAD_PRIORITY_HIGHEST | ThreadPriority.Highest | |
보통 초과(AboveNormal) | THREAD_PRIORITY_ABOVE_NORMAL | ThreadPriority.AboveNormal | |
보통(Normal) | 7-9 | THREAD_PRIORITY_NORMAL | ThreadPriority.Normal |
보통 미만(BelowNormal) | THREAD_PRIORITY_BELOW_NORMAL | ThreadPriority.BelowNormal | |
낮음(Lowest) | THREAD_PRIORITY_LOWEST | ThreadPriority.Lowest | |
휴지(Idle) | 1 | THREAD_PRIORITY_IDLE |
이름: MultiThread.cs namespace csharp { using System; using System.Threading; class MultiThreadApp { public static void Main() { MultiThreadApp app = new MultiThreadApp(); app.DoTest(); } // End of Main() private void DoTest() { Thread[] aThread = { new Thread( new ThreadStart(DoPrinting) ), new Thread( new ThreadStart(DoSpelling) ), new Thread( new ThreadStart(DoSaving) ) }; foreach( Thread t in aThread) { t.Start(); } } // End of DoTest() private void DoPrinting() { Console.WriteLine("인쇄 시작"); for ( int LoopCtr = 0; LoopCtr < 100; LoopCtr++) { Thread.Sleep(50); Console.Write("p|"); } Console.WriteLine("인쇄 완료"); } // End of DoPrinting() private void DoSpelling() { Console.WriteLine("철자 검사 시작"); for ( int LoopCtr = 0; LoopCtr < 100; LoopCtr++) { Thread.Sleep(50); Console.Write("c|"); } Console.WriteLine("철자 검사 완료"); } // End of DoSpelling() private void DoSaving() { Console.WriteLine("저장 시작"); for ( int LoopCtr = 0; LoopCtr < 100; LoopCtr++) { Thread.Sleep(50); Console.Write("s|"); } Console.WriteLine("저장 완료"); } // End Of DoSaving() } // End of class MultiThreadApp } // end of namespace csharp |
결과에서 볼 수 있는 것처럼 저장, 인쇄, 철자 검사를 시작하고 차례대로 저장, 인쇄, 철자 검사 순으로 완료되는 것을 볼 수 있다. 이제 각각의 쓰레드의 우선 순위를 변경해서 철자 검사를 가장 먼저 끝내고, 인쇄를 가장 마지막에 끝나도록 해보자. 쓰레드 우선 순위는 다음과 같이 설정한다.
someThread.Priority = ThreadPriority.Highest; |
someThread.Priority = ThreadPriority.Normal + 2; // Highest 사용 |
Thread[] aThread = { new Thread( new ThreadStart(DoPrinting) ), new Thread( new ThreadStart(DoSpelling) ), new Thread( new ThreadStart(DoSaving) ) }; // 인쇄 쓰레드의 우선 순위를 낮음으로 설정한다. aThread[0].Priority = ThreadPriority.Lowest; // 철자 검사 쓰레드의 우선 순위를 높음으로 설정한다. aThread[1].Priority = ThreadPriority.Highest; Console.WriteLine("인쇄 쓰레드 우선 순위 : " + aThread[0].Priority); Console.WriteLine("철자 쓰레드 우선 순위 : " + aThread[1].Priority); Console.WriteLine("저장 쓰레드 우선 순위 : " + aThread[2].Priority); |
위에서 알 수 있는 것처럼 각각의 쓰레드에 대해서 쓰레드 우선 순위를 지정할 수 있다. [표1]에서 알 수 있는 것처럼 CLR에서는 5가지의 우선 순위를 지정할 수 있다. CLR은 현재 MS의 윈도우에서만 실행되지만 다른 운영체제에서도 실행될 수 있다. 다른 운영체제에서는 3가지의 우선 순위를 가진다면 C#에서 지정한 우선 순위중에 몇 가지 같은 우선 순위로 지정할 것이다. 다행히도 NT는 7가지의 우선 순위를 가지지만, 윈도우 95/98과 같은 운영체제는 AboveNormal과 BelowNormal 우선 순위를 운영 체제의 다른 우선 순위로 지정할 것이다. 쓰레드 우선 순위와 스케줄러 멀티 쓰레드 응용 프로그램에서 쓰레드에 대해서 다른 우선 순위를 지정하여 실행 순서를 다르게 할 수 있다. 그러면 같은 우선 순위를 가진 쓰레드가 여러 개 있는 경우에는 어떤 쓰레드가 우선 순위를 가지게 될까? 이 경우에는 어떤 쓰레드가 우선 순위를 갖는 다고 보장할 수 없다. 이것은 전적으로 호스트되는 OS의 쓰레드 스케줄러에 달려있다. CLR은 같은 우선 순위를 갖는 쓰레드가 공평하게 실행된다고 보장하지 않는다. 같은 순위의 쓰레드를 차례대로 실행할 수도 있고, 무작위로 실행할 수도 있다. 심지어는 제어권을 양보한 쓰레드를 다시 실행할 수도 있다. OS의 쓰레드 스케줄러가 동일 우선 순위를 갖는 쓰레드를 어떻게 스케줄링하든지 간에 한 번 실행된 쓰레드가 다시 실행되는 것을 방지하려면 쓰레드의 루프안에 Sleep()을 사용하도록 해야 한다. 탐욕 쓰레드(Selfish Thread) 지금까지 글을 읽은 독자중에 자바와 같은 언어에서 쓰레드 프로그래밍을 한 경험이 있다면 이상하다고 여기는 부분이 있을 것이다. 지금까지 필자가 만든 예제들은 모두 한 가지 작업을 할 때마다 제어권을 양보하고 있다. 마지막에 만든 코드의 실행결과를 살펴보아도 각각의 쓰레드 우선 순위가 Highest, Normal, Lowest인데도 불구하고, 실행 순서와 종료 순서가 바뀐 것 이외에는, "c", "p", "s"가 사이 좋게 번갈아가며 찍히는 것을 보았을 것이다. 각각 다른 우선 순위를 갖는 쓰레드가 사이좋게 제어권을 양보하면서 차례대로 실행될 수 있는 것은 각각의 루프안에 있는 Sleep() 때문이다. Sleep() 함수가 없는 쓰레드의 실행시간이 길다면 홀로 스레드 실행시간을 독차지 하는 것을 볼 수 있다. 이와 같은 쓰레드를 탐욕 쓰레드라고 한다. 탐욕 쓰레드를 알아보기 위해 각각의 Do 함수의 Thread.Sleep()을 지우고, 루프의 횟수를 2000회로 늘렸다(이와 같이 루프를 늘리는 것은 시스템에 따라 정상적으로 쓰레드 교환(Switching)이 일어나더라도 한 번에 200∼270회의 출력을 할 수 있어 교환이 일어나는 것을 관찰할 수 없기 때문이다). 다시 한 번 전체 소스를 살펴보도록 하자.
이름: Selfish.cs namespace csharp { using System; using System.Threading; class MultiThreadApp { public static void Main() { MultiThreadApp app = new MultiThreadApp(); app.DoTest(); } private void DoTest() { Thread[] aThread = { new Thread( new ThreadStart(DoPrinting) ), new Thread( new ThreadStart(DoSpelling) ), new Thread( new ThreadStart(DoSaving) ) }; aThread[1].Priority = ThreadPriority.Normal + 2; foreach( Thread t in aThread) { t.Start(); } foreach( Thread t in aThread) { t.Join(); } Console.WriteLine("모든 쓰레드가 종료되었습니다"); } private void DoPrinting() { Console.WriteLine("인쇄 시작"); for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++) { Console.Write("p|"); } Console.WriteLine("인쇄 완료"); } private void DoSpelling() { Console.WriteLine("철자 검사 시작"); for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++) { Console.Write("c|"); } Console.WriteLine("철자 검사 완료"); } private void DoSaving() { Console.WriteLine("저장 시작"); for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++) { Console.Write("s|"); } Console.WriteLine("저장 완료"); } } } |
// aThread[1].Priority = ThreadPriority.Normal + 2; |
private void DoPrinting() { Console.WriteLine("인쇄 시작"); for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++) { Console.Write("p|"); Thread.Sleep(1); } Console.WriteLine("인쇄 완료"); } private void DoSpelling() { Console.WriteLine("철자 검사 시작"); for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++) { Console.Write("c|"); Thread.Sleep(1); } Console.WriteLine("철자 검사 완료"); } private void DoSaving() { Console.WriteLine("저장 시작"); for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++) { Console.Write("s|"); Thread.Sleep(1); } Console.WriteLine("저장 완료"); } |
private void DoSpelling() { Console.WriteLine("철자 검사 시작"); for ( int LoopCtr = 0; LoopCtr < 1000000; LoopCtr++) { Console.Write("c|"); } Console.WriteLine("철자 검사 완료"); } |
정적 멤버 | 설명 |
CurrentContext | 현재 실행중인 쓰레드 컨텍스트를 가져온다. |
CurrentThread | 현재 실행중인 쓰레드의 참조를 가져온다. |
GetData() SetData() | 쓰레드의 현재 도메인에 대해서 실행중인 쓰레드의 특정 슬롯의 값을 설정하거나 가져온다. |
GetDomain() GetDomainID() | 현재 쓰레드가 실행중인 도메인에 대한 참조를 가져온다. |
Sleep() | 현재 실행중인 쓰레드를 일정 시간 동안 중지시킨다. |
인스턴스 멤버 | 설명 |
IsAlive | 쓰레드가 시작된 이후로 살아있는지를 알려준다. |
IsBackground | 쓰레드가 백그라운드 쓰레드인지 아닌지를 설정하거나 알려준다(ThreadState.Background). |
Name | 쓰레드의 이름을 설정하거나 가져올 수 있다. |
Priority | ThreadPriority 열거형에 정의된 쓰레드 우선 순위를 정의하거나 알려준다. |
ThreadState | ThreadState 열거형에 정의된 쓰레드의 상태를 알려주는 읽기 전용 프로퍼티 |
Abort() | 쓰레드를 죽일 때 사용한다. 이 메소드를 사용하면 ThreadAbortException 예외가 발생한다(ThreadState.Aborted, ThreadState.AbortRequested). |
Interrupt() | 현재 쓰레드를 중지시킨다. |
Join() | 현재 쓰레드가 완료될 때까지 기다린다(ThreadState.WaitSleepJoin). |
Suspend() | 쓰레드를 잠시 대기상태로 만든다(ThreadState.Suspended, ThreadState.SuspendRequested). |
Resume() | 대기 상태로 만든 쓰레드를 다시 활성상태로 만든다. |
Start() | ThreadStart에 위임한 쓰레드 실행을 시작한다(ThreadState.Unstarted, ThreadState.Running). |
이전 글 : 왜 윈도우 대신 명령라인을 사용해야 하는가?
다음 글 : 외로운 파이썬
최신 콘텐츠