멀티 스레드 - 0. 스레드의 기초

LeeTaeHwa·2022년 7월 31일
0

멀티 스레드

목록 보기
1/1

주저리 주저리...


못해도 1주일에 한 개의 포스팅을 하기로 했는데, 취업준비에 쫓기다 보니 마음에 여유가 나지 않은 것인지, 스스로의 게으름을 못 이긴 탓인지 꽤 오래동안 방치하였다. 아무튼 새로 업무를 시작하면서 병렬성과 관련한 코드들을 마주하게 되었는데, 단순하게 스레드를 생성하고 시작하는 그런 내용은 아니었다.

C#TPL(작업 병렬 라이브러리)를 맞닥뜨리면서 나 스스로가 얼마나 트렌드에 뒤쳐져있었는지를 실감하면서, C#을 바탕으로 하여서 멀티 스레드를 공부하기로 결심하였다. 현재 목표는 RX(Reactive Extension)까지 다루어볼 계획이며, 공부중에 새로운 것들을 마주하면 이래 저래 다른 내용을 다루거나 빠질지도 모르겠다.

멀티 스레드


무어의 법칙은 깨진지가 오래이며, 오늘 날의 CPU는 코어를 늘림으로써 성능 향상을 도모하고 있다. 이런 이야기는 이제는 너무 식상하기가 짝이 없고, 게임 커뮤니티에서 멀티 코어 지원 여부를 중요시 할 만큼 멀티 스레드는 널리 퍼진 주제가 되었다.

이런 만큼 프로그래머의 입장에서도 병렬성은 한 번쯤은 맞닥뜨리는 주제가 되었고, 본 주제를 알면 좋은 것이 아니라 모르면 이상한 것이 되었다. 그래서 병렬성, 멀티 스레드에 대한 중요성은 여러번 말해봐야 입아픈 이야기다.

그래서 이와 관련한 주제로 시리즈를 시작하며, 이번에는 스레드의 기본 동작부터 살펴보도록 하겠다. 본래는 심도깊은 이론적 이야기를 다루고 코드 레벨을 언급하는 것을 선호하는데, 포스팅의 베이스가 되는 책이 쿡북류의 책이다 보니 이론적 이야기가 적다. 그래서 코드를 중심으로 내용을 설명하고, 책에서 명쾌하게 설명하지 않는 부분을 개인적으로 조사하여 다루고자 한다.

스레드의 기본 동작들


스레드가 수행 할 수 있는 몇 가지 기본 동작들이 있다. 첫 번째는 스레드의 생성 및 시작이다.

var thread = new Thread( () => Console.WriteLine("Hello!") );
thread.Start();

스레드를 생성 할 때에 스레드가 수행 할 함수를 입력해주고, 그 다음에 스레드를 시작한다. 그리고 단순히 시작하는 것 외에도 잠시 정지시키거나, 쓰레드를 종료를 기다리거나 아예 소멸시킬수도 있다.

다음은 스레드를 일시 정지 시키는 코드다.

var thread = new Thread( () =>
{
	Thread.Sleep(1500);
    Console.WriteLine("Awake and run!");
});

thread.Start();

Sleep 메소드를 통해서 일정시간 동안 스레드를 잠시 정지 시킬 수가 있다. 여기서 입력되는 숫자의 스케일은 millisecond로 1/1000 단위다.

그리고 다음은 스레드의 종료를 대기하는 코드다.

var thread = new Thread( () =>
{
	Thread.Sleep(1500);
    Console.WriteLine("Awake and run!");
});

thread.Start();
thread.Join();

Join 메소드를 통해서 해당 스레드가 종료되기 전까지 호출 한 스레드를 블록을 한다. 때문에 스레드의 종료까지 기다리게 된다. 그래서 스레드의 종료를 알기위한 수단으로 사용되기도 하는데, 문제는 Join을 호출한 스레드가 종료되기 전까지 무기한 블록이 된다는 점이다. 그래서 Join을 호출 할 때에 이런 점을 유의해야 한다.

그 다음으로는 스레드를 아예 소멸시키는 것인데, 이는 다음과 같다.

var thread = new Thread( () =>
{
	Thread.Sleep(1500);
  	Console.WriteLine("Awake and run!");
});

thread.Start();
thread.Abort();

Abort를 호출하여서 스레드를 종료시킬 수가 있다. 하지만 이는 매우 위험한 행동으로 사용 안하는 것을 권장한다. 왜냐하면 해당 스레드가 어떤 상태로 종료되었는지가 알 수가 없기 때문에 상태 예측을 할 수가 없으며, 자원 소유 및 반환과도 관련하여 문제가 생길 수도 있다.

그래서 현재는 사용되지 않는 코드이며, 몇 몇 플랫폼에서는 지원되지 않기때문에 에러를 뱉으며 종료 될 수도 있다. 실제로 dotnet으로 Abort를 시도 할 경우 에러를 뱉으며 종료가 되었다.

때문에 프로그래머는 스레드의 취소에 대한 동작을 관리해야 하며, 이에 대한 내용은 차후 포스팅으로 다루어보겠다.

스레드의 상태


