메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

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

한빛미디어

|

2002-03-18

|

by HANBIT

22,045

저자: 한동훈

이번에는 쓰레드 동기화에 대해서 알아보도록 하자. 쓰레드 프로그래밍은 상당히 어렵다. 쓰레드 프로그래밍이 어려운 이유는 쓰레드에 할당되는 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 쓰레드가 실행된다. 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이 아닌 다른 값으로 변경되었다해도 문제없이 코드가 실행될 수 있다.

이러한 종류의 버그는 가끔 일어나며, 재현하기 어렵기 때문에 디버깅하는 것도 어렵다. 위에서 예로든 은행 예금과 같이 반드시 하나의 단위로 실행되어야하는 코드 블록을 최소 단위 오퍼레이션이라한다.

커널 객체

커널 오브젝트는 운영체제의 자원을 뜻하며, 주로 쓰레드, 프로세스, 이벤트, 뮤텍스, 세마포어, 파일, 파일 매핑, 콘솔 I/O, 명명 파이프(named pipe)를 뜻한다.

커널 객체는 시스템 전체에서 사용할 수 있는 이름을 가질 수 있다. 다시 말해, 다른 프로세스에서 같은 이름을 사용하여 커널 객체에 접근할 수 있다.

커널 객체는 커널에 의해서 관리되며, 커널 객체 참조 카운터를 사용하여 생명 주기(life-cycle)을 관리한다. 이 부분은 Win32와 깊게 관련된 부분이고, 닷넷과는 관련이 없어보이지만 나중에 Win32와 닷넷이 얼마나 비슷한 방식으로 쓰레드를 구현하고 있는가를 알게 될 것이다.

경쟁 조건(race condition)

race condition은 경쟁 조건 내지는 경쟁 상태라고 한다. 경쟁 조건은 여러 개의 쓰레드가 공유 데이터를 사용할 때 발생한다. 이것은 흔히 생산자/소비자(Producer/Consumer)의 문제로도 알려져 있다.

생산자/소비자 프로그래밍 모델은 하나의 쓰레드는 데이터를 생성하도록 하고, 다른 쓰레드는 데이터를 소비하도록 하는 것이다. 이 모델의 이점은 생산자가 최대한의 데이터를 생성하도록 하고, 소비자는 최대한 많은 데이터를 소비하도록 하는 것이다. 그리고 생산자와 소비자는 서로 비동기적으로 실행되어야한다. 만약 동기적으로 실행된다면 소비자는 생산자가 데이터를 생산할 때 까지 기다려야하며, 이것은 멀티 쓰레드 프로그래밍이 아니며 효율을 살릴 수 없게 된다.

예를 들어서, 생산자가 정수를 생성하고 소비자는 생성된 정수를 소비한다고 하자. 생산자가 정수를 생산하는 속도가 소비자가 정수를 소비하는 속도보다 빠르다면 소비자는 생산자가 생성한 숫자들을 속도차 만큼 놓치게 된다.

반대로 생산자가 정수를 생산하는 속도보다 소비자가 정수를 소비하는 속도가 더 빠르다면 이미 가져간 값을 다시 가져다 쓰게 되는 경우가 발생하게 된다. 이러한 경우를 경쟁 조건 또는 경쟁 상태라고 한다.

이와 같이 여러 개의 쓰레드가 동시에 같은 자원을 공유하고 있을 때 발생하게 되며, 동기화를 사용하여 이 문제를 해결할 수 있다.
생산자/소비자 프로그래밍 모델은 단일 생산자/단일 소비자, 다중 생산자/단일 소비자, 단일 생산자/다중 소비자, 다중 생산자/다중 소비자 모델로 다시 나눌 수 있다.


궁핍 현상

궁핍 현상은 쓰레드 우선 순위와 관련된 부분이 많다. 먼저, 두 개의 쓰레드 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 자원을 소유하고 있다. A 쓰레드는 실행을 위해서 a 자원을 획득하기 위해 대기하고 있으며, B 쓰레드는 실행을 위해서 b 자원을 획득하기 위해 대기하고 있다고 하자. 이 경우에 각각의 쓰레드는 소유하고 있는 자원을 해제하기 위한 수행을 할 수 없으므로 모든 쓰레드의 실행이 중지된다. 이러한 문제를 해결하기 위해 임계 영역이나 뮤텍스와 같은 동기화 도구를 사용한다.

닷넷과 가비지 컬렉터(Garbage Collector)

여러분들도 잘 알고 있는 것처럼 닷넷에서는 프로그래머가 직접 자원을 해제하지 않는다. 프로그래머가 Finalize등을 사용하여 직접 자원을 해제한다고 해도, 이 자원들은 모두 메모리 상에 남아 있으며, 메모리 공간을 확보할 필요가 있을 때 가비지 컬렉터에 의해서 수집된다.(물론, 가비지 컬렉터의 자원 정리 과정 역시 간단하지 않으며, 객체의 부활(resurrection)에 대한 논의가 필요하며, 멀티 쓰레드 응용 프로그램에서 절대로 해제되지 않는 쓰레드를 생성하게 될 수도 있다)

Win32나 기존의 멀티 쓰레드 프로그래밍과 닷넷 에서의 멀티 쓰레드 프로그래밍은 자원 정리라는 면에서 다르다. 기존의 멀티 쓰레드 프로그래밍에서는 자원의 해제에 대해서 프로그래머가 신경써야 했으나, 닷넷에서는 프로그래머가 신경쓰지 않아도 된다.

그러나 쓰레드 내에서 생성한 객체가 비관리형 코드(unmanaged code)로 작성된 라이브러리라면 쓰레드를 종료하기 전에 이 객체를 정리해야한다는 것에 주의하기 바란다.

가비지 컬렉터 : 두 가지 버전

닷넷에서 가비지 컬렉터에는 두 가지 버전이 있다. 하나는 웍스테이션 버전(mscorwks.dll)이며, 다른 하나는 서버 버전(mscorsvr.dll)이다.

서버 버전 GC
  • 다중 프로세서 지원, 병렬 처리 지원
  • 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 알고리즘에 대해 설명하고 있다. 자바와 닷넷 모두 자원 정리를 위해 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)
마지막으로 이 소스는 필자가 고의로 부활한 객체를 가비지 컬렉터가 제거하지 못하도록 작성한 코드다.(결국 무한히 실행된다)
TAG :
댓글 입력
자료실

최근 본 상품0