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

한빛출판네트워크

한빛랩스 - 지식에 가능성을 머지하다 / 강의 콘텐츠 무료로 수강하시고 피드백을 남겨주세요. ▶︎

IT/모바일

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

한빛미디어

|

2002-05-09

|

by HANBIT

30,758

저자: 한동훈(traxacun@unitel.co.kr)

지난 시간까지 다양한 동기화 객체에 대해서 살펴보았다. 이렇게 살펴본 것들에는 뮤텍스, 이벤트, 모니터등이 있다. 힙에 대해서 살펴볼 것이며, 닷넷 환경에서 최적화를 위해 어떤 것들을 주의해야 하는지 설명할 것이다.

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

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 비트 정수형(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));

    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));
    }
  }
}
예제를 컴파일하고 실행한 결과는 다음과 같을 것이다.

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());
      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 클래스를 사용하여 액세스해야한다.

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;
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 런타힘 힙 할당자에서 관리하며, 여기서 가상 메모리를 할당한다.

힙 할당자는 8 바이트에서 1024 바이트까지 128개의 할당자로 구성된다. 힙을 할당 할 때 사용 가능한 블록을 찾으며, 사용 가능한 블록을 발견할 수 없을 때 가상 메모리를 예약하고 힙을 확장한다. 마찬가지로 임시 메모리의 사용이 끝나면 힙을 반환한다. 프로세스 힙은 기본적으로 병합을 수행한다. 즉, 인접한 힙이 비어있는 경우에 힙을 하나로 합쳐서 내부 단편화를 막는다. 이와 같이 힙을 할당하고, 힙을 병합하거나 가상 메모리를 예약하고 확장하는 것과 같은 작업은 오버헤드를 갖고 있으며, 수행 성능을 저하시킨다. 그리고 당연한 얘기지만 닷넷 런타임은 큰 블록을 할당하여 자체 메모리 관리를 수행한다. 닷넷은 하나의 큰 연속된 공간을 위해 64M를 예약하며, 공간이 부족한 경우에 3M 단위로 블록을 확장한다.

힙은 128개의 할당자중에 127개의 할당자를 사용하고, 첫번째 할당자는 다른 용도로 사용한다. 이들 할당자는 이중 링크드 리스트로 관리되며, 첫번째는 top chunk 사용을 위해 예약되었다.

힙의 성능저하

힙의 수행 속도를 떨어뜨리는 경우는 다음과 같다.
할당작업
앞에서 얘기한 것처럼 힙을 할당할 때 이용할 수 있는 목록이 없으면 런타임에서 사용할 수 있는 힙을 검색하며, 런타임에서 이용할 수 있는 힙이 없는 경우에 힙을 확장할 것을 요청하기 때문에 수행 속도를 떨어뜨리게 된다.

해제작업
힙을 해제하는 경우에는 해제 자체보다는 해제된 힙을 병합할 때 오버헤드가 발생하며, 병합하는 동안 인접 항목을 찾아내어 더 큰 블록을 만들고 해제한다. 이러한 찾기가 발생하는 동안에 힙에 대한 임의의 액세스가 발생하고, 힙이 할당되는 경우에 캐시 누락이 발생하고 성능이 저하될 수 있다.

경쟁
여러 개의 쓰레드가 동시에 데이터에 액세스하려고 하면 하나의 쓰레드가 작업중인 동안 다른 스레드는 작업을 대기하게 된다. 또한 많은 메모리를 사용하는 DLL을 여러 개의 쓰레드에서 실행하거나 다중 프로세서 시스템에서 실행하면 실행이 느려진다.

힙 손상
대부분의 경우에 닷넷에서 관리하기 때문에 프로그래머에 의해 힙 손상이 발생하는 경우는 없지만 Win32 API를 사용하거나 컴파일을 할 때 힙 손상 에러가 발생하는 경우가 있다. 힙 손상은 이미 해제된 블록을 다시 해제하는 경우, 해제한 블록을 사용하려는 경우, 블록 경계를 벗어나거나 다른 쓰레드에 의해서 블록을 덮어쓴 경우에 발생한다.
힙 성능 개선

윈도우 2000은 NT에 비해서 힙에 대한 성능을 개선시켰고, 이러한 것은 CLR에도 반영되었다. 예를 들어서, 프로세스 기본 힙에 대한 잠금을 최소화 하도록 알고리즘을 개선했으며, 할당 캐시(Alloc-Cache)를 사용하는 대신에 128개의 할당자에서 이용할 수 있는 목록을 별도로 관리하는 Lookaside 목록을 사용하여 힙 성능을 개선했다.(여전히 할당 캐시도 유효한 방법이지만, 닷넷에 대해서만 논하도록 하자. 보다 관심이 있는 분들은 할당 캐시, MP 힙에 대해서 찾아보기 바란다) 대부분의 경우는 가비지 컬렉터에 맡기면 되지만 코드에서 몇 가지 최적화를 할 수 있다. 이러한 최적화는 초당 1000개 이상의 요청을 처리하는 경우에 뚜렷한 차이가 난다.
Boxing과 Unboxing을 최소화
박싱과 언박싱은 스택과 힙 사이를 데이터가 오가는 것이기 때문에 많은 오버헤드가 발생한다. 만약 정수형 데이터에 대해서 박싱이 발생한다면 정수형 데이터에 대한 래퍼 클래스를 작성하거나 인터페이스를 사용한다. 인터페이스가 참조 유형이므로 값 형식의 데이터에 대해서도 인터페이스 참조만을 갖는다는 것을 이용한 것이다.

잦은 할당을 최소화
닷넷에서 메모리 관리를 대신하기 때문에 직접 메모리를 할당할 필요가 없지만, 내부적으로는 메모리 할당과 해제가 발생한다. 예를 들어서 System.String에 문자열을 설정하고, 다시 문자열을 추가하는 경우에 내부적으로 블록이 반복적으로 할당되고 해제되는 과정이 수행된다.(System.String은 한 번 설정된 문자열을 변경할 수 없다) 따라서 문자열 연결 작업을 최소화하거나 StringBuilder 클래스를 사용하는 것이 수행 성능에 도움이 된다.

쓰레드 고유 힙 생성
프로세스의 기본 힙은 여러 쓰레드가 공유해야하므로 경쟁이 발생하며, 수행 성능이 저하된다. 쓰레드가 빠른 읽기와 쓰기를 필요로 하고, 적은 공간의 메모리 블록을 필요로 하는 경우에 쓰레드 고유 힙을 생성할 수 있다. 닷넷에서는 이것을 직접 지원하지 않으며 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에 대한 소스 코드를 받아볼 수 있으며, 관련 사이트와 뉴스 그룹에 대한 정보를 얻을 수 있다.
TAG :
댓글 입력
자료실

최근 본 상품0