멈추지 않고 끈질기게
[컴퓨터공학] 스레드의 동기화 본문
※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.
이번 포스팅에서는 멀티 스레드 환경에서 스레드들을 동기화하는 방법에 대하여 알아보겠습니다.
1. 임계 구역(critical section)과 레이스 컨디션(race condition)
이전 포스팅에서 살펴본 공유 자원에 다수의 스레드가 접근 시 발생하는 문제를 다시 살펴보겠습니다.
class Program
{
static int count = 0;
static void Main(string[] args)
{
Thread t1 = new Thread(Plus);
Thread t2 = new Thread(Minus);
//스레드 시작
t1.Start();
t2.Start();
//스레드 종료까지 대기
t1.Join();
t2.Join();
Console.WriteLine(count);
}
static void Plus()
{
for (int i = 0; i < 10000; i++)
{
count++;
}
}
static void Minus()
{
for (int i = 0; i < 10000; i++)
{
count--;
}
}
}
스레드 t1, t2에서 각각 Plus(), Minus() 함수를 실행하였으며 각 함수는 count를 1 증가, 1 감소를 만번씩 실행하는 내용입니다. 막상 실행해보면 count 값이 매번 달라지는 것을 확인할 수 있습니다. count라는 공유자원에 두 스레드가 동시에 접근하기 때문입니다.
이렇게 둘 이상의 스레드(프로세스)가 동시에 접근하면 문제가 발생하는 코드 영역을 임계 구역(critical section)이라고 하며, 임계 구역에 둘 이상의 스레드(프로세스)가 동시에 접근하는 상황을 레이스 컨디션(race condition)이라고 합니다. 상기 코드로 예를 들면 공유 자원인 count를 증감시키는 부분이 임계 구역이 되고, 임계 구역을 실행하는 t1, t2 스레드가 별도의 동기화 없이 같이 실행되었으니 레이스 컨디션이 발생했다고 할 수 있습니다. 즉, 레이스 컨디션을 막으려면 임계 구역을 실행하는 스레드들이 동시에 접근하지 못하게 해야 합니다.
2. 뮤텍스 락(MUTal EXclusion lock)
뮤텍스 락(mutex lock)은 한 스레드가 임계 구역에 접근하는 순간 나머지 스레드의 접근을 완전히 배제하는 방식입니다. 비유하자면 한 사람이 임계 구역이라는 방에 들어가는 순간 문을 잠궈 다른 사람이 입장하지 못하게 하고, 임계 구역을 나오면서 잠금을 해제하여 다른 사람이 입장할 수 있게 되는 식입니다.
class Program
{
static object lockObj = new object(); //락 생성용 오브젝트
static int count = 0;
static void Main(string[] args)
{
for(int i = 0; i < 5; i++)
{
Thread t1 = new Thread(Plus);
Thread t2 = new Thread(Minus);
//스레드 실행
t1.Start();
t2.Start();
//스레드 종료까지 대기
t1.Join();
t2.Join();
Console.WriteLine(count);
count = 0;
}
}
static void Plus()
{
for (int i = 0; i < 10000; i++)
{
lock (lockObj) //임계구역 블록(lock)
{
count++;
}
}
}
static void Minus()
{
for (int i = 0; i < 10000; i++)
{
lock (lockObj) //임계구역 블록(lock)
{
count--;
}
}
}
}
상기 C# 코드는 맨 위에서 사용한 코드에 단순한 뮤텍스 락을 구현하고, 스레드 실행 및 결과 출력을 5번 반복하는 내용입니다. Plus(), Minus() 함수의 count를 증감시키는 임계구역 코드를 lock 구역 안에 넣었습니다. 실행 중 lock 코드를 만나는 순간 ()안의 오브젝트를 체크하고, 해당 오브젝트를 사용중인 lock 구역이 없다면 구역 내 코드를 실행, 사용중인 lock 구역이 있다면 사용이 끝날때까지 대기합니다. 이렇게 되면 레이스 컨디션이 발생하지 않기 때문에, 실행해보면 원했던 결과인 0이 계속 출력되는 것을 볼 수 있습니다.
다만 뮤텍스 락은 대기 비용이 비교적 많이 발생합니다. 사용중인 lock 구역이 있다면 끝날때까지 대기한다고 하였는데, 말 그대로 루프를 돌며 끊임없이 잠김 여부를 확인합니다. 위의 비유로 치면 문이 잠긴 뒤에 들어간 사람이 나올때까지 끊임없이 노크하는 것과 같습니다. 이런 방식을 바쁜 대기(busy waiting)라 하며, 임계 구역의 실행 시간이 길어질수록 대기 비용(cpu 점유율)이 많이 발생하게 됩니다.
3. 세마포(semaphore)
세마포도 기본적으로 임계 구역의 접근을 배제하는 부분은 뮤텍스 락과 같으나, 공유 자원이 여러개인 경우에 사용하기 적합하다는 차이점이 있습니다. 세마포에서는 뮤텍스 락에서 문을 잠그는 부분과 해제하는 부분을 별도의 함수로 구현하며, 각 함수에서 사용 가능한 공유 자원의 숫자를 관리하는 식입니다. 예를 들어 공유 자원의 숫자를 S, 임계 구역 입장 시 호출하는 함수를 Wait(), 임계 구역 퇴장 시 호출하는 함수를 Release()라 한다면 의사 코드는 다음과 같습니다.
void Wait()
{
S--;
if (S < 0) //공유자원이 모자라다면
{
// 해당 스레드를 대기 큐에 삽입
// 추가 호출이 있을때까지 대기
}
}
void Release()
{
S++;
if (S <= 0) //공유자원이 남는다면
{
// 대기 큐에서 스레드를 꺼냄
// 해당 스레드 이어서 실행
}
}
임계 구역 입장 전에 Wait()을 호출함으로서 공유 자원을 사용함을 명시하고(S--), 남은 공유 자원 수를 체크하여 여유가 있다면 그대로 실행, 0보다 작아진다면(모자라다면) 해당 스레드를 대기 큐에 삽입하고 대기시킵니다. 임계 구역 퇴장시에는 Release()를 호출하여 공유 자원을 반환했음을 명시하고(S++), S가 0 이상이 된다면(공유 자원에 여유가 생겼다면) 대기 큐에서 스레드를 꺼내서 이어서 실행하는 식입니다. 상호 배제하는 방식은 뮤텍스 락과 비슷하나, 대기해야 되는 스레드를 대기 큐에 넣고 대기 상태로 만듦으로서 busy waiting에서 벗어나는 장점이 있습니다.
또한 스레드의 실행 순서는 코드 작성자의 예상을 빗나가게 바뀔 수 있는데, 세마포는 스레드의 실행 순서를 제어하는 용도로 사용할 수도 있습니다. 공유 자원 S를 0으로 초기화하고 먼저 실행되어야 할 스레드에는 코드 마지막에 Release()만, 나중에 실행할 스레드에는 코드 앞쪽에 Wait()만 작성하는 것입니다.
위 그림은 세마포를 이용한 스레드 순서 제어를 도식화한 것입니다. t1 스레드에는 코드 뒤쪽에 Release()만 붙이고, t2 스레드에는 코드 앞쪽에 Wait()을 붙인 상태입니다. 이 경우 t1 -> t2 순서로 실행되면 t1은 별 문제없이 코드를 실행한 후 Release()를 호출하여 S값을 1 늘리며, 이후에 t2가 실행되며 Wait()을 호출하여 S값이 감소해도 0이므로 문제없이 실행됩니다. t2 -> t1 순서로 실행되면 t2가 Wait() 호출 시 S가 -1이 되며 대기상태에 접어들고, 이후 t1이 실행되어 자신의 코드를 실행한 후 Release()로 S값을 0으로 만들어주고 나서 t2가 마저 실행되게 됩니다. 즉, 어느 스레드가 먼저 실행되던 코드 내용은 반드시 t1 -> t2 순서대로 실행되게 됩니다.
class Program
{
//세마포 클래스 초기화(초기값 0, 최대값 1)
static Semaphore sp = new Semaphore(0, 1);
static void Main(string[] args)
{
Thread t1 = new Thread(Run1);
Thread t2 = new Thread(Run2);
//스레드 역순으로 실행
t2.Start();
Thread.Sleep(1000);
t1.Start();
}
static void Run1()
{
Console.WriteLine("Run1 실행!");
sp.Release();
}
static void Run2()
{
sp.WaitOne();
Console.WriteLine("Run2 실행!");
}
}
C#의 세마포를 구현해놓은 Semaphore 클래스를 활용한 예제 코드입니다. 해당 클래스 초기화 시 생성자로 자원(S)의 초기값과 최대값을 설정할 수 있습니다. 위에서 설명한대로 초기값을 0으로 생성한 뒤, 먼저 실행할 Run1() 함수에는 코드 끝에서 Release() 함수를, 나중에 실행할 Run2() 함수에는 코드 앞에서 WaitOne()을 호출하게 했습니다. 메인 함수에서 t2(Run2) 스레드를 먼저 실행한 후, 1초 지연 후 t1(Run1)을 실행했지만 Run1() -> Run2() 순서대로 실행된 것을 볼 수 있습니다. 이렇게 세마포는 임계 구역을 상호 배제하기 위한 동기화 외에도, 실행 순서의 동기화 용으로 사용할 수 있습니다.
다만 세마포의 경우 Wait()과 Release()의 사용에 주의해야 합니다. 상호 배제용으로 사용 시 Wait()과 Release()는 항상 쌍을 이루어야 합니다. 특히 Wait()은 임계 구역 전에만 입력하면 되지만, Release()의 경우 도중에 return을 만나는 등 임계 구역에서 빠져나가는 모든 상황을 고려해야 합니다. 이를 놓칠 경우 동기화가 깨져서 맨 처음 예제처럼 엉뚱한 결과를 초래할 수 있습니다. 또한 실행 순서 제어용으로 사용하는 경우 상기 예제처럼 일부러 Wait()과 Release()를 각각 한쪽에만 사용할 수 있는데, 이런 코드에 실수로 양쪽 모두에 Wait()을 붙여줄 경우 S의 초기값이 0이므로 둘 다 접근하지 못하는 상황이 발생할 수 있습니다.
'컴퓨터공학' 카테고리의 다른 글
[컴퓨터공학] 엔디안(Endian) (0) | 2023.03.04 |
---|---|
[컴퓨터공학] 교착 상태(Dead Lock) (0) | 2023.03.04 |
[컴퓨터 공학] 가상 메모리(virtual memory) (0) | 2023.02.10 |
[컴퓨터 공학] 0.11f * 3 == 0.33f ? (0) | 2023.02.03 |
[컴퓨터 공학] 멀티 프로세스 vs 멀티 스레드 (0) | 2023.02.01 |