스레드들은 상태값을 가지는데, 초기값은 Unstarted로 설정이 된다. 그리고 Start() 메소드를 호출하면, Running 상태로 전이된다. 그리고 일시 중단 상태가 되면 Suspended로 전이가 되며, 동작을 마치고 종료가 되면 Stopped로 전이되며 종료된다. 그리고 스레드는 Start를 통해 Running 상태가 되면 다시 Unstarted로 전이 할 수 없다.

스레드의 상태를 확인하는 코드는 다음과 같다.

var thread = new Thread( () =>
{
	Thread.Sleep(1500);
  	Console.WriteLine("Awake and run!");
});

Console.WriteLine("ThreadState: {0}", thread.ThreadState);
thread.Start();

Console.WriteLine("ThreadState: {0}", thread.ThreadState);
       
Thread.Sleep(1500);
Console.WriteLine("ThreadState: {0}", thread.ThreadState);

그리고 스레드의 상태는 backgroundforeground로 나눌 수가 있는데, 이 둘의 차이는 메인 스레드가 스레드의 종료를 기다려 주는가다. 그래서 background 스레드의 경우엔 메인 스레드가 종료를 기다려주지 않고 프로세스를 끝내버린다. 그리고 스레드는 기본적으로는 foreground 이기 때문에 background 스레드를 생성하기 원한다면 별도로 설정을 해주어야 한다. 그 예는 다음과 같다.

var thread = new Thread( () =>
{
	Thread.Sleep(1500);
   	Console.WriteLine("Awake and run!");
});

thread.IsBackground = true;
thread.Start();

이 경우엔 background 스레드 이기 때문에 바로 프로세스가 종료되어서 콘솔창에 메시지 출력을 하지 않는다.

레이스 컨디션과 동기화


스레드들이 각자 자기만의 고유 자원을 가지고 동작을 한다면 크게 문제가 될 부분은 없다. 하지만 서로 자원을 공유하게 되면 상당히 골치가 아프게 된다. 다음과 같은 경우를 한번 보도록 하자.


int n = 0;

var th1 = new Thread(() =>
{
	for(int i = 0; i < 100000; i++)
    {
    	n++;
        n--;
    }
});

var th2 = new Thread(() =>
{
	for(int i = 0; i < 100000; i++)
    {
    	n++;
        n--;
    }
});

th1.Start();
th2.Start();

각 스레드들은 n 정수값 변수를 서로 공유하는 상태에서 증감연산을 하게 된다. 정상적으로 동작하였다면 n은 0이 되어야 하겠지만, 결과는 그렇지 않을 것이다. 왜냐하면 n의 상태가 1일 때에 n을 다시 1늘리고 줄여서 저장하게 될 수도 있기 때문이다. 그리고 이것이 상당수 반복되면 0과는 아주 다른 값으로 저장 될 수도 있다.

본 문제가 발생하는 이유를 명확하게 이해를 할려면 메모리에서 값을 읽고 레지스터에 적재하고, 계산이 끝난 다음에 메모리에 쓰는 과정을 알아야 한다. 그래서 이 과정에 대한 이해가 없다면 레이스컨디션에 대한 이해는 피상적으로 그치게된다. 굳이 이런 주제가 아니더라도 스레드를 공부함에 있어 컴퓨터 구조 및 운영체제에 대한 지식이 있다면 도움이 된다.

아무튼 이런 문제를 해결하기 위해서 동기화가 필요한데, 여기에 Monitor를 이용하거나 lock을 이용하는 방법이 있다. 먼저 lock을 이용한 예제는 다음과 같다.


int n = 0;
var sync = new Object();

var th1 = new Thread(() =>
{
	for(int i = 0; i < 100000; i++)
    {
    	lock(sync)
        {
        	n++;
            n--;
        }
    }
});

var th2 = new Thread(() =>
{
	for(int i = 0; i < 100000; i++)
    {
    	lock(sync)
        {
        	n++;
            n--;
        }
    }
});

th1.Start();
th2.Start();

각 스레드들은 lock을 획득하면 그 블록 안의 자원들을 독점하게 된다. 그리고 lock 블록을 벗어나면 다시 반환하게 된다. 여기서 눈에 띄는 점은 lock에 사용되는 object 변수다. lock을 하기위한 매개변수는 굳이 object가 아니어도 무방하지만, 주로 object 타입의 변수를 사용한다.

그리고 Monitor 클래스를 이용하는 방법도 있는데, 사실 lock은 Monitor를 사용한 동기화 코드를 간소화 시켜주는 신택스 슈거이다. 그래서 사실상 같은 방법을 사용하지만, lock을 사용하면 좀 더 편리하다. Monitor를 이용한 예는 다음과 같다.


int n = 0;
var sync = new Object();

var th1 = new Thread(() =>
{
	for(int i = 0; i < 100000; i++)
    {
    	Monitor.Enter(sync);
        
        try
        {
        	n++;
            n--;
        }
        finally
        {
            Monitor.Exit(sync);
        }
    }
});

var th2 = new Thread(() =>
{
	for(int i = 0; i < 100000; i++)
    {
    	Monitor.Enter(sync);
        
        try
        {
        	n++;
            n--;
        }
        finally
        {
            Monitor.Exit(sync);
        }
    }
});

th1.Start();
th2.Start();
profile
하늘을 향해 걸어가고 있습니다.

0개의 댓글