about c# thread · 2015-01-22 · 스레드, 그리고 java와 c# by 한동훈 이 논의는...

204
About C# Thread 스레드, 그리고 Java 와 C# - p2 C# 쓰레드 이야기: 1. 쓰레드는 무엇인가? - p19 C# 쓰레드 이야기: 2. 다중 쓰레드 p24 C# 쓰레드 이야기: 3. 쓰레드 제어 p30 C# 쓰레드 이야기: 4. 쓰레드 기본 개념 p38 C# 쓰레드 이야기: 5. NT vs UNIX p54 C# 쓰레드 이야기: 6. 쓰레드 예외 처리 p60 C# 쓰레드 이야기: 7. C#으로 만드는 WinTop p73 C# 쓰레드 이야기: 8. 동기화 p94 C# 쓰레드 이야기: 9. 임계 영역 p101 C# 쓰레드 이야기: 10. 뮤텍스(Mutex) p120 C# 쓰레드 이야기: 11. 이벤트(Event) p132 C# 쓰레드 이야기 - 12. 식사하는 철학자 p142 C# 쓰레드 이야기 - 13. Interlocked, Heap p170 C# 쓰레드 이야기 - 14. 마지막 이야기 p182 1

Upload: buikien

Post on 05-May-2019

214 views

Category:

Documents


0 download

TRANSCRIPT

About C# Thread

스레드, 그리고 Java 와 C# - p2

C# 쓰레드 이야기: 1. 쓰레드는 무엇인가? - p19

C# 쓰레드 이야기: 2. 다중 쓰레드 – p24

C# 쓰레드 이야기: 3. 쓰레드 제어 – p30

C# 쓰레드 이야기: 4. 쓰레드 기본 개념 – p38

C# 쓰레드 이야기: 5. NT vs UNIX – p54

C# 쓰레드 이야기: 6. 쓰레드 예외 처리 – p60

C# 쓰레드 이야기: 7. C#으로 만드는 WinTop – p73

C# 쓰레드 이야기: 8. 동기화 – p94

C# 쓰레드 이야기: 9. 임계 영역 – p101

C# 쓰레드 이야기: 10. 뮤텍스(Mutex) – p120

C# 쓰레드 이야기: 11. 이벤트(Event) – p132

C# 쓰레드 이야기 - 12. 식사하는 철학자 – p142

C# 쓰레드 이야기 - 13. Interlocked, Heap – p170

C# 쓰레드 이야기 - 14. 마지막 이야기 – p182

1

스레드, 그리고 Java 와 C#

By 한동훈 이 논의는 스레드에 대한 이야기와 이 스레드를 지원하고 있는 Java와 C#언어에 대한 얘기들이다.

중간중간에 코드 조각들을 갖고 얘기를 하지만, 실행되는 완전한 코드는 아니며, 완전한 실행을 위해서는

약간의 코딩이 필요하다. Trax : 오늘 얘기하려고 하는 건 Java나 C#이 어떤 언어인지, 둘 중에 어떤 것이 더

좋다는 얘기를 하려는 것은 아닙니다. 스레드에 대해서 얘기하려고 합니다. C# 이전에 스레드를 언어에서

지원하는 것은 Java 뿐이었죠. 프로그래밍 언어는 크게 명령형 언어와 함수형 언어로 나눌 수 있습니다. C

언어와 같은 구조적 프로그래밍, 또는 절차적 프로그래밍을 지원하는 언어들은 함수형 언어입니다. APL과 같은

언어는 진정한 함수형 언어로 볼 수 있습니다. 예… 그만큼 APL로 작성된 코드는 판독하기가 어렵습니다.

프로그래밍 언어에 있어서 유명한 저자인 코넬도 단 4 줄의 APL 코드를 분석하는 데 4 시간이 걸렸다고

털어놓았던 적이 있으니까요. 명령형 언어는 요즘에 많이 볼 수 있는 객체 지향 언어들을 말합니다. C++, Java,

C#과 같은 언어가 명령형 언어이고, C++보다는 Java나 C#과 같은 언어가 명령형 언어에 더 가깝습니다. 제가

말하는 명령형 언어와 함수형 언어는 일반적인 프로그래밍 언어의 구분과는 조금 다릅니다. 그러니까 LISP 같은

언어는 논의에 두지 않고 있는 거죠. 어쨌든 논의에서 제외한 LISP과 같은 언어를 빼면 명령형 언어나 함수형

언어 모두 전통적인 폰 노이만 구조를 갖고 있습니다. 폰 노이만 구조라는 건 메모리에 데이터와 코드를 모두

저장하고, CPU에서 데이터와 코드를 조금씩 가져와서 차례대로 처리하는 것을 말합니다. 한 번에 조금만

가져올 수 있으니까 전체를 처리하려면 같은 일을 몇 번은 반복해야겠지요. 그래서 폰 노이만 구조라는 건

메모리에 데이터와 코드를 배정하고, CPU에서 연산을 처리하고, 이 같은 일을 반복하는 루프를 갖는 구조를

말합니다. 약간 사담이지만, 어떤 특정 언어의 습득에만 의미를 두는 것 보다는 폰 노이만 구조와 프로그래밍에

대해서 한 번은 공부해두면 조금은 시야가 넓어진다고 생각합니다. 다시 논의로 돌아와서 대부분의 프로그래밍

언어는 폰 노이만 구조를 갖고 있기 때문에 프로그래밍 방법론이 근본적으로 갖다고 할 수 있습니다.

배정(Allocation), 연산(operation), 반복(loop)이라는 구조를 갖기 때문에 대부분의 프로그래밍 언어는

비슷합니다. 조금 본질을 벗어나면 폰 노이만 구조외에 병렬 처리를 위한 적용(Adaptive) 언어도 있습니다.

그러면 왜 전통적인 폰 노이만 구조를 따르는 언어들 중에 Java와 C#과 같이 프로세스 제어, 즉 스레드를

지원하게 되었는지 궁금하지 않습니까? Java라는 언어가 나타나기 이전의 컴퓨팅 환경은 단일 태스크 작업

환경이었고, 그 이후의 시대에는 윈도우와 같은 멀티 태스크 작업 환경이 보편화된 것이 이유입니다. 사실대로

말하면 Java는 UI와 실제 처리를 위해서 스레드를 필요로 하기 때문이지만…. 어쨌든 그 이후로는 응용

프로그램이 DOS와 같은 단일 태스크 환경에서 윈도우와 같은 멀티 태스크 환경으로 옮기는 것 뿐만 아니라

2

멀티 태스크 환경을 이용한 스레드 제어의 필요성이 생겼다는 거죠. 이 때문에 윈도우 개발자들 사이에서

스레드는 최고의 유행어가 되었던 때도 있었죠. 하지만 언어 자체에서 스레드를 지원하는 것이 아니기 때문에

OS에서 제공하는 기능을 빌려 쓰거나, 직접 스레드를 제어하는 비표준 API를 만들어 써야 했습니다. 만약에

다른 OS로 옮긴다면 스레드와 관련된 부분은 다시 작성될 필요가 있었던 거죠. 그래서 이런저런 이유로 새로운

언어는 프로세스 제어 기능이 언어 설계에서 요구되고, 새롭게 설계되는 언어들은 프로세스 지향적인 성격을

띄게 되었습니다. 이러한 프로세스 지향적인 언어로는 Java, C#, Ada95, Perl이 있습니다. 멀티 태스크

환경에서 사용자는 더 많은 것들을 하기를 원하지만, 그것이 어떻게 돌아가는지는 신경 쓰지 않죠. 반대로

개발자는 그것이 어떻게 돌아가는지 신경 써야 하구요. -_-. 이러한 배경으로 인해서 스레드 프로그래밍을

일반적으로 하게되고, OS에서 빌려 쓰거나, Java와 같은 언어를 사용해서 스레드를 구현합니다. 스레드를

사용하는 가장 대표적인 예가 워드 프로세서일 것입니다. 문자를 입력하는 동안 백그라운드 작업으로 출력을 할

수 있고, 문법 검사를 수행하고 파일을 저장할 수 있습니다. 새로운 브라우저 창을 만들어도 새로운 프로세스가

시작하는 것이 아니라 하나의 스레드만 추가되기 때문에 공통되는 자원을 공유할 수 있고, 적은 메모리로 여러

사이트를 돌아 다닐 수도 있는 거겠죠. 스레드를 이해하지 못하는 프로그래머의 코드는 지금과 같은 멀티

태스크 환경에서 스레드를 이용하는 코드보다 수행 성능이 떨어질 수 밖에 없고, 보다 많은 PC 자원을

독점합니다. 다시 말해, 스레드는 응용 프로그램이 보다 효율적으로 실행되고, 자원(특히, CPU 사이클)을

독점하지 않는 프로그램을 작성할 수 있도록 해주며, 보다 고급 기술을 익힘으로써 프로그래머 자신의 가치를

높일 수 있을 겁니다. 그리고 프로그래밍 언어 설계도 이제는 프로세스 지향이라는 새로운 패러다임을 향해

나아가고 있다는 것이죠. 4baf : 얘기는 잘 들었습니다. 예, 자바는 스레드를 언어 차원에서 지원하는 첫번째

언어죠. 그리고 C#도 마찬가지로 스레드를 언어 차원에서 지원합니다. 하지만 자바보다 C#의 스레드 지원이

더 풍부합니다. 파이썬도 스레드를 지원합니다. 프로세스 제어 가능 API라는게 무슨 말인지 잘 몰라서 자바,

C#의 그것과 같은지 정확하게 말은 못하겠네요. 아무튼, 파이썬도 자바처럼 프로그래밍이 됩니다.

class PyClass(Thread) :

def __init__(self) :

Thread.__init__(self)

def run(self) :

작업

3

대강 이런 식이었던 것 같습니다. 동기화 부분은 공부를 안해서 모르겠네요. 사실 자료도 구할 수 없었습니다.

자바에서 스레드를 대부분의 프로그래머가 이용하지 않는다고 하셨는데, 아마 자바 스윙을 사용하는 사람들은

대부분 이용합니다. 스윙이 워낙 느립니다. 정말 상상하기도 싫을 정도로… 그 덕에 별도의 스레드에서

일찌감치 GUI 객체들을 생성해 놓지 않으면 다운된건지 아닌지 알 수가 없습니다. JSP/서블릿이나 EJB 도,

기본적으로 모두 스레드로 돌아갑니다. 초보들은 JSP 가 스레드에 의해서 돌아간다는 것도 모르지만,

자동적으로 스레드에 의해서 실행 되죠. 초보자들은 그래서 자원에 대한 동기화를 안하고 결과적으로 진짜 CPU

두 개 달린 시스템으로 옮기면 완전히 죽어버리는 코드를 생산하게 됩니다. Trax : 제가 얘기하고자 하는 논지를

완전히 벗어나셨군요. -_-. 미들티어에서 COM/DCOM, MTS 와 통합된 COM+ 모두 스레드를 생각하지 않고는

얘기할 수 없습니다. 마찬가지로 Java 쪽의 미들티어 기술은 EJB 에서 스레드를 빼고 생각하는 것은 말도

안됩니다. 또한 모든 웹 어플리케이션 서버들은 모두 스레드 기반하에 돌아갑니다. 이것은 웹이 갖고 있는

특성에 기인합니다. (ColdFusion, JRun Server, ASP, JSP, PHP, 탱고) 제가 하고자 하는 말은 이러한 것을

얘기하는 것이 아니고 Java 와 C#을 비교하자는 것도 아닙니다. 언어의 계보를 따져보면 CPL -> BCPL -> B -

> C -> C++ -> Java -> C# 이 될 겁니다. 또는 C++의 다른 갈래로 C#이 될 것입니다. 물론 제가 얘기하고

싶은 것은 이것도 아닙니다..!! -_-. 프로세스와 스레드에 대해 깊이 아는 것은(예... 알고 보면 별거 없지만)

프로그래머의 기본 소양으로 자리잡아 갈 것이라는 겁니다. 현대 프로그래밍에서 짚고 넘어갈 기본 테마는

스레드와 네트워크입니다. 네트워크 없이는 말도 할 수 없는 환경이니 네트워크는 기본인데... 왜 스레드인가?

라는 거겠죠. 이것은 멀티 태스크 OS 의 등장과 그에 따른 응용 프로그램의 필요성의 대두입니다. 그리고

지금까지 많은 개발자들은 OS 의 스레드 처리 기능을 가져와서 편하게 사용하기 위해 저마다 고유의

라이브러리를 만들어 왔습니다. 그리고 일부는 이것을 라이브러리로서 판매해왔습니다. (Sheridan 의 Active

Thread 같은 라이브러리가 잘 알려져 있습니다.) 그러나 이것은 너무나 반복적인 작업이기 때문에 이제는 언어

설계 자체에 프로세스처리가 반 되고 있다는 얘기입니다. 물론, 가상 머신을 갖고 있으니 스레드 지원이 더

쉬운 점도 있겠지요. 앞으로 기업환경에서 스레드에 대한 요구는 점점 더 많아질 것이고, 배경지식이 무르익을

것입니다. 나중에는 프로그래머 지망생들은 당연히 스레드에 대해서 배우지 않을까요. 사실 Linux 나 Unix 에서

프로그래밍을 처음 공부한 사람들은 입문과정에서 프로세스와 스레드에 대해서 학습합니다. 이제 새롭게

프로그래머가 되는 사람들은 윈도우와 같은 멀티 태스크 OS 만을 사용한 세대들이죠. 그러므로 멀티 태스크와

스레드에 대한 이해가 자연스러운 것이 됩니다. 바꿔 말하면 저와 같은 도스 세대들은 화장실에서 똥 누면서

양치질 하면서 신문을 보는 엽기적인 행각을 벌이는 멀티 태스킹을 제대로 이해하지 못하고 상상도 하지

못합니다. 그리고 실제로 접하면서 이것이 도스와 다른 점을 깨닫고 멀티 태스킹에 대해 이해하려고 하지요. 즉,

4

멀티 태스크에 대해 장황한 말이 필요합니다. 하지만 처음부터 윈도우와 같은 멀티 태스크 OS 를 사용한

세대들에게는 당연한 것이죠. 마찬가지로 스레드와 같은 기술이 VC++에서도 있었지만, 사용의 어려움 때문에

널리 쓰이지 못했고, 꼭 필요할 때만 울며 겨자 먹기 식으로 쓰는 기술이다라는 인식이 더 강했습니다. 그리고

그 수준조차 초등학교 수준이랄까... 수박 겉핥기를 벗어나지 못합니다. 그러나 앞으로의 언어들은 프로세스

지향적인 부분을 갖게 되고, 프로세스와 스레드는 기본이 될 것이고, 개발자가 이러한 것들을 처리하기위해

비슷비슷한 것들을 반복해서 개발하는 일에서 탈피할 수 있다는 것입니다. 예... 이것은 전적으로 제 개인적인

생각입니다. VB.NET 도 프로세스와 스레드에 대한 처리를 지원합니다. (CLR 때문이라 생각하고 별 것 아니라고

치부할지 모르지만...) 앞으로의 모든 언어들은 프로세스 지향적인 성격을 갖게 됩니다. 분명 현재 베타버전, 즉

Perl 6 을 릴리스하기 위한 단계에 있는 Perl 조차 스레드를 지원하고 있습니다. 최근에 개발된 언어들은

Eiffel(1992), Java(1995), Ada95(1995), C# & VB.NET(2000)입니다. Eiffel 과 Ada95 는 제가 사용해본 적이 없는

언어입니다. 그러나 이 두 가지를 제외하고도 다른 언어들은 모두 프로세스 지향적인 성격을 갖고 있습니다.

이제 좀 감이 잡히는지요? 프로그래밍 언어 설계의 패러다임도 프로세스 지향으로 가고 있습니다. 제가

얘기하는 것은... 네트워크, 스레드를 빼고는 앞으로 좋은 프로그래머가 될 수 없다는 얘기이고, 먼 시일을

내다보면 이들 프로그래밍도 점점 보편화된다라는 것입니다. 동기화는 네트웍과 프로세스 모두에서 중요한

주제입니다. 특별히 동기화를 주제로 빼고 싶은 생각은 없습니다. 어떤 언어를 쓰거나 상관하지 않습니다.

네트웍이나 프로세스 둘 중에 하나라도 깊이 있게 원론적으로 알 정도로 파고들라는 얘기입니다. 왜 프로세스와

스레드라고 말하는가? 뭐... 스레드라면 단순하지만... C#을 보면 프로세스 자체를 제어할 수 있는 모니터,

뮤텍스를 클래스 차원에서 지원하고 있습니다(너무 단순하고 쉬워서 황당할 정도지요. 물론 기존 프로세스와

스레드 프로그래밍과 비교해서이지만). 즉, 스레드는 이제 기본입니다. 그 이상을 나아가서... 모니터, 모니커,

뮤텍스까지 자세히 알라는 얘기입니다. 지금까지도 프로그래밍 언어는 3~4 년에 하나씩 등장하고 있습니다.

예... 미안하게도 스크립트형 언어는 프로그래밍 언어라는 관점에서는 주목을 받지 않습니다. 왜냐면 그것은

로직을 편하게 하기 위한 개념이고, 주요 특징들은 컴파일러형 언어에서 모두 다 나타나기 때문입니다. 더

엄밀히 말하자면 C 언어에 대해서도 C 컴파일러는 C 언어 가상 컴퓨터라고 얘기합니다. 컴파일러는 소스코드를

기계어로 번역할 뿐입니다. 제가 얘기하는 것은 Java 와 C#이 주는 의미는 앞으로의 경향을 단적으로 보여주는

것이라는 것입니다. 언어 습득에 급급해서 나무만 보지 말고 언어 전체의 숲을 보라는 얘기입니다. 프로세스와

스레드를 더 깊이 들어가보면 하나의 부모 프로세스 밑에 자식 스레드들이 있는 경우에 부모 프로세스에서

자식 스레드들간의 정보 교환이나 관리하는 역할을 합니다. 예로, 어머니가 자신의 귀여운 자식들인 고추와

딸기가 티격태격 싸우면 뜯어 말리기도 하고, 딸기는 방 청소하고, 고추는 유리창 닦기를 시키라고 하는 것과

5

같습니다. 하지만 옆집에 있는 어머니와 직접 얘기하지 못하기 때문에 전화를 이용한다든가 하는 점에서는

프로세스간에 직접 통신을 하지 못하기 때문에 IPC(Inter-Protocol Change)와 같은 방법으로 간접적으로

통신하는 것과 비슷할 것입니다. 그러나 응용 프로그램의 범위를 벗어난 통신은 불가능합니다. -_-. C#은

AppDomain 을 통해서 응용 프로그램의 범위를 벗어난 통신이 가능합니다. 그러나 저 조차도 이 분야에

대해서는 깊이 있는 지식이 없어서 이것으로 무엇이 가능한가에 대해서는 상상조차 하지 못합니다. 예...

프로세스와 스레드의 전문가들은 벌써 생각하고 있는 것들이 있는 것 같은데 얘기를 안해주는군요. -_-. 언어

자체에서 응용 프로그램 도메인 범위까지 넘나들 수 있는 것은 C#밖에 없습니다. parting : Trax 님과 4baf 님의

얘기는 잘 들었습니다. 저는 멀티 스레딩에 대해서 얘기해 보죠. 멀티 스레딩이라는 것은 이런겁니다. 제가

자주 써먹는 예제지만.. 은행구좌의 금액을 읽어와서 입금한 금액을 더해서 다시 저장하는 과정이 하나의

쓰레드라고 보고 코드로 쓰면 이런식으로 될 겁니다.

clientBalance = Balance.getBalance();

clientBalance += deposit;

Balance.setBalance(clientBalance)

근데, 실제로 은행에서는 이 일들이 하나가 아니라 수십개 이상 독립적으로 구좌를 갱신할거라는 거죠. 그러니

일은 멀티스레딩으로 일어난다는 것. 스레드는 각각 독립적으로 작업을 수행해야 하고, 같은 객체를 공유할

수도 있다는 거죠. 같은 객체에 공유 접근하는게 유용하면서도, 사실 에러의 근본 원인이 된다는 겁니다. 즉 두

스레드가 거의 동시에 같은 데이터를 수정하다가 그 데이타 객체가 행여나 잘못되면 망한다는 거죠 -_-.;; 예를

들면, 하나의 구좌에 입금을 하고 있는 와중에 다른 사람이 다른 은행원에게 같은 구좌에 또 입금을 원한다고

하면 즉, 저번 작업을 하고 있는 도중에 다른 작업의 접근이 또 이뤄지면 헷갈릴 수가 있습니다. 그래서 나중

작업만 기록이 되어버리는 사태가 발생할 수 있습니다. 은행에서는 이럴 경우 "작업중이니 기다리쇼"라고

팻말을 붙여 놓겠죠...아마도.. 컴퓨터도 마찬가지로 객체를 잠궈버려서 접근을 막는 식으로 작동을 합니다.

멀티 스레딩은 하나의 어플리케이션의 수행간 즉, 같은 프로세스에서 동작을 더 세분화한다고 보면 됩니다.

하나의 스레드가 입력을 받는 동안 다른 스레드는 그전에 입력 받은 걸 계산해서 화면이 출력하는 루틴을

돌린다거나 이렇게 하면 된다는 거죠.. 자바에서 스레드는 표준 클래스로 정의되어 있답니다. 스레드 객체를

생성하면 되죠... ping, pong 예제가 유명할겁니다..(유명한가? -_-.;;)

class PingPong extends Thread {

String word;

6

int delay;

PingPong(String WhatToSay, int delayTime) {

word = WhatToSay;

delay = delayTime;

}

public void run() {

try{

for(;;) {

System.out.print(word+"");

sleep(delay);

}

} catch(InterruptedException e){

return;

}

}

public static void main(Stringp[] args) {

new PingPong("ping", 33).start();

new PingPong("Pong", 100).start();

}

}

PingPong 이라는 스레드형을 정의합니다. 루프를 돌리면서 delayTime 동안만 대기합니다. 출력을 하면

핑퐁핑퐁 그러는데 갈수록 퐁의 빈도가 낮아질겁니다. 딜레이 타임이 다르니까... 쓰레드 객체를 만든 다음에

start 하면 쓰레드 인스턴스를 만들고.. run 메쏘드를 호출해서 쓰레드가 작동한답니다.. 이외에도 동기화, 즉

synchronized method 를 호출하면 객체가 잠금 상태가 되서 서스펜드모드로 들어가는, 즉, 두개의 스레드가

상호배타적으로 수행되게 해주는 게 있습니다. 아까 은행원 얘기에서 잠깐 메모 붙여서 일을 기다려서 하게

하는 그걸 말하는 겁니다. 이를테면 이렇게 되겠죠..

7

class Account {

private double balance;

public Acccount(double initialDeposit) {

balance = inintialDeposit;

}

public synchronized double getBalance() {

return balance;

}

public synchronized void deposit(double amount){

balance += amount;

}

}

이런 식으로 될 겁니다. 그 다음에는 스레드 스케줄링에 관한 것이 있을 것이고.... 스레드마다 우선순위를 주고

수행시간을 할당해줄겁니다....^^;; 운 체제가 해주든지 자바 가상머신(VM)이 해주든지.. 누군가는 해줄겁니다.

-_-.;;; 그 외에는 스레드 서스펜드(대기), 스레드 인터럽트가 있습니다. 또 다른 것으로는 Runnable

Interface 를 참조해서 구현하는 것도 있습니다. 자바에서 제공하는 것은 스레드 보안과 디버깅 정도입니다.

Trax : parting 님의 멀티 스레딩과 Java 에서의 처리에 대한 얘기 죠. 저는 하던 업무가 주문, 재고, 창고... 이

개념이기 때문에... 동시 다발적으로 일어나는 주문(Order)에 대해 재고를 가감하는 경우에 재고에 대한

락(Lock)이 필요합니다(Rock 이 아닙니다... 돌... 돌... 돌... 요즘 해코님이 골이 좀 비었다고 성급하게 돌을

채운다고 하지요. -_-.). 하지만 이 락은 간단히 쓰기는 편한데... 많아지면... 복잡하고 엄청난 노가다가 됩니다.

또한 어디서 락을 해야 하고 풀어야 하는지를 프로그래머가 결정해야합니다. 하지만 Java 나 C#이나

InterLocked 클래스에서 제공하는 것은 그저 숫자형 변수를 넘겨받아서 값을 증가시키거나 감소시키는

역할밖에는 하지 않습니다. 예... Java 는 아직 저수준에 머물러 있어서 보다 고급스러운 건 직접 개발해야

합니다. 반면에 C#은 코드 블록을 적절히 묶어서 처리할 수 있도록 해줍니다. 이러한 것을 위해서 모니터

클래스를 제공하지요... -_-. 즉, 정수값을 바꾸는 정도가 아니라 문자열을 갖고 있든지 뭐든지간에 해당

블록안의 값들의 변경에 대한 락을 알아서 지원하는 겁니다. 단지, Monitor.Enter()와 Monitor.Exit() 만으로

말이지요. -_-. (얼마나 황당하고 편합니까... 써드 파티에서는 몇 백만원씩 받으면서 이런걸 판매하고 있는데...

Sheridan 의 ActiveThread 가 아주 유명하지요. -_-.) 스레드 프로그래밍 해야 하는데 VC++이나 C++처럼

8

OS 에서 빌려와서 할래?(하는 거에 비하면 프로그래머가 해야 할 게 너무 많음.) 아니면 Java 나 C#은

자체적으로 지원하니까 쉽거든 이거 할래? 라는 문제가 되겠죠. 언어를 선택할 수 있는 상황이라면 Java 나

C#을 선택할 겁니다. 4baf: 코드 블록을 묶는다는 말에 있어서 물어볼게 있어요. 자바도 메소드 단위로

synchronized 하는 것 말고

class ThreadDemo {

private aObject = new Object();

public void doJob()

{

이것저것하기...

synchronized(aObject) {

동기화 부분

}

}

}

하면 코드블록을 처리할 수 있잖아요. 이것과 다른 것을 의미하는 건가요? 그리고 다른 얘기 하나 더, EJB 를

쓰면 스레드 동기화까지 알아서 되는데, 어플리케이션 개발자 입장에서 그런 컨설턴트가 필요한가요? Trax :

스레드에서의 동기화 관점에서라면... -_-. C#과 Java 모두 Interlocked 클래스를 사용합니다. 하지만 이것은

하나의 변수 정도만 동기화를 유지하는데 사용할 수 있는 수단입니다. 문자열이나 객체를 담고 있는 것을

동기화 할 수는 없습니다. 또한 모든 것을 일일이 동기화하기 위해 같은 코드를 사용하면 코드가 상당히

지저분해 지겠지요. -_-. 그래서 이보다 한발 더 나아가서... Lock 객체를 통한 Lock 을 지원합니다. 즉,

Interlocked 는 값을 하나 바꾸는 데는 유용하지만 그 이상에서는 유효하지 않습니다(코드만 지저분하게

만들뿐이지요). 그래서 C#에서는 lock 을 사용해서 다음처럼 코딩 할 수 있습니다.

public void Increment()

{

try

{

while( counter < 1000)

9

{

lock(this)

{

// 여기에 동기화할 코드를 위치

int temp = counter;

temp++;

Thread.sleep(1000);

counter = temp;

} // end of lock

} // end of while

} // end of try

catch ( ThreadInterruptedException)

{

Console.WriteLine("Thread {0} interrupted!...", Thread.CurrentThread.Name);

} // end of catch

}

이 정도와 같을까요? 단순히 lock() 만으로 가능해집니다. Java 의 synchronized 와 비슷하지요. 하지만 보다

복잡한 프로그램에서 보다 정교하게 자원을 제어할 필요가 있다면 이것만으로 충분할까요? (저는 Java 가

스레드를 지원해도 이 부분에서는 기존의 언어들이 OS 에서 빌려오는 것 못지않게 복잡하고 어렵다고 생각하고

있습니다. -_-.) 예를 들어 보통의 스레드를 이용한 응용 프로그램들은 t1 스레드가 A 작업을 요청했는데 이미

a1, a2 와 같은 작업이 기다리고 있습니다. 그러면 보통의 경우에는 a1, a2, t1 의 순서로 스레드가 쌓입니다.

결국 t1 스레드는 여기서 병목을 일으키며 순서를 기다리게 됩니다. 그리고 다른 일을 할 수 있어도 하지 않게

됩니다. 만약 A 라는 작업이 처리시간이 긴 작업이라면 이 응용 프로그램은 여기서 모두 병목 현상을 일으키게

되고, 거의 모든 스레드는 여기서 작업을 기다리게 됩니다. 미들 티어에서 이렇게 된다면 어떻게 될까요?

그러면... 스레드가 어떤 작업을 요청했을 때 이미 사용중이라면 여기서 기다려야 할지, 다른 작업을 먼저

10

처리한 다음에 다시 돌아와서 작업을 처리할 것인지를 제어할 수 있어야합니다. 첫 번째 순차작업만을 하던

사람은 스레드를 접하면 작업 시간을 단축할 수 있다는 데에 매료되고 적용하게 됩니다. 그러나 시간이 지나면

이러한 문제점이 하나씩 나타나게 됩니다... 심지어는 평소에는 스레드간의 이동 작업과 같은 부하가 미미한

작업조차도 동시에 엄청나게 생성되는 스레드 숫자로 인해서 시스템이 반응조차 하지 않을 정도로 죽어버리고,

대부분의 CPU 사이클를 스레드의 작업 처리가 아니라 스레드의 변환작업에 소비하게 되는 현상을 겪게 됩니다.

모니터는 동기화에 있어서 동기화를 할 부분과 하지 않을 부분을 프로그래머가 결정할 수 있도록 해줍니다.

또한, 다른 코드 역이 해제가 될 때까지 기다릴 수 있도록 해줍니다. 일종의 smart lock 의 개념이

모니터입니다. 모니터를 사용할 수 없으면, 모니터가 보호하고 있는 객체가 사용중이 됩니다. lock() 대신에

Monitor.Enter(this); 와 같이 쓸 수 있습니다. 그리고 제어문들을 통해서 Monitor.Pulse(), Monitor.Exit(),

Monitor.Wait()등으로 세세한 제어를 할 수 있습니다. 특히, Monitor.Pulse(this); 와 같은 구문을 통해 스레드의

상태 변경을 알 수 있습니다. 즉, 현재 free 인지 waiting 인지를 알아낼 수 있고 적절한 작업을 지시할 수

있습니다. 이외에도 수작업으로 스레드 생성과 동기화를 할 수도 있습니다. -_-. 경쟁 조건이나 교착 상태 등을

제어할 수 있다는 거겠지요. -_-. 현재 Ultra Editor 등을 보면 파일이 외부에서 변경된 것을 알아냅니다. 심지어

삭제된 것도 알아내고 질문을 합니다. 이것이 의미하는 것은? 즉 스레드의 처리입니다. 파일의 처리자체가 멀티

태스크 OS 에서는 중요한 문제가 되는 것입니다. -_-. 옛날 버전의 Ultra Editor 가 이런 것을 알았을까요?

모릅니다. -_-. 어떤 스레드가 파일을 열려고 하고, 어떤 스레드는 파일을 쓰려고 한다. 그러면 이도 저도

못하고 교착상태에 빠지겠지요? 어쨌거나 이러한 것을 간단하게 할 수 있다는 것입니다. Java 에 비하면 더

세세하게 제어할 수 있다고 생각합니다. 물론, 모니터까지 사용하는 사람은 많지 않을 겁니다. 지금까지는

모니터와 같은 구현은 직접 하거나 서드 파티에서 라이브러리를 구입하거나 하는 둘 중 하나 고 직접

구현하는 것도 어려운 것은 마찬가지입니다. -_-. C#은 이걸 쉽게 만들었으니 더 많이 쓰지 않을까라는

생각입니다. 객체 지향 언어에서 자주 부딪히는 문제에 대해서 다음과 같은 디자인 패턴으로 스레드 동기화

문제를 다룰 것입니다.

• 상호 배제(mutual exclusion) 데이터나 코드와 같은 시스템 자원이 동시에 둘 이상의 스레드에 의해서

안전한 방법으로 접근될 수 없을 때 사용하는 방법입니다.

• 단일 허용 관문(single admission gate) 동시에 하나의 스레드만이 특정 코드 역(specific code

area)을 실행하도록 하지만 같은 코드를 수행하려는 다른 스레드를 막지 않습니다. 대신에 보호된 코드

역에 들어가려고 하는 스레드는 바로 그 함수로부터 반환됩니다.

11

• 경계적 대기(alertable wait) 위에 제가 예로 든 것입니다. 일반적으로 스레드는 자원이 사용 가능할

때까지 기다려야 하지만, 경우에 따라서는 대기 상태(waiting)에서 자원이 사용 가능할 때 까지

기다리기 전에 리턴해야 할 필요가 있을 때 사용합니다. -_-.

• 제한된 자원에 대한 경쟁(race condition) 아마 가장 고전적인 문제인데 식사하는 철학자 문제로 잘

알려져 있습니다. 테이블에 5 개의 스파게티 접시와 5 개의 포크가 있습니다. 5 명의 철학자가 있고

철학자는 식사하는 것과 생각하는 것만 할 수 있으며, 스파게티를 먹기 위해선 두 개의 포크를

사용해야 합니다. 제한된 철학자만 식사를 하는 식으로 루프를 돌면 엄청나게 많은 불필요한 CPU

자원을 소비하게 될 것이고, 모든 철학자가 오른쪽 포크를 들면 왼쪽 포크를 들 수 있을 때까지

기다리게 되는 교착상태에 빠지게 될 겁니다. 예, 여기서 등장하는 개념이 뮤텍스입니다.

• 생산자와 소비자 문제(producer/consumer) 이것은 효율을 극대화하기 위해 다루는 문제입니다.

생산자는 최대한의 데이터를 생산하고, 소비자는 최대한 데이터를 소비해야 합니다. 그리고 이들

생산자와 소비자는 전혀 별개의 독립되어 있어야 합니다. 그렇지 않다면 생산자가 데이터를 생산하는

동안 소비자는 아무것도 못하고, 소비자가 데이터를 소비하는 동안 생산자는 아무것도 생산하지 못하게

될 것입니다. (반대로 생각하면 소비할게 없어서 소비자가 놀고, 생산할게 없어서 생산자가 논다고

생각해보시길...) 이러한 생산자와 소비자사이에 데이터를 주고 받는 메커니즘이 필요합니다. (얼마를

생산했다, 얼마를 소비했다.. 소비할 데이터가 얼마가 필요하다 등등.) 이것은 단일 생산자와 단일

소비자 문제와 다중 생산자와 다중 소비자 문제로 깊이 있게 나누어집니다.(물론, 단일 생산자와 다중

소비자, 다중 생산자와 단일 소비자로 있습니다. 총 4 가지의 조합 가능한 문제)

자자... 스레드에서 이 정도 디자인 패턴이 '기초'에 해당합니다. 더 깊이 들어가 볼까요? 객체와 스레드의

동기화의 문제가 생깁니다. 내부에서 동기화하고, 클라이언트(즉 객체를 이용하는 어플)에서는 동기화에 관여를

못하게 할 것인가(내부 동기화), 아니면 객체에서 동기화를 제공할 것인가(외부 동기화)의 문제가 있고 이것

역시 어떤 경우냐에 따라 다릅니다(답이 없음). 데이터 저장은 단일 스레드로 통일할 것인가, 다중 스레드로

분할하여 처리할 것인가라는 문제가 있고, 저장소는 정적 스레드 로컬 저장소를 사용할 것인가, 동적 스레드

로컬 저장소를 사용할 것인가...(즉, 정정 TLS 와 동적 TLS 문제로 나뉘게 됨) 그리고 이러한 것들을 뛰어넘어

스레드 풀을 구현하게 됩니다. 보통의 경우에 이러한 스레드 풀은 MTS 나 COM+에서 제공하게 됩니다. 그리고

이것은 CLR 에서도 마찬가지입니다. 스레드 풀의 구현이 필요 없습니다. 그러나 만약 스레드풀의 구현이

필요하다면? (온라인 게임 사이트나 모바일 관련 기기의 과금 시스템을 위한 Proxy 를 VC++ 이나 Solaris 에서

12

C++로 개발하게 되는 상황이라면? ) 멀티 스레드를 지원하는 DLL 의 작성과, 멀티 스레드 인터페이스까지 갈

수 있겠지요. (즉, 작업이 많아도 화면 반응이 좋고, 빨리 빨리 동작하는 것처럼 보이게 만드는 거겠지만) 멀티

스레드 GUI 역시 상당히 방대한 내용을 담고 있습니다.(말처럼 간단하지도 않고 생각처럼 간단하지도

않습니다.) 이제 개발이 종반에 이르면 어떤 문제가 생길까요? 멀티 스레드 응용 프로그램은 디버깅이 상상을

초월할 정도로 어렵습니다. PC 를 두 대 연결해서 Remote Debugging 을 해야 할 정도랍니다. -_-. 예외 처리도

어렵지요... 그에 비하면 C#은 상당히 쉬워서... 그 정도의 고민은 안해도 됩니다. ^^ 하지만 그래도 고민은

남는게.... 어떤게 주 스레드인지 찾아내는 문제 그리고 지금 내가 어떤 스레드를 디버깅하고 있는지를 알아야

한다는 거지요. A 라는 작업을 동시에 처리하는 스레드가 10 개 있는데 그 중에 하나가 문제를 일으킨다면?

이러한 많은 문제들이 발생하게 되고, 프로세스와 스레드에 대해서 자세히 알지 않고 응용 프로그램에 적용하게

되면 위와 같은 문제들이 동시에 발생하기 때문에 스레드는 어려운 것이라는 인식이 있습니다. 4baf: Trax 님

말씀 잘 들었습니다. 말씀 중에서 나온 소비자/생산자 문제 등 4 가지인가 예를 드신 패턴들은 어떤 책을

통해서 전문적으로 공부 할 수 있나요. 소개 부탁드립니다. 또 한가지, C#에서 쓰레드 프로그래밍시 예외처리가

간단하다고 했는데 자바의 그것에 비해 저는 C#의 예외처리가 별로 안좋다고 생각하거든요. 자바는

RuntimeException, 혹은 그것의 derived exception 을 제외하고 모든 메소드는 그것이 스스로 catch 하지 않는

exception 에 대해서 throws 절을 추가해야 합니다. 하지만 C#은 그렇지 않죠 예를 들면

public void thisMethodThrowsException() thorws new SQLException {

thorw new SQLException();

}

이런 식으로 구현해야만 해요. 그리고 이 메소드가 던지는 예외는 쭉 위로 올라가다가 어느 시점에서든 반드시

catch 가 되어야 합니다. 안그러면 컴파일타임 에러죠. 제가 C#은 catch()를 반드시 할 필요가 없던 것

같은데요. 그렇다면 어떤 예외를 던져지는지 명확한 검사가 이루어지지 않고 나중에 문제발생 소지도 커지는 것

같은데, 어떻게 생각하세요. Trax 님께서 말씀하신 그 예외처리의 강력함이 자바와 비교하여 어느 것이 더

낫다고 하신 것인지 잘 이해를 못하겠어요. 그리고 제가 약간의 태클을.. 위에 예로 드신 코드를 자바로

짜볼께요.

public void Increment()

{

try

13

{

while( counter < 1000)

{

synchronized(this)

{

// 여기에 동기화할 코드를 위치

int temp = counter;

temp++;

Thread.sleep(1000);

counter = temp;

} // end of lock

} // end of while

} // end of try

catch (InterruptedException ie)

{

System.out.println("Interrupted at : " + new Date());

} // end of catch

'모니터'란 용어의 정확한 의미는 모르지만 자바역시 C# 과 거의 다르지 않게 구현할 수 있습니다. 위처럼요.

this 인스턴스에 대한 모니터 객체를 획득하죠. 물론 wait() notify() notifyAll() 과 같은 거도 가능하지요. 이

메소드들은 java.lang.Object 에 정의되어 있으며 Object클래스는 C#의 object처럼 모든 클래스의

parent입니다. 모든 클래스가 이용 가능하죠. 다만 pulse()같은 메소드는 없습니다. C#이 다양한 쓰레드 지원

클래스가 있다는 것은 알고 있습니다. 자바에는 그게 부족하죠. 하지만 기본적인 모니터는 위처럼 지원한답니다.

JAVAONE 같은 데 보면 외국 사람들이 많든 mutex클래스를 가지고 세미나도 하더군요. 썬측에 이런

클래스들을 지원해달라고 요청도 하구요. Trax: 4baf님이 물어보신 별도의 책은 없습니다. 디자인 패턴에 대한

것들은 parting님이 잘 아시기 parting님께 물어보도록 하죠. 참고가 될지는 모르겠지만 『Win32 멀티스레드

프로그래밍』이 있고, 여기서 많은 기본 지식을 얻을 수 있을 것 같습니다. C#으로 된 책은 있을 리 없겠고,

14

디자인 패턴은 Java가 비교적 광범위하게 되어 있습니다. Java가 전문이시니 Java 관련서로 보는 게 좋을 것

같습니다. 쉽게 볼 수 있는 거로는 『VIsual Basic Design Patterns』가 있습니다. 4baf님이 얘기하신 것처럼

C#은 throw를 반드시 명시할 필요는 없지만 C#도 throw를 지원합니다. Java의 경우도 C#과 비슷하지만 모든

예외는 Exception 클래스에서 파생된 것이고, 이것들은 모두 동일합니다. 즉, 다른 점은 없습니다. 메소드,

속성도 같고, 하는 동작도 같습니다. 다만 InvalidCastException과 같이 이름만 다를 뿐입니다. 즉, 이러한 예외

처리의 목적은 어떤 것에서 예외가 발생했는가를 알아내기 위한 것일 뿐입니다. 다시 말하면, 여기서 걸러지지

않으면 다시 상위로 올라간다는 것입니다.

public void thisMethodThrowsException() thorws new SQLException {

thorw new SQLException();

}

>

즉, 위의 Java 코드와 같이 throws new 를 장황하게 써가면서 선언문을 길게 하지 않아도 됩니다. 중간에

얼마든지 원하는 예외를 잡기위해 catch 를 여러번 쓸 수 있습니다.

try {

if ()

{

throw new exception ("망할~ 에러났잖아~!");

}

}

catch ( InvalidCastException e)

{}

catch ( Exception e)

{}

15

이와 같이 예외를 던질 수도 있고, 다른 예외를 처리할 수도 있습니다. 이것은 C# 코드입니다. Exception 은

가장 상위입니다. 즉, 모든 예외는 Exception 에서 잡을 수 있지만, 그렇지 않고 InvalidCastException 에서만

잡고 싶다면 그렇게 할 수 있습니다. 어차피 나머지는 건너 뛰게 됩니다.

public void Increment()

{

try

{

while( counter < 1000)

{

synchronized(this)

{

// 여기에 동기화할 코드를 위치

int temp = counter;

temp++;

Thread.sleep(1000);

counter = temp;

} // end of lock

} // end of while

} // end of try

catch (InterruptedException ie)

{

System.out.println("Interrupted at : " + new Date());

} // end of catch

'모니터'란 용어의 정확한 의미는 모르지만 자바역시 C# 과 거의 다르지 않게 구현할 수 있습니다. 위처럼요...

this 인스턴스에 대한 모니터 객체를 획득하죠. 물론 wait() notify() notifyAll() 과 같은 거도 가능하져. 이

16

메소드들은 java.lang.Object 에 정의되어 있으며 Object 클래스는 C#의 object 처럼 모든 클래스의

parent 입니다. 모든 클래스가 이용가능하죠. 위 코드는 제가 C#에서 작성한 lock()에 대해서 4baf 님이 Java 로

다시 작성하신 겁니다. C#에서 더 풍부하다고 얘기했던 부분은 synchronized(this) 이 부분에서 동기화가

들어가면 어디서 나올 수 있는가? 라는 것입니다. 단지 선언된 시점에서부터 모든 것이 동기화에 들어가는

것이지요 (제가 잘못 이해하고 있는 건지? -_-.) 하지만 C#에서는 언제 어디서나 들어갔다 나올 수

있습니다(Monitor.Enter, Monitor.Exit). 그리고 스레드가 대기 상태에서 자유가 되었는지, 자유 스레드가 대기

상태가 되었는지를 알아낼 수 있는 Pulse() 등을 쓸 수 있어서 스레드를 보다 손쉽게 정교하게 제어할 수

있습니다. -_-. Java 에서 mutex 클래스를 구현한 것도 있다고 하셨는데, 사실은 mutex 뿐만 아니라 스레드와

그에 따른 문제점들을 보다 효율적으로 처리하기 위한 디자인 패턴들이 있습니다. 4baf : 예, 말씀하신 것은

알겠습니다. Java 의 synchronized 가 C#의 lock 이라 생각하면 됩니다. Java 에는 모니터 클래스라는 게 따로

있지는 않습니다. 제가 말한 C#의 exception 단점은, 그 exception 을 반드시 catch 하지 않아도 된다는

것입니다. 자바는 반드시 메소드내에서 발생한 에러를 catch 하거나 혹은 throws 를 씀으로써 catch 하지

않겠다고 명시해야 합니다. public static void main() 메소드 내에서는 throws 를 명시할 수 없고, throws 로

선언된 메소드들을 호출한 경우 반드시 catch 해야 합니다. 즉 프로그램내에서 어디선가 exception 이 반드시

처리된다는 의미이고, C#은 그렇지 않습니다. 콘솔 어플리케이션이나 엔터프라이즈 솔루션을 C#으로 만든 경우

예외하나 catch 안했다가 시스템 exit()될겁니다. 자바의 쓰레드에 관한 제 두개의 코드중 첫번째 aObject 를

synchronized 시킨 경우는 aObject 에 대한 모니터를 획득했고, 두번째 코드는 this 인스턴스에 대한 모니터를

획득했습니다. 각각 aObject, this 에 대한 모니터를 획득하고자 하는 코드들은 해당 블럭 ( {로 시작하고 }로

끝나는)을 실행중인 쓰레드가 있는 동안은 synchroznied 블럭 앞에서 대기하게 됩니다. 이런 면에서 C#의

lock 과 동일합니다. Nuthack : perl 의 멀티 쓰레드 예제 입니다..-_-.;; (Trax : 드디어 등장하셨군요.. ^^)

use Thread;

my $t1 = new Thread &start_sub;

my $t2 = new Thread &start_sub;

$t1->join;

$t2->join;

17

while ( 1 )

{

print "I am the main threadn";

sleep 1;

}

sub start_sub

{

my $tid = Thread->self->tid;

while ( 1 )

{

print "I am thread $tidn";

sleep 1;

}

}

4baf : 혹시 디자인패턴 자료 알고 계시면 좀 알려주세요. 제가 본 패턴책은 『Design Patterns Java

Companion』입니다. 이 책은 Java와 관련된 소스로 꽉 찬 책이었죠. parting : 4baf님이 보신 것 외에 주로

스몰토크 관련 사이트를 가면 많습니다. 스몰토크가 순수 객체 지향을 추구하다 보니 디자인 패턴 연구가

활발했었다는… 메이저급 스몰토크 개발환경 개발사들에 가면 자료 꽤 있습니다. 제가 추천하는 자료는

www.object-arts.com(돌핀 스몰토크 만드는 회사)에 있는 튜토리얼하고, www.aw.com(애디슨 웨슬리입니다.

설마 모르는 사람이야 없겠지)에 가면 『design pattern with smalltalk companion』이라는 책에 실린 디자인

패턴 예제들이 PDF 문서로 들어있더군요. 저는 디자인 패턴이 심각하게 필요한 사람이 아니라서 사실 별

관심은 없습니다. 스몰토크 공부하다가 우연히 봤는데 Trax님 말을 들어보니 '아하, 저게 그렇게 중요한

거 구나~'하는… -_-;; 저는 디자인 패턴을 그냥 빵굽는 틀로밖에 생각을 안하기 땜에.. -_-; 제 목표는

빵공장이 아니라 집에서 구워먹는 맛있는 빵인 관계로 ^^;; Nuthack : 상호 배제(mutual exclusion)라는 게

뮤텍스가 아닌가요? Trax : 예, 상호 배제(mutual exclusion)가 뮤텍스(mutex) 맞습니다. 이것도 디자인 패턴을

이용해서 개발자가 구현을 쉽게 할 수 있습니다(말은 쉽지요... -_-.). 그래서 실제로 직접 구현하는

프로그래머들도 많습니다. 일반적으로 OS가 제공하는 뮤텍스는 자신들의 API에 적합하게 구현한 것이지요.

18

이것은 경쟁 조건에 있는 자원들에 대해서 루프를 돌면서 순차적으로 처리하느라 CPU 자원을 소모하는 대신에

일정 개수의 토큰을 발급하는 것입니다.. 식사하는 철학자 문제도 역시 이러한 토큰의 발급... 즉, 토큰을

받으면 식사를 하고 토큰이 없으면 생각하도록 하는 것입니다.

정리 꽤 긴 시간동안의 토의 던 것 같습니다. 특별히 내릴만한 결론은 없었던 것 같지만, 스레드에 대한

얘기와 디자인 패턴, 그리고 Java 와 C#에서 구현하는 스레드에 대해서 좋은 얘기를 나눈 것 같습니다. 부디

이 토론이 저희 토론자 뿐만 아니라 다른 분들에게도 도움이 되고, 감을 줄 수 있었으면 좋겠습니다. 주요

논의자 4baf - OCP, Java Programmer 이며, 현재는 C#으로 닷넷 환경에서 웹 서비스를 이용한 솔루션 개발

parting - TFT 팀 팀장이며, 주요 관심사는 스몰토크와 디자인 패턴이며 현재는 닷넷 웹 서비스 솔루션을 위한

설계를 하고 있음. Trax - Solaris System Administrator 로 일했으며, EIP 솔루션 개발을 했으며, 소일거리로

관심을 갖고 있는 분야는 C#이며 프로그래밍 이론 전반에 대해 관심을 갖고 있다. 현재는 무위도식중. Nuthack

- 진짜 필명은 '골빈해커'이며, 열렬한 Perl 지지자로 이 세상 모든 것을 다시 Perl 로 만들고 싶다고 한다. 주로

Python, Ruby, Perl 과 같은 스크립트 언어를 다룬다. Dicajohn - 삼성 소프트웨어 멤버십 회원이며, Java 와

모바일 프로그래밍이 주요 관심사다. 이번 기사 '스레드, 그리고 Java 와 C#'는 앞으로 우리나라를 이끌어갈

차세대 프로그래머들이 스레드에 대해 수 시간에 걸쳐 메신저로 논의한 내용을 한동훈님께서 정리한 토론

내용입니다.

19

C# 쓰레드 이야기: 1. 쓰레드는 무엇인가?

by 한동훈([email protected]) 요구사항: .NET Framework SDK beta 2 우리가 흔히 사용하고 있는 OS는

'멀티 OS'라고 한다. 이것의 의미는 동시에 여러 가지 작업을 한다는 것을 뜻한다. MP3 를 들으며 워드를

작성하면서 인터넷 서핑을 할 수 있다. 이때 각각의 응용 프로그램은 하나의 프로세스를 갖는다. 그러니까 MP3

플레이어도 하나의 프로세스이고 워드 프로세서도 하나의 프로세스이고, 인터넷 브라우저도 하나의

프로세스라는 뜻이다.

반면에 쓰레드는 프로세스를 여러 개로 나눈 조각과 갖다고 설명할 수 있다. 워드

프로세서를 사용하는 경우를 예로 들자. 워드에서 글자를 입력하는 동안 파일을

디스크에 저장하고 있고, 내용을 프린터에 출력하고 있고, 입력하는 동안에 맞춤법

검사를 수행한다. 사용자의 입력을 받는 동안 행하는 이 모든 작업들은 각각의

쓰레드에 의해서 이루어진다. 글자를 입력 받는 쓰레드, 파일을 디스크에 저장하는

쓰레드, 출력할 내용을 프린터에 보내는 쓰레드, 입력하는 동안 맞춤법 검사를

수행하는 쓰레드 등이 있다. 즉, 워드 프로세서라는 큰 프로세스 하나에 여러 개의

쓰레드가 모여있는 것이다. 실제로 프로세스는 하나의 어드레스 공간을 갖고 있고,

모든 응용 프로그램은 메인 응응 프로그램을 위한 하나의 쓰레드를 갖는다. 그리고

여기에 다른 쓰레드들이 함께 수행될 수 있고, 각각의 쓰레드들은 자신을 관리하는

프로세스의 어드레스를 갖고 있다. 즉, 프로세스는 쓰레드에 대한 일종의 컨테이너역할을 한다. 쓰레드를

이용하면 얻을 수 있는 이점은 무엇인가? 사실 필자는 쓰레드를 그다지 이용하지 않기에 잘 모르지만, 다른

사람들의 얘기를 들어보면 잘 쓰면 명약이요, 잘못 쓰면 독약인 것이 쓰레드라고 하는 것 같다. 쓰레드를

이용하면 하나의 프로그램에서 한 번에 하나의 일을 처리하는 것이 아니라 동시에 많은 일을 처리할 수 있다는

장점이 있다. 뿐만 아니라 같은 일을 더 빠른 시간안에 처리할 수 있을 것이다. 처리 시간이 오래 걸리는

작업에 대해서 쓰레드에게 처리를 맡기고, 다른 일을 계속해서 처리할 수도 있는 것이다. 다음 그림을 보도록

하자.

C# 에센스

20

그림에서처럼 하나의 프로세스에서 처리해야하는 세 가지의 작업 A, B, C 가 있고 각각의 처리시간이 위의

길이와 같다고 할 경우에 첫번째와 같이 순차적으로 처리하는 경우보다는 두 번째와 같이 쓰레드를 이용하여

동시에 처리하는 것이 처리시간이 더 짧다는 것을 알 수 있을 것이다. 이러한 쓰레드의 위력은 많은 동시

사용자를 처리하는 환경이나 한 번에 많은 작업을 처리하는 응용 프로그램에서 그 위력을 발휘할 것이다. 이제

간단한 쓰레드 프로그램의 예제를 살펴보도록 하자.

SimpleThread.cs

using System;

using System.Threading;

public class Tester

{

public static void Main()

{

Tester t = new Tester();

t.DoTest();

}

public void DoTest()

{

21

Thread t1 = new Thread(

new ThreadStart(WorkerThreadMethod) );

Console.WriteLine("쓰레드를 생성");

t1.Start();

Console.WriteLine("쓰레드 시작을 요청 받았음");

}

public void WorkerThreadMethod()

{

Console.WriteLine("WorkerThreadMethod 시작했음");

}

}

각각의 코드를 살펴보도록 하자.

using System;

using System.Threading;

System은 C#에서 응용 프로그램 개발에 있어서 필요하며 여기서는 Console.WriteLine을 사용하기 위해

선언했다. 중요한 것은 두 번째 문장으로 쓰레드 프로그래밍을 하기 위해서는 System.Threading 네임

스페이스를 선언하기만 하면 된다. (아… 깜빡한 사실이 있는데, C#에서는 쓰레드를 지원한다. Java의 쓰레드와

비슷하다고 생각하면 된다. C#과 Java의 쓰레드 지원에 대한 논의는 쓰레드, 그리고 Java와 C#을 참고하기

바란다)

public class Tester

{

public static void Main()

22

{

Tester t = new Tester();

t.DoTest();

}

Tester 라는 임시 클래스를 만들었고 Main() 함수를 선언했다. 여기서는 Main() 함수에 직접 함수를 두지 않고

DoTest()와 같은 별도의 함수를 만들어서 처리하고 있다. Main()은 응용 프로그램의 진입점(entry point)으로

사용하고, 실제 처리 코드는 별도의 함수로 만들어서 처리하는 것을 권한다.

Thread t1 = new Thread(

new ThreadStart(WorkerThreadMethod) );

여기는 쓰레드를 생성하는 부분이다. t1 이라는 쓰레드를 생성하고, 쓰레드에서 호출할 함수는

ThreadStart(WorkerThreadMethod)와 같이 사용한다. WorkerThreadMethod()는 함수이므로

ThreadStart(WorkerThreadMethod())와 같이 사용해도 된다. 주의할 점은 ThreadStart 에 지정하는 함수는

반드시 void 함수이어야 한다는 것이다.

t1.Start();

쓰레드의 시작을 요청한다. Start() 함수를 만나기 전까지 쓰레드는 시작되지 않는다는 것도 알아두자.

public void WorkerThreadMethod()

{

Console.WriteLine("WorkerThreadMethod 시작했음");

}

쓰레드가 사용하는 함수를 정의한 부분이다. 위에서 알 수 있는 것처럼 쓰레드에 의해서 호출되는 메소드는

반드시 void 형으로 정의해야 한다. 다음에는 여러 개의 쓰레드를 동시에 처리하는 멀티쓰레드에 대해서

알아보도록 하자. 그때까지 심심하더라도 잘 지내기 바란다.

23

C# 쓰레드 이야기: 2. 다중 쓰레드

by 한동훈([email protected]) 지난 시간(1. 쓰레드는 무엇인가?)에는 간단한 쓰레드를 생성하는 방법에

대해서 살펴보았다. 이번에는 동시에 여러 개의 쓰레드를 다루는 다중 쓰레드에 대해서 알아보자. 여러 개의

쓰레드를 이용하는 프로그램을 작성하는 것은 쉽다. 원하는 수 만큼 쓰레드를 생성하는 프로그램을 작성하기만

하면 된다. 여기서는 하나의 응용프로그램이 세 개의 쓰레드를 갖고 있고, 각각의 쓰레드는 인쇄하기, 저장하기,

철자 검사하기 작업을 시뮬레이션 한다고 가정한다.

namespace csharp

{

using System;

using System.Threading;

csharp 라는 네임 스페이스를 만든다. 기본 라이브러리인 System 네임 스페이스와 쓰레드를 사용하기 위해

System.Threading 네임 스페이스를 선언한다.

class MultiThreadApp

{

public static void Main()

{

MultiThreadApp app = new MultiThreadApp();

app.DoTest();

}

클래스 명은 MultiThreadApp 로 하고 Main() 메소드에서 MultiThreadApp 클래스의 인스턴스를 하나 생성한다.

그리고 실제 코드는 DoTest() 함수에서 처리하기 때문에 Main()에는 간단한 코드만이 필요하다.

private void DoTest()

{

Thread t1 = new Thread(

new ThreadStart(DoPrinting) );

24

Thread t2 = new Thread(

new ThreadStart(DoSpelling) );

Thread t3 = new Thread(

new ThreadStart(DoSaving) );

t1.Start();

t2.Start();

t3.Start();

}

먼저 각각의 쓰레드 t1, t2, t3 를 만들고, 각 쓰레드에는 DoPrinting, DoSpelling, DoSaving 메소드를 위임한다.

쓰레드의 시작을 위해 각 쓰레드의 Start()를 호출한다. 이제 각각의 쓰레드를 시작하도록 했으므로 각각의 함수

DoPrinting DoSpelling, DoSaving 을 살펴보도록 하자.

private void DoPrinting()

{

Console.WriteLine("인쇄 시작");

for (int LoopCtr = 0; LoopCtr < 100; LoopCtr++)

{

Thread.Sleep(120);

Console.Write("p|");

}

Console.WriteLine("인쇄 완료");

}

먼저 쓰레드를 이용하는 메소드는 void 타입이어야 한다고 설명했었다. 즉, 반환값을 갖지 않는다. 인쇄 시작을

출력하고 100 번의 루프를 돌도록 한다. Thread.Sleep(120)은 쓰레드에게 120ms 동안 기다리라는 것을

의미하고, 진행중임을 알기 위해 여기서는 루프를 돌 때마다 p|을 출력한다. 그리고 모든 작업이 끝나면 '인쇄

완료'를 출력한다. DoSpelling(), DoSaving()도 이와 같은 역할을 하는 코드이며, 쓰레드를 이용해서 동시에

처리하고 있음을 설명하기 위해 Thread.Sleep()의 시간을 각각 다르게 지정했다. 전체 소스는 다음과 같다.

25

<B<MULTITHREADAPP.CS< b>

namespace csharp

{

using System;

using System.Threading;

class MultiThreadApp

{

public static void Main()

{

MultiThreadApp app = new MultiThreadApp();

app.DoTest();

}

private void DoTest()

{

Thread t1 = new Thread(

new ThreadStart(DoPrinting) );

Thread t2 = new Thread(

new ThreadStart(DoSpelling) );

Thread t3 = new Thread(

new ThreadStart(DoSaving) );

t1.Start();

26

t2.Start();

t3.Start();

}

private void DoPrinting()

{

Console.WriteLine("인쇄 시작");

for ( int LoopCtr = 0; LoopCtr < 100; LoopCtr++)

{

Thread.Sleep(120);

Console.Write("p|");

}

Console.WriteLine("인쇄 완료");

}

private void DoSpelling()

{

Console.WriteLine("철자 검사 시작");

for ( int LoopCtr = 0; LoopCtr < 100; LoopCtr++)

{

Thread.Sleep(100);

Console.Write("c|");

}

Console.WriteLine("철자 검사 완료");

27

}

private void DoSaving()

{

Console.WriteLine("저장 시작");

for ( int LoopCtr = 0; LoopCtr < 100; LoopCtr++)

{

Thread.Sleep(50);

Console.Write("s|");

}

Console.WriteLine("저장 완료");

}

}

}

이 코드의 컴파일은 다음과 같이한다.

csc /t:exe /out:Multi.exe MultiThreadApp.cs

코드를 실행하면 결과는 다음과 같다.

결과에서 알 수 있는 것처럼 파일을 디스크에 저장하는 작업을 가장 먼저 시작하고(t1), 그 다음에 인쇄

작업(t2), 철자 검사(t3)를 시작한 것을 알 수 있다. 그러나 각각의 작업을 뜻하는 알파벳을 출력하도록 한

결과를 유심히 살펴보면 s|c|s|p|s|c 와 같이 각각의 작업이 뒤죽박죽으로 섞여서 동시에 처리되고 있다는

28

것을 알 수 있을 것이다(Thread.Sleep() 함수에 각각 다른 지연 시간을 지정했기 때문에 이와 같은 결과를

나타낸다. 이 부분을 제거하면 각각의 작업은 같은 비율로 처리 시간을 갖게 될 것이다). 또한 가장 마지막에

시작했던 철자 검사 작업이 두 번째로 끝난다는 것을 알 수 있다. 쓰레드를 동시에 처리하는 것을 조금 더

탐구하고 싶다면 각 Do 함수의 루프 카운터와 Thread.Sleep()의 지연 시간을 바꿔가면서 테스트 해보기 바란다.

다음은 위 예제에서 각각의 쓰레드를 생성해서 처리하던 것을 쓰레드의 배열로 만들어서 보 다 간결하게

쓰레드를 처리하는 예제다. 위 코드에서 DoTest() 함수만 변경되었다.

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();

}

}

다음 단계 다음 시간에는 쓰레드를 종료하는 방법과 예외처리, 백그라운드 처리와 같은 방법에 대해서 알아볼

것이다. 쓰레드에 대한 기본 개념을 코드를 통해서 충분히 연습하고 익힌 다음에는 다중 쓰레드 응용

프로그램에서 발생할 수 있는 많은 문제들에 대해서 살펴볼 때 많은 도움이 될 것이다. 천리길도 한 걸음부터…

우리가 아직 오를 계단은 많이 남아있다. 다음 계단을 오를 때 까지 코드를 변형해보면서 테스트하고, 보다

많은 것들을 살펴보고자 한다면 온라인 도움말을 찾아보기 바란다. 궁금한 사항이 있다면 게시판이나

전자우편으로 질문하기 바란다.

29

C# 쓰레드 이야기: 3. 쓰레드 제어

편집자주: 모든 코드는 .NET Framework beta2(1.0.2419)와 .NET Framework RC(1.0.3328.4)에서 테스트되었다.

지난 시간(2. 다중 쓰레드)에는 여러개의 쓰레드를 생성하는 방법에 대해서 간단히 살펴보았다. 생각처럼

어렵지 않았을 것이다. 단순히 여러개의 쓰레드를 생성하고 각각의 함수를 위임하고 실행하면 그만이었을

것이다. 쓰레드 기다리기(Joining Thread) 일반적인 쓰레드 처리는 코드의 수행과 상관없이 계속해서 실행된다.

만약, 여러분이 쓰레드를 이용해서 긴 시간 동안 처리해야 하는 작업을 맡았고, 그 작업이 끝나면 결과를

받아서 화면에 출력을 하거나 계산을 하는 프로그램을 작성한다고 생각해보자. 이 경우에 쓰레드에서 처리하고

있는 작업이 끝나기도 전에 프로그램은 이미 출력이나 계산을 수행하는 코드를 실행하게 된다(아마도 에러를

만나게 될 것이다). 이러한 경우에 화면에 출력하거나 계산을 수행하기 전에 쓰레드가 끝나는 것을 기다려야

한다.

먼저, 쓰레드를 기다리지 않을 경우에 어떤 일이 일어나는지 알아보기 위해 지난

시간에 사용했던 코드를 이용해 보도록 하자. 지난 시간에 사용한 코드에서

다음부분을 찾아서 수정한다(빨간색으로 표시된 부분).

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();

}

C# 에센스

30

aThread[1].Interrupt();

Console.WriteLine("모든 쓰레드가 종료되었습니다");

}

또한 모든 Do 함수에서

Thread.Sleep(50);

과 같이 모두 같은 시간동안 수행되도록 한다. 코드를 다시 컴파일하고 실행하면 다음과 같은 결과가 나타날

것이다.

코드가 쓰레드로 수행되고 있기 때문에 실제로 모든 쓰레드가 종료되지 않았는데 '모든 쓰레드가

종료되었습니다'라는 메시지가 중간에 나타나는 것을 알 수 있을 것이다. 모든 쓰레드가 끝난 다음에 '모든

쓰레드가 종료되었습니다'라고 나타나도록 하려면 다음과 같이 코드를 변경하면 된다.

foreach( Thread t in aThread)

{

t.Start();

}

aThread[0].Join(); // 인쇄 작업

aThread[1].Join(); // 철자 검사

aThread[2].Join(); // 저장 하기

31

Console.WriteLine("모든 쓰레드가 종료되었습니다");

}

foreach 루프문에서 Join()을 수행하지 않고, 바깥에서 쓰레드의 배열에서 직접 Join()을 수행한다는 것에

주의하기 바란다. 각각의 쓰레드는 인쇄 작업, 철자 검사, 저장 하기를 수행한다. 또한 모든 쓰레드가 종료될

때까지 Console.WriteLine()은 수행되지 않는다.

결과에서 알 수 있는 것처럼 모든 쓰레드가 끝난 다음에 Console.WriteLine()이 실행되는 것을 알 수 있다.

이와 같이 각각의 쓰레드가 독립적으로 처리되고, 모든 쓰레드가 종료된 다음에 특정 코드를 실행하기 위해서

위와 같이 Join()을 사용할 수 있다. 쓰레드 t1 과 t2 가 각각 작업을 수행하고 있을 때,

t2.Join();

과 같은 코드를 넣으면, 쓰레드 t1 에서 수행중인 함수의 실행을 중지하고, 쓰레드 t2 가 종료될 때 까지 실행을

기다리게 된다. 위에서는 aThread[0], aThread[1], aThread[2]에 대해서 각각 Join()을 호출하 다. 각 쓰레드

aThread[0], aThread[1], aThread[2]는 응용 프로그램 쓰레드에 대해서 Join()을 수행한다. 따라서 응용

프로그램 쓰레드는 실행을 중지하고 각각의 aThread[0], aThread[1], aThread[2]의 실행이 종료될 때까지

기다리게 된다. 다시 정리하면 다음과 같다.

aThread[0].Join(); // 응용 프로그램 쓰레드 정지하고 인쇄 쓰레드 완료를 기다림

aThread[1].Join(); // 응용 프로그램 쓰레드 정지하고 철자 쓰레드 완료를 기다림

aThread[2].Join(); // 응용 프로그램 쓰레드 정지하고 저장 쓰레드 완료를 기다림

첫번째 시간에 응용 프로그램의 프로세스와 쓰레드에 대해서 이야기 한 것을 기억하는가? 기억하지 못하는

독자를 위해서 잠시 기억을 되살려 보도록 하자. 응용 프로그램은 하나의 프로세스를 가진다. 이 프로세스는

실제로 하나의 쓰레드이며, 프로세스는 어드레스 공간에 대한 단순한 주소라고 이야기 했었다. 또한 프로세스는

32

단순히 쓰레드에 대한 컨테이너 역할을 한다고 얘기했었다. 다시 말해 각각의 aThread[].Join()은 응용

프로그램 쓰레드 Main()에 대한 Join()이라는 것을 알 수 있을 것이다. 여기서는 모든 쓰레드가 응용 프로그램

쓰레드에 대해서 Join()을 했으므로 모든 쓰레드가 종료될 때까지 응용 프로그램 쓰레드 Main()을 실행을

중지하고 기다린다. 반대로 aThread[0].Join();을 지우고, DoPrinting() 함수에서 Thread.Sleep(100);으로 다시

컴파일하여 실행해보면 다음과 같은 결과를 볼 수 있을 것이다.

결과에서 알 수 있는 것처럼 철자 검사 aThread[1]과 저장 aThread[2]가 끝나고 "모든 쓰레드가

종료되었습니다"라는 메시지를 출력한 다음에도 계속해서 aThread[0]은 실행되는 것을 알 수 있다. 이렇게

실행되는 이유는 aThread[1]과 aThread[2]가 Join()을 수행하여 각각의 쓰레드가 종료 될 때 까지 응용

프로그램 쓰레드를 중지시키고 대기 상태로 두는 반면에 인쇄 작업을 수행하는 aThread[0]은 응용 프로그램

쓰레드에 관계없이 동시에 실행되기 때문이다. Join 에서 주의할 점 위에서 Join 이 어떻게 작동하는 가에

대해서 자세하게 설명했다(어쩌면 독자들의 머리를 아프게 했을지도 모르겠다). 여러분은 여러 개의 쓰레드를

foreach 를 사용하여 멋지게 처리하는 것을 보았는데, 왜 루프안에 Join()을 넣지 않았는지 의아해 했을지도

모르겠다. '왜 루프안에서 Join()을 수행하지 않고, 루프 밖에서 일일이 수행하는 거지?' 이제, 이것에 대해서

설명하고자 한다. 먼저 다음과 같이 코드를 변경해 보도록 하자.

foreach( Thread t in aThread)

{

t.Start();

t.Join();

}

33

Console.WriteLine("모든 쓰레드가 종료되었습니다");

}

여러분이 생각하는 것처럼 foreach 루프안에서 일괄적으로 쓰레드를 시작하고 Join()을 수행하고 있다. 코드를

다시 컴파일하고 결과를 살펴보면 다음과 같은 결과가 나타날 것이다.

결과에서 알 수 있는 것처럼 쓰레드로 동시에 처리되는 것이 아니라 순차적으로 실행되는 것을 알 수 있다.

루프안에서 Start()에 의해서 쓰레드가 시작되고, Join()에 의해서 응용 프로그램 쓰레드에 대해서 Join()을

수행하게 된다. 다시 말해, 응용 프로그램은 t.Join();까지 실행한 다음에 쓰레드 t 의 수행이 끝날 때까지 모든

작업을 중지하고 기다리게 된다. 마찬가지로 두 번째 루프를 처리할 때도 같은 방식으로 동작한다. 때문에

쓰레드 프로그래밍을 하는 경우에는 이와 같이 루프안에서 Join()을 사용해서는 안된다. 쓰레드에 대해서

Join()을 수행하고 싶다면 다음과 같은 방식으로 코딩하기 바란다.

foreach( Thread t in aThread)

{

t.Start();

}

foreach( Thread t in aThread)

{

t.Join();

}

34

쓰레드를 시작하는 부분과 쓰레드를 Join()하는 부분을 각각 두 개의 루프로 분리해서 처리하도록 한다. Join

대기하기 먼저 각각의 쓰레드 t1, t2, t3 가 있다고 하자. 다음과 같이 모두 Join()을 수행하고 있다고 가정하자.

t1.Join();

t2.Join();

t3.Join();

이 세가지 쓰레드는 각각의 쓰레드가 종료되고 제어권을 다시 넘길 때까지 응용 프로그램 쓰레드를 중지시킨다.

만약에 이 쓰레드 중에 어떤 쓰레드가 종료되지 않는다면 응용 프로그램은 원히 종료되지 않을 수도 있다.

이러한 경우에 일정 시간 동안만 응용 프로그램 쓰레드를 대기 시키도록 할 수 있다. 사용법은 다음과 같다.

t1.Join(100); // 100ms 동안만 응용 프로그램 쓰레드를 중지시킨다.

t2.Join(100);

t3.Join(100);

이제 앞에서 살펴본 예제에서 각각의 쓰레드를 다음과 같이 변경하도록 하자.

foreach( Thread t in aThread)

{

t.Start();

}

aThread[0].Join(100);

aThread[1].Join(100);

aThread[2].Join(100);

이와 같이 코드를 변경한 다음에 실행다면 다음과 같은 결과를 확인할 수 있을 것이다.

35

결과에서 알 수 있는 것처럼 각각의 쓰레드가 Join(100)에서 지정했으므로 300ms 초 이후에 "모든 쓰레드가

종료되었습니다"라는 메시지가 출력된 후에 계속해서 쓰레드가 수행되는 것을 알 수 있다. 각각의 쓰레드에

대해서 Join(1000)으로 변경한다면 결과는 다음과 같을 것이다.

각각의 쓰레드에 대해서 Join(1000)으로 변경했으므로 3000ms 이후에 응용 프로그램 쓰레드에 제어권이

넘어가게 되고, "모든 쓰레드가 종료되었습니다"라는 메시지가 출력되는 것을 알 수 있을 것이다. 앞의 예제에서

각각의 쓰레드에 대해서 Join(1000), Join(2000)과 같이 값을 바꿔보면서 여러가지로 테스트 해 보면 Join()에

대해서 더 잘 이해할 수 있을 것이다. 쓰레드 정리 지난 시간에 쓰레드를 종료하기 위해 Interrupt()를 사용했던

것을 기억하는지 모르겠다. 닷넷 환경은 CLR에 의해서 쓰레기 수집(Garbage Collection)을 하기 때문에

프로그래머가 쓰레드를 직접 정리하지 않아도 된다. 정리 마지막으로 쓰레드에 대해서 정리해 보도록 하자. 위

예제에서는 3 개의 쓰레드를 사용했다. 그렇다면 실제로 응용 프로그램은 몇 개의 쓰레드를 사용했는가? 답은

'4 개의 쓰레드를 사용했다'이다. 이 한 개는 응용 프로그램에 대한 쓰레드가 되며, 또한 하나의 프로세스를

사용한다. 이 프로세스는 4 개의 쓰레드에 대한 컨테이너가 된다. 응용 프로그램 쓰레드를 1 차 쓰레드라고 하며,

예제에서 생성한 각각의 쓰레드들은 모두 2 차 쓰레드라고 한다. 따라서 2 차 쓰레드에 대한 제어권은 1 차

36

쓰레드가 갖고 있다. 벌써 머리가 복잡해 지는가? 이제 잠시 쉬면서 다음을 위한 휴식을 하도록 하자. 다음

단계 지금까지 쓰레드에 대해 알아보면서 쓰레드의 처리를 제어할 수 있는 것들을 알아봤다. 첫번째는

Thread.Sleep()을 이용해서 쓰레드의 처리를 단순히 지연시키는 것에서부터, 쓰레드의 처리가 종료될 때까지

선행 쓰레드를 멈추게 하는 Join()에 대해서 알아봤으며, 쓰레드를 종료 시킬 수 있는 Interrupted()에 대해서

알아보았다. 다음에는 1 차 쓰레드와 2 차 쓰레드에 대해서, 그리고 System.Threading 네임 스페이스와

System.Threading.Thread 클래스에 대해서 자세히 살펴보는 시간을 갖도록 하자. 아무쪼록 다음 시간까지

즐거운 탐험의 시간이 되기를 바란다. ☞ 소스 다운로드

37

C# 쓰레드 이야기: 4. 쓰레드 기본 개념

By 한동훈 필자는 쓰레드에 대해서 글을 쓰지만, 전혀 쓰레드에 대한 전문가가 아니라는 사실을 독자들이

알았으면 싶다. 사람들은 종종

'쓰레드? 그건 왜 쓰는데? 백그라운드 프로세스로 여러 개를 동시에 처리하면 되잖아..'

라고 얘기하곤 한다. 그래서 이번에는 실제 코드와는 관계없이 쓰레드의 기본 개념에 대해서 설명하고자 한다.

물론 여기서 소개하는 기본 개념은 공통 언어 런타임(이하 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) 구조의 큐에

38

들어가며, 스케줄러는 가장 우선 순위가 높은 쓰레드를 차례대로 수행한다. 스케줄러는 이 큐의 쓰레드를

차례대로 수행하면서 점점 낮은 순위의 큐로 이동한다. 만약, 새로 생성된 쓰레드가 현재 스케줄러가 있는

큐보다 우선 순위가 높은 큐에 들어가게 되면, 스케줄러는 우선 순위가 높은 큐로 이동하여 쓰레드를 수행하게

된다. 쓰레드의 우선 순위는 숫자로 표시하며, 이 숫자값은 운 체제마다 다르다. 윈도우 2000 의 작업

관리자에서 해당 프로세스에서 마우스 오른쪽 버튼을 클릭하면 다음과 같이 우선 순위를 설정하는 것을 볼 수

있다.

그림에서 볼 수 있는 것처럼 윈도우 2000 서버는 실시간, 높음, 보통 초과, 보통, 보통 미만, 낮음과 같은

프로세스 우선 순위를 설정할 수 있다. 윈도우의 우선 순위는 32 를 기준으로 설정한다. 이 숫자값이 높으면

높을수록 우선 순위가 높다. 다음 표를 참고하자.

우선 순위 숫자값 플래그 C# 플래그

실시간(Time Critical) 32 THREAD_PRIORITY_TIME_CRITICAL

39

실시간(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

[표 1] Win32 와 CLR 우선순위 표에는 Win32 와 CLR 의 우선 순위를 각각 정리해 두었다. 표에서 알 수 있는

것처럼 닷넷 환경에서는 다섯 가지의 우선 순위만을 지정할 수 있으며, 시스템의 수행에 결정적인 향을 줄 수

있는 실시간 우선 순위 등은 지원하지 않는 다는 것을 알 수 있다. 참고로 윈도우 9x 계열의 운 체제는 보통

초과(AboveNormal), 보통 미만(BelowNormal) 우선 순위는 지원되지 않는다. 이러한 우선 순위의 종류나

각각의 숫자값은 운 체제마다 다르다. [표 1]의 Win32 플래그는 winbase.h 헤더파일에 정의되어 있다.

일반적으로 독자들이 쓰고 있는 NT 운 체제는 7 가지 쓰레드 우선 순위를 지원한다. 대부분의 멀티 쓰레드

프로그래밍을 하는 경우에는 프로세스의 우선 순위보다는 쓰레드의 우선 순위에 많은 신경을 쓰게 된다.

프로세스의 우선 순위는 운 체제에서 알아서 하는 것이 대부분이지만, 응용 프로그램에서의 쓰레드 우선

순위는 프로그래머가 직접 제어한다. 예를 들어서, 시스템에 특정 파일을 검색하는 검색 프로그램을 작성한다고

하자. 이 경우에 검색을 수행하는 쓰레드와 사용자 인터페이스를 담당하는 쓰레드가 있어야 한다. 검색을 하는

도중에 사용자가 검색을 중지하고자 '중지'버튼을 클릭한다면 바로 반응을 보일 수 있어야 한다. 이 경우에는

언제든지 사용자에게 반응하도록 하기 위해 인터페이스 쓰레드의 우선 순위를 검색 쓰레드보다 상위에 두도록

한다. 닷넷에서의 쓰레드 우선 순위 닷넷에서 쓰레드 우선 순위를 변경하여 프로그램이 어떻게 동작하는지

알아보도록 하자. 먼저 지난 시간에 이용한 예제를 다시 한 번 살펴보도록 하자. 여기서는 Join()문을 없앴으며

각각의 Do 함수들도 Thread.Sleep(50)으로 같은 주기를 갖도록 하 다.

이름: MultiThread.cs

namespace csharp

40

{

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()

41

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()

{

42

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;

또는 다음과 같이 사용할 수도 있다([표 1. Win32 와 CLR 우선순위] 참고).

someThread.Priority = ThreadPriority.Normal + 2; // Highest 사용

43

ThreadPriority 는 열거형(enum)이고, 사용할 수 있는 종류는 [표 1]에 있는 것과 같다. DoTest() 함수를 다음과

같이 바꿔보도록 하자.

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);

코드를 모두 변경했으면 다시 컴파일하고 실행해 보도록 하자. 결과는 다음과 같을 것이다.

44

위에서 알 수 있는 것처럼 각각의 쓰레드에 대해서 쓰레드 우선 순위를 지정할 수 있다. [표 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

{

45

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();

}

46

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|");

}

47

Console.WriteLine("철자 검사 완료");

}

private void DoSaving()

{

Console.WriteLine("저장 시작");

for ( int LoopCtr = 0; LoopCtr < 1000; LoopCtr++)

{

Console.Write("s|");

}

Console.WriteLine("저장 완료");

}

}

}

실행 결과를 살펴보면 다른 쓰레드가 시작하기 전에 철자 검사 쓰레드가 시작하고 종료되는 것을 알 수 있다.

Thread.Sleep()이 없기 때문에 그런 것이지 원래 쓰레드는 이렇게 실행되는 것이다. 실행시간이 너무 짧아서

제대로 확인할 수 없다고 의심할 수도 있을 것이다. -_-; 그러면 위 코드에서 다음 부분을 주석 처리하고 다시

컴파일하여 실행해 보기 바란다.

// aThread[1].Priority = ThreadPriority.Normal + 2;

실행 결과는 각각의 쓰레드가 비슷하게 시작하고, 비슷하게 종료된다. 만약 파일을 검색하는 프로그램을

만든다고 가정하자. 이때 파일을 검색하는 쓰레드에 Sleep() 구문이 없다면 화면에 버튼을 클릭하려해도 반응을

보이지 않을 것이다. 특정 쓰레드가 자원을 독점하지 않도록 하려면 쓰레드 루프안에 Thread.Sleep()이

들어가야 한다. 이제 각각의 Do 함수를 다음과 같이 수정하고 다시 컴파일하고 실행해 보도록 하자.

private void DoPrinting()

48

{

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++)

{

49

Console.Write("s|");

Thread.Sleep(1);

}

Console.WriteLine("저장 완료");

}

실행결과를 보면 'c', 'p', 's'가 사이 좋게 번갈아가며 찍히는 것을 알 수 있을 것이다. 위 예제에서 탐욕

쓰레드를 언급하면서 하나의 쓰레드에 대해서만 Highest 를 주었다. 만약 세 개의 쓰레드가 모두 Normal 인

경우에는 탐욕 쓰레드가 있을 수 없는 걸까? 확인해보기 위해서 Do 함수에서 Thread.Sleep(1)을 모두

지워버리자. 물론, 우선 순위를 설정하는 부분이 있다면 그 부분도 지우도록 한다. DoSpelling() 함수를 다음과

같이 수정한다.

private void DoSpelling()

{

Console.WriteLine("철자 검사 시작");

for ( int LoopCtr = 0; LoopCtr < 1000000; LoopCtr++)

{

Console.Write("c|");

}

Console.WriteLine("철자 검사 완료");

}

실행 결과를 보면 불행히도 쓰레드의 자원 독점은 일어나지 않는다. 각각의 쓰레드가 여전히 번갈아가며

실행되는 것을 알 수 있다. 이것은 타임 슬라이싱 때문에 그렇다. 타임 슬라이싱(Time Slicing) CPU 가 하나인

시스템에서 쓰레드는 한 번에 하나의 코드 만을 실행할 수 있다고 말한 것을 기억하는가. 그렇다면 운 체제는

새로운 쓰레드를 스케줄링하기 위해 어떻게 쓰레드에서 CPU 의 제어권을 가져올 수 있는가? 운 체제도 결국

코드에 불과한데, 어떻게 해서 제어권을 가져올 수 있을까? 그 정답은 타임 슬라이싱에 있다. NT 와 같은

운 체제는 컴퓨터의 하드웨어에서 일정 시간 주기로 인터럽트를 발생시키도록 타이머를 지정한다. 스케줄러는

쓰레드에게 적절한 시간을 할당한다. 다음 인터럽트가 발생할 때까지 쓰레드는 CPU 를 독점적으로 사용하게

50

된다. 인터럽트가 발생하면 쓰레드는 스케줄러에게 제어권을 넘겨준다. 쓰레드가 제어권을 넘겨주면 스케줄러는

같은 우선 순위를 갖고 있는 다른 쓰레드에게 제어권을 넘겨준다. 만약 같은 우선 순위를 갖고 있는 다른

쓰레드가 없다면 같은 쓰레드가 다시 실행 시간을 할당 받고 CPU 를 독점적으로 사용하도록 한다. 따라서 위

예제에서 같은 우선 순위를 갖고 있는 경우에는 자원의 독점적인 사용이 일어나지 않는다. 마찬가지 이유로

하나의 쓰레드의 우선 순위가 높은 경우에는 하나의 쓰레드가 계속해서 실행된다. 그러나 CLR 이 NT 가 아닌

다른 운 체제에서 호스트된다면 이와 같은 동작은 보장할 수 없다. 마지막으로 Thread 클래스에서 관심을

가질만한 멤버들을 표에 정리해 두었다.

정적 멤버 설명

CurrentContext 현재 실행중인 쓰레드 컨텍스트를 가져온다.

CurrentThread 현재 실행중인 쓰레드의 참조를 가져온다.

GetData()

SetData()

쓰레드의 현재 도메인에 대해서 실행중인 쓰레드의 특정 슬롯의 값을 설정하거나 가져온다.

GetDomain()

GetDomainID()

현재 쓰레드가 실행중인 도메인에 대한 참조를 가져온다.

Sleep() 현재 실행중인 쓰레드를 일정 시간 동안 중지시킨다.

[표 2] Thread 클래스의 정적 멤버

인스턴스

멤버

설명

IsAlive 쓰레드가 시작된 이후로 살아있는지를 알려준다.

IsBackground 쓰레드가 백그라운드 쓰레드인지 아닌지를 설정하거나 알려준다(ThreadState.Background).

Name 쓰레드의 이름을 설정하거나 가져올 수 있다.

Priority ThreadPriority 열거형에 정의된 쓰레드 우선 순위를 정의하거나 알려준다.

ThreadState ThreadState 열거형에 정의된 쓰레드의 상태를 알려주는 읽기 전용 프로퍼티

51

쓰레드를 죽일 때 사용한다. 이 메소드를 사용하면 ThreadAbortException 예외가

발생한다(ThreadState.Aborted, ThreadState.AbortRequested).

Abort()

Interrupt() 현재 쓰레드를 중지시킨다.

Join() 현재 쓰레드가 완료될 때까지 기다린다(ThreadState.WaitSleepJoin).

Suspend() 쓰레드를 잠시 대기상태로 만든다(ThreadState.Suspended, ThreadState.SuspendRequested).

Resume() 대기 상태로 만든 쓰레드를 다시 활성상태로 만든다.

Start() ThreadStart 에 위임한 쓰레드 실행을 시작한다(ThreadState.Unstarted, ThreadState.Running).

[표 3] Thread 클래스의 인스턴스 멤버 IsAlive 의 주의할 점은 이 쓰레드가 실행 가능한지, Sleep, Join 등에

의해 블록되어 있는지, 지금 실행되고 있는지를 알 수는 없다. 또한 쓰레드가 실행할 수 없는 상태와 종료된

상태를 구별하지는 못한다. 쓰레드의 상태를 알고자 하는 경우에는 ThreadState 를 사용하도록 한다. Abort()에

대하여 설명했으나 실제로 멀티 쓰레드 응용 프로그램에서 쓰레드를 중지시킬 때는 Abort()를 사용하지 않고

Interrupt()를 사용하도록 한다. Suspend()를 사용하는 것은 바람직하지 않으며 대신에 Sleep()을 사용하도록

한다. 만약 입력 상태에서 쓰레드가 대기 상태가 되었다면 쓰레드를 Resume() 할 수 없게 된다. 이와 같이

수행이 보류된 스레드는 별도의 작업(Resume() 사용과 같이)이 없는 경우에는 다시 수행되지 않는다. 이러한

쓰레드를 보류된 쓰레드라 한다. 보류된 쓰레드와 블로킹된 쓰레드(Blocked Thread)의 차이점은 다음과 같다.

보류된 쓰레드는 별도의 작업이 없으면 더 이상 실행되지 않으며, 블로킹된 쓰레드는 프로그래머가 자발적으로

Sleep(), Join()과 같은 함수를 호출하여 특정 기간 동안 쓰레드의 수행을 보류하는 것이다. 따라서 지정한

기간이 지나거나(Sleep 의 경우), 특정 쓰레드의 실행이 끝난 경우(Join 의 경우)에 쓰레드는 다시 실행되게 된다.

요약 쓰레드 스케줄링과 쓰레드 우선순위에 대하여 알아보았다. 반드시 기억해야 할 것은 다음과 같다.

• 쓰레드가 독점하지 않도록 하려면 쓰레드 루프에 Thread.Sleep() 등을 사용하여 적절히 제어권을

양보하도록 해야 한다.

• 멀티 쓰레드 응용 프로그램은 단순히 쓰레드 우선 순위 제어만으로는 쓰레드의 실행 순서를 제어할 수

없다.

52

• 코드를 실행하도록 하기 위해서 우선 순위를 제어하는 것은 잘못하고 있는 것이다. 따라서

멀티쓰레드를 제어하는 신뢰성있는 기법을 익히기 위해 이 강좌를 보거나 다른 참고문헌을 보는 것이

좋다.

다음 단계 다음에는 스케줄러, 멀티 프로세서 환경에서의 쓰레드와 쓰레드 친밀도(Thread Affinity)에 대해서

간단히 알아볼 것이다. 또한, 쓰레드의 예외 처리에 대해서 알아보고, 어떠한 문제점이 있는지 알아보도록 하자.

참고로 필자는 쓰레드에 대해서 모르기 때문에 글이 틀릴 수가 있다. 그러니 이상한 점이 있다면 주저말고

Email([email protected])을 보내주기 바란다.

53

C# 쓰레드 이야기: 5. NT vs UNIX

지난 시간에 스케줄러에 대해서 이야기했다. NT 를 사용하거나 UNIX 를 사용하거나 각각의 OS 에는 스케줄러가

있다. 그리고 둘 다 라운드 로빈 방식에 의해서 스케줄링을 한다는 것만 기억하자. NT 와 UNIX 의 차이는 NT 는

쓰레드로 스케줄링을 한다는 것이고 UNIX 는 프로세스로 스케줄링을 한다는 것이다. NT 는 하나의 프로세스에

여러 개의 쓰레드를 두어 처리하는 방법을 따르고 UNIX 는 fork()와 같은 함수에 의해서 부모 프로세스를

복제하여 자식 프로세스를 만드는 것과 같이 여러 개의 자식 프로세스를 두어 처리하는 방법을 따른다. 예를

들어, 동시에 200 개 이상의 작업을 처리한다고 할 때, NT 에서는 200 개의 쓰레드로 하지만 UNIX 에서는

200 개의 프로세스로 처리하기 때문에 NT 에서 비교적 더 잘 처리할 수 있다. 그러나 NT 에서도 동시에 많은

작업을 처리하는 데에는 쓰레드가 무겁기에 경량화(lightweight) 쓰레드를 소개했고, UNIX 에서도 마찬가지로

경량화 프로세스, 즉 쓰레드를 제공하고 있다(아마도 웹과 같은 환경이 가장 큰 요인이겠지만). NT 와 UNIX

모두 모든 것을 갖고 있는 종합 선물 세트 대신에 빠르게 처리할 수 있는 경량화된 쓰레드를 제공하고 있다.

UNIX 의 경량화 프로세스, 즉 쓰레드를 이용하게 된 것은 최근의 일이다. 시스템에 있는 프로세스의 확인은

NT 는 시스템의 프로세스를 작업 관리자(Task Manager)를 통해서 할 수 있고, UNIX 는 ps 와 같은 명령으로 할

수 있다. GNU 유틸리티인 top 을 이용할 수도 있다. 참고: NT 4.0 에서 소개된 경량화 쓰레드인 fiber 는 다소

다르다. 이 fiber 는 프로그램내에서 수동으로 스케줄링된다. 이것은 Win16 에서 자체적으로 쓰레드를

스케줄링하도록 작성된 응용 프로그램을 NT 로 쉽게 포팅할 수 있도록 하기 위해 소개되었다. 물론 fiber 는

생성한 쓰레드내에 있으므로 이 쓰레드의 스케줄링을 따른다. 쓰레드 프로그래밍 이점 쓰레드 프로그래밍이

갖고 있는 이점은 무엇일까? 먼저 프로세스는 전역 데이터, 환경 데이터, 힙, 쓰레드 스택, 쓰레드 코드로

이루어진다. 그림을 그려보면 다음과 같을 것이다.

54

두 개의 쓰레드를 갖고 있다면 다음 그림과 같이 될 것이다.

그림에서 알 수 있는 것처럼 쓰레드는 프로세스 내에 있는 데이터를 공유할 수 있다. 만약 프로세스간에

데이터를 주고 받으려면 IPC(Inter Process Communication)와 같은 복잡한 환경을 이용해야 한다. 기본적으로

프로세스간에 데이터를 주고 받는 것은 어렵다. 반면에 쓰레드간에 데이터를 주고 받는 것은 간단하다.

프로세서 친밀도 시스템에 있는 모든 프로세스는 프로세서 친밀도를 갖고 있다. 프로세서 친밀도는 프로세스가

어떤 CPU 에서 실행될 것인지를 결정한다. 프로세서 친밀도는 NT 와 UNIX 프로세스 모두 갖고 있다. 참고로

윈도우 2000 부터는 하나의 프로세스에 있는 쓰레드에 대해서도 프로세서 친밀도를 지정할 수 있다. 또한

쓰레드 친밀도는 프로세스의 친밀도와 같은 값을 갖는다. 프로세서 친밀도는 멀티 프로세서를 갖고 있는

55

환경에서 시스템 부하가 많이 걸리는 작업을 특정 CPU 에 나누어서 부하를 줄이고, 처리 시간을 줄이는 데

유용하게 사용될 수 있지만 사용할만한 확실한 이유가 없는 한 사용하지 않는 것이 좋다. 즉, 꼭 필요한

경우에만 사용하도록 한다. NT 는 운 체제의 버전과 종류에 따라 사용할 수 있는 프로세서의 개수가 제한되어

있지만, 보통 8 개의 CPU 를 가진 환경에서 사용할 수 있는 값들은 다음과 같다.

Bit mask

Binary value

Eligible processors

0x0001

00000000 00000001

1

0x0003

00000000 00000011

1, 2

0x0007

00000000 00000111

1, 2, 3

0x0009

00000000 00001001

1, 4

0x007F

00000000 01111111

1, 2, 3, 4, 5, 6, 7

자신의 시스템이 몇 개의 CPU 를 갖고 있는 가는 NT 에서는 성능 뷰어나 작업 관리자에서 볼 수 있고,

UNIX 에서는 mpstat 나 dmesg 와 같은 명령으로 간단히 확인할 수 있다. C#에서 간단히 현재 실행중인

프로그램의 프로세서 친밀도를 알아내는 것은 다음과 같다. 대개의 경우 자신의 CPU 가 1 개일 것이므로 값은

1 이 될 것이다. 그리고 여러 개의 CPU 를 갖고 있다면 이 값을 변경하여 이 프로세스가 실행될 수 있는

CPU 를 제한할 수 있을 것이다.

56

이름: ProcessApp.cs

using System;

using System.Diagnostics;

using System.ComponentModel;

class ProcessApp

{

public static void Main()

{

Process proc = Process.GetCurrentProcess();

try

{

//Process Name

Console.WriteLine("Process Name: " + proc.ProcessName);

//Processor Affinity

Console.WriteLine("Processor Affinity: " + proc.ProcessorAffinity);

}

catch(Win32Exception e)

{

Console.WriteLine(e.ToString());

}

}

}

현재 실행중인 프로그램의 프로세스를 가져오기 위해 Process.GetCurrentProcess()를 사용하고 이것을

사용하기 위해서는 System.Diagnostics 가 필요하다.

57

Process proc = Process.GetCurrentProcess();

NT 에서 몇몇 시스템 서비스의 경우에는 프로세서 친밀도 값을 가져올 수 없다. 이러한 경우에

Win32Exception 이 발생한다. 이 예외를 처리할 수 있도록 하기 위해 System.ComponentModel 네임

스페이스가 필요하다. 만약 현재 2 개의 CPU 를 갖고 있는 환경이라면 다음과 같이 프로세서 친밀도를 바꿀 수

있다.

proc.ProcessorAffinity = (IntPtr)3;

이 경우에는 두 개의 프로세서에서 모두 실행되도록 할 것이다. 단일 CPU 환경에서 이와 같이 한다면 에러를

만나게 될 것이다. IntPtr 데이터형은 시스템에 따른 고유한 정수 값 형식을 지정한다. Int 형을 2 바이트로

쓴다면 2 바이트를 사용하고, 4 바이트를 사용하는 환경에서는 4 바이트를 사용하도록 하는 정수형이다. 다음은

시스템에 있는 모든 프로세스를 출력하는 예제다.

이름: pinfo.cs

using System;

using System.Diagnostics;

class pinfoApp

{

public static void Main()

{

Process[] procs = Process.GetProcesses();

foreach(Process proc in procs)

{

// Process Name

Console.Write("Process Name: " + proc.ProcessName);

58

// Process Id

Console.WriteLine(", Process ID: " + proc.Id.ToString());

}

}

}

다음 단계 프로세서 친밀도에 대해서 알아보았다. 다음에는 쓰레드의 예외 처리에 대해서 알아보도록 하자.

59

C# 쓰레드 이야기: 6. 쓰레드 예외 처리

쓰레드의 예외 처리는 여러 가지가 있지만, 여기서는 몇 가지만을 간단히 다룰 것이다. 그러니까 늘 그렇듯이

이 글은 아주 짧다. ^^ 이전하고 비슷하게 이번에는 4 개의 메시지를 출력하는 각각의 쓰레드를 만들어보자.

예제는 다음과 같다.

이름: ThreadEx.cs

using System;

using System.Threading;

using System.ComponentModel;

class ThreadExceptionApp

{

public static void Main()

{

ThreadExceptionApp te = new ThreadExceptionApp();

te.DoTest();

}

private void DoTest()

{

try

{

Thread[] myThreads =

{

new Thread( new ThreadStart(SayHello) ),

new Thread( new ThreadStart(SayMerry) ),

new Thread( new ThreadStart(SayPapi) ),

60

new Thread( new ThreadStart(SayHappy) )

};

foreach(Thread myThread in myThreads)

{

myThread.Start();

}

}

catch(ThreadStateException)

{

Console.WriteLine("--- ThreadStateException occured.");

}

} // end of function DoTest()

private void SayHello()

{

for(int LoopCtr = 1; LoopCtr < 10; LoopCtr++)

{

Console.WriteLine("Hello, everyone.");

Thread.Sleep(100);

}

}

private void SayMerry()

{

61

for(int LoopCtr = 1; LoopCtr < 10; LoopCtr++)

{

Console.WriteLine("Merry~~~~~~");

Thread.Sleep(100);

}

}

private void SayPapi()

{

for(int LoopCtr = 1; LoopCtr < 10; LoopCtr++)

{

Console.WriteLine("Papi sounds good.");

Thread.Sleep(100);

}

}

private void SayHappy()

{

for(int LoopCtr = 1; LoopCtr < 10; LoopCtr++)

{

Console.WriteLine("All, Happy Programming.");

Thread.Sleep(100);

}

}

} // end of class ThreadExceptionApp

예제를 실행하면 각각의 메시지가 잘 출력되는 것을 확인할 수 있다. 전에 설명한 것과 같이 쓰레드를

인터럽트하는 Interrupt(), 다른 쓰레드의 실행을 기다리는 Join(), 쓰레드를 중지시키는 Abort()가 있다. 그리고

이들 메소드중에 Interrupt()와 Abort()는 예외를 발생시키며, 각각의 예외는 ThreadInterruptedException 과

62

ThreadAbortException 이다. 먼저 ThreadInterruptedException 에 대해 알아보기 위해 예제에서 먼저 DoTest()

함수에서 다음 부분을 변경하도록 한다.

foreach(Thread myThread in myThreads)

{

myThread.Start();

}

Thread.Sleep(5);

myThreads[1].Interrupt();

myThread[1]은 SayMerry 함수를 뜻한다. Thread.Sleep(5)와 같이 약간의 지연 시간을 준 것은 바로 이전

코드가 쓰레드를 시작하는 부분이기 때문에 실제 코드가 시작하기 전에 쓰레드가 인터럽트 되는 것을 막기

위한 것이다. 즉, 쓰레드가 실제로 시작되도록 하기 위해 약간의 지연 시간을 주었다.

private void SayMerry()

{

try

{

for(int LoopCtr = 1; LoopCtr < 10; LoopCtr++)

{

Console.WriteLine("Merry~~~~~~");

Thread.Sleep(100);

}

}

catch(ThreadInterruptedException)

{

Console.WriteLine("--- ThreadInterrupedException - SayMerry() ---");

63

}

}

쓰레드가 인터럽트되는 경우에 실제 쓰레드로 실행되는 함수에서 예외를 잡아야 한다. 이제 코드를 다시

컴파일하고 실행하면 결과는 다음과 같을 것이다.

Hello, everyone.

Merry~~~~~~

Papi sounds good.

All, Happy Programming.

All Thread ended.

--- ThreadInterrupedException - SayMerry() ---

Hello, everyone.

Papi sounds good.

All, Happy Programming.

볼 수 있는 것처럼 중간에 쓰레드가 인터럽트되었다는 것을 알 수 있다. 다른 예외로는 이미 죽어있는 쓰레드에

어떤 실행을 기대하는 경우가 있을 수 있다. 이러한 경우에는 ThreadStateException 예외가 발생한다. 예를

들어 다음 코드와 같이 한 번 죽인 쓰레드를 다시 죽이면 예외가 발생한다.

MyThreads[1].Abort(); // 쓰레드를 죽인다.

… some code …

myThreads[1].Resume(); // 쓰레드의 실행을 계속하라고 지시한다.

이 경우에 쓰레드를 이미 종료했으므로 원하는 대로 쓰레드가 계속 실행되지 못할 것이다. 예제에서 DoTest()

함수를 다음과 같이 변경해보도록 하자.

private void DoTest()

{

try

{

Thread[] myThreads =

{

64

new Thread( new ThreadStart(SayHello) ),

new Thread( new ThreadStart(SayMerry) ),

new Thread( new ThreadStart(SayPapi) ),

new Thread( new ThreadStart(SayHappy) )

};

foreach(Thread myThread in myThreads)

{

myThread.Start();

}

Thread.Sleep(5);

myThreads[1].Abort(); // 쓰레드를 중지시킴

for(int LoopCtr = 1; LoopCtr < 100; LoopCtr++)

{

//do nothing

}

myThreads[1].Resume(); // ThreadStateException 예외 발생

}

catch(ThreadStateException)

{

Console.WriteLine("--- ThreadStateException occured.");

}

} // end of function DoTest()

65

Thread.Sleep(5)은 각각의 쓰레드가 실제로 시작하도록 하기 위한 약간의 지연 시간이다. 이와 같은 지연

시간이 없다면 Start() 다음에 Abort()를 호출하기 때문에 쓰레드의 상태는 Unstarted 가 된다.

myThreads[1].Abort();

SayMerry 함수를 위임한 두 번째 쓰레드를 중지시킨다. 1 부터 100 까지 루프를 실행하는 부분은 실제로

수행되는 코드를 대신하기 위한 것이다. 이제 중지된 쓰레드를 계속 실행하도록 Resume()을 호출하면 예외가

발생한다. 코드를 다시 컴파일하고 실행하면 결과는 다음과 같을 것이다.

Hello, everyone.

Merry~~~~~~

Papi sounds good.

All, Happy Programming.

--- ThreadStateException occured.

Hello, everyone.

Papi sounds good.

All, Happy Programming.

쓰레드의 상태를 알려면 ThreadState 속성을 사용하면 된다.

Console.WriteLine("Thread State : " + myThreads[3].ThreadState);

일반적으로 쓰레드의 예외처리는 SayMerry 와 같이 쓰레드에 위임되는 함수에서 처리하며, 쓰레드 상태와 같은

몇 가지 특별한 예외들은 쓰레드를 이용하는 DoTest()와 같은 함수에서 처리한다는 것에 주의하기 바란다.

다음으로 알아볼 것은 Abort()를 사용할 때 발생하는 ThreadAbortException 예외다. 이 예외는 잡을 수 없는,

즉 catch 할 수 없는 특별한 예외이다. 예외가 발생하면 쓰레드를 강제로 죽이기 전에 쓰레드에 위임된 함수의

finally 블록을 실행한다. 따라서 finally 블록에서 예기치 않은 연산을 수행할 수도 있기 때문에 쓰레드의 종료를

보장하기 위해 Join()등을 호출해야 한다. Join()은 쓰레드가 실행을 멈출때까지 반환하지 않기 때문에 쓰레드를

블로킹하는 호출이다. 다시 말해서, Abort()를 사용했을 때 쓰레드의 상태가 WaitSleepJoin 이 아니면 예외가

발생하지 않는다. 이것은 실제로 Abort()를 사용할 때 미묘한 문제를 일으키게 된다. 지금부터 예제를

살펴보면서 알아보도록 하자. 먼저 앞에서 사용한 예제에서 쓰레드를 WaitSleepJoin 상태로 만들 수 있는 모든

코드를 제거할 것이다. 먼저 각각의 Say 함수의 Thread.Sleep()을 제거하고, 각각의 Say 함수의 루프는

66

100 회씩 반복하도록 수정한다. 마찬가지로 DoTest() 함수도 수정한다. 수정된 DoTest(), Say 함수들은 다음과

같다.

private void DoTest()

{

try

{

Thread[] myThreads =

{

new Thread( new ThreadStart(SayHello) ),

new Thread( new ThreadStart(SayMerry) ),

new Thread( new ThreadStart(SayPapi) ),

new Thread( new ThreadStart(SayHappy) )

};

foreach(Thread myThread in myThreads)

{

myThread.Start();

}

for(int LoopCtr = 1; LoopCtr < 1000; LoopCtr++)

{

//do nothing

}

Console.WriteLine(" --- Thread State : " + myThreads[1].ThreadState);

myThreads[1].Abort();

Console.WriteLine(" --- Thread State : " + myThreads[1].ThreadState);

}

67

catch(ThreadStateException)

{

Console.WriteLine("ThreadStateException occured");

}

} // end of function DoTest()

private void SayHello()

{

for(int LoopCtr = 1; LoopCtr < 100; LoopCtr++)

{

Console.WriteLine("Hello, everyone.");

}

}

private void SayMerry()

{

try

{

for(int LoopCtr = 1; LoopCtr < 100; LoopCtr++)

{

Console.WriteLine("Merry~~~~~~");

}

}

catch(ThreadAbortException)

{

68

Console.WriteLine("------------------------");

Console.WriteLine("--- SayMerry Aborted ---");

Console.WriteLine("------------------------");

}

finally

{

Console.WriteLine("------------------------");

Console.WriteLine("--- SayMerry finally ---");

Console.WriteLine("------------------------");

}

}

private void SayPapi()

{

for(int LoopCtr = 1; LoopCtr < 100; LoopCtr++)

{

Console.WriteLine("Papi sounds good.");

}

}

private void SayHappy()

{

for(int LoopCtr = 1; LoopCtr < 100; LoopCtr++)

{

Console.WriteLine("All, Happy Programming.");

}

}

69

먼저 DoTest() 함수에서 Abort()를 사용하기 전에 쓰레드의 상태를 출력하도록 한다. 이 경우에는 사용자마다

조금씩 다른 결과를 볼 수 있다. 어떤 사용자는 Running 과 Aborted 상태만을 볼 수 있는가 하면 어떤

사용자는 AbortRequested 와 Stopped 를 보게 될 수도 있다.

Console.WriteLine(" --- Thread State : " + myThreads[1].ThreadState);

myThreads[1].Abort();

Console.WriteLine(" --- Thread State : " + myThreads[1].ThreadState);

SayMerry()에서는 예외가 발생한 부분을 알아보기 쉽도록 하기 위해서 위와 같이 하 다. 여기서는 쓰레드를

WaitSleepJoin 으로 하는 부분을 배제했기 때문에 catch 블록은 실행되지 않고 finally 블록만 실행된다는 것에

주의하기 바란다. 코드를 실행하고 컴파일하면 다음과 같은 결과를 볼 수 있을 것이다. 다음 결과는 편의상

생략한 부분이 있다.

[생략]

Merry~~~~~~

Merry~~~~~~

------------------------

--- SayMerry finally ---

------------------------

Papi sounds good.

Papi sounds good.

[생략]

All, Happy Programming.

All, Happy Programming.

All, Happy Programming.

[생략]

--- Thread State : Running

--- Thread State : Aborted

결과에서 알 수 있는 것처럼 Abort()를 호출했지만, 예외가 발생하지 않았다. 이제 DoTest() 함수를 다음과

같이 변경해 보도록 하자.

70

for(int LoopCtr = 1; LoopCtr < 1000; LoopCtr++)

{

//do nothing

}

Thread.Sleep(10);

myThreads[1].Abort();

예제에서 볼 수 있는 것처럼 Abort()를 호출하기 전에 Thread.Sleep()과 같이 블로킹하는 부분이 있으면

ThreadAbortException 을 잡을 수 있다는 것을 알 수 있다. 또한 Thread.Sleep()이 없으면 시작과 함께

종료되기 때문에 실제 쓰레드 상태가 Unstarted 로 프로그램이 종료되게 된다. 따라서 실제 쓰레드를 시작한

다음에 종료하기 위해 Thread.Sleep()을 넣었다. 결과는 다음과 같을 것이다.

Papi sounds good.

Papi sounds good.

Papi sounds good.

------------------------

--- SayMerry Aborted ---

------------------------

------------------------

--- SayMerry finally ---

------------------------

Thread.Sleep 으로 인해 쓰레드의 상태가 WaitSleepJoin 이 되기 때문에 예외가 발생한다. 마찬가지로 Join 이나

Suspend 등으로 쓰레드가 블로킹되어 있는 경우에 Abort()를 사용할 경우에는 예외를 잡을 수 있으나 그렇지

않은 경우에는 예외를 잡을 수 없다. 따라서 Abort()로 쓰레드를 죽인 다음에, 쓰레드에 위임된 함수의 finally

블록을 실행하고, 쓰레드의 종료를 보장하려면 Join() 등을 호출하여 ThreadAbortException 예외를 잡을 수

있도록 해야 한다(즉, 쓰레드의 상태를 WaitSleepJoin 으로 만들어서 예외를 잡을 수 있도록 한다). 여기서는

설명하지 않지만 가끔 쓰레드를 사용하다 보면 Win32Exception 을 만나는 경우가 발생할 수 있다. 그러한

경우에는 System.ComponentModel 네임스페이스를 사용하도록 하고, Win32Exception 예외를 잡아서 처리하기

바란다. Win32Exception 에는 일반 예외와 다른 몇 가지 메소드가 있는데, 이들은 CLR 런타임의 예외 코드를

71

Win32 예외 코드로 변환해준다. 요약 쓰레드의 예외 처리는 교착상태, 경쟁 조건 등과 같은 여러 가지 문제에

따라 다양한 예외처리가 있을 수 있지만, 여기서는 닷넷에서 지원하는 기본적인 예외처리를 살펴보았다.

쓰레드의 예외 처리는 예외의 종류에 따라 위임된 함수에서 처리해야 하는 예외와 쓰레드를 이용하는 함수에서

처리해야 하는 예외가 다르므로 주의하기 바란다. 또한, ThreadAbortException 예외는 쓰레드가 WaitSleepJoin

상태가 아닌 경우에는 예외를 잡을 수 없다는 것을 기억한다. 다음 단계 다음에는 UNIX 나 LINUX 에서 많이

보던 top 과 비슷한 wintop 을 C# 언어와 윈도우 폼으로 작성해 볼 것이다. 쓰레드 프로그래밍을 하다 보면

NT 의 작업 관리자의 정보로는 부족한 경우가 있다. 때문에 보다 자세한 정보를 볼 수 있는 윈도우 버전의

top 을 작성해 볼 것이다.

72

C# 쓰레드 이야기: 7. C#으로 만드는 WinTop

지금까지 쓰레드를 이용하는 방법에 대해서 알아보았고, 예외 처리에 대해서 짤막하게 알아보았다. 이번에는

이러한 지식들을 정리하고 한데 모아보는 의미에서 프로세스 모니터를 만들어 볼 것이다. NT 에서의 작업

관리자나 Linux 에서의 top 과 같은 유틸리티를 사용하여 시스템에서 실행중인 프로세스의 상태를 일목요연하게

알아볼 수 있었다. 그러나 프로세스 밑에서 실행중인 쓰레드의 정보를 얻는 것은 작업 관리자로는 어렵다. ^^;

WinTop 을 작성하는 방법은 메모장과 같은 텍스트 에디터를 사용하는 방법과 VS.NET 과 같은 개발환경을

사용할 수 있다. 어느쪽을 사용해도 되며, 여기서는 메모장만 이용한다. 여기서 작성할 프로그램의 실행 화면은

다음과 같다.

그리고 각각의 프로세스를 클릭했을 때 쓰레드에 대한 자세한 정보를 볼 수 있으며 결과는 다음과 같다.

73

이 화면은 MS 워드의 프로세스에 대한 정보를 선택한 것이다. 먼저 첫번째 화면부터 작성해보도록 하자.

이름: WinTop.cs

using System;

using System.Collections;

using System.Drawing;

using System.ComponentModel;

using System.Windows.Forms;

using System.Diagnostics;

namespace WinTop

{

public class MyForm : System.Windows.Forms.Form

{

public static void Main()

{

Application.Run(new MyForm());

}

각각의 필요한 클래스들을 선언한다. 윈도우 폼을 사용하기 위해 System.Windows.Forms 네임 스페이스를

사용하며, 폼에 각각의 컨트롤을 그리고 위치시키기 위해 System.Drawing 네임 스페이스를 사용한다. Timer 와

컨트롤의 자원 해제 등을 위해서 System.ComponentModel 네임 스페이스를 사용한다. 마지막으로 프로세스와

쓰레드 컬렉션을 다루기 위해 System.Collections 네임 스페이스를 사용하고, 시스템의 프로세스와 쓰레드

정보를 얻어내기 위해 System.Diagnostics 네임 스페이스를 사용한다.

#region Control Declaration

private System.Windows.Forms.ListView lstProcess;

private System.Windows.Forms.Label lblTotal;

74

private System.Windows.Forms.Label lblProcessTotal;

private System.Windows.Forms.Label lblThreadTotal;

private System.Windows.Forms.ColumnHeader colHeader1;

private System.Windows.Forms.ColumnHeader colHeader2;

private System.Windows.Forms.ColumnHeader colHeader3;

private System.Windows.Forms.ColumnHeader colHeader4;

private System.Windows.Forms.ColumnHeader colHeader5;

private System.Windows.Forms.ColumnHeader colHeader6;

private System.Windows.Forms.CheckBox chkRefresh;

private System.ComponentModel.IContainer components;

private System.Windows.Forms.Panel panTotal;

private Timer timer;

public Process[] procs;

public int m_processCount; // 총 프로세스 수

public int m_threadCount; // 총 쓰레드 수

#endregion

폼에서 사용할 각각의 컨트롤을 private 으로 선언하고, 몇 가지를 전역변수로 사용하기 위해 public 으로

선언한다. 각각의 선언은 다음과 같이 전체 이름을 사용했다.

private System.Windows.Forms.ListView lstProcess;

간단히 하려면 다음과 같이 짧게 사용할 수 있다.

private ListView lstProcess;

여기서 주목할 부분은 IContainer 인터페이스를 사용한 components 이다. IContainer 인터페이스는 컨트롤에

대한 컨테이너 역할을 한다. ISite, IComponent 인터페이스의 구현이 IContainer 에 포함되어 있다. #region 과

#endregion 이라는 지시자를 사용하여 VS.NET 에서 코드를 숨기도록 할 수도 있다. 다음은 MyFrom 클래스에

75

대한 생성자를 정의하는 부분이다. 여기서는 앞에서 선언한 각 윈도우 폼 컨트롤들의 인스턴스를 생성하고,

적절한 초기화를 한다.

#region form constructor

public MyForm()

{

// ComponentModel 지원

this.components = new System.ComponentModel.Container();

//timer 초기화

this.timer = new System.Windows.Forms.Timer(this.components);

timer.Enabled = true;

timer.Interval = 1000;

timer.Tick += new System.EventHandler(this.timer_Tick);

components 컨테이너에 timer 를 담아두며, 타이머를 설정한다. 타이머 이벤트가 발생하는 간격은

1000ms(1 초)로 설정하며, 이벤트 핸들러를 선언하여 이벤트가 발생할 때 실행할 메소드를 위임한다.

// form Init.

this.Text = "WinTop";

// lstProcess ListView Init.

this.lstProcess = new System.Windows.Forms.ListView();

lstProcess.Name = "lstProcess";

lstProcess.Size = new System.Drawing.Size(550, 500);

lstProcess.HeaderStyle = ColumnHeaderStyle.Clickable;

lstProcess.View = View.Details;

lstProcess.FullRowSelect = true;

lstProcess.Sorting = SortOrder.Ascending;

76

다음은 폼의 제목을 WinTop 으로 설정하며, 프로세스 목록을 보여줄 ListView 클래스를 적절하게 선언하는

부분이다. ListView 는 탐색기에서 각각의 항목을 보여주는 데 사용하는 클래스이다. 주의할 것은 우리가 원하는

모습의 ListView 를 사용하려면 View 를 View.Details 로 선언해야 한다. FullRowSelect 는 개별적으로 열을

선택하지 않고 전체 열을 하나로 하여 선택하도록 하는 부분이다. 다음은 프로세스의 이름을 오름차순으로

정렬하기 위해 Sorting 을 사용하 다.

// lstProcess ColumnHeader Init.

this.colHeader1 = new System.Windows.Forms.ColumnHeader();

this.colHeader2 = new System.Windows.Forms.ColumnHeader();

this.colHeader3 = new System.Windows.Forms.ColumnHeader();

this.colHeader4 = new System.Windows.Forms.ColumnHeader();

this.colHeader5 = new System.Windows.Forms.ColumnHeader();

this.colHeader6 = new System.Windows.Forms.ColumnHeader();

colHeader1.Text = "Process";

colHeader1.Width = 70;

colHeader1.TextAlign = HorizontalAlignment.Left;

colHeader2.Text = "Process ID";

colHeader2.Width = 70;

colHeader2.TextAlign = HorizontalAlignment.Left;

colHeader3.Text = "Priority";

colHeader3.Width = 70;

colHeader3.TextAlign = HorizontalAlignment.Right;

colHeader4.Text = "Physical Mem(KB)";

colHeader4.Width = 120;

colHeader4.TextAlign = HorizontalAlignment.Right;

77

colHeader5.Text = "Virtual Mem(KB)";

colHeader5.Width = 120;

colHeader5.TextAlign = HorizontalAlignment.Right;

colHeader6.Text = "Start Time";

colHeader6.Width = 150;

colHeader6.TextAlign = HorizontalAlignment.Left;

lstProcess.Columns.AddRange( new System.Windows.Forms.ColumnHeader[] {

colHeader1,

colHeader2,

colHeader3,

colHeader4,

colHeader5,

colHeader6

});

lstProcess.Click += new System.EventHandler(lstProcess_OnClick);

이 부분은 ListView 에 추가할 각각의 컬럼을 추가하는 부분이다. 하나의 컬럼은 ColumnHeader 타입으로

정의해야 한다. 마지막에는 ListView 에서 프로세스를 클릭하면 해당 프로세스에 대한 쓰레드 정보를 보여주기

위해 이벤트를 할당한 부분이다.

this.lblTotal = new System.Windows.Forms.Label();

lblTotal.Text = "Total";

lblTotal.Size = new System.Drawing.Size(35, 10);

lblTotal.Location = new System.Drawing.Point(557, 5);

78

this.lblThreadTotal = new System.Windows.Forms.Label();

lblThreadTotal.Text = @"Thread: ";

lblThreadTotal.Size = new System.Drawing.Size(100, 10);

lblThreadTotal.Location = new System.Drawing.Point(5, 20);

this.lblProcessTotal = new System.Windows.Forms.Label();

lblProcessTotal.Text = @"Process: ";

lblProcessTotal.Size = new System.Drawing.Size(100, 10);

lblProcessTotal.Location = new System.Drawing.Point(5, 40);

각각의 Label 을 설정하고 위치와 크기를 정하는 부분이다. 그 외 Text 속성을 정의할 때 @를 사용하 다.

lblProcessTotal.Text = @"Process: ";

@는 형식문자 등을 해석하지 않고 문자 그대로 출력하는 것을 뜻한다. ':'과 같은 특수문자는 실제로 출력되지

않지만, @을 사용하여 출력하도록 하 다. 물론 '₩:'와 같이 사용할 수도 있다.

lblPath.Text = "C:₩₩WinNT₩₩System32";

위와 같이 '₩'를 쓰기 위해 '₩₩'과 같이 복잡하게 쓰는 대신에 @를 사용하여 다음과 같이 간단하게 쓸 수

있다.

lblPath.Text = @"C:₩WinNT₩System32";

다음은 Panel 을 만들고, Panel 에 앞에 만든 Label 을 추가한 부분이다. 보면 무엇을 하는지 다들 잘 알 수 있을

것이다.

// Panel Init.

this.panTotal = new System.Windows.Forms.Panel();

panTotal.Size = new System.Drawing.Size(150, 100);

panTotal.Location = new System.Drawing.Point(555, 5);

panTotal.BorderStyle = System.Windows.Forms.BorderStyle.Fixed3D;

panTotal.Controls.AddRange(new System.Windows.Forms.Control[] {

lblProcessTotal,

79

lblThreadTotal,

});

다음은 체크박스를 만드는 부분이다. 체크박스는 화면에 표시되는 쓰레드 정보를 갱신하기 위해 사용한다.

// CheckBox Init.

this.chkRefresh = new System.Windows.Forms.CheckBox();

chkRefresh.Text = "Refresh";

chkRefresh.Checked = false;

chkRefresh.Size = new System.Drawing.Size(80, 20);

chkRefresh.Location = new System.Drawing.Point(555, 480);

다음은 Controls.AddRange 를 사용하여 지금까지 작성한 컨트롤을 폼에 추가한다.

// add controls to form.

this.Controls.AddRange(new System.Windows.Forms.Control[] {

lstProcess,

lblTotal,

panTotal,

chkRefresh

});

this.AutoScaleBaseSize = new System.Drawing.Size(5, 13);

this.ClientSize = new System.Drawing.Size(720, 500);

this.Load += new System.EventHandler(this.MyForm_Load);

} // end of MyForm()

#endregion

위 코드의 마지막에는 폼에 Load 이벤트를 추가하고, 이벤트에 사용할 함수를 위임했다. 다음은 ListView 를

초기화하고, 앞에서 정의한 ColumnHeader 를 추가한다.

80

private void InitializelstProcess()

{

// Add ListView Column Headers

lstProcess.Clear();

lstProcess.Columns.AddRange( new System.Windows.Forms.ColumnHeader[] {

colHeader1,

colHeader2,

colHeader3,

colHeader4,

colHeader5,

colHeader6

});

}

다음은 체크 박스 chkRefresh 가 선택되었는지를 확인하여 선택되어있으면 프로세스 목록을 갱신하도 록 하는

부분이다. 이 부분은 timer 를 이용한다.

private void timer_Tick(object sender, System.EventArgs e)

{

if ( chkRefresh.Checked )

{

RefreshList();

}

}

private void RefreshList()

{

InitializelstProcess();

81

FillProcessView();

}

RefreshList()에서는 단순히 ListView 를 초기화하고 프로세스를 채우는 FillProcessView() 함수를 호출한다.

private void FillProcessView()

{

try

{

string[] strThread = new String[6];

// Process 와 Thread Count 는 항상 초기화.

m_threadCount = 0;

procs = Process.GetProcesses();

m_processCount = procs.Length;

lstProcess.BeginUpdate();

foreach(Process aProc in procs)

{

// Physical/Virtual Mem in KB

long physicalMem = aProc.WorkingSet/1024;

long virtualMem = aProc.VirtualMemorySize/1024;

strThread[0] = aProc.ProcessName.ToString();

strThread[1] = aProc.Id.ToString();

strThread[2] = aProc.BasePriority.ToString();

strThread[3] = physicalMem.ToString() + " KB";

strThread[4] = virtualMem.ToString() + " KB";

82

strThread[5] = aProc.StartTime.ToString();

// Add Item to the list view

ListViewItem item = new ListViewItem(strThread, 0);

lstProcess.Items.Add(item);

// Sum Threads Count from each Process

m_threadCount = m_threadCount + aProc.Threads.Count;

}

lstProcess.EndUpdate();

lblProcessTotal.Text = @"Processes: " + m_processCount;

lblThreadTotal.Text = @"Threads : " + m_threadCount;

}

catch(Exception e)

{

System.Diagnostics.EventLog evlog = new System.Diagnostics.EventLog();

evlog.Log = "Application";

evlog.Source = e.Source;

evlog.WriteEntry(e.Message);

}

}

먼저 Process.GetProcesses()를 이용해서 시스템에 있는 모든 프로세스를 가져온다(이것을 이용해서 콘솔에

프로세스를 출력하는 예제는 지난 시간에 쓴 글을 참고하기 바란다). lstProcess 의 BeginUpdate()와

EndUpdate()는 ListView 를 갱신하는 동안 화면에 처리를 반 하지 않는다. 만약 프로세스에 대한 정보를

얻는데 실패하면 NT 의 이벤트 로그에 로그를 기록하도록 하 다.

83

private void lstProcess_OnClick(object sender, System.EventArgs e)

{

ListViewItem li = lstProcess.SelectedItems[0];

string strProcID = lstProcess.SelectedItems[0].SubItems[1].Text;

string strProcName = lstProcess.SelectedItems[0].SubItems[0].Text;

int currProcID = Convert.ToInt32(strProcID);

string currProcName = Convert.ToString(strProcName);

WinTopThreadInfo aFrm = new WinTopThreadInfo();

aFrm.DisplayForm(currProcID, currProcName);

}

ListView 에서 해당 프로세스를 클릭하면 프로세스의 쓰레드에 대한 정보를 새로운 창에 띄우는 부분이다.

코드를 보면 무슨 내용인지 알 수 있을 것이다. 쓰레드 정보를 보여주는 부분은 WinTopThreadInfo 클래스로

작성할 것이고, DisplayForm 메소드를 갖는다.

protected override void Dispose(bool disposing)

{

if(components != null)

{

components.Dispose();

}

base.Dispose(disposing);

}

}

}

84

마지막은 자원을 정리하는 부분이다. 여기서는 굳이 정의하지 않아도 되지만 예의상(?) 사용하 다. Dispose 는

WinTopThreadInfo 클래스에서도 사용하며, 새로운 창을 생성하는데 사용한 자원을 반환하기 위해 사용한다.

코드를 작성했으면 다음은 쓰레드 정보를 보여주는 WinTopThreadInfo 클래스를 작성하도록 하자.

이름 : WinTopThreadInfo.cs

using System;

using System.Windows.Forms;

using System.Drawing;

using System.ComponentModel;

using System.Diagnostics;

using System.Collections;

namespace WinTop

{

public class WinTopThreadInfo : System.Windows.Forms.Form

{

private System.Windows.Forms.Label lblTotalThread;

private System.Windows.Forms.ListView lstThread;

private long procID;

private ProcessThreadCollection m_Threads;

public int m_threadCount;

private System.ComponentModel.Container components = null;

여기서 주의할 것은 namespace WinTop 과 같이 같은 네임스페이스안에 클래스를 선언한다는 점이다. 또한

프로세스에 속한 쓰레드에 대한 정보를 담기 위해 ProcessThreadCollection 을 사용했다. 또한

85

System.ComponentModel.Container components 와 같이 사용하여, 폼에서 사용하는 컴포넌트에 대한

컨테이너로 사용한다.

public WinTopThreadInfo()

{

// Label Init.

this.lblTotalThread = new Label();

lblTotalThread.Size = new System.Drawing.Size(70, 12);

lblTotalThread.Location = new System.Drawing.Point(3, 5);

lblTotalThread.Text = "Total Threads : ";

// ListView Init.

this.lstThread = new ListView();

lstThread.Name = "lstThread";

lstThread.Size = new System.Drawing.Size(500, 500);

lstThread.FullRowSelect = false;

lstThread.View = View.Details;

lstThread.Sorting = SortOrder.Ascending;

lstThread.Location = new System.Drawing.Point(3, 20);

InitializeThreadList();

this.Controls.AddRange(new System.Windows.Forms.Control[] {

lblTotalThread,

lstThread

});

this.AutoScaleBaseSize = new System.Drawing.Size(5, 13);

this.ClientSize = new System.Drawing.Size(510, 530);

86

} // end of WinTopThreadInfo

이 부분은 앞에서 설명한 WinTop.cs 와 같으므로 코드를 보면 무엇을 하는지 알 수 있을 것이다.

private void InitializeThreadList()

{

lstThread.Columns.Add("ID", 50, HorizontalAlignment.Left);

lstThread.Columns.Add("Priority", 70, HorizontalAlignment.Left);

lstThread.Columns.Add("Base Priority", 70, HorizontalAlignment.Left);

lstThread.Columns.Add("Total Process Time", 100, HorizontalAlignment.Left);

lstThread.Columns.Add("State", 70, HorizontalAlignment.Left);

lstThread.Columns.Add("Start Time", 150, HorizontalAlignment.Left);

lstThread.Columns.Add("User Time", 120, HorizontalAlignment.Left);

} // end of InitializeThreadList

WinTop.cs 에서는 ColumnHeader 를 직접 생성하여 ListView 에 추가했지만 여기서는 ListView.Columns.Add 를

사용하여 간편하게 컬럼을 설정하도록 하 다.

private void FillThreadList(int procID)

{

string[] strThreads = new String[7];

m_Threads = GetThreads(procID);

ProcessThreadCollection threads;

Process proc;

proc = Process.GetProcessById(procID);

threads = proc.Threads;

m_threadCount = threads.Count;

lblTotalThread.Text = @"Total threads: " + m_threadCount;

87

for(int Indx = 0; Indx < threads.Count; Indx++)

{

strThreads[0] = threads[Indx].Id.ToString();

strThreads[1] = threads[Indx].CurrentPriority.ToString();

strThreads[2] = threads[Indx].BasePriority.ToString();

strThreads[3] = threads[Indx].TotalProcessorTime.ToString();

strThreads[4] = threads[Indx].ThreadState.ToString();

strThreads[5] = threads[Indx].StartTime.ToString();

strThreads[6] = threads[Indx].UserProcessorTime.ToString();

ListViewItem li = new ListViewItem(strThreads, 0);

lstThread.Items.Add(li);

}

} // end of FillThreadList

FillThreadList()는 ListView 에 쓰레드의 정보를 채운다.

public static ProcessThreadCollection GetThreads(int procID)

{

try

{

Process proc=Process.GetProcessById(procID);

ProcessThreadCollection threads = proc.Threads;

return threads;

}

catch ( Exception )

{

return null;

}

88

} // end of GetThreads

프로세스 ID 를 이용하여 프로세스에 속한 쓰레드를 가져온다. 가져오는 데 실패하면 null 을 반환하도록 한다.

protected override void Dispose(bool disposing)

{

if(components != null)

{

components.Dispose();

}

base.Dispose(disposing);

} // end of Dispose

}

}

폼이 없어질 때 자원을 정리하기 위해서 Dispose()를 오버라이드하 다. 사용자 정의 컨트롤이나 인터페이스에

관심 있는 분들은 MSDN.NET 에서 ISite, IComponent, DesignTime 등을 주제로 하여 찾아보기 바란다.

컴파일은 다음과 같이 한다.

Csc /t:winexe /out:WinTop.exe WinTop.cs WinTopThreadInfo.cs

컴파일이 끝나면 윈도우용 WinTop 이 간단하나마 만들어졌을 것이다. 코드를 한 줄 한 줄 살펴보면서

프로세스와 쓰레드에 대한 정보를 어떻게 가져오는지 살펴보기 바란다. System.Diagnostics 에는 프로그램을

추적하고 분석할 수 있는 기능도 있지만, Process, ProcessModule, ProcessThread 클래스를 사용하여

프로세스를 제어할 수 있는 기능도 있다. System.Diagnostics.Process.Kill()과 같은 인스턴스 메소드를 사용하여

원하는 프로세스를 종료할 수도 있다. 시스템의 페이지된 메모리 크기와 페이지 되지 않은 메모리 크기를 알고

싶다면 System.Diagnostics.Process.NonpagedSystemMemorySize 와 .PagedMemorySize 를 이용하면 된다.

보다 더 많은 것들에 관심 있는 분들은 MSDN 을 찾아볼 것을 권한다. 끝으로 VS.NET beta2 와 VS.NET RC 에

들어있는 MSDN 은 닷넷을 위하여 나온 것으로 MSDN Subscription 으로 제공되는 MSDN 과 MS 의 MSDN

온라인에는 없는 많은 유용한 정보를 갖고 있다(특히 VS.NET RC 에는 많은 자료가 들어있다). 때문에 여기서

적절한 관련 자료에 대한 URL 을 제공하지 못하는 것을 안타깝게 생각하지만 MSDN.NET 에서 충분한 자료를

찾아볼 수 있을 것이다 생각한다. VS.NET beta 2 에서는 ListView 를 추가하고 컴파일하면 폼에서 생성하는

89

코드에 오류가 있어서 실행되지 않을 것이다. (VS.NET RC 에서는 잘 동작한다.) 따라서 윈도우 폼에 대해서 잘

모른다면 메모장으로 직접 코드를 입력하면서 구성이나 개념을 아는 것이 더 좋다고 생각한다. 마지막으로

쓰레드와는 무관하지만 시스템의 정보를 알아내려면 다음과 같은 코드를 컴파일하여 실행해보기 바란다.

이름 : info.cs

namespace csharp

{

using System;

using System.Collections;

public class SystemInfo

{

public static void Main()

{

SystemInfo si = new SystemInfo();

si.Display();

}

private void Display()

{

Console.WriteLine("Starting Tick Count : {0}", Environment.TickCount);

Console.WriteLine("Current OS : {0}", Environment.OSVersion);

Console.WriteLine(".NET Framework Version : {0}", Environment.Version);

Console.WriteLine("Command Line : {0}", Environment.CommandLine);

Console.WriteLine("Machine Name(NetBIOS) : {0}", Environment.MachineName);

Console.WriteLine("Current Directory : {0}", Environment.CurrentDirectory);

90

Console.WriteLine("System Directory : {0}", Environment.SystemDirectory);

Console.WriteLine("User Name : {0}", Environment.UserName);

Console.WriteLine("User Domain Name : {0}", Environment.UserDomainName);

Console.WriteLine("Physical Memory of process : {0}", Environment.WorkingSet);

string [] drives = Environment.GetLogicalDrives();

int Indx = 0;

foreach( string drive in drives)

{

Console.WriteLine("Drive {0} - {1}", Indx.ToString(), drive.ToString());

Indx++;

}

Console.WriteLine("Ending Tick Count : {0}", Environment.TickCount);

}

}

}

이상으로 시스템에 있는 프로세스와 쓰레드 정보를 알아내고, 제어할 수 있는 방법에 대해서 알아보았다. 보다

자세한 부분에 관심이 있는 분들은 MSDN 등의 온라인 문서를 참고하기 바란다. 마지막으로 어찌보면 참으로

쓸데없는 내용을 적어놓은 건지도 모르지만, 과연 이러한 것들을 어디에 써먹을 것인가를 한 번은 나름대로

생각해 보는 것도 좋을 것 같다. 닷넷 프레임워크에서 제공하는 이러한 클래스를 이용해서 현재 실행중인

프로그램의 윈도우 타이틀을 가져오는 것은 어떨까. 이 타이틀을 이용해서 응용 프로그램을 제어하는 것도

가능할 것이다. (관심이 있다면 System.Runtime.InteropServices와 DllImport에 대해서 찾아보기 바란다.)

예제를 위해서 비교적 간단하게 작성했기 때문에 다소 객체 지향과는 동떨어진 구성을 했지만, 전체적인 틀을

새롭게 작성해서 리모팅이나 웹 서비스를 이용해서 원격으로 시스템의 프로세스 정보를 얻을 수도 있을 것이고,

특성 프로세스가 실행중인지 등을 알아낼 수도 있을 것이다. 또한 원격으로 특정 프로세스를 종료할 수도 있을

91

것이다. 추가적으로 NetWkstaGetInfo()와 같은 Win32 API를 이용한다면 원격으로 특성 서버의 종류(PDC, BDC,

SQL Server, Novell Network Server, NT Member, Windows 9x)를 알아내는 것도 가능할 것이다. 기존에 작성한

클래스를 웹 서비스로 제공하는 wsdl.exe은 /username, /password, /domain과 같은 옵션을 이용하여 관리자

권한으로 접근하도록 제한된 역에 접근할 수 있는 프록시 클래스를 생성할 수 있으며, 관리자 권한으로 할 수

있는 작업을 할 수도 있을 것이다. 무엇을 할 것인가는 여러분에게 달려있다. 다음 단계 다음에는 드디어

기다리던 멀티 쓰레드에서의 동기화에 대해서 다룰 것이다. 늘 하는 얘기지만 필자는 전문가도 아니며 모르기

때문에 질문이나 문의사항을 언제나 환 한다. 또한 For Guru(http://forguru.home.dhs.org)에서 많은 논쟁을

벌이는 것을 좋아한다. 필자가 틀렸다고 뒤에서 험담하기 보다는 For Guru나 email을 통해서 필자에게 틀린

점을 알려주기 바란다. (아까운 정력, 생산적인 곳에 쓰도록 하자) 벌써 12 월이고, 한 달 후면 컴퓨터를 처음

접한지 16 년째가 되고, 프로그래머로 일한지 6 년이 되어 간다니 새삼스럽다. C#이라는 유행과 같은 언어로

쓰레드에 쓰고 있지만, 그 보다는 그 기본이 되는 바탕을 설명하려고 하는데 그러지 못해 아쉬운 점이 많다.

유행과 새로운 기술을 따라가는 것도 중요하지만, 그 바탕이 되는 기술을 아는 것이 더 중요하다는 것을 시간이

갈수록 느끼고 있다. 끝으로 필자의 부족한 실력이나마 WinTop을 작성할 수 있도록 도와준 프로그래머들에게

감사드린다. 전체 뼈대를 제공한 Melanie, ListView와 관련하여 조언을 아끼지 않았으며 지금까지의 C# 쓰레드

글들에 대해서 끊임없는 피드백을 해주신 4baf, Linux에서의 쓰레드로 필자를 괴롭히는 dicajohn, Perl에서의

쓰레드 프로그래밍에 대한 조언과 도움을 준 nuthack, 원고를 쓸 때마다 MSN으로 괴롭히는 운령, 모르는

부분에 대해 많은 참고가 되는 도서를 제공해준 한빛미디어, 그 외 많은 분들에게도 감사를 드린다. 필자의

부족한 실력으로 이런 글을 쓸 수 있을리없으며 많은 분들이 도와주신 덕분이다. [☞ 소스 다운 로드] 참고도서

• 『닷넷 프레임워크 에센스』(쑤안 타이, 호앙 램 저, 한빛미디어 2001) - 프레임워크 전반에 관해 꼭

필요한 부분만 있기 때문에 전체적인 코딩 틀이 생각나지 않을 때마다 참고하고 있다.

• 『C# 프로그래밍』(제시 리버티 저, 한빛미디어, 2001) - C#에 대한 훌륭한 책이며, 쓰레드에

대해서도 장을 할애하고 있다. 처음 C# 쓰레드에 쓰기 전에 이 책을 보면서 쓰레드에 대해 쓸 것을

생각하 다. ADO.NET과 DataGrid에 대한 부분이 다른 책들보다 자세하게 되어 있으므로 데이터베이스

관련 프로그래밍을 하는 분들에게 도움이 될 것이다. (이 책은 훌륭한 입문서다. 쓰레드 관련서는

아니다. -_-)

92

• 『VB.NET In a Nutshell』(Steven Roman, Ron Petrusha, Paul Lomax 저, 오라일리, 2001) - 쓰레드

예외처리에 대한 부분과 전체 닷넷 프레임워크의 충실한 분류와 개요 설명은 C#에서도 충분히 많은

도움이 되고 있다.

• 『Subclassing & Hooking with Visual Basic』(Stephen Teilhet 저, 오라일리, 2001) - 비록 비주얼

베이직에서의 서브클래싱과 후킹에 대한 글이지만, 후반에 다루어져 있는 VB.NET에서의 서브 클래싱과

후킹부분과 상호 운 성 부분에서 많은 참고가 되었던 책이며, Win32 Msg를 닷넷 프로그램과 주고

받는 방법에 대해서 많은 참고가 되었다.

• 『Inside C#』 - C# 언어보다는 그 설계의 이면에 대해 많은 이야기를 제공해주고 있으며, 가비지

컬렉터(Garbage Collector)와 쓰레드 부분에 있어서 많은 참고가 되었다.

• 『C# & .NET Platform』- C#과 닷넷 플랫폼에 대한 자세한 해설이 들어있지만 솔직한 느낌은 두께에

비해 C#도, 닷넷 플랫폼에도 비중을 제대로 두지 못한 것 같아 아쉬움이 남는다. 쓰레드에 대해서

다루고 있는 부분은 없지만 응용 프로그램 제작시 많이 쓰게 되는 인터페이스의 사용에 있어서 많은

참고가 되었다.

93

C# 쓰레드 이야기: 8. 동기화

이번에는 쓰레드 동기화에 대해서 알아보도록 하자. 쓰레드 프로그래밍은 상당히 어렵다. 쓰레드 프로그래밍이

어려운 이유는 쓰레드에 할당되는 CPU 시간을 프로그래머가 제어할 수 없기 때문이다. 전에 얼핏 얘기한

것처럼 하드웨어에서 일정하게 발생하는 인터럽트에 의해서 스케줄링하기 때문에 어떤 쓰레드가 언제

실행되는지 프로그래머가 알 수 없다. 실은 이것보다 더 중요한 문제가 있는데 하나의 CPU 를 사용하고 있는

환경에서 멀티 쓰레드 프로그래밍을 경험하는 것과 2 개 이상의 CPU 를 사용하고 있는 환경에서 멀티 쓰레드

프로그래밍을 경험하는 것이 다르다는 것이다. 1 개의 CPU 에서 잘 동작하는 코드가 2 개의 CPU 에서는 제대로

동작하지 않는 코드가 된다는 것은 직접 경험해 보기전에는 알 수 없다. 그리고 이 글을 보는 대부분의

독자들은 하나의 CPU 를 사용하고 있는 환경이기 때문에 이것이 올바른 멀티 쓰레드 코드인지 알 수 없다. -

이 이유에 대해서는 동기화에 대해서 계속 설명하면서 자세히 풀어 나가도록 하겠다.

동기화에 대해서 또 하나의 중요한 주제인 프로세스 우선 순위와 쓰레드 우선 순위에 대해서는 이전 글에서

다루었으며, 멀티 프로세서 환경에서 쓰레드가 실행될 프로세스를 지정하는 방법에 대해서, 즉 프로세서

친밀도에 대해서 설명했다.

최소 단위 오퍼레이션

최소 단위 오퍼레이션이라는 것은 동기화를 보장하기 위해 어떠한 방해도 받지 않고 반드시 실행되어야 하는

단위를 말한다. 각각의 쓰레드에게 CPU 시간이라는 '밥'을 주는 인터럽트는 하드웨어에 의해서 비동기적으로

발생한다. 즉, 프로그래머가 인터럽트의 발생을 제어할 수 없다.

은행에서 예금을 옮기는 것을 예로 설명해보자. A, B, C 는 각각의 은행 계좌이며, a, b, c 는 각각의 은행

계좌에서 대해서 처리하는 쓰레드라고 가정한다. A 라는 계좌에 있는 돈을 B 라는 계좌에 이체시키는 경우를

생각해보자. A 가 100 만원을 갖고 있는데 10 만원을 B 계좌로 옮기려고 한다. 다음은 예금을 옮기려고 하는

경우에 발생할 수 있는 몇 가지 문제중의 하나다.

A 의 계좌에 10 만원이 있는지 확인하는 순간에 인터럽트가 발생하여 a 쓰레드는 중지하고, c 쓰레드가

94

실행된다. c 쓰레드는 C 의 계좌에서 40 만원을 A 의 계좌로 옮겼으며, A 의 잔액은 140 만원이 되었다. 이

순간에 다시 쓰레드 a 가 실행되었다. 쓰레드 a 는 A 의 10 만원을 B 로 옮기고, A 의 잔액이 100 만원이라고

알고 있으므로 10 만원을 뺀 90 만원으로 변경했다. A 계좌에 있는 잔액은 실제로 90 만원이 아니라 130 만원이

되어야한다.(100 - 10 + 40 만원)

실제로 여러분 중에는 위의 말이 이해가 되지 않을 수 있을 것이다. 코드에서 보면 2-3 줄 이내의 아주 간단한

문장이기 때문에 그렇게 생각할 수 있을 것이다. 만약 다음과 같은 문장이 최소 단위 오퍼레이션으로

실행되어야 한다고 생각해보자.

if ( i == 53 )

{

Console.WriteLine(i.ToString());

}

실제 코드에서는 Console.WriteLine() 대신에 어떤 코드가 들어갈 것이다. 위 코드를 어셈블리 코드로 보면

다음과 같다.

IL_0004: ldc.i4.s 53

IL_0006: bne.un.s IL_0014

IL_0008: ldloca.s V_0

IL_000a: call instance string [mscorlib]System.Int32::ToString()

IL_000f: call void [mscorlib]System.Console::WriteLine(string)

이것은 IL 코드이며, 해당 CPU 에 맞는 기계어로 변환된다면 코드는 더 길어질 것이며, 이 실행의 중간에 다른

쓰레드로 바뀔 수 있다는 것을 이해할 수 있을 것이다. 예를 들어서 IL_0008 까지 실행된 다음에 다른 쓰레드로

전환되어 i 의 값이 53 이 아닌 다른 값으로 변경되었다해도 문제없이 코드가 실행될 수 있다.

이러한 종류의 버그는 가끔 일어나며, 재현하기 어렵기 때문에 디버깅하는 것도 어렵다. 위에서 예로든 은행

예금과 같이 반드시 하나의 단위로 실행되어야하는 코드 블록을 최소 단위 오퍼레이션이라한다.

커널 객체

95

커널 오브젝트는 운 체제의 자원을 뜻하며, 주로 쓰레드, 프로세스, 이벤트, 뮤텍스, 세마포어, 파일, 파일 매핑,

콘솔 I/O, 명명 파이프(named pipe)를 뜻한다.

커널 객체는 시스템 전체에서 사용할 수 있는 이름을 가질 수 있다. 다시 말해, 다른 프로세스에서 같은 이름을

사용하여 커널 객체에 접근할 수 있다.

커널 객체는 커널에 의해서 관리되며, 커널 객체 참조 카운터를 사용하여 생명 주기(life-cycle)을 관리한다. 이

부분은 Win32 와 깊게 관련된 부분이고, 닷넷과는 관련이 없어보이지만 나중에 Win32 와 닷넷이 얼마나 비슷한

방식으로 쓰레드를 구현하고 있는가를 알게 될 것이다.

경쟁 조건(race condition)

race condition 은 경쟁 조건 내지는 경쟁 상태라고 한다. 경쟁 조건은 여러 개의 쓰레드가 공유 데이터를

사용할 때 발생한다. 이것은 흔히 생산자/소비자(Producer/Consumer)의 문제로도 알려져 있다.

생산자/소비자 프로그래밍 모델은 하나의 쓰레드는 데이터를 생성하도록 하고, 다른 쓰레드는 데이터를

소비하도록 하는 것이다. 이 모델의 이점은 생산자가 최대한의 데이터를 생성하도록 하고, 소비자는 최대한

많은 데이터를 소비하도록 하는 것이다. 그리고 생산자와 소비자는 서로 비동기적으로 실행되어야한다. 만약

동기적으로 실행된다면 소비자는 생산자가 데이터를 생산할 때 까지 기다려야하며, 이것은 멀티 쓰레드

프로그래밍이 아니며 효율을 살릴 수 없게 된다.

예를 들어서, 생산자가 정수를 생성하고 소비자는 생성된 정수를 소비한다고 하자. 생산자가 정수를 생산하는

속도가 소비자가 정수를 소비하는 속도보다 빠르다면 소비자는 생산자가 생성한 숫자들을 속도차 만큼 놓치게

된다.

반대로 생산자가 정수를 생산하는 속도보다 소비자가 정수를 소비하는 속도가 더 빠르다면 이미 가져간 값을

다시 가져다 쓰게 되는 경우가 발생하게 된다. 이러한 경우를 경쟁 조건 또는 경쟁 상태라고 한다.

96

이와 같이 여러 개의 쓰레드가 동시에 같은 자원을 공유하고 있을 때 발생하게 되며, 동기화를 사용하여 이

문제를 해결할 수 있다.

궁핍 현상

궁핍 현상은 쓰레드 우선 순위와 관련된 부분이 많다. 먼저, 두

개의 쓰레드 A 와 B 가 있다고 하자. A 의 쓰레드 우선 순위가 높고,

B 의 쓰레드 우선 순위가 A 보다 상대적으로 낮다고 하자. A 가 CPU 할당 시간에 작업을 끝내고 대기 상태로

전환되고, 쓰레드 B 도 대기 상태라고 하자. 그러면 우선 순위 정책에 의해서 A 가 다시 CPU 시간을 할당받게

된다. 만약 적절한 정책이 사용되지 않는다면 쓰레드 A 가 종료될 때까지 쓰레드 B 는 CPU 시간을 할당 받을

수 없게 된다. 다시말해 CPU 시간이라는 '밥'을 먹을 수가 없으므로 쓰레드 B 는 굶어죽게 된다.

또 다른 경우는 각각의 쓰레드 A, B, C 의 우선 순위가 A > B > C 라고 하자. A 는 R1 과 R2 라는 공유 자원에

접근하여 실행되는 쓰레드이며, B 는 R1 공유 자원을 접근하여 실행되는 스레드이며, C 는 R2 에 접근하여

실행되는 쓰레드라고 하자. B 와 C 가 각각의 공유 자원에 접근하여 처리중이라면 쓰레드 A 는 우선 순위가

높더라도 접근할 수 있는 공유 자원이 모두 사용중이므로 대기 상태가 되며 CPU 시간을 할당 받지 못한다.

이러한 경우에 쓰레드 B 와 스레드 C 의 처리가 모두 끝나고 공유 자원 R1, R2 를 반납하기 전에는 가장 높은

우선 순위를 갖고 있음에도 불구하고 쓰레드 A 는 CPU 시간을 할당 받을 수 없으며, 대기 상태로 남아있게

된다. 이와 같은 것을 궁핍 현상이라 하며, 또 다른 말로는 우선 순위 역전이라 한다. 우선 순위 역전은 NT 와

같이 우선 순위에 기반한 스케줄러를 사용하는 운 체제에서 나타나는 현상이다. 위와 같이 높은 우선 순위를

갖고 있는 프로그램이 먼저 실행되어야하는데 그렇지 못한 것은 우선 순위의 사용 목적에 어긋난다. 이것은

운 체제 뿐만 아니라 닷넷에서도 마찬가지다.

교착 상태(deadlock)

교착 상태라는 것은 여러 개의 쓰레드가 여러 개의 공유 자원에 대해서 소유하고, 실행을 위해서 다른 공유

자원을 대기하는 경우를 뜻한다. 예를 들어서 A 쓰레드가 b 자원을 소유하고 있고, B 쓰레드가 a 자원을

생산자/소비자 프로그래밍 모델은 단일

생산자/단일 소비자, 다중 생산자/단일

소비자, 단일 생산자/다중 소비자, 다중

생산자/다중 소비자 모델로 다시 나눌 수

있다.

97

소유하고 있다. A 쓰레드는 실행을 위해서 a 자원을 획득하기 위해 대기하고 있으며, B 쓰레드는 실행을 위해서

b 자원을 획득하기 위해 대기하고 있다고 하자. 이 경우에 각각의 쓰레드는 소유하고 있는 자원을 해제하기

위한 수행을 할 수 없으므로 모든 쓰레드의 실행이 중지된다. 이러한 문제를 해결하기 위해 임계 역이나

뮤텍스와 같은 동기화 도구를 사용한다.

닷넷과 가비지 컬렉터(Garbage Collector)

여러분들도 잘 알고 있는 것처럼 닷넷에서는 프로그래머가 직접 자원을 해제하지 않는다. 프로그래머가

Finalize 등을 사용하여 직접 자원을 해제한다고 해도, 이 자원들은 모두 메모리 상에 남아 있으며, 메모리

공간을 확보할 필요가 있을 때 가비지 컬렉터에 의해서 수집된다.(물론, 가비지 컬렉터의 자원 정리 과정 역시

간단하지 않으며, 객체의 부활(resurrection)에 대한 논의가 필요하며, 멀티 쓰레드 응용 프로그램에서 절대로

해제되지 않는 쓰레드를 생성하게 될 수도 있다)

Win32 나 기존의 멀티 쓰레드 프로그래밍과 닷넷 에서의 멀티 쓰레드 프로그래밍은 자원 정리라는 면에서

다르다. 기존의 멀티 쓰레드 프로그래밍에서는 자원의 해제에 대해서 프로그래머가 신경써야 했으나,

닷넷에서는 프로그래머가 신경쓰지 않아도 된다.

그러나 쓰레드 내에서 생성한 객체가 비관리형 코드(unmanaged code)로 작성된 라이브러리라면 쓰레드를

종료하기 전에 이 객체를 정리해야한다는 것에 주의하기 바란다.

가비지 컬렉터 : 두 가지 버전

닷넷에서 가비지 컬렉터에는 두 가지 버전이 있다. 하나는 웍스테이션 버전(mscorwks.dll)이며, 다른 하나는

서버 버전(mscorsvr.dll)이다.

서버 버전 GC

• 다중 프로세서 지원, 병렬 처리 지원

98

• CPU 마다 하나의 GC 쓰레드

• 표시(marking) 하는 동안 프로그램의 실행이 잠시 중단된다

웍스테이션 GC

• 자원 정리를 수행하는 동안 실행되는 응용 프로그램의 지연 시간을 최소로 한다.

당연한 얘기겠지만 가비지 컬렉터로 인해서 자원 정리에 대해서 신경쓰지 않아도 된다는 이점이 있지만 서버

버전의 GC, 즉 멀티 프로세서 환경에서의 자원 정리에 대해서는 객체의 부활과 관련한 문제가 발생한다.

마치며

멀티 쓰레드 응용 프로그램에서 가장 중요한 점은 공유 자원의 사용이다. 실질적으로 우리가 쓰고 있는 x86

프로세서들은 32 bit 데이터에 대해서는 인터럽트되지 않는 동작을 보장한다. - 이 사실을 확인시켜준

진일군에게 감사를… -

프로세스나 쓰레드에 대해서는 많은 내용들이 있고, 더 자세한 내용을 알고 싶다면 Operating System Concepts,

6th 를 참고하기 바란다.(참고로 5 판은 Pascal 코드이나 6 판은 모두 C 코드로 다시 쓰여졌다. 많은 분들이

'공룡책'으로 부르고 있다)

몇 가지 용어와 개념에 대해서 간단히 정리하는 것으로 마친다. 동기화에는 임계 역, 세마포어, 뮤텍스등

많은 방법들이 쓰인다. 다음 시간에는 이 중에서 임계 역에 대해서 살펴볼 것이다.

참고

http://www.brpreiss.com/books/opus6/html/page417.html

Bruno R. Preiss 의 홈페이지이며, Data Structures and Algorithms with Object-Oriented Design

Patterns 에 대한 온라인 도서를 제공한다. 이 문서는 C++, Java, C# 각각의 세 가지 버전으로

제공되며 독자가 편한 것을 선택하기 바란다. 위 링크는 C# 버전의 Garbage Collection 알고리즘에

99

대해 설명하고 있다. 자바와 닷넷 모두 자원 정리를 위해 Mark & Sweep 알고리즘을 사용하고 있으며,

참조 카운팅을 이용한 방법을 비롯한 다양한 알고리즘을 소개하고 있다.

http://www.programmersheaven.com/zone28/articles/article667.htm

MSDN Magazine 2000 년 11 월후에 실린 가비지 컬렉터의 자원 정리에 대한 기사인 Automatic

Memory Management in the Microsoft .NET Framework 과 닷넷 가비지 컬렉터에서의

부활(resurrection)에 대한 기사를 제공하고 있다.

소스 다운로드(resurrection.cs)

마지막으로 이 소스는 필자가 고의로 부활한 객체를 가비지 컬렉터가 제거하지 못하도록 작성한

코드다.(결국 무한히 실행된다)

100

C# 쓰레드 이야기: 9. 임계 영역

지난 시간에는 동기화에 대해서 이야기 했으며, 멀티 쓰레드 환경에서 어떤 문제가 생길 수 있는지 간략히

살펴보았다. 여러 개의 쓰레드가 하나의 정수 데이터를 공유하는 것은 빈번하기 때문에 정수 데이터의 증가와

감소를 동기화할 수 있는 Interlocked 클래스가 제공된다. 이것은 동기화의 가장 간단한 형식에 속한다. 그러나

정수형이 아닌 데이터베이스에 데이터를 쓰거나 읽는 작업을 쓰레드를 이용해서 동시에 처리한다면 데이터를

저장하는 동안에는 데이터를 읽지 못하게 해야하고, 데이터를 읽는 동안에는 데이터를 쓰지 못하도록 하는 것이

필요하다. 이와 같이 하나의 쓰레드가 공유 데이터를 사용하고 있으면 다른 쓰레드들이 접근하지 못하도록 하는

것을 베타적 접근이라한다.

멀티 쓰레드에서 베타적 접근은 가장 일반적으로 사용되는 방법이며, 베타적 접근에 사용할 수 있는 것 중에

하나가 임계 역이라는 것이다.

임계 역(Critical Section)

임계 역은 가장 간단하며, 속도도 빠르다. 임계 역은 한 번에 하나의 쓰레드만 접근할 수 있도록 되어 있는

역(Section)을 뜻한다. 임계 역의 구조는 보통 운 체제에 의해서 숨겨져 있으며, 닷넷에서도 이점은

같다.(Win32 에서 임계 역의 구조는 winnt.h 에 CRITICAL_SECTION 으로 정의되어 있다)

임계 역은 프로세스 공간에 있으며, 다른 프로세스와 직접 통신할 수 없다. 닷넷에서는 이러한 프로세스는

응용 프로그램 도메인(AppDomain)안에서 실행되며, 응용 프로그램 도메인을 사용하여 프로세스간에 데이터를

교환할 수도 있지만, 여기서는 범위를 벗어나므로 다루지 않는다.

커널 객체에 대해서 소개한 것을 기억하고 있다면 알 수 있겠지만, 임계 역은 커널 객체가 아니다. 때문에

각각의 프로세스마다 고유의 임계 역을 갖고 있으며, 다른 프로세스에 의해서 임계 역을 침범할 수 없다.

NT 에서 프로그래밍을 하고 있다면 커널 객체를 획득하려면 적절한 권한이 필요하지만, 임계 역은 그러한

과정이 필요없기 때문에 속도가 빠르다.

101

닷넷에서 동기화 객체

닷넷에서는 동기화를 위한 몇가지 객체가 제공된다. 다음은 닷넷의 주요 동기화 객체의 클래스 계층이다.

System.Object

System.MarshalByRefObject

System.Threading.WaitHandle

System.Threading.AutoResetEvent

System.Threading.ManualResetEvent

System.Threading.Mutex

위에서 알 수 있는 것처럼 닷넷 동기화 객체는 WaitHandle 기본 클래스를 상속한 AutoResetEvent 와

ManualResetEvent, Mutex 클래스가 있으며, 이들 각각의 동기화 객체는 실행시간에 WaitHandle 객체로

대표해서 처리할 수 있다는 것을 알 수 있다. 또한, WaitHandle 클래스는 Win32 동기화 핸들을 캡슐화하고

있다. AutoResetEvent 와 ManualResetEvent 는 쓰레드 동기화를 위해 이벤트를 사용하는 경우에 사용한다.

WaitHandle 기본 클래스는 시그널링(signaling) 메커니즘을 사용하기 때문에 멀티 쓰레드 처리에서 쓰레드

안정성(thread safety)을 갖고 있다고 한다.

위 동기화 객체 외에 처리를 대기시킬 수 있는 객체에는 Process, Thread, IOStream, AppDomain,

ReaderWriterLock 이 있다. Process, Thread 는 앞에서 이미 다루었으며, ReaderWriterLock 은 앞에서 소개한

생산자/소비자 프로그래밍 모델에 대해서 소개했는데 닷넷에서는 이것에 대해서 ReaderWriterLock 으로 구현해

두었다. IOStream 은 여기서 다루지 않는다.

Win32 에서의 임계 역

굳이 Win32 에서의 임계 역을 소개하려는 것은 닷넷에서는 모든 것을 개발자가 알 필요없도록 감춰두었기

때문이다. Win32 에 대한 코드는 모두 C++를 사용했다. Win32 에서의 임계 역은 다음과 같은 순서로 된다.

Win32 임계 역 의사 코드

102

// 임계 영역을 선언한다.

CRITICAL_SECTION cs;

// 임계 영역을 초기화한다.

InitializeCriticalSection(&cs);

// 임계 영역으로 들어간다. 여기서는 한 번에 하나의 쓰레드만

// 임계 영역으로 들어갈 수 있으며, 이미 임계 영역에 들어간

// 쓰레드가 있으면 다른 쓰레드는 여기서 대기한다.

EnterCriticalSection(&cs);

try

{

// 한 번에 하나의 쓰레드에 의해서만 실행될 수 있는 코드 블록이다.

}

finally

{

LeaveCriticalSection(&cs); // 임계 영역을 빠져나온다.

}

DeleteCriticalSection(&cs); // 임계 영역을 정리한다.

닷넷에서의 임계 역 - Monitor

Win32 임계 역 코드에서 볼 수 있는 것처럼 임계 역을 선언, 초기화, 진입, 빠져나오기, 정리와 같은

번거로운 작업이 필요하다. 닷넷에서는 위와 같은 번거로운 작업을 모두 없애고 간단하게 사용하기 위해

System.Threading.Monitor 클래스를 제공한다. 이 클래스의 사용법은 다음과 같다.

Monitor.Enter(this); // 임계 영역을 시작한다

Monitor.Exit(this); // 임계 영역을 종료한다

닷넷에서 임계 역을 사용하는 예제를 살펴보도록 하자.

이름 : CritSec01.cs

103

using System;

using System.Threading;

public class AppMain

{

private int counter = 0;

public static void Main()

{

AppMain app = new AppMain();

app.DoTest();

}

public void DoTest()

{

Thread t1 = new Thread( new ThreadStart(Incrementer) );

Thread t2 = new Thread( new ThreadStart(Decrementer) );

t1.Start();

t2.Start();

}

private void Incrementer()

{

Monitor.Enter(this);

104

try

{

while ( counter < 10 )

{

Console.WriteLine("Incrementer : " + counter.ToString());

counter++;

}

} // end of try

finally

{

Monitor.Exit(this);

} // end of finally

} // end of Incrementer

private void Decrementer()

{

Monitor.Enter(this);

try

{

while ( counter > 0 )

{

Console.WriteLine("Decrementer : " + counter.ToString());

counter--;

}

} // end of try

105

finally

{

Monitor.Exit(this);

} // end of finally

} // end of Decrementer

}

위 예제에서 알 수 있는 것처럼 두 개의 쓰레드를 생성하고, 각각의 쓰레드는 counter 의 값을 조건에 따라

증가시키거나 감소시킨다. 여기서 counter 는 두 쓰레드가 공유하는 공유 데이터가 된다. 따라서 이 값을

변경하는 코드 부분을 Monitor 클래스를 사용하여 임계 역으로 선언하고 있다. 코드를 컴파일하고 실행하면

결과는 다음과 같을 것이다.

하나의 쓰레드가 이미 임계 역을 사용중일 때 다른 쓰레드가 임계 역을 사용하려면 임계 역을 사용할 수

있게 될 때 까지 기다려야다. 만약에 쓰레드가 기다리는 것을 것을 원하지 않는다면 Monitor.Enter() 대신에

Monitor.TryEnter()를 사용하도록 한다.

106

Monitor.TryEnter()는 Monitor.Enter()와 비슷하지만 임계 역이 사용중이더라도 기다리지 않고 계속해서

실행한다. Monitor.TryEnter()는 락(lock)을 설정하지 못하면 false 를 반환하며, 임계 역에 들어가지 않은

상태에서 코드를 실행한다.

위 예제에서 사용한

Monitor.Enter(this);

를 모두 다음과 같이 변경한다.

Monitor.TryEnter(this);

다시 컴파일하여 실행된 결과는 다음과 같다.

결과에서 볼 수 있는 것처럼 Decrementer 쓰레드의 첫번째 출력은 7 이며, 다음 출력은 9 가 되는 것을 알 수

있을 것이다.(여러 번 실행해야 위와 같은 결과를 볼 수 있으며, 실행할때마다 약간씩 다른 결과가 나타나는

것을 볼 수 있을 것이다) 위에서 설명한 것처럼 Monitor.TryEnter()는 임계 역에 들어갔는지와 관계없이

코드를 실행한다. 따라서 Monitor.TryEnter()를 사용하려면 다음과 같이 수정해야한다. 여기서는

Decrementer()만을 수정했다.

107

private void Decrementer()

{

// 임계 영역에 들어가지 못했다.

if ( Monitor.TryEnter(this) == false )

{

Console.WriteLine("임계 영역에 들어가는데 실패");

}

// 임계 영역에 들어간 경우

else

{

while ( counter > 0 )

{

Console.WriteLine("Decrementer : " + counter.ToString());

counter--;

}

Monitor.Exit(this);

}

} // end of Decrementer

닷넷에서의 임계 역 - lock()

다음은 예제에서 사용했던 Monitor 클래스 대신에 lock()을 사용하여 동기화를 수행하도록 변경한 것이다.

전체예제에서 Incrementer()와 Decrementer() 부분만 수정하 다.

private void Incrementer()

{

lock(this)

{

while ( counter < 10 )

108

{

Console.WriteLine("Incrementer : " + counter.ToString());

counter++;

}

} // end of lock

} // end of Incrementer

private void Decrementer()

{

lock(this)

{

while ( counter > 0 )

{

Console.WriteLine("Decrementer : " + counter.ToString());

counter--;

}

} // end of lock

} // end of Decrementer

컴파일하여 실행하면 결과가 같다는 것을 알 수 있을 것이다. 실제로 lock()은 Monitor.Enter()와

Monitor.Exit()의 역할을 수행한다. Monitor 클래스보다 lock()이 더 사용하기 간편하다는 것을 알 수 있을

것이다. lock()은 특정 코드 블록을 잠금으로서 동기화하는데 유용하며, Monitor 클래스는 lock()보다 공유

자원에 대해서 정교한 제어가 필요할 때 사용한다. 그렇다면 이러한 Monitor 클래스와 lock()은 어떻게 구현된

것일까? 필자는 Monitor 클래스와 lock()을 C++로 구현하 다.

Win32 에서 Monitor 구현

닷넷에서 사용한 Monitor 클래스를 Win32 에서 구현해보도록 하자. 먼저 동기화에 대한 공통된 인터페이스를

갖고 있는 추상 클래스 SyncHandle 을 만든다.

109

이름 : SyncHandle.h

#include <windows.h>

class SyncHandle

{

protected:

HANDLE hObject; // Monitor 객체에 대한 핸들

public:

SyncHandle::SyncHandle();

SyncHandle::~SyncHandle();

virtual bool enter(DWORD dwTimeOut = INFINITE);

virtual bool exit() = 0;

};

hObject 는 객체에 대한 핸들을 갖고 있으며, 생성자와 파괴자를 정의하고, 임계 역에 들어가는 enter 와

exit 를 구현하고 있다. 여기서는 간단히 하기 위해 enter 에 대해서는 오버로딩을 구현하지 않았다. 독자중에

System.Threading.Monitor 클래스 멤버들을 MSDN 에서 찾아보았다면 Monitor.Enter()가 여러가지 버전으로

오버로딩되어 있다는 것을 알 수 있을 것이다. 이에 대해서는 다음에 다룰 것이다.

다음은 SyncHandle.cpp 의 소스다.

이름 : SyncHandle.cpp

#include "SyncHandle.h"

SyncHandle::SyncHandle()

{

110

hObject = NULL;

}

SyncHandle::~SyncHandle()

{

if ( NULL != hObject )

{

::CloseHandle(hObject);

hObject = NULL;

}

}

bool SyncHandle::enter(DWORD dwTimeOut)

{

if ( WaitForSingleObject(hObject, dwTimeOut) == WAIT_OBJECT_0)

return true;

else

return false;

}

파괴자에서는 객체에 대한 핸들이 있는지 확인하며, 핸들을 갖고 있다면 핸들을 정리한다. enter 는 실제로 임계

역에 들어가는 것이다. enter 의 실제 구현은 WaitForSingleObject 로 되어 있는데 하나의 객체에 대해서만

임계 역에 들어갈 때까지 기다린다는 것을 의미한다. enter 의 선언부분을 보면 virtual bool enter(DWORD

dwTimeOut = INFINITE)이므로, 임계 역을 획득할 때까지 무한히(INFINITE) 기다린다는 것을 뜻한다. 다시말해

Monitor.Enter()와 동일한 동작을 한다. WAIT_OBJECT_0 는 대기동작이 성공한 것을 뜻한다.(대기동작의 실패는

WAIT_FAILED 를 사용한다)

다음은 SyndHandle 클래스를 상속한 Monitor 클래스의 선언 파일이다.

이름 : Monitor.h

111

#include <windows.h>

#include "SyncHandle.h"

class Monitor : public SyncHandle

{

CRITICAL_SECTION cs;

public:

Monitor();

~Monitor();

virtual bool enter(DWORD dwTimeOut = INFINITE);

virtual bool exit();

bool tryEnter();

};

Monitor 클래스의 선언파일은 SyncHandle 에서 상속한 enter 와 exit 에 대한 선언 뿐만 아니라 임계 역에서

사용할 수 있는 tryEnter 를 추가로 구현한다. 또한 임계 역 cs 를 선언하고 있는 것에 주의한다.

이름 : Monitor.cpp

#include "Monitor.h"

Monitor::Monitor()

{

// 임계 영역을 초기화한다

InitializeCriticalSection(&cs);

}

112

Monitor::~Monitor()

{

// 임계 영역을 정리한다

DeleteCriticalSection(&cs);

}

bool Monitor::enter(DWORD /*not used*/)

{

// 임계 영역에 들어간다.

EnterCriticalSection(&cs);

return true;

}

bool Monitor::exit()

{

// 임계 영역에서 빠져나온다

LeaveCriticalSection(&cs);

return true;

}

bool Monitor::tryEnter()

{

#if( _WIN32_WINNT >= 0x0400 )

if ( TryEnterCriticalSection(&cs) )

return true;

else

#endif

113

return false;

}

Monitor 클래스는 .NET 의 Monitor 클래스와 큰 차이를 느끼지 못할 것이다. .NET 에서 사용했던

Monitor.Enter(), Monitor.Exit(), Monitor.TryEnter()를 구현한 것이다. 실제로 임계 역이 사용중일 때 대기하지

않도록 하려면 TryEnterCriticalSecion() Win32 API 를 사용한다. 이 API 의 동작은 Monitor.TryEnter()와 동일하다.

#if 는 C++에서 처리하는 전처리기이며, _WIN32_WINNT >= 0x0400 은 TryEnterCriticalSecion() Win32 API 가

윈도우 NT 4.0 이상에서만 동작하기 때문에 사용한 것이다. 윈도우 9x 계열에서는 이 API 가 동작하지 않는다.

참고로 윈도우 2000 은 _WIN32_WINNT 값이 0x0500 이며, 윈도우 XP, .NET Server 는 0x0501 이다.(필자의

예상이 맞다면 닷넷에서 Monitor.TryEnter()는 윈도우 9x 계열과 NT 3.5 이하에서는 동작하지 않을 것이다)

지금까지 작성한 Monitor 클래스를 이용하는 예제 프로그램을 살펴보도록하자.

이름 : CritSec01.cpp

#include <windows.h>

#include <iostream>

#include "Monitor.h"

using namespace std;

// 쓰레드 함수의 원형

DWORD WINAPI incrementer(LPVOID pv);

DWORD WINAPI decrementer(LPVOID pv);

Monitor monitor;

int counter = 0;

114

int main()

{

char* ps[] = {"incrementer", "decrementer"};

DWORD threadID;

const int nThread = 2;

HANDLE hThreads[nThread];

hThreads[0] = CreateThread( NULL,

0,

incrementer,

(LPVOID)ps[0],

0,

&threadID);

hThreads[1] = CreateThread( NULL,

0,

decrementer,

(LPVOID)ps[1],

0,

&threadID);

// 모든 쓰레드가 종료할 때 까지 기다린다.

//DWORD rc =

WaitForMultipleObjects(nThread, hThreads, TRUE, INFINITE);

115

CloseHandle(hThreads[0]);

CloseHandle(hThreads[1]);

return 0;

}

DWORD WINAPI incrementer(LPVOID pv)

{

monitor.enter();

char* ps = reinterpret_cast<char*>(pv);

while ( counter < 10 )

{

counter++;

cout << ps << " : " << counter << endl;

}

monitor.exit();

return 0;

}

DWORD WINAPI decrementer(LPVOID pv)

{

monitor.enter();

char* ps = reinterpret_cast<char*>(pv);

while ( counter > 0 )

116

{

counter--;

cout << ps << " : " << counter << endl;

}

monitor.exit();

return 0;

}

코드를 모두 입력했다면 저장한다. 지금까지 총 5 개의 C++ 파일을 작성하 다: SyncHandle.h,

SyncHandle.cpp, Monitor.h, Monitor.cpp, CritSec01.cpp

입력된 코드의 컴파일은 다음과 같이 한다. 참고로 모든 C++ 코드는 순수 C++로 작성되었으며 Borland C++나

Visual C++ 양쪽 모두에서 제대로 컴파일된다.(필자는 C++ Builder 5.0 과 Visual C++ 6.0 으로 테스트하 다)

C++ Builder 또는 Borland C++를 사용하고 있다면 다음과 같이 컴파일한다.

bcc32 -c SyncHandle.cpp

bcc32 -c Monitor.cpp

bcc32 CritSec01.cpp SyncHandle.obj Monitor.obj

Visual C++을 사용하고 있다면 다음과 같이 컴파일한다.

cl /c SyncHandle.cpp

cl /c Monitor.cpp

cl /EHs CritSec01.cpp SyncHandle.obj Monitor.obj

두 컴파일러 모두 c 옵션은 C++ 소스 파일을 실행파일로 만들지말고 기계어 파일(.obj)로 만들라는 것을

뜻한다. VC++은 몇가지 차이점이 있기 때문에 예외가 발생하면서 컴파일되기 때문에 예외를 화면에 출력하지

않도록 하기 위해 EHs 옵션을 사용하 다. 실행 결과는 다음과 같다.

117

결과에서 알 수 있는 것처럼 닷넷에서 했던것과 Win32 API 를 사용하여 직접 Monitor 클래스를 구현한 것과 큰

차이가 없다는 것을 알 수 있을 것이다. 여기서는 일부러 Win32 API 를 사용하여 Monitor 클래스를 직접

구현했다. 임계 역이 무엇인지, Monitor 클래스가 어떻게 동작하는지 알고 있다면 문제가 생겼을 때 문제의

원인을 하나씩 풀어가기 쉬운 것은 당연할 것이다. 마지막으로 글이 길어지면 한빛미디어 편집진에게 혼나기

때문에 닷넷의 lock()에 대한 구현은 여기서 소개하지 않고, 소스에만 포함시켜 두었다. 관심있는 분들은 Lock.h,

Lock.cpp, CritSec02.cpp 를 살펴보기 바란다. C#에서의 lock()을 C++에서는 Lock l(&cs);와 같이 사용한다는

정도의 차이만이 있을 뿐이다.(Lock 클래스는 Lock.h 에 정의되어 있다) 이 소스를 곰곰히 분석해보면 닷넷에서

lock()이 어떻게 임계 역으로 설정될 수 있는 범위를 설정하고(Enter) 나올 수 있는지(Exit) 알 수 있을 것이다.

Win32 API 구현에서 보면 WaitForSingleObject Win32 API 를 볼 수 있는데 이것은 세번째 글에서 다루었던

Thread.Join()에서 이미 설명한 것이다. WaitForMultipleObjects Win32 API 에 대한 부분은 아직 설명하지 않았다.

118

닷넷에서는 이 부분이 크게 세가지로 나누어져 있다: WaitOne, WaitAny, WaitAll.

이 부분에 대해서는 나중에 설명할 것이다.

마치며

이번에는 쓰레드 동기화에서 가장 자주 사용되는 임계 역에 대해서 살펴보았으며, 임계 역에서 가장 많이

사용하는 방법중에서 Monitor 와 lock 에 대해서 살펴보았다. 다음에는 이 보다 발전적인 방법인

뮤텍스(Mutex)에 대해서 설명할 것이다. 뮤텍스는 매우 강력한 동기화 도구지만 커널 객체이기 때문에 성능이

떨어지지만 프로세스간에 동기화를 수행할 수 있다. 왜 뮤텍스를 써야하는지 다음 시간에 자세히 살펴보도록

하자. 마지막으로 멋진 동기화 코드를 소개한 Julian 에게 감사드리며, Win32 와 닷넷간의 차이점에 대해서

명쾌한 답변을 해준 Barak 에게 감사드린다.

119

C# 쓰레드 이야기: 10. 뮤텍스(Mutex)

지난 시간에는 임계 역(Critical Section)에 대해서 살펴보고, 동기화를 위해 사용할 수 있는 Monitor 클래스와

lock()에 대해서 살펴보았다. 이번에는 동기화에 유용하게 사용할 수 있는 뮤텍스에 대해서 살펴보도록 하자.

신호 메커니즘(Signaling Mechanism)

멀티 쓰레드 시스템에서 쓰레드를 동기화하기 위해 신호 메커니즘(Signaling Mechanism)을 사용한다. 여러

개의 쓰레드가 사용하는 공유 자원이 손상되는 것을 막기 위하여 동기화를 사용한다. 동기화를 위해 사용하는

객체를 동기화 객체라 한다. 이러한 동기화 객체는 사용할 수 있는 상태를 알려줄 수 있다. 이러한 상태를

알려주는 것을 시그널링(Signaling)이라 한다. 동기화 객체가 사용가능할 때를 시그널되었다(Signaled)라고 하며,

동기화 객체가 사용중일 때를 시그널되지 않았다(Nonsignaled)라고 한다.

Signaling Mechanism - 흔히들 시그널링 메커니즘이라고 얘기하며, 필자는 신호 메커니즘이라는 용어로

사용한다.

Signaled - 대부분의 도서들이 Signaled 원문으로 표시하거나 시그널 되었다로 표시하고 있으나 이러한

용어는 매우 모호하다. 필자는 신호상태(Signaled)로 사용한다.

Nonsignaled - Signaled 와 마찬가지로 필자는 비신호상태(Nonsignaled)로 사용한다.

하나의 쓰레드가 공유 자원을 사용하고 있을 때 공유 객체는 비신호상태가 된다. 다른 쓰레드가 공유 자원을

사용하려고 하면 비신호상태이기 때문에 공유 자원이 이미 사용중이라는 것을 알 수 있다. 따라서 공유 자원을

사용할 수 있게 될 때 까지 기다린다. 공유 자원의 사용이 다 끝나면 공유 자원이 반환된다. 공유 자원이

반환되면 다른 쓰레드에게 공유 자원이 사용가능한 상태임을 알리고(Signaling), 신호상태(Signaled)가 된다.

식사하는 철학자(Dining Philosophers)

쓰레드에 대한 설명에서 빠지지 않는 고전적인 문제가 있으니, 그것을 식사하는 철학자 문제라 한다. 이 문제는

다음과 같다. 하나의 테이블에 다섯명의 철학자가 앉아있다. 테이블에는 5 개의 스파게티 접시가 있고, 5 개의

포크가 있다. 스파게티는 매우 미끄럽기 때문에 식사를 하려면 2 개의 포크를 사용해야한다. 철학자는 생각하는

120

것과 식시하는 것, 두 가지 일만 할 수 있다. 철학자는 생각을 하다가 배가 고프면 식사를 한다.

이 문제에서 철학자가 쓰레드를 뜻하는 것임은 잘 알 수 있을 것이다. 철학자가 식사를 하려고 할 때 이용할 수

있는 포크가 있으면 먼저 오른쪽 포크를 든다. 그 다음에 왼쪽 포크를 이용할 수 있는지 확인하고 남아있는

포크가 있으면 왼쪽 포크를 집어서 식사를 한다. 루프를 돌면서 다섯 개의 포크를 이용할 수 있는지 확인하는

것도 하나의 방법이 될 것이다. 그러나 이 경우에 교착상태(deadlock)에 빠질 위험이 있다. 만약에 다섯명의

철학자가 동시에 배가 고파져서 동시에 오른쪽 포크를 집는 다면 어떻게 될까? 모든 철학자가 다른 포크를

이용할 수 있을 때까지 기다리게 되므로 철학자는 굶어죽게 된다. 이런 경우에는 하나의 포크를 집은 다음에

일정 시간 동안 다른 포크를 갖지 못한다면 다시 포크를 내려놓게 하는 방법으로 철학자가 굶어죽는 것을 막을

수 있을 것이다. 그러나 이러한 해결 방법은 비효율적이다. 다섯명의 철학자가 포크를 들고 있다가, 한 명의

철학자가 포크를 내려놓는 순간 다른 철학자가 포크를 들어서 식사한다고 생각해 보자. 세 명의 철학자는 한

손에 포크를 들고 있으며, 한 명의 철학자는 두 개의 포크를 들고 식사를 하고 있으며, 다른 한 명의 철학자는

아무 포크도 갖고 있지 않을 것이다. 실제로 다섯 개의 포크가 있으므로 최소한 두 명의 철학자가 동시에

식사를 할 수 있는데, 한 명의 철학자만 식사를 하고 있으므로 비효율적이다.

철학자 문제의 목적은 모든 철학자중에 어떤 철학자도 굶어죽는 일이 없어야한다는 것이다.

이 문제에 대한 해법은 여러가지가 있지만, 그 중에 필자가 알고 있는 것들은 다음과 같다.

• 철학자들은 식사할 때만 테이블에 있도록 한다. 테이블에는 4 명의 철학자만 있도록 한다.(N 명의

철학자에 대하여 N-1 명의 철학자만 테이블에 있도록 한다)

• 식사를 하기 전에 양옆의 포크를 모두 이용할 수 있는지 확인한 다음에 짚는다.

• 홀수번째 철학자는 오른쪽 포크를 먼저들고, 짝수번째 철학자는 왼쪽 포크를 먼저들도록 한다.

여기서 마지막 해결 방법이 획기적인 아이디어로 생각되지만, 이 세 가지 해결책은 하나의 철학자가 굶어죽는

문제를 해결하지는 못한다.

이 문제를 해결하는 가장 좋은 방법은 다음과 같다.

121

철학자가 생각을 하다가 배가 고프면 양쪽에 포크를 이용할 수 있는지 확인한다. 양쪽에 있는 포크를 모두

이용할 수 있으면 식사를 하지만, 그렇지 않다면 식사를 하지 않고 대기중(wait)으로 표시한다. 양쪽에 있는

포크를 모두 이용할 수 있으면 식사를 시작하고 식사중으로 표시한다. 식사가 끝나면 포크를 다시 양옆에

내려놓고 식사가 끝났다는 사실을 대기중인 철학자에게 알려준다(signaling). 여기서 대기중인 철학자는

누군가가 알려주기 전에는 절대 깨어나지 않는다. 그러나 대기중인 철학자가 아무도 없다면 식사가 끝났다는

사실을 알려줘도 아무 일도 일어나지 않는다.

멀티 쓰레드 프로그래밍에는 자주 발생하기 문제에 대한 몇 가지 동기화 방법이 있으며, 이러한 동기화 방법을

설계 패턴(Design Pattern)이라 한다. 식사하는 철학자 문제에 사용하는 방법은 상호배제(Mutual Exclusion)이며,

간단히 뮤텍스(Mutex)라 한다. 뮤텍스는 공유 자원에 대해서 상호 배타적 접근을 위해서 사용된다.

뮤텍스(Mutex)

뮤텍스는 지금까지 소개한 임계 역이 프로세스 객체에 속한 것과는 달리 커널 객체(Kernel Object)중에

하나다. 커널 객체는 보안과 관련되어 있기 때문에 자원을 사용하기 전에 보안을 확인하기 때문에 임계 역을

사용하는 것보다 느리다. Win32 에서 뮤텍스는 Win32 커널 객체에 속하며, 닷넷에서는 닷넷 커널 객체에

속한다.(이렇게 얘기하고 있지만 닷넷에서 Mutex 클래스는 Win32 CreateMutex 와 동일하다)

뮤텍스는 자원을 최소 수행 단위로 획득하기 위해 사용되며, 위 철학자 문제에서 최소 수행 단위로

획득해야하는 자원은 포크 2 개가 된다. 또한 철학자들간에 식사를 위해서 대기중이거나, 포크를 다 사용했다는

사실을 알리는 것처럼 하나의 프로세스(테이블)에 있는 여러 개의 쓰레드(철학자)간에 통신을 할 수 있는

방법을 제공한다. 뮤텍스는 커널 객체이기 때문에 프로세스간에 통신을 하기 위해서 사용할 수 있다.

Mutex 클래스

Mutex 클래스는 System.Threading 네임스페이스에 있으며, 생성자는 다음과 같다.

생성자 설명

122

public Mutex(); 기본값으로 뮤텍스를 초기화한다.

호줄하는 쓰레드가 뮤텍스의 초기 소유권을

갖는지를 정한다.

public Mutex(bool);

public Mutex(bool, string); 뮤텍스의 이름을 정한다.

뮤텍스의 초기 소유권, 뮤텍스 이름,

메소드가 뮤텍스의 초기 소유권 반환 여부

public Mutex(bool, string, bool);

첫번째는 기본값으로 뮤텍스를 초기화하는 것이다. 두번째는 뮤텍스를 생성하는 쓰레드가 뮤텍스의 소유권을

갖도록 할 것인가를 결정한다. true 로 되면 뮤텍스를 생성한 쓰레드가 소유권을 갖게된다. 뮤텍스를 이용하여

프로세스간에 동기화를 하려면 뮤텍스 이름을 이용한다. 세번째와 네번째 생성자는 뮤텍스의 이름을 지정한다.

myMutex 와 같은 문자열을 이름으로 사용하여 동기화할 수 있으며, 각각의 쓰레드는 같은 뮤텍스에 대해서

다른 핸들을 갖게된다. 이처럼 실제로 하나의 핸들(뮤텍스)에 대해서 다른 핸들을 갖게 되는 것을 모사 핸들

내지는 의사 핸들이라한다. 쉽게말하면 가짜 핸들이다.

식사하는 철학자 문제에서와 같이 공유 자원을 뮤텍스가 관리하도록 한다. 공유 자원이 필요한 스레드는

뮤텍스에게 공유 자원을 요청하도록 한다. 뮤텍스의 사용은 다음과 같다.

Mutex mutex = new Mutex();

mutex.WaitOne();

// 공유 자원 사용

mutex.ReleaseMutex();

WaitOne()은 하나의 자원이 이용가능해질 때까지 대기하는 것을 뜻한다. 이 메소드는 WaitHandle 클래스에서

상속받은 것이다. WaitOne(), WaitAny(), WaitAll()을 사용할 수 있다. 뮤텍스에서는 Wait 를 호출한 만큼

ReleaseMutex() 호출해야한다. 그렇지 않으면 자원은 해제되지 않으며 다른 쓰레드들은 계속해서 자원이

해제되기를 기다리게 된다.

Monitor 나 Mutex 사용시 주의할 점

123

Monitor 나 Mutex 를 사용하여 공유 자원에 대한 동기화를 할 때 다음과 같은 코드를 작성하지 않도록 주의한다.

public int Data

{

get

{

mMutex.WaitOne();

int lData = mData;

return lData;

mMutex.ReleaseMutex();

}

}

빨간색으로 표시된 부분과같이 공유 자원에 대한 락을 획득한 상태에서 함수를 빠져나가면 자원은 해제되지

않고 교착상태에 빠지게된다. 따라서 Mutex 를 먼저 해제한 다음에 return 을 사용하여 값을 반환하고 함수를

빠져나가도록 해야한다. 다행히도 닷넷에서 이러한 종류의 오류를 하게되면 코드는 컴파일되지 않으며

Unreachable code detected 오류 메시지를 출력한다.

다음은 뮤텍스를 이용한 동기화 예제다.

이름 : Mutex01.cs

using System;

using System.Threading;

public class SharedDataObject

{

public SharedDataObject ()

{

mMutex = new Mutex ();

124

}

public int Data

{

get

{

Console.WriteLine("Mutex-get : " + Thread.CurrentThread.Name);

mMutex.WaitOne();

Thread.Sleep(100);

int lData = mData;

mMutex.ReleaseMutex();

return lData;

}

set

{

Console.WriteLine("Mutex-set : " + Thread.CurrentThread.Name);

mMutex.WaitOne();

Thread.Sleep(2000);

mData = value;

mMutex.ReleaseMutex();

}

}

private int mData;

static Mutex mMutex = new Mutex(true);

}

125

public class AppMain

{

AppMain()

{

Console.WriteLine("Begin Application");

Thread.CurrentThread.Name = "Primary Thread";

}

~AppMain()

{

Console.WriteLine("End Application");

}

static void Main()

{

AppMain ap = new AppMain();

ap.DoTest();

}

private SharedDataObject msdo = new SharedDataObject();

private void DoTest()

{

// Initialize shared data.

msdo.Data = 0;

126

Thread[] threads = {

new Thread ( new ThreadStart(DoReading) ),

new Thread ( new ThreadStart(DoReading) ),

new Thread ( new ThreadStart(DoReading) ),

new Thread ( new ThreadStart(DoWriting) )

};

threads[0].Name = "Read 1";

threads[1].Name = "Read 2";

threads[2].Name = "Read 3";

threads[3].Name = "Write 1";

threads[0].Start();

threads[1].Start();

threads[2].Start();

threads[3].Start();

Thread.Sleep(3000);

}

private void DoReading()

{

for ( int loopctr = 0; loopctr < 10; loopctr++)

{

Console.WriteLine(Thread.CurrentThread.Name + " ctr " + loopctr.ToString() +

" : " + msdo.Data);

127

}

}

private void DoWriting()

{

Console.WriteLine("Started - DoWriting1()");

for( int loopctr = 0; loopctr < 10; loopctr++)

{

Console.WriteLine("Shared Data Increased");

msdo.Data++;

}

Console.WriteLine("Ended - DoWriting1()");

}

}

공유 데이터는 SharedDataObject 클래스로 캡슐화하고 있으며, Mutex.WaitOne()을 사용하여 뮤텍스를 가져오고,

뮤텍스의 사용이 끝나면 Mutex.ReleaseMutex()를 사용하여 뮤텍스를 해제한다. 처리를 명확하게 하기 위해

get 과 set 에서 콘솔에 메시지를 출력하도록 하 다. get 과 set 부분을 자세히 살펴보도록 하자.

get

{

Console.WriteLine("Mutex-get : " + Thread.CurrentThread.Name);

mMutex.WaitOne();

Thread.Sleep(100);

int lData = mData;

mMutex.ReleaseMutex();

128

return lData;

}

mMutex.WaitOne()은 하나의 뮤텍스를 얻기 위해 사용한다. 사용가능한 뮤텍스가 없으면 여기서 사용가능한

뮤텍스가 있을 때까지 기다리게 된다. 뮤텍스를 얻은 다음에 가상의 처리를 에뮬레이트하기 위해

Thread.Sleep 을 사용하 다. 처리가 끝나면 ReleaseMutex()를 사용하여 뮤텍스를 반환한다.

set

{

Console.WriteLine("Mutex-set : " + Thread.CurrentThread.Name);

mMutex.WaitOne();

Thread.Sleep(2000);

mData = value;

mMutex.ReleaseMutex();

}

이것은 set 에 대한 부분이다. get 과 크게 바른 부분은 없으며, 보다 큰 처리시간을 에뮬레이트하기 위해

Thread.Sleep(2000)을 사용하 다. 이 때문에 실제로 예제를 수행하면 3 개의 읽기 쓰레드는 처리시간이 짧기

때문에 같은 시간에 데이터를 상호 배타적으로 읽지만, 처리시간이 긴 쓰기 쓰레드가 작업중일 때는 읽기

쓰레드가 시작되지 않는다는 것을 알 수 있다.

다시 말해서 쓰기 쓰레드가 작업중인 동안에 읽기 쓰레드가 수행하는 get 부분의 mMutex.WaitOne()에서

사용가능한 뮤텍스가 없기 때문에 더 이상 수행하지 않고 대기한다. 쓰기 작업이 끝나고 뮤텍스가 반환되면

대기중인 쓰레드들 중에 한 쓰레드가 뮤텍스를 획득한다. 뮤텍스를 획득한 쓰레드는 잠에서

깨어나(mMutex.WaitOne) 계속해서 처리를 하게 된다.

이 예제를 실행해보고 무슨 일이 일어나는지 이해했다면 한 가지 의문이 생길 것이다. 데이터를 쓰는 동안은

데이터를 읽지 못하도록 할 필요가 있지만, 데이터를 쓰는 중이 아니라면 읽기 동작에 굳이 동기화를 할 필요가

있을까?

이것은 생산자/소비자(Provider/Consumer) 모델 또는 독자/작가(Reader/Writer) 모델로 알려져 있다. 특히 위

129

예제와 같은 것을 단일 생산자/다중 소비자 모델이라고 한다. 닷넷에서는 이러한 모델에 대한 클래스를

제공하는데 ReaderWriterLock 클래스다. 이 클래스에 대해서는 다음 시간에 뮤텍스에 이어서 계속 설명하겠다.

lock()의 함정

마지막으로 지난 시간에 간단하게 다루었던 lock()에 대한 것인데 다음과 같이 사용하면 절대로 동기화되지

않는다.

int iData = 0;

public void DoSomething()

{

lock(iData)

{

// do something

}

}

위와 같은 코드는 잘 실행되며, 분명히 어떤 에러도 발생하지 않는다. 또한 어떤 쓰레드도 잠기지 않는다.

쓰레드를 잠그려 하면 값 타입(value type)인 iData 는 참조 타입(reference type)으로 박싱(boxing)되기 때문에

결과적으로 iData 값은 새로운 스택에 복사된다. 때문에 위와 같은 메소드가 호출될 때마다 스택에 이 정수형의

다른 사본이 생성된다. 때문에 위와 같은 코드를 실행할 때마다 스레드는 다른 객체에 대해서 락을 하는 것이

되기 때문에 어떤 쓰레드도 블록되지 않는다. 따라서 lock()에는 레퍼런스 타입만 사용할 수 있다.(일반적으로

많이 쓰는 lock(this)등은 레퍼런스 타입이므로 문제가 되지 않는다) 이것은 다른 동기화 객체에도 해당된다.

실제로는 위와 같은 문장은 컴파일되지 않는다. 그러나 lock((object)iData)와 같이 하면 문제없이 컴파일할 수

있으며, 위와 같은 이유로 동기화는 수행되지 않는다.

System.Type 클래스의 객체는 클래스의 static 메소드를 사용하여 상호 배제를 구현할 수 있다.

class Shared

{

130

public static void Add(object x) {

lock (typeof(Shared)) {

// do something

}

}

public static void Remove(object x) {

lock (typeof(Shared)) {

// do something

}

}

}

마치며

이번에는 뮤텍스에 대해서 알아봤다. 아직 뮤텍스를 제대로 설명한 것은 아니다. 다음 시간에는 뮤텍스에

대해서 보다 자세히 알아볼 시간을 가질 것이다. WaitOne, WaitAny, WaitAll 의 사용에 대해서 이해하는 시간을

가질 것이며, 이벤트에 대해서도 살펴볼 것이다. 아마도 몇 주 후에는 지금까지 살펴본 클래스들에 대해서

정리하는 시간을 갖게 될 것이다.

131

C# 쓰레드 이야기: 11. 이벤트(Event)

지난 시간에는 식사하는 철학자 문제와 뮤텍스에 대해서 소개했으며, 이번에는 뮤텍스의 나머지 부분에 대해서

알아보자. 그전에 이벤트에 대해서 알아보자.

이벤트 지금까지 살펴본 모니터나 뮤텍스는 하나의 쓰레드가 공유 데이터를 액세스하는데 유용하지만, 여러

개의 쓰레드가 서로의 실행을 방해하지 않으면서 쓰레드간에 메시지를 주고 받으려면 어떻게 해야할까?

이런 경우를 생각해보자. A 쓰레드가 작업이 끝나면 이 사실을 전달받은 B 쓰레드가 작업을 시작한다. 두

쓰레드간에 데이터를 공유할 필요도 없고, 동기화를 사용할 필요도 없다. 이와 같은 작업을 파이프 라인으로

물이 흘러가는 것과 같다고 해서 pipe-lined execution 이라고도 한다. 이 경우에는 단지 대기중인 쓰레드에게

작업이 끝난다는 사실만을 전달해주면 된다. 이런 경우에 이벤트를 사용한다.

참고 도서

IT 백두대간, C# 프로그래밍

김대희

목차보기

이벤트는 신호상태(Signaled)와 비신호상태(Non-Signaled) 두 가지 상태를 갖고 있다. 신호상태와

비신호상태라는 것은 마치 등이 하나뿐인 신호등과 같다. 신호등에 대기중인 쓰레드는 등이 켜지지 전(non-

signaled)에는 대기하고, 신호등이 켜지면(signaled) 가던 길을 계속 갈 수 있게된다. 신호 메커니즘에 대한

보다 자세한 글은 지난 글을 참고하기 바란다.

이벤트는 두 가지 종류가 있다. 하나는 AutoReset 이벤트이며 다른 하나는 ManualReset 이벤트이다. 이들

132

각각의 이벤트는 실제로 닷넷에서 AutoResetEvent 와 ManualResetEvent 클래스로 구현되어 있다. 이들

클래스의 생성자는 다음과 같다.

public ManualResetEvent(bool initialState);

public AutoResetEvent(bool initialState);

initialState 는 true 나 false 를 사용할 수 있으며, 이벤트의 초기상태를 신호상태로 할 것인지, 비신호상태로 할

것인지를 지정한다. 대부분의 경우에 false 를 사용한다.

먼저 AutoResetEvent 를 사용하는 방법에 대해서 살펴보자. 이벤트를 생성하려면 다음과 같이 한다.

여기서는 각각의 작업 3 개에 대해서 3 개의 이벤트를 생성한다.

public AutoResetEvent areStep1 = new AutoResetEvent(false);

public AutoResetEvent areStep2 = new AutoResetEvent(false);

public AutoResetEvent areStep3 = new AutoResetEvent(false);

위 예제에서는 false 를 사용하기 때문에 비신호상태로 선언한다는 것을 알 수 있다. 비신호상태라는 것은

신호가 될 때까지 신호등 앞에서 기다린다는 의미로 해석하면 된다. 즉, 처음부터 이벤트는 대기상태가 된다.

필자는 쓰레드들의 각각의 작업이 Step1 → Step2 → Step3 으로 수행되기를 원한다. 또한 이들 각각의 작업은

데이터를 공유하지 않으며, 단순히 대기중인 쓰레드에게 작업이 끝났다는 사실만을 알려주기 위해 이벤트를

사용한다. 다음은 Step1 에 대한 함수이다.

public void Step1()

{

Console.WriteLine("Processing Step1");

Thread.Sleep(3000);

areStep1.Set();

}

먼저 화면에 Step1 을 처리중이라는 메시지를 출력한다. 그리고 실제로 어떤 작업을 수행하는 함수가

들어가야하지만 여기서는 간단히 어떤 작업을 처리하는 것을 에뮬레이트하기 위해 Thread.Sleep(3000)을

사용하여 3 초간 처리중인 것처럼 하 다. 처리가 끝나면 areStep1.Set()을 사용하여 첫번째 이벤트 areStep1 을

133

신호상태로 변경한다. areStep1 이 신호상태로 바뀐 것을 감지하고 작업을 수행하는 쓰레드는 Step2 이다. 이제

Step2 는 어떻게 Step1 의 쓰레드가 이벤트를 신호상태로 바꾼 것을 알고, 작업을 처리하는지 살펴보자.

public void Step2()

{

areStep1.WaitOne();

Console.WriteLine("Processing Step2");

Thread.Sleep(1000);

areStep2.Set();

}

가장 중요한 부분인데, areStep1.WaitOne()이라고 되어 있다. 즉, 첫번째 이벤트 areStep1 이 신호상태가 될

때까지 기다린다는 것을 의미한다. 즉, 쓰레드가 처리하는 어떤 곳에서든지 areStep1 이 신호상태가 되면

그것을 감지하고 대기중인 쓰레드를 잠에서 깨우는 역할을 하는 부분이다. Step2 에서도 Thread.Sleep(1000)

대신에 DoPerformStep2()와 같이 어떤 함수를 사용할 것이지만 여기서는 작업을 에뮬레이트하기 위해 간단히

Thread.Sleep()을 사용하 다. 마찬가지로 작업이 끝난 다음에 이벤트 areStep2 를 신호상태로 변경하여 다른

쓰레드들에게 알린다. Step3 은 Step2 와 동일하며, 단지 대기중인 이벤트만 다르다.

이에 대한 전체 소스는 다음과 같다.

이름 : event01.cs

using System;

using System.Threading;

public class AppMain

{

public AutoResetEvent areStep1 = new AutoResetEvent(false);

public AutoResetEvent areStep2 = new AutoResetEvent(false);

public AutoResetEvent areStep3 = new AutoResetEvent(false);

134

public void Step1()

{

// do something

Console.WriteLine("Processing Step1");

Thread.Sleep(3000);

areStep1.Set();

}

public void Step2()

{

areStep1.WaitOne();

Console.WriteLine("Processing Step2");

Thread.Sleep(1000);

areStep2.Set();

}

public void Step3()

{

areStep2.WaitOne();

Console.WriteLine("Processing Step3");

areStep3.Set();

}

public void DoTest()

{

Thread thread1 = new Thread(new ThreadStart(Step1) );

135

Thread thread2 = new Thread(new ThreadStart(Step2) );

Thread thread3 = new Thread(new ThreadStart(Step3) );

Console.WriteLine("Thread 1, 2, 3 are started");

thread1.Start();

thread2.Start();

thread3.Start();

}

public static void Main()

{

AppMain ap = new AppMain();

ap.DoTest();

}

}

위에서는 AutoResetEvent 를 살펴보았다. AutoResetEvent 와 ManualResetEvent 의 차이점은 여러분이 짐작하고

있는 것처럼 이벤트의 상태가 자동으로 초기화되느냐 그렇지 않느냐의 차이다. 위의 예제에서 Step2 를

처리하는 쓰레드는 이벤트가 신호상태가 되기를 대기한다. 신호상태가 되면 이벤트는 쓰레드를 통과시키고 다시

자동으로 비신호상태가 된다. 따라서 직접 Reset()을 사용할 필요가 없다. 반면에 ManualResetEvent 는 한 번

신호상태가 되면 다시 Reset()을 명시적으로 호출하여 비신호상태로 만들때까지 계속 신호상태를 유지한다.

참고로 Main()에서 두 개의 인스턴스를 생성하여 실행해보도록한다.

public static void Main()

{

AppMain ap = new AppMain();

ap.DoTest();

AppMain ap2 = new AppMain();

ap2.DoTest();

136

}

이와 같이 변경한 다음에 실행해보면 두 인스턴스의 각각의 쓰레드들이 병렬적으로 실행된다는 것을 알 수

있을 것이다.(전부 몇 개의 쓰레드가 실행중인지 확인하고 싶다면 Thread.Sleep()의 시간을 충분히 늘려놓은

다음에 7 회에서 작성한 WinTop 을 이용해서 몇 개의 쓰레드가 실행중인지 확인해보는 것도 좋을 것이다) 또는

다음과 같이 Main()을 수정하고 실행해본다.

public static void Main()

{

AppMain ap = new AppMain();

ap.DoTest();

ap.DoTest();

}

즉, 특정 작업이 Step1 → Step2 → Step3 으로 병렬적으로 실행되는데 좋다는 것을 알 수 있을 것이다.

대용량의 데이터 다섯 개를 동시에 1M 씩 메모리로 읽어들이고, 1M 씩 계산하고, 처리된 결과를 저장하는 것과

같은 단계별 작업이 필요하다면 최소한 3 개의 쓰레드가 이벤트를 통해서 신호를 주고 받으면서 작업할 수 있을

것이다.(이 경우에 공유 데이터가 없다는 사실에 유의한다.)

수동 이벤트에 대해서 알아보기 위해 위 예제를 ManualResetEvent 를 사용하도록 변경해보자.

public ManualResetEvent mreStep1 = new ManualResetEvent(false);

public ManualResetEvent mreStep2 = new ManualResetEvent(false);

public ManualResetEvent mreStep3 = new ManualResetEvent(false);

ManualResetEvent 역시 AutoEventReset 과 동일한 생성자를 갖는다. 여기서도 마찬가지로 세 개의 이벤트를

모두 비신호상태로 둔다. Step1 은 다음과 같이 변경한다.

public void Step1()

{

mreStep1.WaitOne();

mreStep1.Reset();

Console.WriteLine("Processing Step1");

Thread.Sleep(3000);

137

mreStep1.Set();

}

WaitOne()으로 이벤트가 신호상태가 되기를 기다리는 코드를 추가하 다. AutoResetEvent 와 달리 신호상태에서

하나의 대기 쓰레드를 통과시킨 다음에 자동으로 비신호상태가 되지 않으므로 Reset()을 호출하여 명시적으로

비신호상태로 전환한다.

이름: event02.cs

using System;

using System.Threading;

public class AppMain

{

public ManualResetEvent mreStep1 = new ManualResetEvent(false);

public ManualResetEvent mreStep2 = new ManualResetEvent(false);

public ManualResetEvent mreStep3 = new ManualResetEvent(false);

public void Step1()

{

mreStep1.WaitOne();

mreStep1.Reset();

Console.WriteLine("Processing Step1");

Thread.Sleep(3000);

mreStep1.Set();

}

public void Step2()

{

mreStep1.WaitOne();

138

mreStep1.Reset();

Console.WriteLine("Processing Step2");

Thread.Sleep(1000);

mreStep2.Set();

}

public void Step3()

{

mreStep2.WaitOne();

mreStep2.Reset();

Console.WriteLine("Processing Step3");

mreStep3.Set();

}

public void DoTest()

{

Thread thread1 = new Thread(new ThreadStart(Step1) );

Thread thread2 = new Thread(new ThreadStart(Step2) );

Thread thread3 = new Thread(new ThreadStart(Step3) );

thread1.Start();

thread2.Start();

thread3.Start();

Console.WriteLine("Thread 1, 2, 3 are started");

}

139

public static void Main()

{

AppMain ap = new AppMain();

ap.DoTest();

}

}

위 코드를 컴파일하고 실행하면 아무것도 실행되지 않는다는 것을 알 수 있다. Step1 에서도

mreStep1.WaitOne()으로 첫번째 이벤트 mreStep1 이 신호되기를 기다리고 있기 때문이다. 따라서 DoTest 를

다음과 같이 수정한다.

public void DoTest()

{

Thread thread1 = new Thread(new ThreadStart(Step1) );

Thread thread2 = new Thread(new ThreadStart(Step2) );

Thread thread3 = new Thread(new ThreadStart(Step3) );

thread1.Start();

thread2.Start();

thread3.Start();

mreStep1.Set();

Console.WriteLine("Thread 1, 2, 3 are started");

}

이벤트를 신호상태로 만들어준다. 컴파일하여 실행하면 AutoResetEvent 를 사용한 것과 차이가 없을

것이다.(내부적으로는 어떻든간에 말이다)

여기서는 AutoResetEvent 와 ManualResetEvent 모두 하나의 이벤트만을 기다리도록 하 다. 그러나 멀티

쓰레드 프로그램에서 어떤 종류의 작업은 동시에 일어나야하는 경우도 있다. AutoResetEvent 는 여러 개의

140

쓰레드가 하나의 작업을 처리하기 위해서 메시지를 주고 받는데 유용하며, ManualResetEvent 는 여러 개의

쓰레드가 동시에 여러 개의 작업을 처리하기 위해서 메시지를 주고 받는데 유용하다. 예를 들어서, 사용자가

워드 파일을 읽어들 을 때, 문서에 있는 단어수를 세는 쓰레드가 하나, 문서를 화면에 표시하는 쓰레드가 하나,

맞춤법을 검사하는 쓰레드가 하나, 인쇄를 하는 쓰레드가 하나. 이렇게 4 개의 쓰레드가 문서를 읽어들이는

시점에 동시에 발생해야한다면 ManualResetEvent 를 사용하도록 한다.

일반적으로 ManualResetEvent 는 Set()을 호출하고 바로 Reset()을 호출하는 것이 대부분이기 때문에 Pulse()와

같은 메소드를 갖고 있어야하겠지만(Win32 에서는 그렇다!) 닷넷에서는 이런 종류의 Pulse()는 없다. 따라서

필요하다면 자신이 만들어쓰도록 한다. 이러한 Pulse()는 Monitor 클래스에서 볼 수 있다.(Pulse 나 PulseAll 에

대해서는 MSDN 을 참고한다)

마치며

뮤텍스와 이벤트가 무슨 관계가 있을까?라고 생각하는 분들도 있을 것이다. 이벤트가 뮤텍스의 소유권에 대한

상태를 알린다고 하면, 쓰레드들간에 이벤트를 주고 받음으로써 뮤텍스의 소유권을 넘겨 받을 수 있다는 것을

생각할 수 있을 것이다. 다음에는 이벤트와 뮤텍스를 이용하는 것에 대해서 살펴보도록 하자. 끝으로 다음에는

식사하는 철학자 문제를 이벤트(배고픔)와 뮤텍스(포크)를 사용해서 풀어보도록 하자.

141

C# 쓰레드 이야기 - 12. 식사하는 철학자

지난 2 주 동안 Mutex(뮤텍스)에 대해 설명했으며, 식사하는 철학자 문제를 소개했다. 그리고 이벤트에 대해서

설명했다. Mutex 를 사용하여 자원을 동기화하는 방법을 살펴봤으며, 이벤트를 사용하여 쓰레드간에 신호를

주고 받음으로서 쓰레드간에 동기화하는 방법을 살펴봤다. 이번시간에는 WaitHandle 클래스와 함께 식사하는

철학자 문제의 해결책을 알아보기로 하자.

사실, 필자의 심경으로는 밥 쳐먹고 몽상하는 게 전부인데다가, 이 위생 관념도 없는 집단은 남이

쓰던 포크도 마다 않고 쓰는 똘아이 집단을 거창하게 '식사하는 철학자'라고 소개하는 것은 뭔가

불합리하다고 생각한다. -_-

WaitHandle 클래스

Mutex를 이용한 동기화와 식사하는 철학자에 대해서 설명하기 전에 WaitHandle 클래스부터 설명하는 것으로

시작하자. 9. 임계 역에서 동기화 클래스 계층구조를 보여준 적이 있다. 기억이 안나는 분들을 위해서 다시

소개하면 다음과 같다.

System.Object

System.MarshalByRefObject

System.Threading.WaitHandle

System.Threading.AutoResetEvent

System.Threading.ManualResetEvent

System.Threading.Mutex

즉, 뮤텍스나 이벤트 모두 WaitHandle 클래스에서 파생된 클래스라는 것을 알 수 있다. WaitHandle 은 공유

자원에 대한 배타적 접근 허용을 위해서 제공되는 클래스다. 다시 말해서, WaitHandle 은 커널 객체에 대한

동기화를 제공하는 Win32 API 에 대한 래퍼 클래스다. WaitHandle 클래스는 추상 클래스이므로 동기화 객체를

위해서 클래스를 상속시킬 수 있다.

WaitForSingleObject, WaitForMultipleObjects 와 같은 Win32 API 에 대한 래퍼 역할을 하며, 각각

WaitHandle.WaitOne, WaitHandle.WaitAny, WaitHandle.WaitAll 로 정의되어 있다.

142

MsgForMultipleObjects, WaitForInputIdle, WaitForDebugEvent 에 대한 래퍼는 제공하지 않는다(이들 Win32

API 는 System.Threading.Process.EnterDebugMode, System.Threading.Process.LeaveDebugMode,

System.Threading.Process.WaitForInputIdle, System.Threading.Process.WaitForExit 에서 찾을 수 있다).

WaitHandle 클래스 주요 멤버

이름 타입 설명

Handle IntPtr 운 체제 핸들을 가져오거나 설정한다.

WaitTimeout int

대기 핸들이 일어나기 전에 WaitAny 의 제한 시간이

초과했음을 나타낸다.

Close void 핸들이 갖고 있는 자원을 해제한다.

WaitHandle.WaitOne 은 WaitHandle 에서 신호를 받을 때 까지 쓰레드를 대기하도록 한다. WaitOne()의

오버로드된 목록은 다음과 같다.

public virtual bool WaitOne();

public virtual bool WaitOne(int, bool);

public virtual bool WaitOne(TimeSpan, bool);

첫번째는 자원에 대한 핸들을 얻을 때 까지 무한히 대기한다. 두번째와 세번째는 지정된 시간까지 대기할 것을

지정하며, 지정된 시간 동안 자원에 대한 핸들을 얻지 못하면 false 를 반환한다. 핸들을 얻었는지의 여부에

따라서 bool 값 true/false 를 반환한다.

대기 시간이 두 가지 종류가 있는데 int 타입은 밀리 초 단위로 대기시간을 지정하며, 지정할 수 있는 시간의

범위는 32 비트 정수형과 같다. TimeSpan 은 64 비트 정수형이며 1 TimeSpan 은 100 Tick(틱)과 같은 시간을

나타낸다. 따라서 보다 정교한 시간 제어가 필요하다면 TimeSpan 을 쓰도록 하지만, 대부분의 경우에 밀리초

단위로 설정한다.

세번째 요소는 핸들을 기다리기 전에 동기화 도메인을 빠져나올지를 지정한다. 대부분의 예제에서 동기화

143

도메인을 사용하지 않기 때문에 false 를 지정하면 된다.

WaitHandle.WaitAny(WaitHandle [])은 WaitHandle[]과 같이 지정된 배열에 있는 요소들 중에 어떤 하나의

핸들을 얻을 때 까지 대기한다. 오버로드된 목록은 다음과 같다.

public static int WaitAny(WaitHandle[]);

public static int WaitAny(WaitHandle[], int, bool);

public static int WaitAny(WaitHandle[], TimeSpan, bool);

WaitOne 과 마찬가지로 두 번째와 세번째는 모두 지정된 시간까지 대기할 것을 지정하며, 지정된 대기 시간

동안 자원에 대한 핸들을 얻지 못하면 false 를 반환한다.

한글 VS.NET 에 있는 MSDN 을 보면 WaitHandle.WaitAny 에 대한 설명이 "지정된 배열의 모든

요소가 신호를 받기를 기다립니다."와 같이 되어 있는데, 이는 모호한 설명이다. WaitHandle[]에

2 개의 핸들을 지정했을 때, 2 개의 핸들 중에 하나를 받으면 쓰레드 대기상태(WaitSleepJoin)가

해제되고 작업을 진행한다(Suspended 또는 Running).

문 VS.NET 에 있는 MSDN 에는 "Waits for any of the elements in the specified array to receive a

signal."와 같이 되어 있으며, 원문에 대한 오역이라는 것을 알 수 있을 것이다. WaitAny[]에

대해서는 예제에서 설명할 것이다.

WaitHandle.WaitAll(WaitHandle [])은 WaitHandle[]과 같이 지정된 배열의 모든 요소에 대한 핸들을 얻을 때

까지 대기한다. 오버로드된 목록은 다음과 같다.

public static int WaitAll(WaitHandle[]);

public static int WaitAll(WaitHandle[], int, bool);

public static int WaitAll(WaitHandle[], TimeSpan, bool);

WaitHandle[]과 같이 지정된 요소에 대한 모든 핸들을 얻을 때 까지 대기하는 점을 제외하면 WaitAny()와

동일하다.

이외에 다른 WaitHandle 멤버는 모두 다른 클래스에서 파생된 것이다. Mutex 는 WaitHandle 에서

파생되었으므로 위에 설명한 모든 메소드를 제공한다.

144

WaitHandle 에 대해서 알아보았으니 Mutex 와 WaitHandle 을 어떻게 이용하는지 예제에서 살펴보도록 하자.

여기서 소개할 예제는 MSDN 에도 있으며 설명하기에 가장 좋기에 여기에 소개한다.(사실은 저작권 문제도 있을

것이고, 조금 마음에 들지 않는 부분도 있어서 읽기 쉽도록 다시 작성해봤다. 겉모양은 달라도 로직은 같다.)

이름 : MutexWait.cs

using System;

using System.Threading;

public class AppMain

{

static Mutex gM1;

static Mutex gM2;

static AutoResetEvent are1 = new AutoResetEvent(false);

static AutoResetEvent are2 = new AutoResetEvent(false);

static AutoResetEvent are3 = new AutoResetEvent(false);

static AutoResetEvent are4 = new AutoResetEvent(false);

public static void Main()

{

AppMain ap = new AppMain();

Thread.CurrentThread.Name = "Primary Thread";

Thread thread1 = new Thread(new ThreadStart(ap.DoSomething1));

Thread thread2 = new Thread(new ThreadStart(ap.DoSomething2));

145

Thread thread3 = new Thread(new ThreadStart(ap.DoSomething3));

Thread thread4 = new Thread(new ThreadStart(ap.DoSomething4));

gM1 = new Mutex(true);

gM2 = new Mutex(true);

AutoResetEvent[] IAutoResetEvent = new AutoResetEvent[4];

IAutoResetEvent[0] = are1;

IAutoResetEvent[1] = are2;

IAutoResetEvent[2] = are3;

IAutoResetEvent[3] = are4;

thread1.Start();

thread2.Start();

thread3.Start();

thread4.Start();

Console.WriteLine("-------- " + Thread.CurrentThread.Name + " - owns gM1 and gM2 " );

Thread.Sleep(3000);

Console.WriteLine("-------- " + Thread.CurrentThread.Name + " - releases gM1");

gM1.ReleaseMutex();

Thread.Sleep(2000);

Console.WriteLine("-------- " + Thread.CurrentThread.Name + " - releases gM2");

gM2.ReleaseMutex();

Thread.Sleep(2000);

146

WaitHandle.WaitAll(IAutoResetEvent);

Console.WriteLine("All threads just have finished");

}

public void DoSomething1()

{

Console.WriteLine("DoDomething1 started, Mutex.WaitAll(Mutex[])");

Mutex[] IMutex = new Mutex[2];

IMutex[0] = gM1;

IMutex[1] = gM2;

Mutex.WaitAll(IMutex);

Console.WriteLine("DoSomething1 finished, Mutex.WaitAll(Mutex[])");

are1.Set();

}

public void DoSomething2()

{

Console.WriteLine("DoDomething2 started, gM1.WaitOne()");

gM1.WaitOne();

Console.WriteLine("DoSomething2 finished, gM1.WaitOne()");

are2.Set();

147

}

public void DoSomething3()

{

Console.WriteLine("DoDomething3 started, Mutex.WaitAny(Mutex[])");

Mutex[] IMutex = new Mutex[2];

IMutex[0] = gM1;

IMutex[1] = gM2;

Mutex.WaitAny(IMutex);

Console.WriteLine("DoSomething3 finished, Mutex.WaitAny(Mutex[])");

are3.Set();

}

public void DoSomething4()

{

Console.WriteLine("DoDomething4 started, gM2.WaitOne()");

gM2.WaitOne();

Console.WriteLine("DoSomething3 finished, gM2.WaitOne()");

are4.Set();

}

}

예제를 모두 입력하고 컴파일 한 결과는 다음과 같다.

148

using System;

using System.Threading;

public class AppMain

{

static Mutex gM1;

static Mutex gM2;

static AutoResetEvent are1 = new AutoResetEvent(false);

static AutoResetEvent are2 = new AutoResetEvent(false);

static AutoResetEvent are3 = new AutoResetEvent(false);

static AutoResetEvent are4 = new AutoResetEvent(false);

콘솔로 작성할 것이고, 쓰레드 응용 프로그램이므로 네임 스페이스 System 과 System.Threading 을 사용한다.

클래스에서 Mutex 와 Event 를 static 으로 선언한다. static 으로 선언하면 인스턴스 수준에서 자원을 공유할 수

있게 된다. 쓰레드 지역 저장소(Thread Local Storage, TLS)에 대해서는 나중에 설명할 것이다.

149

두 개의 Mutex 를 사용하여 WaitOne, WaitAny, WaitAll 이 어떻게 동작하는지 살펴볼 것이고, 4 개의 쓰레드의

작업이 끝났다는 것을 알기 위해 이벤트를 사용한다.

gM1 = new Mutex(true);

gM2 = new Mutex(true);

AutoResetEvent[] IAutoResetEvent = new AutoResetEvent[4];

IAutoResetEvent[0] = are1;

IAutoResetEvent[1] = are2;

IAutoResetEvent[2] = are3;

IAutoResetEvent[3] = are4;

Mutex 를 생성하는데 true 를 사용하면 현재 Mutex 를 생성하는 쓰레드가 초기 소유권을 갖게 된다. 여기서는

Main 쓰레드에서 생성하므로 Main 쓰레드가 뮤텍스 gM1, gM2 에 대한 소유권을 갖게 된다.

이벤트에 대한 배열을 IAutoResetEvent 로 선언한다. IAutoResetEvent 는 다른 모든 이벤트들에 대한 컨테이너

역할을 하기 때문에 I 를 접두어로 사용하여 명명하 다.(실제로 실행 코드를 분석해보면 IAutoResetEvent 는

다른 이벤트들에 대한 참조만을 갖고 있으며, 단순한 컨테이너 역할만 한다는 것을 알 수 있다.)

thread1.Start();

thread2.Start();

thread3.Start();

thread4.Start();

Console.WriteLine("--------- " + Thread.CurrentThread.Name + " - owns gM1 and gM2 " );

4 개의 쓰레드를 시작하고 Mutex gM1 과 gM2 의 소유권이 메인 쓰레드에 있다는 것을 콘솔에 출력한다.

Thread.Sleep(3000);

모든 쓰레드가 시작된 다음에 Main 쓰레드를 3 초동안 대기시킨다. 3 초 동안 대기되는 동안 각각의 쓰레드는

시작하지만, 어떤 뮤텍스도 소유할 수 없으므로 대기상태가 된다는 것을 보여주기 위한 것이다.

Console.WriteLine("--------- " + Thread.CurrentThread.Name + " - releases gM1");

150

gM1.ReleaseMutex();

Thread.Sleep(2000);

뮤텍스 gM1 을 해제한다는 메시지를 콘솔에 출력하고 ReleaseMutex()를 호출하여 해제한다. 뮤텍스 gM1 을

해제한 다음에 무슨 일이 일어나는지 알아보기 위해 2 초동안 대기한다.

Console.WriteLine("--------- " + Thread.CurrentThread.Name + " - releases gM2");

gM2.ReleaseMutex();

Thread.Sleep(2000);

마찬가지로 뮤텍스 gM2 를 해제하고 무슨 일이 일어나는지 알아보기 위해 2 초 동안 대기한다.

WaitHandle.WaitAll(IAutoResetEvent);

Console.WriteLine("All threads just have finished");

}

WaitHandle 을 이용하여 모든 이벤트가 신호상태가 될 때 까지 기다린다. 실제로 모든 쓰레드가 종료되기

때문에 이 조건은 바로 만족되고 메시지가 콘솔에 출력된다.

public void DoSomething1()

{

Console.WriteLine("DoDomething1 started, Mutex.WaitAll(Mutex[])");

Mutex[] IMutex = new Mutex[2];

IMutex[0] = gM1;

IMutex[1] = gM2;

Mutex.WaitAll(IMutex);

Console.WriteLine("DoSomething1 finished, Mutex.WaitAll(Mutex[])");

are1.Set();

}

151

첫번째 쓰레드 thread1 에서 실행하는 메소드 DoSomething1 은 두 개의 뮤텍스를 소유할 때만 일을 처리한다.

결과화면에서 알 수 있는 것처럼 thread1 이 제일 먼저 시작했지만 뮤텍스를 가장 마지막에 소유하므로

완료(finished) 메시지가 가장 마지막에 출력되는 것을 알 수 있다. 결과에서 알 수 있는 것처럼 WaitAll 은

배열에 지정된 모든 요소에 대한 핸들을 가질 때 까지 대기한다. 마지막에 are1.Set()은 thread1 의 작업이

끝났음을 알려주는 것이다. are1.Set()은 이벤트를 비신호상태에서 신호상태로 변경한다. 이벤트에 대해서

기억나지 않는다면 이전 글, 11. 이벤트를 참고하기 바란다.

public void DoSomething2()

{

Console.WriteLine("DoDomething2 started, gM1.WaitOne()");

gM1.WaitOne();

Console.WriteLine("DoSomething2 finished, gM1.WaitOne()");

are2.Set();

}

thread2 에서 실행하는 메소드 DoSomething1 은 gM1.WaitOne()을 사용해서 뮤텍스 gM1 을 이용할 수 있게 될

때 까지 기다린다. 결과화면에서 알 수 있는 것처럼 Main 이 뮤텍스 gM1 의 소유권을 해제한 다음에 가장 먼저

실행된다는 것을 알 수 있다. 마찬가지로 실행이 끝난 다음에는 이벤트 are2 를 신호상태로 설정한다.

public void DoSomething3()

{

Console.WriteLine("DoDomething3 started, Mutex.WaitAny(Mutex[])");

Mutex[] IMutex = new Mutex[2];

IMutex[0] = gM1;

IMutex[1] = gM2;

Mutex.WaitAny(IMutex);

152

Console.WriteLine("DoSomething3 finished, Mutex.WaitAny(Mutex[])");

are3.Set();

}

thread3 에서 실행하는 메소드 DoSomething3 은 IMutex 에 지정된 뮤텍스 gM1 과 gM2 를 기다린다. 여기서

WaitAny 를 사용하 으므로 배열에 지정된 핸들 중에 먼저 사용할 수 있는 핸들이 있으면 그 핸들에 대한

소유권을 얻고 쓰레드의 대기 상태를 해제한다. 결과화면에서 알 수 있는 것처럼 Main 에서 뮤텍스 gM1 을

해제했을 때 gM1 에 대한 소유권을 얻고 대기상태를 해제한다는 것을 알 수 있다.

IMutex 는 실제로 뮤텍스를 생성하는 것이 아니라 뮤텍스에 대한 컨테이너 역할을 하기 때문에 필자는

인터페이스를 뜻하는 I 를 사용하 다. 앞에서 얘기한 것처럼 실제로 생성된 코드를 살펴보면 뮤텍스 gM1,

gM2 에 대한 참조만을 갖고 있는 컨테이너라는 것을 알 수 있다.

public void DoSomething4()

{

Console.WriteLine("DoDomething4 started, gM2.WaitOne()");

gM2.WaitOne();

Console.WriteLine("DoSomething3 finished, gM2.WaitOne()");

are4.Set();

}

thread4 에서 실행하는 메소드 DoSomething4 는 gM2.WaitOne()을 사용하는 것에서 알 수 있는 것처럼 뮤텍스

gM2 를 얻을 수 있을 때 까지 대기한다. 결과화면에서 알 수 있는 것처럼 Main 쓰레드에서 뮤텍스 gM2 의

소유권을 해제한 다음에 실행되는 것을 알 수 있다.

이것으로 위 예제의 전체 소스를 살펴보았다. 그러면 위에서 살펴본 예제와 Wait 메소드에 대해서 알아보도록

하자.

153

예제에서 알 수 있는 것들

위 예제에 대해서 이제 몇 가지를 더 생각해 보도록 하자. 먼저 각각의 쓰레드들은 뮤텍스를 이용할 수 있을 때

까지 기다린다. Main 쓰레드에서 뮤텍스 gM1 을 해제했을 때 gM1.WaitOne 을 사용하는 DoSomething2 와

Mutex.WaitAny 를 사용하는 DoSomething3 가 경쟁하게 된다. 또한 첫번째 쓰레드가 실행하는

DoSomething1 이 가장 먼저 시작하므로, 뮤텍스를 가장 먼저 기다리게 된다. 그렇다면 Main 에서 gM1 을

해제했을 때 가장 먼저 gM1 을 소유해야하는 쓰레드는 첫번째 쓰레드가 아닐까? 가장 큰 의문이 하나 더 있다.

Main 에서는 뮤텍스를 소유하고 있다가 명시적으로 ReleaseMutex 를 사용하여 뮤텍스의 소유권을 해제하여

다른 쓰레드들이 뮤텍스를 이용할 수 있도록 하 다. 다른 쓰레드에서는 뮤텍스를 해제하는 메소드를 호출하지

않는데 어떻게 다른 쓰레드들이 사이좋게 뮤텍스를 사용할 수 있을까? (^^;)

실제로 WaitAll 은 모든 자원을 동시에 이용할 수 없으면 자원을 소유하지 않는다. 따라서 Main 이 처음에

gM1 을 해제했을 때 DoSomething1 에서는 gM1 을 소유하지 않는다. 마찬가지로 WaitOne 과 WaitAny 는 어떤

것이든 하나의 핸들에 대한 소유권을 얻으면 동작하기 때문에 문제없이 동작한다. 따라서 Main 이 뮤텍스

gM1 의 소유권을 해제했을 때, gM1.WaitOne 을 실행하는 DoSomething2 와 WaitAny 를 실행하는

DoSomething3 중에 먼저 gM1 의 소유권을 얻은 쪽이 실행된다.(콘솔 응용 프로그램이 단순해서 아마도 항상

같은 결과가 나오겠지만)

마찬가지로 Main 에서 뮤텍스 gM2 를 해제했을 때 gM2.WaitOne 을 실행하는 DoSomething3 과 WaitAll 을

실행하는 DoSomething1 이 경쟁하게 된다. 왜냐하면 gM2 가 해제될 때는 WaitAll 에 지정된 두 뮤텍스 gM1 과

gM2 모두 사용할 수 있기 때문이다.

마지막으로 쓰레드들에서 모두 뮤텍스를 해제하지 않았는데 어째서 모두 사이좋게 뮤텍스를 가질 수 있었는가?

라는 질문을 생각해보자.

Win32 API 에는 WaitForSingleObject 와 WaitForMultipleObjects 가 있는데 왜 닷넷에서는 WaitOne, WaitAny,

WaitAll 과 같이 세 가지로 나뉘어 진 것인가? 라는 질문에 대한 답도 될 것 같다. 이들 Win32 API 들은 모두

프로세스나 쓰레드 객체에 대해서 부작용이 있다. 10 개의 쓰레드가 WaitForSingleObject 를 호출하고, 동일한

154

객체에 대한 핸들을 얻을 때 까지 기다린다고 하자. 다른 쓰레드가 이 객체을 반환할 때, 객체는 "이제 사용할

수 있음!"을 뜻하는 신호 상태가 된다. 그러면 이 객체를 기다리던 10 개의 쓰레드가 동시에 일어나서 작업을

수행한다.

반면에, 뮤텍스, 세마포어, 이벤트와 같은 객체들에 대해서 WaitForSingleObject 와 WaitForMultipleObjects 는

위와 같은 상황에서 객체가 "이제 사용할 수 있음!"을 뜻하는 신호 상태가 되었을 때, 한 쓰레드가 깨어나면

객체를 바로 "흑, 난 이미 임자가 있어요!"를 뜻하는 비신호 상태로 바꿔버린다.(이벤트는 닷넷에서

AutoResetEvent 를 뜻한다)

이와 같은 이유 때문에 Win32 API 에는 약간의 모호함이 있었고, 닷넷에서는 이것을 WaitOne, WaitAny,

WaitAll 로 명확하게 나누었다는 것을 알 수 있다. 그리고 모든 WaitHandle 을 이용하는 공유 자원은 한

쓰레드가 소유권을 갖게 되면 즉시 비신호 상태로 바꿔버린다.(Reset)

지금까지 WaitOne, WaitAny, WaitAll 을 살펴보았다. 만약에 여러 개의 공유 자원이 있고, 여러 개의 쓰레드는

반드시 2 개(또는 그 이상)의 공유 자원을 갖고 있을 때 실행해야 한다면 어떻게 제어할까? 이 문제에 대한

답을 알아보기 위해 이번 회의 본론인 식사하는 철학자 문제를 살펴보자.

식사하는 철학자

식사하는 철학자 문제는 5 명의 철학자(쓰레드)가 5 개의 공유 자원(포크)중에 2 개의 공유 자원을 가져야만

식사를 할 수 있는 것을 말한다. 식사하는 철학자 문제는 10 번째 기사에서 자세히 설명했으므로, 여기서는

바로 이를 구현한 예제에 대해서 설명하도록 하자.

이 예제는 먼저 Table 클래스가 있고, 5 개의 포크(뮤텍스)를 갖고 있다. 각각의 철학자를 뜻하는 Philosopher

클래스는 이 테이블 클래스를 이용하여 포크를 얻어서 식사도 하고, 생각을 하기도 한다. 각각의 식사 시간과

생각하는 시간을 불규칙적으로 하기 위해 Random 클래스를 사용하여 1 부터 100 사이의 임의의 시간을

이용하도록 했다.

이름 : dining.cs

155

using System;

using System.Threading;

namespace hanbit

{

class Table

{

static Mutex gM1 = new Mutex(false);

static Mutex gM2 = new Mutex(false);

static Mutex gM3 = new Mutex(false);

static Mutex gM4 = new Mutex(false);

static Mutex gM5 = new Mutex(false);

static Mutex[] gFork = new Mutex[5];

static bool bContinue;

public bool Continue

{

get

{

return bContinue;

}

set

156

{

bContinue = value;

}

}

public void Stop()

{

bContinue = false;

}

public Table()

{

bContinue = true;

gFork[0] = gM1;

gFork[1] = gM2;

gFork[2] = gM3;

gFork[3] = gM4;

gFork[4] = gM5;

}

public void GetForks(int threadID)

{

Mutex[] IFork = new Mutex[2];

IFork[0] = gFork[threadID];

IFork[1] = gFork[(threadID + 1) % 5];

157

WaitHandle.WaitAll(IFork);

public void DropForks(int threadID)

{

gFork[threadID].ReleaseMutex();

gFork[(threadID + 1) % 5].ReleaseMutex();

}

}

class Philosopher

{

Random rand = new Random(DateTime.Now.Millisecond);

private int ThreadID;

private Table aTable;

public Philosopher(int threadId, Table table)

{

this.ThreadID = threadId;

this.aTable = table;

}

private void Think()

{

Console.WriteLine(Thread.CurrentThread.Name + " Thinking.");

Thread.Sleep(rand.Next(1, 200));

158

}

private void Eat()

{

Console.WriteLine(Thread.CurrentThread.Name + " Eating.");

Thread.Sleep(rand.Next(1, 200));

}

public void Philosophize()

{

while (aTable.Continue)

{

aTable.GetForks(ThreadID);

//Eat

this.Eat();

aTable.DropForks(ThreadID);

//Think

this.Think();

}

}

}

~Philosopher()

{

}

}

159

class AppMain

{

static void Main(string[] args)

{

Table table = new Table();

Philosopher[] IPhil = new Philosopher[5];

Thread[] IThread = new Thread[5];

for (int loopctr = 0; loopctr < 5; loopctr++)

{

IPhil[loopctr] = new Philosopher(loopctr, table);

IThread[loopctr] = new Thread(new ThreadStart(IPhil[loopctr].Philosophize) );

IThread[loopctr].Name = "Philosopher " + loopctr;

IThread[loopctr].Start();

}

Thread.Sleep(5000);

table.Stop();

Thread.Sleep(1000);

Console.WriteLine("Primary Thread ended.");

Console.WriteLine("Press any key to return.");

Console.Read();

}

}

}

예제를 컴파일하고 실행하면 결과는 다음과 같을 것이다.

160

소스 코드를 살펴보도록 하자

class Table

{

static Mutex gM1 = new Mutex(false);

static Mutex gM2 = new Mutex(false);

static Mutex gM3 = new Mutex(false);

static Mutex gM4 = new Mutex(false);

static Mutex gM5 = new Mutex(false);

static Mutex[] gFork = new Mutex[5];

static bool bContinue;

public bool Continue

{

161

get

{

return bContinue;

}

set

{

bContinue = value;

}

}

Table 클래스를 선언하고, 5 개의 뮤텍스를 생성한다. 생성하는 쓰레드에서 뮤텍스의 소유권을 갖도록 할 것이

아니므로 false 를 사용하여 생성한다. gFork 는 5 개의 뮤텍스에 대한 컨테이너로 사용되고, 식사하는 철학자

문제에서 포크를 뜻한다. bContinue 는 식사하는 철학자 프로그램을 계속 실행할지를 결정하기 위해 사용한다.

public void Stop()

{

bContinue = false;

}

public Table()

{

bContinue = true;

gFork[0] = gM1;

gFork[1] = gM2;

gFork[2] = gM3;

gFork[3] = gM4;

gFork[4] = gM5;

162

}

Stop 은 bContinue 를 false 로 설정하고, 식사하는 철학자 프로그램을 끝내기 위해 호출한다. Table

생성자에서는 bContinue = true 로 설정하고, 각각의 포크를 컨테이너 gFork 에 넣어둔다.

public void GetForks(int threadID)

{

Mutex[] IFork = new Mutex[2];

IFork[0] = gFork[threadID];

IFork[1] = gFork[(threadID + 1) % 5];

WaitHandle.WaitAll(IFork);

}

public void DropForks(int threadID)

{

gFork[threadID].ReleaseMutex();

gFork[(threadID + 1) % 5].ReleaseMutex();

}

GetForks 와 DropForks 는 철학자가 테이블에서 포크를 갖는 것(뮤텍스에 대한 핸들을 얻는 것)과 포크를

테이블에 내려 놓는 것(뮤텍스에 소유권을 해제하는 것)을 나타낸 것이다. IFork[0]에는 철학자의 왼쪽에 있는

포크를, IFork[1]에는 철학자의 오른쪽에 있는 포크를 들도록 한 것이다. 마찬가지로 식사가 끝나면 철학자의

왼쪽에 있는 포크를 내려놓고, 그 다음에 오른쪽에 있는 포크를 내려놓도록 한 것이다.

class Philosopher

{

Random rand = new Random(DateTime.Now.Millisecond);

private int ThreadID;

private Table aTable;

163

public Philosopher(int threadId, Table table)

{

this.ThreadID = threadId;

this.aTable = table;

}

이것은 철학자 클래스이며, 식사하는 시간과 생각하는 시간을 임의의 숫자로 할당하기 위해 Random 클래스를

사용한다. 항상 다른 숫자를 얻기 위해 시스템 시간을 토대로하여 난수를 생성하도록 한다. 철학자 클래스

생성자는 쓰레드 ID 를 갖고 있으며, 사용할 테이블을 지정하도록 하고 있다.

private void Think()

{

Console.WriteLine(Thread.CurrentThread.Name + " Thinking.");

Thread.Sleep(rand.Next(1, 200));

}

private void Eat()

{

Console.WriteLine(Thread.CurrentThread.Name + " Eating.");

Thread.Sleep(rand.Next(1, 200));

}

각각의 철학자는 생각하는 것과 식사하는 것을 하므로, 이를 묘사하는 두 개의 함수를 정의한다. 각각의 함수는

임의의 시간 동안 생각하고 식사할 수 있도록 하기 위해 1 에서 200 사이의 숫자만큼 대기하도록 한다.

public void Philosophize()

{

while (aTable.Continue)

{

aTable.GetForks(ThreadID);

//Eat

164

this.Eat();

aTable.DropForks(ThreadID);

//Think

this.Think();

}

}

실제로 철학자의 역할을 반복하는 함수다. Table.Continue 가 true 인 동안은 무한히 반복해서 실행한다. 포크를

들고 식사를 하고, 포크를 내려놓고 다시 식사하는 과정을 반복한다.

class AppMain

{

static void Main(string[] args)

{

Table table = new Table();

Philosopher[] IPhil = new Philosopher[5];

Thread[] IThread = new Thread[5];

for (int loopctr = 0; loopctr < 5; loopctr++)

{

IPhil[loopctr] = new Philosopher(loopctr, table);

IThread[loopctr] = new Thread(new ThreadStart(IPhil[loopctr].Philosophize) );

IThread[loopctr].Name = "Philosopher " + loopctr;

IThread[loopctr].Start();

}

Thread.Sleep(5000);

table.Stop();

Thread.Sleep(1000);

165

Console.WriteLine("Primary Thread ended.");

Console.WriteLine("Press any key to return.");

Console.Read();

}

}

Main 클래스로 다섯 명의 철학자가 사용할 table 을 만들고, 5 명의 철학자를 IPhil [] 컨테이너에 할당한다.

루프를 돌면서 각각의 철학자에게 Pholosopher.Philosophize()를 쓰레드로 실행하도록 위임하고, 철학자에게

이름을 부여하고, 쓰레드를 시작한다. Thread.Sleep(5000) 부분은 이 식사하는 철학자 문제를 5 초 동안

실행하라는 의미를 갖는다. 5 초 동안 실행한 다음에는 식사하는 철학자 문제를 끝내기 위해 table.Stop()을

호출하여 table.Continue 를 false 로 설정한다. 쓰레드는 Philosophize 를 실행중이기 때문에 바로 모든

쓰레드가 종료되지 않는다. 따라서 Thread.Sleep(1000)을 두어서 종료 메시지를 처리하기 전에 잠시 동안

기다린다. Console.Read()를 사용하여 사용자가 아무키나 입력할 때 까지 대기한다.

아마도, 위 식사하는 철학자 문제에서 철학자들이 제대로 두 개의 포크를 들고서 식사하는지 궁금할지도

모르겠다. 궁금한 독자들은 GetForks 를 다음과 같이 수정한다.

public void GetForks(int threadID)

{

Mutex[] IFork = new Mutex[2];

IFork[0] = gFork[threadID];

IFork[1] = gFork[(threadID + 1) % 5];

WaitHandle.WaitAll(IFork);

Console.WriteLine("Handle 1 - " + IFork[0].Handle + " Handle 2 - " + IFork[1].Handle );

}

각각의 핸들을 화면에 출력하도록 해서 어떤 핸들을 갖고 있는지 확인하면 된다.

166

사실대로 말하자면 위 예제에서는 반드시 철학자의 오른쪽과 왼쪽에 있는 포크를 들지는 않는다. 정확히는

테이블 가운데에 5 개의 포크가 있고, 식사가 끝나면 다시 테이블 가운데에 2 개의 포크를 돌려놓는다.

포크를 기다리는 방법

위 철학자 문제에서 포크를 기다리는 방법은 실제로 여러가지가 있다. 위 방법은 그 중에 한 가지에 불과하다.

메소드 위임을 통해서 두 개의 포크를 받을 수 있는 방법도 있다. 이 방법은

IAsyncResult.AsyncWaitHandle.WaitOne 을 사용하는 방법도 있을 수 있으며, 위의 GetForks 구현대신에

WaitHandle.WaitOne 메소드를 오버라이드하여 두 개의 포크를 기다리도록 할 수도 있다.(메소드 이름이 뜻하는

것과는 달리 WaitTwo 의 의미가 되겠지만 말이다)아니면 WaitHandle 을 상속하여 다른 Wait 함수와 함께

WaitTwo 를 구현하는 방법도 있을 수 있다. 그러나 위와 같이 컨테이너를 사용하여 WaitAll 을 호출하는 것이

가장 괜찮은 방법인 것 같다.(더 좋은 방법들도 있겠지만, 필자는 그만큼 알지는 못한다) IAsyncResult 의

경우에는 멀티 쓰레드와 비동기 웹 서비스를 구현하는데 자주 사용하므로 자세히 알아두면 좋을 것이다.

디버깅에 대하여

멀티 쓰레드 응용 프로그램을 디버깅하자는 것은 아니다. 실제로 어떤 것들이 실행되는지 자세히 조사하고

싶다면 디버거를 이용해서 확인할 수 있다. 닷넷 프레임워크는 두 개의 훌륭한 디버거를 제공한다. 관심있는

독자는 찾아보기 바란다. 각각의 디버거의 자세한 사용법은 MSDN 이나 밑에 있는 참고 자료를 참고하기 바란다.

디버거를 사용하려면 코드를 컴파일 할 때 다음과 같이 하도록 한다.

csc /debug:full dining.cs

이와 같이 하면 디버깅 정보가 함께 생성된다.

콘솔에서 이용할 수 있는 디버거는 여러분이 시스템에 닷넷 프레임워크를 설치한 방법에 따라 다르다. 닷넷

프레임워크만 받아서 설치한 경우에는 C:\Program Files\Microsoft.NET\FrameworkSDK\bin 에 있으며,

167

VS.NET 에 있는 윈도우 구성요소 업데이트(Windows Component Update)에서 설치한 경우에는 C:\Program

Files\Microsoft Visual Studio .NET\FrameworkSDK\bin 에 있다. 실행 파일명은 cordbg.exe 이다.

b 56

go

p vars

p

reg

이와 같이 56 번 라인에 중단점(break point)을 설정하고, 실행(go)하고, 변수 vars 의 값을 출력하거나(p vars),

현재 프로세스의 값을 볼 수 있다(p). 현재 쓰레드에 대한 스택 상태를 보고 싶다면 reg 를 사용하면 볼 수 있다.

주의할 것은 이러한 디버거를 이용하려면 반드시 /debug:full 을 사용하여 디버깅해야한다.

화면에서 볼 수 있는 것처럼 p 와 reg 를 사용해서 자세한 명령을 볼 수 있으며, p 위에는 현재 실행한 곳의

코드를 볼 수도 있다. (cordbg) 콘솔에서 ?를 입력하면 보다 자세한 명령을 볼 수 있다.

닷넷 프레임워크에 들어있는 두번째 디버거는 DbgCLR.exe 다. 이것 역시 닷넷 프레임워크를 설치한 방법에

따라 위치가 다르다.

168

C:\Program Files\Microsoft.NET\FrameworkSDK\GuiDebug 또는 C:\Program Files\Microsoft Visual

Studio .NET\FrameworkSDK\GuiDebug 에 있다.

이것은 완전한 GUI 환경을 제공하는 디버거다. 디버그 | 디버깅할 프로그램을 선택해서 디버깅할 프로그램을

선택할 수 있으며, 이미 실행중인 프로그램을 디버깅할 수 있는 Process Attach Debugging 은 도구 | 프로세스

디버그에서 사용할 수 있다. 주로 프로세스 디버깅은 실행중인 웹 서비스나 ASP.NET 에 대한 프로세스

aspnet_wp.exe 를 디버깅할 때 유용하게 사용할 수 있다.

화면에서 볼 수 있는 거처럼 콘솔 응용 프로그램의 경우에 콘솔 창이 따로 나타나지만 모든 출력은 출력 창에

나타나는 것을 볼 수 있으며, 중단된 시점의 각 변수들의 값을 일목 요연하게 볼 수 있다는 것을 알 수 있다.

주의할 점이 있는데, 멀티 쓰레드 응용 프로그램의 경우에 중단점에서 디버거가 멈춰 있어도 계속해서 실행되기

때문에 잠시 머뭇거린 사이에 응용 프로그램이 종료된다. 따라서 Main 에서 Thread.Sleep 을 충분히 늘려놓고

169

사용법을 탐색해 보거나, 아니면 다른 응용 프로그램으로 먼저 연습해 보기 바란다. 지역 창에서 gM1 -

gM5 까지의 트리와 IFork 의 트리를 충분히 펼쳐보면 각각의 핸들(Handle) 값을 볼 수 있으며, 현재 IFork 에서

어떤 gM?에 해당하는 핸들을 갖고 있는지 알 수 있다. 또한 코드 창 위에 보면 실행중인 프로그램, 쓰레드,

스택 프레임이 무엇인지 알 수 있다. CLR 디버거는 CLR 환경에 대해서만 디버깅할 수 있는 단점은 있지만

닷넷 프레임워크만으로도 충분히 훌륭하게 디버깅할 수 있다는 것을 알 수 있다.

VS.NET 이 있다면 VS.NET 에 있는 디버거를 사용하는 것도 좋다. VS.NET 의 디버거는 비 CLR 환경뿐만 아니라

멀티 쓰레드 응용 프로그램에 대한 실시간 스택 트레이스를 기록해 준다. 끝으로 관심 있다면 ildasm.exe 를

이용해서 파일 | 덤프에서 전체 실행 코드를 IL 코드로 덤프할 수 있으니 이 둘을 비교해 보는 것도 재미있을

것이다.

마치며

조금은 정신이 없었을 지도 모르겠다. 이번시간에는 비교적 긴 두 개의 예제를 살펴보았다. 뮤텍스, 이벤트를

살펴보았으며, Wait 함수들이 갖는 의미에 대해서 살펴보았다. 실제로 위에 작성된 예제는 매우 깨지기 쉽다. try

… catch 를 이용한 예외 처리를 전혀하지 않았다. 실제 응용 프로그램을 작성한다면 보다 세심한 주의가

필요할 것이다. 다음 시간에는 지금까지 미뤄왔던 가장 기본적인 동기화 클래스인 Interlocked 에 대해서

알아보고, Monitor 와 Mutex 의 주요 멤버들을 정리해보도록 하자. (앞으로 갈 길이 멀다. ^^;) 궁금한 사항이나

의견이 있다면 한빛미디어 관리자([email protected]) 앞이나 프로그래밍 Q&A 게시판에 올려주기

바란다. 늘 하는 얘기지만 필자가 별로 아는 것도 없고, 틀린 곳도 많을 것이다.

170

C# 쓰레드 이야기 - 13. Interlocked, Heap

지난 시간까지 다양한 동기화 객체에 대해서 살펴보았다. 이렇게 살펴본 것들에는 뮤텍스, 이벤트, 모니터등이

있다. 힙에 대해서 살펴볼 것이며, 닷넷 환경에서 최적화를 위해 어떤 것들을 주의해야 하는지 설명할 것이다.

Interlocked 클래스

Interlocked 클래스는 int 형 값을 증가시키거나 감소시키는데 사용한다. 멀티 쓰레드 환경에서 하나의 int 형

전역 변수를 공유한다고 생각해보자. 이런 경우에 A 쓰레드와 B 쓰레드가 값을 동시에 읽어와서 B 쓰레드가

수정한 값을 저장하고, A 쓰레드가 다시 수정한 값을 저장하게 되면 B 쓰레드의 변경사항을 잃어버리게 된다.

지금까지 이러한 자원의 동기화를 위해서 모니터나 뮤텍스를 사용하는 방법을 설명했지만 간단한 int 형의 값을

여러 쓰레드가 공유하는 것이 일반적이기 때문에 이러한 작업을 캡슐화한 클래스를 제공한다.

Interlocked 클래스는 System.Threading 클래스에 있으며 주요 멤버는 다음과 같다.

메소드 이름 설 명

두 대상을 비교하여 값이 같으면 지정된 값을 설정하고,

그렇지 않으면 연산을 수행하지 않는다.

CompareExchange

Decrement 지정된 변수의 값을 감소시키고 저장한다.

Exchange 변수를 지정된 값으로 설정한다.

Increment 지정된 변수의 값을 증가시키고 저장한다.

표에서 여러분은 Increment 와 Decrement 메소드를 가장 자주 사용할 것이다. 이들 메소드의 오버로드 목록은

다음과 같다.

public static int Increment(ref int);

public static long Increment(ref long);

171

public static int Decrement(ref int);

public static long Decrement(ref long);

오버로드된 목록에서 알 수 있는 것처럼 값을 저장하고 있는 변수에 대한 참조를 전달한다는 것에 주의한다. ref

키워드는 참조를 전달하는 것의 의미한다. 다음 예제와 같이 ref 키워드로 전달된 변수는 메소드를 수행한

다음에 전달한 원래의 값이 변경된다는 것을 의미한다.

int age;

age = 21;

DoSomething(age);

Console.WriteLine(age.ToString());

private void DoSomething(ref Age)

{

Age = 31;

}

위 코드는 단순한 의사코드인데, 실행 결과는 21 이 아닌 31 이 출력된다. private void DoSomething 이므로

아무것도 반환하지 않아도 값이 바뀐다는 것을 알 수 있다. ref 키워드를 삭제하면 값이 변경되지 않고 21 이

출력된다. ref 키워드는 C 언어로 보자면 &와 같다.

Win32 와 닷넷의 Interlocked 비교

Win32 API 에서는 Interlocked 동작을 정의하는 3 개의 API 를 제공한다. 이들 API 는 다음과 같다.

LONG InterlockedIncrement (LPLONG lplValue)

LONG InterlockedDecrement (LPLONG lplValue)

LONG InterlockedExchange (LPLONG lplTarget, LONG lplValue)

위에 소개한 Interlocked 클래스 멤버들은 이들 API 에 대한 간단한 래퍼이며, 닷넷에는 편의를 위해

CompareExchange 메소드가 추가되었다는 것을 알 수 있다. 또한 닷넷은 32 비트 정수형(System.Int32)과 64

172

비트 정수형(System.Int64)를 제공하며, Win32 API 는 32 비트 정수형(LONG)만 제공한다는 점에 주의한다.

어느쪽을 사용하든 간에 이들 Interlocked 는 변수를 변경하는 쓰레드가 이 변수를 베타적으로 액세스하는 것을

보장한다. 즉, 멀티 프로세서, 멀티 쓰레드 환경에서 각각의 CPU 에 있는 쓰레드에 대해서도 베타적으로

액세스하는 것을 보장한다. 두 쓰레드가 동시에 값을 변경할 수 없는 것을 보장한다.

Interlocked 클래스 예제

이름 : interlock.cs

using System;

using System.Threading;

public class AppMain

{

private int m_member;

public static void Main()

{

AppMain ap = new AppMain();

ap.DoTest();

}

private void DoTest()

{

Thread thread1 = new Thread(new ThreadStart(Incrementer));

Thread thread2 = new Thread(new ThreadStart(Decrementer));

173

thread1.Start();

thread2.Start();

}

private void Incrementer()

{

Random rdm = new Random( unchecked((int)DateTime.Now.Ticks) );

for ( int loopctr = 0; loopctr < 10; loopctr++)

{

Interlocked.Increment(ref m_member);

Console.WriteLine("Incrementer : {0}", m_member.ToString());

Thread.Sleep(rdm.Next(1, 200));

}

}

private void Decrementer()

{

Random rdm = new Random( ~unchecked((int)DateTime.Now.Ticks) );

for ( int loopctr = 0; loopctr < 10; loopctr++)

{

Interlocked.Decrement(ref m_member);

Console.WriteLine("Decrementer : {0}", m_member.ToString());

Thread.Sleep(rdm.Next(1, 300));

}

}

}

예제를 컴파일하고 실행한 결과는 다음과 같을 것이다.

174

Incrementer 메소드를 위임받은 thread1 은 1 을 10 번 증가시키고, Decrementer 메소드를 위임받은 thread2 는

1 을 10 번 감소시킨다. 결과적으로 0 이 된다.

위 코드에서 Incrementer 와 Decrementer 대신에 동기화를 수행하지 않는 ++과 ?로 바꾸고, Thread.Sleep()을

제거한다면 위와 같이 되지 않는 다는 것을 알 수 있다. 실질적으로 여러분의 시스템에서는 직접 관찰하기

어렵다. 12 번째 글에서 소개한 것처럼 디버거 수준에서 확인하거나 2 개 이상의 CPU 가 장착된 시스템에서

관찰할 수 있다. 왜 이렇게 되는가는 힙에 대해서 설명하면서 같이 설명하겠다.

private int m_member;

위 코드는 쓰레드간에 공유할 변수를 선언한 것이다.

private void Incrementer()

{

Random rdm = new Random( unchecked((int)DateTime.Now.Ticks) );

for ( int loopctr = 0; loopctr < 10; loopctr++)

{

Interlocked.Increment(ref m_member);

Console.WriteLine("Incrementer : {0}", m_member.ToString());

175

Thread.Sleep(rdm.Next(1, 200));

}

}

Incrementer 함수는 Interlocked.Increment 를 사용하여 값을 증가시키는 메소드다. Interlocked.Increment 에

보면 ref 키워드를 같이 사용하는 것에 주의한다. Decrementer 함수는 Incrementer 함수와 동일하며

Interlocked.Increment 대신에 Interlocked.Decrement 를 사용한다. 또한 Incrementer 와 Decrementer 에서

임의의 숫자를 얻기 위해 사용한 부분에 주의하기 바란다.

Random rdm = new Random( unchecked((int)DateTime.Now.Ticks) );

Random rdm = new Random( ~unchecked((int)DateTime.Now.Ticks) );

위 둘은 각각의 함수에서 사용한 것인데, 동시에 두 개의 쓰레드가 다른 값을 얻기 위해 위와 같은 코드를

사용한 것이다. 여러분이 난수를 생성하기 위해 시스템에서 가져오는 시간값은 1/3 ms 초마다 갱신되기 때문에

두 개의 쓰레드가 값을 가져오는 간격이 1/3 ms 이하인 경우에 같은 값을 가져오게 된다.(시스템마다 차이가

있다) ~는 NOT 연산을 수행하며, 비트를 변경한다. unchecked 는 가져온 값을 int 형으로 가져올 때 발생하는

오버플로우 검사를 수행하지 않도록 한 것이다. unchecked 를 사용하지 않으면 OverflowException 예외가

발생한다.

Interlocked 의 함정

Interlocked 를 사용하여 정수형 값을 수정하기로 했다면 직접 멤버 변수에 값을 설정하려 해서는 안된다.

다음과 같은 코드를 생각해보자.

private int m_member;

m_member++; // 잘못된 방법

Interlocked.Increment(ref m_member);

여러 쓰레드가 항상 유효한 값을 갖도록 하려면 변수에 직접 액세스하지 않고 Interlocked 클래스를 사용하여

액세스해야한다.

176

Interlocked 클래스를 사용할 때 알아야 할 점은 하나의 쓰레드가 변수 값을 읽는 동안에 다른 쓰레드가

Interlocked.Increment 를 호출하는 경우에도 항상 변수 값은 유효하다는 것이다. 즉, 쓰레드는

Interlocked.Increment 에 의해 변경되기 전의 변수 값을 얻거나 아니면 Interlocked.Increment 에 의해 변경된

변수 값을 얻게 된다. 즉, Interlocked 클래스는 쓰레드가 정확히 어느 시점의 값을 얻을지 알 수 없지만,

부분적으로 증가된 값이 아니라 항상 유효한 값을 얻도록 보장한다.

닷넷의 Interlocked 클래스의 구현에 대해서 알고 싶다면 C#과 CLI 에 대한 공유 소스 코드를 다운 받은

다음에 .₩sscli₩clr₩src₩vm₩syncblk.cpp 소스 코드를 참고한다. 이 소스 코드가 조금 버겁다면 필자의 C#

쓰레드 9 번째 글에서 구현한 C++ 코드와 설명을 참고로 읽어 본다면 개념적인 이해에 도움이 될 것이다. 공유

소스 코드를 다운 받을 수 있는 곳은 글의 마지막에 참고자료에 정리해둔다.

힙(Heap)

초보 개발자뿐만 아니라 상당한 개발 경험이 있는 개발자들을 괴롭히는 개념들중에 하나가 스택과 힙이

아닐까하고 의심해본다.(정말일까?)

뭐… 어쨌거나 스택은 단순히 데이터를 넣고 뺄 수 있는 구조이며, 간단한 데이터 형만 저장할 수 있다.

C#에서는 System.ValueType 을 상속하는 기본 데이터형(int, long, bool 등)이 스택을 사용하며, 다른

데이터형은 모두 힙을 사용한다. 스택과 힙 사이의 형 변환을 위해 박싱(boxing)과 언박싱(unboxing)을

제공한다.

힙은 예약된 주소 공간을 뜻한다. 최초에 프로세스가 초기화될 때 시스템은 프로세스 주고 공간 내에 하나의

힙을 생성한다. 이 힙을 프로세스의 기본 힙이라 하며, 기본 할당 크기는 1M 로 정해져있다. 닷넷에서는 가비지

컬렉터가 자원을 관리하기 때문에 힙에 대한 함수를 직접 제공하지 않지만 Win32 API GetProcessHeap()

사용하여 현재 프로세스의 힙을 가져올 수 있다.

의사 코드 수준에서 표현하면 다음과 같은 코드를 사용한다.

using System.Runtime.InteropServices;

177

public unsafe class Memory

{

static int ph = GetProcessHeap();

[DllImport("kernel32")]

static extern int GetProcessHeap();

}

많은 함수들이 임시 메모리 블록을 필요로 하며, 이러한 블록들은 기본 힙에서 할당된다. 일반적으로 C 의

printf() 함수를 호출하는데 500 바이트 정도가, Win32 API 호출에는 2k 정도의 메모리가 필요하다. 닷넷에서

제공하는 많은 메소드들도 기본 힙을 사용한다. 응용 프로그램이 다양한 API 를 호출하는 메소드를 가지기

때문에 기본 힙에 대한 액세스는 순차화(serialize)된다. 즉, 시스템은 한 번에 하나의 쓰레드만 기본 힙으로부터

메모리를 할당받거나 반환하도록 보장한다. 두 개의 쓰레드가 동시에 기본 힙으로부터 메모리 블록을

할당하려고 하면, 하나의 쓰레드만 메모리 블록을 할당받을 수 있고, 다른 쓰레드는 첫번째 쓰레드의 블록이

할당될 때 까지 기다려야한다. 첫번째 쓰레드의 블록이 할당되면 두번째 쓰레드가 힙을 할당받을 수 있도록

허용한다. 이와 같이 한 번에 하나의 힙에 액세스하는 것은 성능을 저하시킨다. 닷넷에서는 가비지 컬렉터가

힙을 관리하기 때문에 힙을 직접 제어하거나 하지는 않는다. 그러나 닷넷에서 힙을 관리하는 방법을 이해한다면

보다 효율적인 프로그램을 작성할 수 있을 것이다.

주의할 점이 있는데, 프로세스가 기본 힙을 할당받으며 어셈블리 또는 DLL 은 자신의 힙을 갖지 않는다. 또한

기본 힙은 프로세스가 생성되기 전에 생성되고, 프로세스가 종료될 때 자동으로 파괴된다.

힙의 구조

힙은 여러가지 힙 할당자에 의해 관리된다. 프로세스별 기본 힙 할당자, 응용 프로그램 전용 힙 할당자, C

런타임 힙 할당자, CLR 힙 할당자로 구성된다.(실은 CLR 힙 할당자가 맞는 용어인지 모르겠다)이러한 힙

할당자는 Win32 힙 할당자에서 관리하고, Win32 힙 할당자는 NT 런타힘 힙 할당자에서 관리하며, 여기서 가상

메모리를 할당한다.

178

힙 할당자는 8 바이트에서 1024 바이트까지 128 개의 할당자로 구성된다. 힙을 할당 할 때 사용 가능한 블록을

찾으며, 사용 가능한 블록을 발견할 수 없을 때 가상 메모리를 예약하고 힙을 확장한다. 마찬가지로 임시

메모리의 사용이 끝나면 힙을 반환한다. 프로세스 힙은 기본적으로 병합을 수행한다. 즉, 인접한 힙이 비어있는

경우에 힙을 하나로 합쳐서 내부 단편화를 막는다. 이와 같이 힙을 할당하고, 힙을 병합하거나 가상 메모리를

예약하고 확장하는 것과 같은 작업은 오버헤드를 갖고 있으며, 수행 성능을 저하시킨다. 그리고 당연한

얘기지만 닷넷 런타임은 큰 블록을 할당하여 자체 메모리 관리를 수행한다. 닷넷은 하나의 큰 연속된 공간을

위해 64M 를 예약하며, 공간이 부족한 경우에 3M 단위로 블록을 확장한다.

힙은 128 개의 할당자중에 127 개의 할당자를 사용하고, 첫번째 할당자는 다른 용도로 사용한다. 이들 할당자는

이중 링크드 리스트로 관리되며, 첫번째는 top chunk 사용을 위해 예약되었다.

힙의 성능저하

힙의 수행 속도를 떨어뜨리는 경우는 다음과 같다.

할당작업

앞에서 얘기한 것처럼 힙을 할당할 때 이용할 수 있는 목록이 없으면 런타임에서 사용할 수 있는 힙을

검색하며, 런타임에서 이용할 수 있는 힙이 없는 경우에 힙을 확장할 것을 요청하기 때문에 수행

속도를 떨어뜨리게 된다.

해제작업

힙을 해제하는 경우에는 해제 자체보다는 해제된 힙을 병합할 때 오버헤드가 발생하며, 병합하는 동안

인접 항목을 찾아내어 더 큰 블록을 만들고 해제한다. 이러한 찾기가 발생하는 동안에 힙에 대한

임의의 액세스가 발생하고, 힙이 할당되는 경우에 캐시 누락이 발생하고 성능이 저하될 수 있다.

경쟁

여러 개의 쓰레드가 동시에 데이터에 액세스하려고 하면 하나의 쓰레드가 작업중인 동안 다른

스레드는 작업을 대기하게 된다. 또한 많은 메모리를 사용하는 DLL 을 여러 개의 쓰레드에서

실행하거나 다중 프로세서 시스템에서 실행하면 실행이 느려진다.

힙 손상

179

대부분의 경우에 닷넷에서 관리하기 때문에 프로그래머에 의해 힙 손상이 발생하는 경우는 없지만

Win32 API 를 사용하거나 컴파일을 할 때 힙 손상 에러가 발생하는 경우가 있다. 힙 손상은 이미

해제된 블록을 다시 해제하는 경우, 해제한 블록을 사용하려는 경우, 블록 경계를 벗어나거나 다른

쓰레드에 의해서 블록을 덮어쓴 경우에 발생한다.

힙 성능 개선

윈도우 2000 은 NT 에 비해서 힙에 대한 성능을 개선시켰고, 이러한 것은 CLR 에도 반 되었다. 예를 들어서,

프로세스 기본 힙에 대한 잠금을 최소화 하도록 알고리즘을 개선했으며, 할당 캐시(Alloc-Cache)를 사용하는

대신에 128 개의 할당자에서 이용할 수 있는 목록을 별도로 관리하는 Lookaside 목록을 사용하여 힙 성능을

개선했다.(여전히 할당 캐시도 유효한 방법이지만, 닷넷에 대해서만 논하도록 하자. 보다 관심이 있는 분들은

할당 캐시, MP 힙에 대해서 찾아보기 바란다) 대부분의 경우는 가비지 컬렉터에 맡기면 되지만 코드에서 몇

가지 최적화를 할 수 있다. 이러한 최적화는 초당 1000 개 이상의 요청을 처리하는 경우에 뚜렷한 차이가 난다.

Boxing 과 Unboxing 을 최소화

박싱과 언박싱은 스택과 힙 사이를 데이터가 오가는 것이기 때문에 많은 오버헤드가 발생한다. 만약

정수형 데이터에 대해서 박싱이 발생한다면 정수형 데이터에 대한 래퍼 클래스를 작성하거나

인터페이스를 사용한다. 인터페이스가 참조 유형이므로 값 형식의 데이터에 대해서도 인터페이스

참조만을 갖는다는 것을 이용한 것이다.

잦은 할당을 최소화

닷넷에서 메모리 관리를 대신하기 때문에 직접 메모리를 할당할 필요가 없지만, 내부적으로는 메모리

할당과 해제가 발생한다. 예를 들어서 System.String 에 문자열을 설정하고, 다시 문자열을 추가하는

경우에 내부적으로 블록이 반복적으로 할당되고 해제되는 과정이 수행된다.(System.String 은 한 번

설정된 문자열을 변경할 수 없다) 따라서 문자열 연결 작업을 최소화하거나 StringBuilder 클래스를

사용하는 것이 수행 성능에 도움이 된다.

쓰레드 고유 힙 생성

프로세스의 기본 힙은 여러 쓰레드가 공유해야하므로 경쟁이 발생하며, 수행 성능이 저하된다.

쓰레드가 빠른 읽기와 쓰기를 필요로 하고, 적은 공간의 메모리 블록을 필요로 하는 경우에 쓰레드

180

고유 힙을 생성할 수 있다. 닷넷에서는 이것을 직접 지원하지 않으며 Win32 API HeapCreate()를

사용하여 수행할 수 있다. (C#은 유일하게 stackalloc 외에 다른 것은 제공하지 않는다)

닷넷의 힙에 대해서 알고 싶다면 C#과 CLI 에 대한 공유 소스 코드를 다운 받은

다음에 .₩sscli₩clr₩src₩vm₩gmheap.cpp 소스 코드를 참고한다.

마치며

지금까지 미처 다루지 못했던 Interlocked 클래스에 대해서 설명했으며, 힙에 대한 기본 개념과 닷넷에서 힙을

관리하는 방법, 힙의 문제점과 성능을 개선할 수 있는 방법에 대해서 알아보았다. 여기서 힙을 설명한 이유는

멀티 쓰레드 응용 프로그램의 경우에 자원에 대한 경쟁이 발생하며, 동시에 많은 요청을 처리할 경우에 심각한

성능 문제로 이어지기 때문이다. 따라서 응용 프로그램을 작성할 때 힙을 어떻게 최적화할 것인지 결정해야

한다.

다음에는 쓰레드 풀링에 대해서 알아볼 것이며, 그 이후에는 다양한 동기화 클래스들과 비동기 처리 및 웹

서비스에서의 비동기 처리에 대해서도 설명할 것이다.(꽤 길다~) 끝까지 읽고, 직접 코드를 테스트하신

분들에게 감사드리며, 의문사항이나 잘못된 점이 있으면 알려주기 바란다.

참고

• The Shared Source CLI Beta

• http://msdn.microsoft.com/net/sscli

• ECMA 에 제출된 C#과 CLI 에 대한 소스 코드를 받아볼 수 있으며, 관련 사이트와 뉴스 그룹에 대한

정보를 얻을 수 있다.

181

C# 쓰레드 이야기 - 14. 마지막 이야기

지난 시간까지 쓰레드와 다중 쓰레드 프로그래밍에 대한 기초적인 설명들은 충분히 해왔다고 생각한다. 이번

시간에는 그동안 설명하지 못했던 부분들과 미흡했던 부분들, 그리고 쓰레드에 대해서 생각해 보아야 할

문제점을 비롯하여 닷넷에서 제공하는 쓰레드 프로그래밍의 문제점과 앞으로의 방향에 대해 함께 생각해보도록

하자.

교착상태의 조건

교착 상태는 아래와 같은 4 가지 경우에만 발생할 수 있다.

• 여러 리소스들의 소유와 대기

• 상호 배제(Mutual Exclusion)

• 무선점(No Preemption)

• 순환 대기(Circular wait)

그렇지만 두 번째와 세 번째는 운 체제에서 보장해 주는 사항이기 때문에 신경쓰지 않아도 된다. 흔히들

윈도우가 처음 나왔을 때 무선점 운 체제이냐 아니냐에 대한 이야기가 많았다는 것을 기억하는 분들이라면

그 '논쟁'이 바로 여기서 말하는 무선점이다. 윈도우 뿐만 아니라 다른 대부분의 운 체제들도 마찬가지로 상호

배제와 무선점을 보장해준다. 결국 프로그래머가 책임질 부분은 첫번째와 네 번째가 되며, 지금까지 이러한

자원들에 대한 베타적인 접근을 허용하기 위해 임계 역, 뮤텍스, 잠금(lock)에 대해 살펴보았다. 이러한 교착

상태를 해결하는 방법은 여러 가지가 있지만 이중에서 대표적인 것을 나열하면 다음과 같다.

• Detect

• Prevent

• Ignore

이중에서 세 번째 '무시한다(ignore)'는 실제로 운 체제에서 사용하는 방법이며, '검출한다(Detect)'는 부하가

많이 걸리기 때문에 실제로 잘 사용하지는 않지만 디버깅과 크리티컬한 서버 시스템을 프로그래밍하는 경우에

주로 사용한다. 실제로 이 방법을 구현하려면 우리가 사용하고 있는 x86 프로세서의 하드웨어적인 구조를 잘

182

알고 있어야 하며, 어셈블리에 대한 지식과 x86 op-code 에 대한 지식도 필요하다. 따라서 일반적으로

프로그래머들은 '예방한다(Prevent)'를 주로 사용하고 있으며, 지금까지 설명해왔던 방법들은 교착상태를

예방하기 위한 방법을 설명한 것이다. 또한, 교착상태를 예방하는 경우는 제한된 리소스에 대한 접근을

베타적으로 허용하게 하는 것과 관련된 것이기 때문에 잘 알려진 몇 가지 패턴들이 소개되어 있다.

이러한 디자인 패턴 중에 잘 알려진 것이 잠금(lock)과 상호배제(Mutex)이다. 닷넷에서는 이들을 하나의 클래스

형태로 제공하고 있으며, 이에 대한 C++ 구현을 이전 연재 기사에서 소개했었다. 그러한 코드를 응용하여

여러분 고유의 잠금과 상호배제 클래스를 구현할 수 있을 것이다.

Mutex 의 못다한 이야기

Mutex 에 대해서 소개하면서 꽤 장황하게 설명한 느낌이 들었다. 하지만 그러면서도 제대로 설명하지 못했다는

생각을 굉장히 많이 했던 것도 사실이다. 다음 이야기는 앞의 기사에서 소개한 임계 역과 뮤텍스를 읽었거나

이미 잘 알고 있다는 전제 하에 설명하는 것이다.

Mutex 는 커널 객체이기 때문에 프로세스 역에 있는 임계 역과는 다르다. 다시 말해 임계 역을 사용한

동기화 보다는 그 속도가 느리다. 그러나 커널 객체이기 때문에 응용 프로그램 프로세서 경계 건너편에

존재한다. 더 쉽게 말하면 두 응용 프로그램 프로세스가 동일한 뮤텍스를 소유할 수 있으며, 응용 프로그램

간에 동기화를 할 수 있는 방법을 제공한다. 더욱 재미있는 것은 각각의 응용 프로그램은 각자 다른 핸들을

가지지만, 실제로 커널 객체에 있는 동일한 뮤텍스를 가리킬 수 있다는 것이다.

즉, 두 응용 프로그램이 각각 뮤텍스에 대한 핸들 7, 10 을 갖고 있고, 각자 다른 뮤텍스를 소유하고 생각할 수

있지만 실제로 두 핸들이 동일한 주소 0x8000 0000 를 가리킬 수 있다는 것이다. 이들 핸들은 32 비트

정수형이다. 앞의 기사에서 설명한 뮤텍스 예제에서는 뮤텍스에 대한 이름을 지정하지 않았기 때문에 각 응용

프로그램간에 독립적이었으며, 같은 뮤텍스를 소유하게 하려면 Named Mutex 를 사용한다. 복잡한 것은 아니고

뮤텍스를 생성할 때 뮤텍스에 대한 이름을 지정하면 된다.

Process 클래스의 못다한 이야기

183

앞에 연재한 기사들 중에서 Process 클래스에 대해서 설명했고, 시스템에 있는 프로세스 정보를 출력하는

간단한 예제를 제시한 적이 있다. Process 클래스에 대한 자세한 내용은 MSDN 을 참고하길 바라지만 자주

사용하는 메소드와 속성을 정리하면 다음과 같다.

• Start, Stop

• Handle

• WairForExit

Start 와 Stop 을 사용해서 프로세스를 시작하고 종료할 수 있다. Win32 환경에서 프로세스는 쓰레드에 대한

컨테이너 역할을 하기 때문에 프로세스를 종료하면 그 프로세스에 속한 자식 쓰레드들도 모두 종료된다는 것을

기억하기 바란다. Process.Handle 을 사용해서 현재 사용중인 프로세스의 핸들을 얻을 수 있다. 프로세스의

핸들을 얻음으로써 다른 응용 프로그램의 프로세스에서 정보를 얻을 수 있다. 게다가 프로세스를 조작할 수

있으며, Named Mutex 를 복제하여 응용 프로그램의 Mutex 로 지정해 줄 수도 있다. WaitForExit 는 프로세스가

종료될 때 까지 기다리는 것을 지시하며, 이것은 주로 이벤트와 관련하여 처리한다. 간단히 말해 긴 시간 동안

파일을 다운 로드하거나 레코딩 작업, 디스크 정리 작업등을 끝낸 다음에 자동으로 응용 프로그램을 종료하거나

서버 프로세스를 종료하는데 사용할 수 있다.

쓰레드 풀링(Thread Pooling)

쓰레드 풀링에 대해서 자세히 설명할 기회를 갖지 못한 것을 아쉽게 생각한다. 쓰레드 풀링은 시스템의 자원을

효율적으로 사용하기 위한 대안이다. 시스템에서 쓰레드를 생성하고, 제거하는 작업 역시 많은 부하를 요구한다.

IIS 와 같은 웹 서버는 사용자가 웹 페이지를 요청할 때 쓰레드를 생성하고, 웹 페이지에 대한 요청을 완료하면

쓰레드를 종료한다. 사용자가 많은 사이트, 예를 들어 1 초에 300 명의 동시 사용자가 있다고 할 경우에 매

초마다 300 개의 쓰레드를 생성하고 제거하는 것은 상당히 비효율적이다.

따라서 서버는 50 개 정도의 쓰레드를 쓰레드 풀(Thread Pool)에 보유하고 사용자의 요청이 들어오면 쓰레드

풀에 있는 자유 쓰레드(Free Thread)가 쓰레드 풀 밖으로 나와서 요청을 처리하고, 나머지 작업에 대해서는

작업자 쓰레드(Worker Thread)에 일임하고 다시 쓰레드 풀로 돌아오는 방식을 취한다. 여기서 작업자 쓰레드는

184

실제 쓰레드가 아니기 때문에 의사 쓰레드 또는 가짜 쓰레드(Pseudo Thread)라고 얘기한다. 사용자가 의사

쓰레드에 작업을 요청하면 다시 쓰레드 풀에 있는 진짜 쓰레드가 나와서 요청을 처리하게 된다.

이러한 과정에서 알 수 있는 것처럼 쓰레드 풀링은 긴 처리시간을 갖는 쓰레드에 대해서는 그다지 적합하지

않으며, 생성과 제거 과정이 빈번한 멀티 쓰레드 프로그래밍에 적합하다. 이외에 쓰레드 풀링이 제공하는

장점과 단점을 정리해보면 다음과 같다.

쓰레드 풀링의 장점

• 쓰레드 생성시 발생하는 문제 한정

멀티 쓰레드 프로그래밍을 할 때, 여러 쓰레드들을 다루는 대신 쓰레드 풀로 일원화하여 다룰 수 있기

때문에 쓰레드와 관련된 문제를 쓰레드 풀로 한정하여 다룰 수 있는 장점을 제공한다.

• 제한된 시스템 자원 사용

실제로 쓰레드 풀링을 사용할 경우 CPU 점유율과 메모리 사용량을 낮출 수 있으며, 보다 적은 수의

쓰레드로 많은 작업을 처리할 수 있다.

• 적은 CPU 시간 소모

• 사용자 UI 의 응답성 향상

쓰레드 풀링의 단점

• 쓰레드 수 만큼의 리소스 사용

• 쓰레드 수 증가에 따른 스케줄링

• 쓰레드 수 증가만큼의 응용 프로그램 복잡성 증가

이러한 얘기들은 쓰레드 풀링에 국한된 것이 아니며, 쓰레드 프로그래밍 자체에 대한 것이다. 오히려, 쓰레드

풀링의 단점이라기 보다는 쓰레드 풀링을 사용할 수 없는 경우에 대해 생각해야 한다.

쓰레드 풀링을 사용할 수 없는 경우

• 작업에 특성 우선 순위가 필요한 경우

185

• 실행 시간이 오래 걸려서 다른 작업을 차단해야 하는 경우

• STA 쓰레드를 사용하는 경우

• 쓰레드 ID 가 필요한 경우

쓰레드 풀링을 사용하는 경우 의사 쓰레드를 사용하기 때문에 작업에 특정 우선 순위를 부여하거나, 개별

쓰레드를 직접 제어하는 것이 불가능하다.

쓰레드 풀링 주요 메소드

쓰레드 풀링은 RegisterWaitForSingleObject(), QueueUserWorkItem(), UnsafeRegisterWairForSingleObject()를

함께 사용하며, 대개의 경우 ReaderWriterLock 클래스와 함께 사용한다. 간단한 쓰레드 풀링 예제는 다음과

같다.

class TestPoolApp

{

static void Main()

{

string str = "Hello from ThreadPool";

ThreadPool.QueueUserWorkItem(new

WaitCallback(TestCallback),str);

Thread.Sleep(2000);

}

static void TestCallback(object state)

{

Console.WriteLine(state);

}

}

타이머

186

타이머는 여러분이 생각하는 것과 달리 시스템과 독립되어 있다. 일반적인 시스템에 장착되어 있는 타이머는

다음과 같이 나눌 수 있다.

• 실시간 시계: 컴퓨터의 메인 보드에 내장되어 있으며, 수은 전지를 통해서 째깍째깍 돌아가는 진짜

시계를 뜻한다.

• 8253 칩: 8253 칩에서 시스템 클럭을 1 초에 120 만번을 만들어 내며, 이 클럭은 16 비트 레지스터에서

카운트 한다. 16 비트 레지스터를 사용하기 때문에 2 의 16 승이 될 때 마다 펄스를 한 번 발생시키고

레지스터를 다시 초기화한다. 즉, 120 만 / 2^16 = 18 만 번의 펄스가 발생하게 된다. 1 초에 발생하는

펄스가 18 만 번이므로, 이를 나누게 되면 1/18 만번 = 0.055 초마다 한 번씩 펄스가 발생하게 된다.

운 체제는 이와 같은 펄스를 사용한다. 즉, 55ms 초에 한 번씩 윈도우 메시지를 체크하고 가져간다.

닷넷에서의 타이머

닷넷에서는 두 가지 타이머를 제공한다. 앞에서 얘기한 것처럼 윈도우 타이머는 System.Windows.Forms.Timer

클래스이며, 55ms 의 한계를 갖지만, 일반적인 용도로는 무리가 없다. 그러나 윈도우 타이머는 시스템의 다른

쓰레드 작업에 의해서 향을 받을 수 있기 때문에 멀티 쓰레드 프로그래밍에는 적합하지 않은 경우가 많다.

위에서 소개한 8253 타이머에서 발생하는 클럭은 CPU 와는 무관하게 하드웨어에서 발생하는 것이다. 즉,

시스템이 바쁜지에 관계없이 항상 일정하게 클럭을 발생시킨다. 이와 같은 클럭을 사용하여 보다 정교한 제어가

필요한 곳에 사용할 수 있다. 이러한 클럭을 제공하는 타이머는 System.Timers.Timer 클래스에서 제공한다.

어떤 타이머를 사용하는 가는 독자의 선택이며, 경우에 따라 적절한 것을 사용하면 된다. VB6 프로그래머나

VC++과 같은 RAD 툴에서 제공하는 타이머는 닷넷의 System.Windows.Forms.Timer 클래스와 같은 것이며,

System.Timers.Timer 와 같은 정교한 타이머를 위해서는 VB6 에서는 상용 컴포넌트를 사용하거나 VC++과 같은

다른 언어로 작성한 컴포넌트를 사용했다.

쓰레드 예외 처리 클래스 계층

쓰레드 예외 처리 클래스 계층 구조에 대해서 미흡하게 설명한 감이 있어서 여기에 그 클래스 계층 구조를

간단히 옮겨둔다. (참고로 MSDN 에서는 참조하기가 너무 어렵게 되어 있다. -_-)

187

SystemException

- ThreadAbortException

- ThreadInterruptedException

- ThreadStateException

- SynchronizationLockException

MulticastDelegate

- IOCompletionCallback

- ThreadExceptionEventHandler

- ThreadStart

- TimerCallback, WaitCallback, WairOrTimerCallback

EventArgs

- ThreadExceptionEventArgs

흥미있는 점은 IOCompletionCallback 에 대한 것인데, IO 작업이 종료될 때 응용 프로그램을 종료할 수 있게

해준다. 이는 Win32 API 인 WaitEx 와 비교해보기 바란다.

ApartmentState

Thread 클래스에서 ApartmentState 라는 것이 있다. VB6 에서는 STA 만을 지원했다는 것은 익히 알려진

사실이다. 굳이 여기서 VB6 를 언급하는 것은 VB6 로 작성한 컴포넌트를 닷넷에서 사용할 경우에는 이

ApartmentState 를 특별히 지정할 필요가 있다. 이 속성은 COM 과의 하위 호환성을 위해서 제공된다고

생각하면 된다.

STA 와 MTA

ApartmentState 는 STA 와 MTA 구조가 있는데, Single Threaded Apartment 라는 것은 하나의 쓰레드가

아파트를 소유하는 구조를 갖는다. 즉, 멀티 쓰레드를 제공할 수 없는 구조로 되어 있다. MTA 는 Multi

188

Threaded Apartment 로 되어 있으며, 하나의 아파트에 여러 쓰레드가 세들어 살 수 있는 구조를 갖으며, 각각의

쓰레드가 독립된 방을 갖는 것이 허용된다. 즉, 멀티 쓰레드를 제공하기에 적합한 구조라고 할 수 있다. 이들을

지정하는 것은 다음과 같다.

Thread.CurrentThread.ApartmentState = ApartmentState.MTA;

Thread.CurrentThread.AprartmentState = ApartmentState.STA;

이와 같이 지정하는 방법도 있으며, 특성(attribute)을 사용하여 지정하는 방법도 있다. 이들은

System.STAThreadAttribute 와 System.MTAThreadAttribute 에 정의되어 있으며, 메소드 앞에 [STAThread]

또는 [MTAThread]와 같이 지정하면 된다. 주의할 점은 이미 시작된 쓰레드에 대해서는 아파트먼트를 지정할

수 없으므로 쓰레드를 시작하기 전(Thread.Start()를 호출하기 전)에 설정해야 한다.

닷넷에서는 MTAThread 가 기본 설정이며, 이는 COM 객체와도 잘 맞는다. 다만, VB6 로 작성한 DLL 에

대해서만 호환성을 위해 STAThread 를 사용하여 COM 객체에 대한 현재 쓰레드를 STA 로 설정하도록 해야한다.

Reader/Writer Model

독자/작가 모델은 생산자/소비자(Producer/Consumer) 모델이라고도 한다. 응용 프로그램에서 쓰레드를

사용하여 데이터를 생산하고, 데이터를 소비한다면 데이터 생산과 소비가 하나로 연결되어 있기 때문에

효율적으로 처리할 수 없을 것이다. 이와 경우에 쓰레드 사용시 발생하는 부하까지 고려한다면 더 비효율적이다.

따라서 생산자와 소비자를 각각의 쓰레드로 분리하여, 생산자 쓰레드는 최대한의 데이터를 생산해내고, 소비자

쓰레드는 최대한 데이터를 소비하는 구조가 보다 효율적일 것이다. 닷넷에서는 이들을 ReaderWriterLock

클래스의 형태로 포장하여 제공하고 있으며, 주요 속성과 메소드는 다음과 같다.

속성

- IsReaderLockHeld

- IsWriterLockHeld

- WriterSeqNum

메소드

- AcquireReaderLock

189

- AcquireWriterLock

- ReleaseReaderLock

- ReleaseWriterLock

각 이름에서 알 수 있는 것처럼 현재 독자(Reader)가 락을 소유하고 있는지, 작가의 일련 번호가 어떻게 되는지

알 수 있으며, 독자와 작가 모두 락을 획득하고 해제할 수 있는 메소드를 제공한다. ReaderWriterLock 은 쓰레드

풀링과 함께 사용하여 하나의 동기화 객체에서 많은 수의 데이터를 처리할 때 유용하다.

생산자/소비자 모델은 경우에 따라 다음과 같이 4 가지로 나눌 수 있다.

• 단일 생산자/단일 소비자

• 단일 생산자/다중 소비자

• 다중 생산자/단일 소비자

• 다중 생산자/다중 소비자

단일 생산자/단일 소비자

이러한 모델들은 서로간에 데이터를 공유하지 않는다. 이벤트에서 설명한 예제에서와 같이 한 쓰레드가

데이터를 읽어서 처리하고, 처리가 끝난 데이터를 다른 쓰레드에 넘겨주는 형태를 띄게 된다. 즉, 생산자와

소비자 간에 데이터가 있음과 없음을 알려주는 이벤트를 사용하는 형태가 된다. 이러한 단일 생산자/단일

소비자 모델은 pipelined 처리라고도 한다.

이러한 pipe-lined 처리의 대표적인 예로는 멀티미디어 플레이어가 있다. 멀티미디어 플레이어는 데이터를 읽어

들이고, 데이터를 디코딩하고, 데이터를 화면에 표시하는 것으로 나눠진다. 이들 쓰레드는 데이터를 공유하지

않으며 처리되는 데이터를 넘겨받는다. 즉, A → B → C 순으로 각 쓰레드가 처리한 데이터를 넘기는 형태를

띈다.

또는 소수를 찾는 알고리즘을 작성한다고 할 때, 루프를 돌면서 무작정 수행하는 것은 바람직하지 않다. 이런

경우에 소수를 찾아서 버퍼에 저장하는 쓰레드와 버퍼에 데이터가 있는지 검사하여 화면에 데이터를 뿌려주는

쓰레드를 같이 나누어서 처리하면 보다 효율적으로 처리할 수 있을 것이다.

190

특히 소수를 찾는다고 했을 때 2 로 나누어지는지 확인하는 쓰레드, 3 으로 나누어지는지 확인하는 쓰레드, 5 로

나누어지는지 확인하는 쓰레드, 그리고 7 과 같이 발견된 소수를 소수를 유지하는 버퍼에 집어넣고, 7 로

나누어지는지 확인하는 쓰레드를 동적으로 생성하고, +2 씩 더해나가면서 소수를 찾다가 11 을 발견하게 되면

11 로 나누어지는지 확인하는 쓰레드를 동적으로 생성하는 구조를 작성했다고 하자. (소수에서 짝수는 2 밖에

없으므로 짝수는 무시하고 +2 씩 증가시키면 된다.) 이처럼 각각을 나누는 함수를 쓰레드로 나누고, 추가된

소수에 대해서 확인하는 쓰레드를 동적으로 생성하면 프로그램의 유연성을 높이고, 보다 빠르게 실행되는

쓰레드를 작성할 수 있다. 즉 11 까지 소수를 발견했을 때 진행되는 처리 순서는 다음과 같이 될 것이다.

(편의상 2 로 나누는 쓰레드는 2T 와 같이 표현한다고 하자.)

2T -> 3T -> 5T -> 7T -> 11T

2T -> 3T -> 5T -> 7T -> 11T

2T -> 3T -> 5T -> 7T -> 11T

이와 같은 구조에서 알 수 있는 것처럼 소수를 확인하는 전체의 알고리즘을 끝내기 전에 한 단계를 수행하고,

바로 다음 단계를 수행할 수 있다. 즉, 각 단계의 처리시간을 1 이라고 산정할 경우에 선형적인 모델은 15 라는

처리 시간이 걸리지만 위와 같은 pipe-lined 구조에서는 7 이라는 시간밖에 걸리지 않는다.(흔히들 말하는

CPU 의 파이프 구조라는 것이 이것을 뜻한다. 이 파이프 구조가 뭐부터 적용되기 시작했는지 기억은 안나지만,

아마 펜티엄 이상은 다 채택된 구조로 기억하고 있다.)

단일 생산자/다중 소비자

우리가 흔히 네트워크에서 접하는 온라인 게임들에 해당한다. 이러한 온라인 게임들은 하나의 서버가 가상의

세계에 대한 실시간 연산을 수행하며, 클라이언트는 이러한 서버의 데이터를 가져와서 읽어들이고 해석하여

적절한 그래픽으로 화면에 뿌려주는 것이다. 온라인 게임 외에 가장 흔히 보는 예로는 온라인 방송도 있다.

하나의 음악 서버에서 음악 데이터를 처리하고, 클라이언트는 이 방송 서버에 연결하여 데이터를 수신하여

처리하는 것이다. 서버에서는 하나의 데이터만 생성하지만 클라이언트는 1000 개가 될 수 있다.

다중 생산자/단일 소비자

191

생산자가 여럿이고, 소비자가 하나인 모델에는 어떤 것이 있을까? 가장 일반적인 것은 큐를 생각할 수 있다.

네트워크에 연결된 프린터를 생각해 볼 경우 여러 PC 들이 프린터로 출력 작업을 보낸다(다중 생산자). 반면

출력 작업은 하나의 프린터에서만 발생한다(단일 소비자). 이러한 구조를 다중 생산자/단일 소비자 모델이라고

한다. 이와 비슷해 보이지만 실제로는 다른 의미를 갖는 것들도 있다.

다중 생산자/다중 소비자

이것은 생산자와 소비자가 여럿인 경우로 대표적인 것은 항공사의 예약 시스템을 들 수 있다. 각 공항의

데스크와 사무실에서 좌석 예매를 처리할 수 있으며, 고객은 언제든지 온라인에서 남아있는 좌석과 좌석 수를

확인하고 예매를 할 수 있다. 혹시나 이러한 항공사 시스템에 대해서 궁금한 분들은 인터넷에 찾아보면 꽤 많은

자료들이 있고, 재미난 자료들도 있으니 찾아보기 바란다. 80 년대에 IBM 이 행했던 항공사 예매 시스템에 대한

자료도 있으며, 90 년대에 공항 안내 화면에 MS 의 블루 스크린이 떴다는 이야기까지 다양한 것들이 준비되어

있을 것이다. ^^;

비동기 대리자(Asynchronous Delegates)

동기 대리자에는 Invoke 메소드가 있으며, 비동기 대리자에는 IAsyncResult 인터페이스가 있다. 실제로 웹의

특성상 동기처리는 거의 사용하지 않으며, 웹 서비스와 관련하여 비동기 서비스를 사용하게 된다. 즉, 사용자가

지역과 지역을 지정하면 지역간 최단 이동경로를 표시하면서 두 지역간의 주요 위치 정보를 가져오는 각각의

쓰레드가 있다고 하면 처리 페이지에서 IAsyncResult 를 사용하여 세 개의 쓰레드가 모두 종료될 때 까지

대기하면서 다른 처리를 계속하여 수행할 수 있고, 처리가 종료되었다고 알려올 때 마다 그에 따른 작업을

처리할 수 있을 것이다.

비동기 처리에 쓰이는 메소드는 BeginInvoke 와 EndInvoke 메소드가 있다. 자세한 것은 MSDN 을 찾아보기

바란다. 실제로 동기 처리를 많이 하지 않는다면 IAsyncResult 와 대리자를 사용하여 쓰레드 사용을 캡슐화할

수 있기 때문에 쓰레드 자체를 거의 쓰지 않게 될 것이다.

IAsyncResult 인터페이스

192

System.Runtime.Remoting 네임 스페이스에 정의되어 있다. AsyncResult 콜백을 사용하며, IsCompleted 속성을

사용하여 작업의 완료 유무를 확인할 수 있다. AsyncResult 클래스는 비동기 작업을 캡슐화했으며,

WebClientAsyncResult 클래스는 XML 웹 서비스 프록시에서 비동기 메소드 패턴을 구현하는데 사용하기 위해

IAsyncResult 인터페이스를 구현한 것이다. 비동기 처리에 대한 예제는 다음과 같다.

using System;

using System.Threading;

using System.Runtime.Remoting.Messaging;

delegate int Compute(string s);

public class Test

{

static int TimeConsumingFunction (string s)

{

return s.Length;

}

static void DisplayFunctionResult(IAsyncResult ar)

{

Compute c = (Compute)((AsyncResult)ar).AsyncDelegate;

int result = c.EndInvoke(ar);

string s = (string)ar.AsyncState;

Console.WriteLine("{0} is {1} characters long", s, result.ToString());

}

static void Main()

{

193

Compute c = new Compute(TimeConsumingFunction);

AsyncCallback ac = new AsyncCallback(DisplayFunctionResult);

string str1 = ".NET Framework";

string str2 = "Seren";

IAsyncResult ar1 = c.BeginInvoke(str1, ac, str1);

IAsyncResult ar2 = c.BeginInvoke(str2, ac, str2);

Console.WriteLine("Ready");

Console.Read();

}

}

비동기 웹 서비스 구현

비동기 웹 서비스를 구현하는 경우에도 IAsyncResult 인터페이스를 구현한 것을 사용하며,

WebClientAsyncResult 클래스를 이용한다. 아래 코드는 간단히 구현한 비동기 웹 서비스의 일부를 옮긴 것이다.

arStart = mapService.BeginGetMap(start, null, null);

arFinish = mapService.BeginGetMap(finish, null, null);

arDirections = mapService.BeginGetDirections(start, finish, null, null);

WaitHandle [] wh = {

arStart.AsyncWaitHandle,

arFinish.AsyncWaitHandle,

arDirections.AsyncWaitHandle

};

WaitHandle.WaitAll(wh);

((WebClientAsyncResult)arDirections).Abort();

교착상태 검출(Deadlock Detection)

194

Never know I'm here.(내가 여기있는걸 절대 모를걸) - Ghost in StarCraft

앞에서 교착상태가 발생하는 네 가지 경우에 대해서 이야기 했고, 그 중 두 가지는 운 체제가 책임지는

부분이며, 다른 두 가지는 프로그래머가 책임져야 한다고 했다. 또한 프로그래머는 교착상태 검출과 예방에

힘쓰며, 대개의 경우에 예방에 힘쓴다고 했다. 실제로 교착상태를 검출할 수 있는 기능을 닷넷 프레임워크는

제공하지 않는다. (사실, 거의 대부분의 언어가 제공하지 않는다.)

교착상태를 검출하려면 다중 프로세서를 사용하는 PC 가 있어야 한다. 그리고 리눅스 서버나 듀얼 메인보드라고

하여 시중에서 판매되는 메인 보드에 일반 펜티엄 III CPU 를 2 개씩 꽂는 것으로는 제대로 테스트할 수 없다.

반드시 ServerWorks 등을 사용한 서버용 메인보드와 Xeon 과 같이 병렬처리를 위해 설계된 CPU 를 2 개 이상

설치한 시스템이 있어야 한다. 만약, 이와 같은 환경이 갖추어지지 않았다면 자신이 작성한 코드가 싱글

프로세서 머신에서 잘 수행된다고 하더라도, 다중 프로세서 머신에서는 제대로 수행되지 않는다고 생각하기

바란다.

다중 프로세서 머신에서 테스트할 수 있는 좋은 방법은 Solaris 와 같은 UNIX 시스템의 계정을 얻어서

프로그래밍을 하는 것이다. 대개의 서버 머신들은 병렬 처리를 위해 설계되어 있으며, 2 개 이상의 CPU 가

설치되어 있다.(보통 2 개 내지 4 개 정도가 장착되어 있으며, 장착된 CPU 의 수는 dmesg 를 사용해서 알 수

있다. 윈도우 NT/2000/XP 환경이라면 set 을 입력하면 PROCESSOR_*로 시작하는 환경 변수에서 CPU 정보를

얻을 수 있다.)

이제, 검출에 대해서 이야기 해보자. 교착상태 검출이라는 것은 특별한 것은 아니다. 메모리에 로드되어 특정

함수가 호출되는가를 감시하는 것이다. 그리고 이러한 함수들을 호출하는 것을 발견하면 해당 쓰레드 ID,

쓰레드의 핸들(32 비트 값이며, 보통 어드레스), 입력값, 반환값, 호출/반환등을 간단히 로그파일에 기록하는

것이다. 응용 프로그램을 테스트하다가 교착상태에 빠지게 되면 응용 프로그램을 종료하고, 로그 파일을

확인해서 어느 위치에서 교착상태가 발생하는지 쉽게 알 수 있다. 로그 파일에 기록된 어드레스를 토대로,

컴파일시 생성한 디버깅 정보를 이용해서 디버거에서 문제를 동일하게 재현하고, 문제를 해결할 수 있다.

그렇다. 내용을 알고나면 별거 아니다. 그러면 뭐가 문제인가? 실제로는 교착상태 검출을 위해서는 검출 루틴

자체가 메모리에 로드되어야 한다. 또한, 실행 파일 뿐만 아니라 실행 파일이 사용하는 DLL 의 복사본이

195

메모리에 로드된다는 것은 익히 알려진 사실이다. 이처럼 메모리상에 로드된 DLL 에서 특정 함수가 호출될 때를

가로챌 수 있어야 한다. 이와 같이 특정 메시지를 가로채는 것을 '갈고리로 채 간다'라는 개념으로

'후킹(hooking)'이라는 용어를 사용한다.

닷넷에서는 후킹에 대한 메소드를 제공하지 않는다(베타 1 에는 있었지만, 이후에 제거되었다). 따라서 Win32

API 함수를 DllImport 특성을 사용해서 선언하여 사용하도록 한다. (실제로 DLL 에서 호출되는 함수에 대한

어드레스 매핑등에 대한 정보는 .idata 섹션에 저장된다. 이러한 실제 구현에 대해서 궁금하다면 PE File

Format 에 대한 문서를 읽어보고, COFF(Common Object File Format)을 참고하면 도움이 될 것이다. 그리고

마이크로소프트웨어 2002 년도 중에 PE File Format 에 대해서 자세히 설명한 기사가 있었던 것으로 기억한다.)

닷넷에서 후킹을 위해 사용할 Win32 API 는 대부분의 경우에 SetWindowsHookEx, UnhookWindowsHookEx,

CallNextHookEx, CopyMemory, MoveMemory 정도가 될 것이다(닷넷에서는 윈도우 메시지를 가로채서 처리할

수 있는 IFilterMessage 와 윈도우 폼에서 컨트롤에 대해 사용할 수 있는 HookChildControls 외에는 제공하지

않고 있다).

후킹을 하려면 WH_*와 같은 윈도우 핸들를 정의하도록 한다. CallNextHookEx 에서 후킹할 것들을 지정할 수

있으므로 원하는 윈도우 핸들을 지정해서 처리할 수 있을 것이다. 셸에 대해서 처리하고 싶다면 WH_SHELL 을,

메시지 처리를 닷넷의 IFilterMessage 대신에 직접 처리하고 싶다면 WH_MSGFILTER 를, 시스템 메시지 처리에

대해서는 WH_SYSMSGFILTER 를, 키보드를 처리하고 싶다면 WH_KEYBOARD 를 사용하며, Alt+Tab, Ctrl+Esc 와

같은 특수 키를 처리하고 싶다면 저수준의 키보드 핸들인 WH_KEYBOARD_LL 을, 컴퓨터 기반 트레이닝에 대한

처리는 WH_CBT 를, 디버그에 대해서는 WH_DEBUG, 마우스에 대해서는 WH_MOUSE, WH_MOUSE_LL 를 각각

사용할 수 있다. 이러한 후킹을 처리하는데 필요한 구조체를 C#에서 정의하려면 구조체를 정의하는 struct

키워드 앞에 StructLayout 특성(attribute)을 함께 사용한다.

[StructLayout(LayoutKind.Sequential)]

public struct MOUSEHOOKSTRUCT

{

public POINTAPI pt;

public int hwnd;

public int wHitTestCode;

196

public int dwExtraInfo;

}

StructLayout 특성에 대한 자세한 설명은 MSDN 을 참고한다.

Concurrent Abstraction Programming

여러 가지가 있겠지만 여기서는 현재 C#에서 제한적으로 구현되어 있는 것에 대해서 소개하려한다. CAP 라는

것은 쓰레드 프로그래밍과 같은 것들을 말 그대로 추상화시켜 일반화한다는 얘기다.

비동기 메소드

async postEvent(EventInfo data)

{

// large method body

}

비동기 메소드는 async 키워드로 구현한다.

Chords

동기 메소드와 비동기 메소드를 조합한 것을 Chords 라 한다.

class Buffer

{

string get() & async put(string s)

{

return s;

}

}

위 코드는 버퍼를 구현한 것이며, 이 버퍼를 이용하는 코드는 다음과 같다.

Buffer buff = new Buffer();

buff.put("blue");

buff.put("sky");

197

Console.Write(buff.get() + buff.get());

먼저 메소드 선언의 왼쪽은 동기 메소드이고, 오른쪽은 비동기 메소드다. 먼저 buff.put("blue")를 사용해서

버퍼에 데이터를 집어넣는다. put 메소드는 비동기 메소드이므로 처리가 완료된다. 두 번째 buff.put("sky")도

마찬가지로 버퍼에 데이터를 집어넣으며 비동기(async)이므로 문제없이 처리가 완료된다.

Console.Write(buff.get() + buff.get());

여기서 get()을 호출한다. chords 로 정의된 메소드는 두 조건이 일치할 때만 수행된다. 즉, 먼저 비동기 메소드

put 을 호출하여 비동기 메소드 조건을 만족시키고, get 을 호출하여 동기 메소드 조건을 만족시킬 때 블록 안의

문장 return s;가 실행된다. 결과는 버퍼에 들어가 있던 "blue"를 꺼내오고, 두 번째는 "sky"를 꺼내온다. 따라서

싱글 프로세서 머신에서는 항상 "bluesky"라는 결과를 보게되며, 다중 프로세서 머신에서는 경우에 따라

"bluesky" 또는 "skyblue"라는 결과를 보게된다.

class Buffer {

string get() & async put(string s) {

return s;

}

string get() & async put(int n) {

return n.ToString();

}

}

이것은 버퍼 클래스에 대해서 오버로딩을 적용한 것이다. 이제 버퍼에는 string 형뿐만 아니라 int 형도 저장할

수 있게 된다.

class OneCell()

public OneCell() { empty(); }

public void put(Object o) & async empty() {

contains(o);

}

public Object get() & async contains(Object o) {

198

empty();

return o;

}

}

이것은 셀을 사용하여 데이터를 저장하는 형태를 가진다. OneCell 클래스를 초기화할 때 생성자에 있는

empty()라는 메시지가 전달된다. 두 번째 메소드에서 async empty()이므로 문제 없이 수행되고, 조건을

만족한다. 이제 put 을 사용해서 어떤 객체를 셀에 저장할 경우 put 과 empty 의 조건을 만족하므로 블록 안에

있는 contains(o);라는 메시지가 전달된다. 이 메시지는 async contains()에서 받아서 처리하게 된다. get 이

호출되면 이제 세 번째 메소드도 조건을 양쪽 모두 만족하게 되기 때문에 empty() 메시지를 전달하고, 객체

o 를 반환한다. 이러한 셀 구조를 사용해서 어떤 형태의 데이터든 저장하고, 가져올 수 있다.

객체 지향 프로그래밍이라고 하면 '현실 세계를 모델링한다.'라는 표현을 사용한다. 그리고 객체와 객체 사이에

메시지를 주고 받는다고 하지만 객체 지향 언어에는 그 메시지 자체가 숨겨져 있다. 그러나 위에서 볼 수 있는

것처럼 Concurrent Abstraction Pattern 에서는 이러한 메시지를 공개하고, 프로그래머가 직접 정의하고 사용할

수 있게 해준다. 이것이 쓰레드와 무슨 상관이 있는 걸까? 조금만 더 보자.

class ReaderWriter {

ReaderWriter() { Idle(); }

public void Shared() & async Idle() { S(1); }

public void ReleaseShared() & async S(int n) {

if ( n == 1 ) Idle(); else S(n-1);

}

public void Exclusive() & async Idle() {}

public void ReleaseExclusive() { Idle(); }

}

이것은 ReaderWriter 를 클래스로 구현한 것이다. 다른 부분은 모두 비슷하니 무시하고, public void

ReleaseShared() & async S(int n) 부분만 설명하겠다. 이 부분은 n 이 1 이 될 때까지 계속해서 펌프질을 해서

공유된 데이터를 비우라는 것을 뜻한다.

class Token {

199

public void Grab() & public async Free() {}

public Token(int n) { for(; n-- > 0;) Free(); }

}

class Heavy {

static Token tk = new Token(2); // limit parallelism

public Heavy (int q) { tk.Grab(); …; } // rather slow

public int Work(int p) { return …; } // rather fast

public void Close() { tk.Free(); }

}

이것은 사용자 지정 스케줄러(Custom Scheduler)를 구현한 것이다. 단 몇 줄로 쓰레드에 대한 스케줄러를

작성할 수 있다는 것을 쉽게 알 수 있을 것이다. 게다가 이러한 메소드 조인은 두 개의 메소드만 조인할 수

있는 것이 아니라 3 개 이상의 메소드를 조인할 수도 있다. 이러한 메소드를 간단히 Join 메소드라고 부르며,

C# 컴파일러는 이것을 해석하여 일반 클래스로 재작성한다(컴파일 과정일 뿐이다). 이것은 아직까지 소개되지

않았고, 언제쯤 Join Method 가 적용될지는 아무도 모른다. 그러나 Concurrent 에 대한 문제를 해결하기 위한

노력은 계속 진행되고 있다.

알려지지 않은 이야기

The truth is out there.(진실은 저 너머에 있다) - Chris Carter

동시성(Concurrent) 제어를 컴파일러를 통해 어셈블리(기계어) 수준에서 제어할 수 있다는 것을 70 년대에

증명하고, 컴파일러로 구현한 것이 Concurrent Pascal이다. 1975 년에 등장한 이 Concurrent Pascal은 플랫폼에

독립적이이면서, 병렬 처리 프로그램을 모니터를 사용해서 안전하게 프로그래밍할 수 있게 해준다.

현대 프로그래밍은 대부분 네트워크를 사용하고, 미들 서버 프로그래밍의 경우에는 쓰레드를 사용하여 보다

효율적으로 처리할 수 있다. 그러나 많은 언어들이 Concurrent Pascal의 아이디어를 채용하고 있는 것은 아니다.

이중에 쓰레드와 동기화를 제공하는 언어에는 자바와 닷넷 플랫폼이 있다.(닷넷 플랫폼 기반의 언어는 모두

동일하므로 C#이 아닌 닷넷 플랫폼이라 지칭한다)

200

그러나 자바에서는 모니터와 같은 핵심적인 구현을 생략했기 때문에 Concurrent 분야에 대한 지난 25 년간의

연구를 모두 무시하는 실수를 저질 다고 비난받고 있다. 그에 비해 닷넷 플랫폼은 모니터와 같은 도구들을

제공하고 있지만, 쓰레드에 대한 여러 가지 클래스와 메소드들의 의미가 상당히 모호하다는 평가를 받고 있다.

자바는 스펙에서 쓰레드에 대한 예외 처리 부족과 매우 빈약한 지원으로 공격당하고 있다. 마찬가지로 닷넷의

쓰레드는 모두 Win32 API 함수에 대한 단순한 래퍼(wrapper)로 되어 있으며, Win32 API 쓰레드의 고질적인

문제점 '당신이 생각한 데로 정확하게 동작하지 않는다'라는 문제점을 지적받고 있다. 사실, Win32 API 전체가

공격받고 있는 것은 아니며, WaitForMultipleObjects()에 대해서 공격받고 있다. 사실상 POSIX Thread(이하

PThread)에서는 WaitForMultipleObjects를 사용하지 않고도 문제를 유연하게 처리할 수 있다. 실제로 이러한

부분들은 자바와 Win32 진 모두 쉬쉬하고 있는 숨겨진 진실이다.

독자들은 이제 슬슬 궁금해질 것이다. "trax씨, 당신은 지금까지 닷넷 쓰레드에 대해서 설명해오지 않았나요?

그런데 지금와서 이것을 모두 부정하면 어쩌라는 거지요?"

부정하는 것은 아니다. 여러분은 어떤 언어를 사용하든지 충분히 훌륭하게 멀티 쓰레드 프로그래밍을 할 수

있다. 자바와 닷넷 플랫폼이 나름대로의 장단점을 갖고 있으며, 단점이 있으면 그것을 피해나갈 수 있을 것이다.

사실, 닷넷 플랫폼은 Win32 쓰레드의 문제점을 해결하고, 의미를 명확하게 하려 했지만, 반은 성공, 반은

실패했다고 생각한다. 문제를 명확하게 나눈 부분도 있지만, Win32 API에 대한 생각이 지배적인지 Win32 API의

사상을 그대로 물려받은 부분들도 있기 때문에 필자는 그렇게 생각한다.

사실, 자바의 부족한 쓰레드 지원과 동기화 모델의 부족을 해결하기 위해서

ZThread(http://zthread.soureforge.net)가 발전중에 있다. ZThread는 POSIX를 준수하며, 객체 지향, 크로스

플랫폼을 위한 C++ 스레딩 패키지 다. 현재는 자바 버전도 함께 제공되기 시작하고 있으며, 사실상 자바에서

POSIX Thread를 사용하려는 사람들에게 많은 지지를 받고 있다.

Win32 환경에서 pthread를 사용하려면 PThread(http://sources.redhat.com/pthreads-win32/)를 사용할 수

있다. 이것은 Unix/Linux 환경의 pthread를 Win32 환경에 구현한 것으로 객체 지향, 크로스 플랫폼을 위한

201

스레딩 패키지이며, C/C++에서 사용할 수 있다. 마찬가지로, DllImport를 사용해서 닷넷 플랫폼에서 사용할 수

있지만, 관리되지 않는 코드(unamanged code)를 사용하는 것에 대해서는 의견이 분분하다. 자바와 마찬가지로

닷넷 플랫폼에 사용할 수 있는 POSIX Thread 패키지가 나온다면 보다 좋을 것이다.

특정 언어가 제공하는 스레딩 지원을 떠나 다양한 플랫폼에 적용할 수 있는 POSIX Thread를 접해보는 것도

중요하며, 자신이 사용하는 언어에서 제공하는 스레딩 모델의 장단점을 객관적으로 바라보고, 그것을 이해하여

개선해나갈 수 있는 방법을 찾아낸다면 더 이상 바랄 것이 없을 것이다.

쓰레드에 대한 많은 구루(guru)들은 자바의 스펙과 스레딩 모델의 지원은 빈약하기 그지없고, 불완전하기

때문에 데이터 훼손(data corruption)을 제대로 막을 수 없다고 비난하고 있고, 닷넷은 다양한 모델을

제공하지만, 생각하는 데로(코딩하는 데로) 동작하지 않는 경우가 많다고 비난하고 있다. 또한, 닷넷 플랫폼의

동기화 예제들 중에 몇몇은 교착상태가 발생하는 것을 예제라고 올려두었다며 심하게 비난하고 있다. (실제로

이러한 코딩은 교착상태가 발견합니다. 라는 예제로 MSDN 사이트의 예제를 인용하고 있으며, 조목조목 잘

설명해 놓았다.) 그리고 구루들은 한결같이 이러한 문제의 대안으로 PThread를 사용할 것을 권하고 있다.

결국, 쓰레드라는 미지의 세계는 어느 한쪽만 알아서도 안되고, 책을 너무 의지해서도 안된다. 많은 현인들의

지혜를 얻어오고 경험을 더 쌓아야 한다는 것을 알 수 있다. 닷넷 플랫폼에서의 쓰레드에 대해서 설명했지만,

구현 예제보다는 운 체제와 관련된 개념을 더 많이 전달하고 싶었는데, 제대로 되지 않아서 아쉽다. 관심이

많은 분들은 운 체제와 POSIX Thread, Win32 Threading Model, Memory Model에 대해서 학습하기 바란다.

지금까지 쓰레드에 대한 기사를 쓰면서 설명했던 예제나 미처 설명하지 못했던 예제들을 제공하려고 한다.

여기에는 필자가 원격으로 동적으로 IP를 할당받은 머신을 찾기 위해 작성한 멀티 쓰레드 포트 스캐너 같은

간단한 유틸리티도 있으며, 어셈블리(DLL)로 스레딩 코드를 작성하고, ASP.NET에서 멀티 쓰레드 검색엔진으로

사용하는 것을 설명한 것도 있다.

모쪼록 부족한 부분이나마 이 글이 닷넷을 포함한 쓰레드 전반에 대한 이해에 도움이 되었으면 싶다. (후일에

좋은 책으로 다시 만날 수 있기를 바란다.) 늘 함께해주는 주위 분들에게 고마움을 전하고, 쓰레드에 대한

어리석은 질문에 친절하게 설명해준 Alexsander Terekhov와 comp.programming.threads 그룹의 많은

202

구루(guru)들에게 감사드린다.

참고자료

• Win32 멀티 쓰레드 프로그래밍, 한빛미디어

Win32 멀티 쓰레드 프로그래밍에 대한 깊은 지식을 전달한다. 상당히 많은 코드와 세련된 프로그래밍

방법을 보여주기 때문에 쓰레드와 관련된 지식 뿐만 아니라 코딩에 대한 여러 가지를 배울 수 있다.

실제로 쓰레드 프로그래밍을 하면서 이 책에 수록된 예제들을 많이 참고하게 될 것이다. 이 책은 주로

실전적인 것들을 설명하고 있으며, Win32 쓰레드에 대한 필독서다.

• Pthreads Programming, O'Reilly

POSIX Threads에 대해서 다루고 있으며, 초보자에게 가장 적합한 입문서이다. 실제로 초보자를 위한

이 분야의 책은 이 책 뿐이다. 단점은 개념 설명 위주로 되어 있기 때문에 이 책을 보면서 실제 코딩에

적용하기는 어렵다. PThreads가 무엇인지 전혀 모른다면 꼭 봐야 할 책이다.

• Programming Applications for Microsoft Windows, MS Press

제목과는 달리 쓰레드 프로그래밍, 메모리 모델, 예외 처리에 대해서 설명하고 있는 책으로, 저자의

뛰어난 지식을 엿볼 수 있다. 윈도우 프로그래밍의 깊은 곳을 탐험하고 싶다면 이 책은 필독서로

꼽아야 한다. 사실, 이 책은 읽어보지 않았지만 이 책의 이전판인 『Advanced Windows NT』는

읽어보았다. 『Advanced Windows NT』의 개정판으로 이전판보다 스레딩에 대해 더 자세하게

설명하고 있다. (당연하지만 『Advanced Windows NT』는 절판되었다) 또한 윈도우 2000 에 대한

내용을 포함하고 있다.

• Modern Concurrency Abstractions for C#, Nick Benton, Luca Cardelli, Cedric Fournet, MS Research

C#에서의 Concurrency 와 비동기 프로그래밍에 대해서 설명한 논문이다.

• Introduction to Polyphonic C#, MS Research

비동기 프로그래밍에 대해 설명하고 있는 설명서다.

203

• Performance Limitations of the Java Core Libraries, Allan Heydon, Marc Najork, Compaq Systems

Research Center

자바의 성능, 클래스 라이브러리, 스레딩에 대해서 설명하고 있다.

• A Portable Object-Oriented C++ Thread Library

객체 지향, 크로스 플랫폼을 지원하는 스레딩 라이브러리이며, 자바에 대한 패키지도 제공한다.

이곳에서 문서, 프로젝트 소스 코드를 얻을 수 있다. Win32, 자바, UNIX/Linux 환경을 모두 지원한다.

• Programming POSIX Threads

POSIX Threads 프로그래밍에 대한 방대한 정보와 링크를 제공한다. 이곳에서 여러분이 필요한

대부분의 것들을 얻을 수 있을 것이다.

• POSIX Threads(pthreads) for Win32

Pthreads에 대한 Win32 구현을 제공한다. Win32 환경에서는 이 라이브러리를 사용하여 pthreads

프로그래밍을 할 수 있다.

• Next Generation POSIX Threading

Linux에서의 POSIX Threads에 대한 프로젝트로 AIX, SGI와 같은 상용 OS에서 제공하는 수준의

스레딩과 SMP 머신에서 쓰레드를 제공하는 것을 목적으로 하는 프로젝트다.

• Interprocess Communications

프로세스간 통신에 대한 내용으로 MSDN이나 MSDN Online의 목차에서 검색하기 바란다. 순서는

Windows Development | Windows Base Services | Interprocess Communications이다. Windows Base

Services | Memory는 Memory Model에 대한 깊은 설명을 제공할 것이고, 여기서 가상 메모리 관리, 힙,

쓰레드 로컬 스토리지(TLS)에 대한 구조와 자세한 설명 뿐만 아니라 특정 메모리 페이지 보호와 같은

깊은 내용들을 볼 수 있을 것이다. 특히, Putting DLDETECT to Work는 교착상태(DeadLock) 검출에

대한 자세한 설명과 실제 구현을 모두 보여주고 있다.

204