
여러 개의 스레드를 사용하여 작업을 병렬로 처리하다 보면
하나의 데이터에 스레드가 동시에 접근하려는 상황이 발생할 수 있습니다.
마치 작업장에서 여러 일꾼이 하나의 망치를 쓰려고 다투는 것처럼요.
이번 글에서는 여러 스레드가 공유된 자원을 사용할 때 발생하는 문제를 예방하고,
질서를 부여하는 기술인 스레드 동기화(Thread Synchronization)에 대해 알아보겠습니다.
가장 먼저 마주하는 문제는 경쟁 상태(Race Condition)입니다.
이는 여러 스레드가 동시에 공유 자원(Shared Resource)에 접근하여 값을 변경하려고 할 때,
실행 순서를 예측할 수 없어 잘못된 결과가 나오는 현상을 말합니다.
부부가 잔액이 10,000원인 공유 계좌를 가지고 있다고 상상해 봅시다.
두 사람이 각자 다른 ATM에서 동시에 10,000원을 출금하려고 합니다.
1. 아내: ATM에서 잔액 10,000원을 확인합니다.
2. 남편: 다른 ATM에서 잔액 10,000원을 확인합니다.
3. 아내: 10,000원을 출금합니다. (계좌 잔액: 0원)
4. 남편: (아내가 출금하기 직전!) 10,000원을 출금합니다. (계좌 잔액: -10,000원)
분명 잔액은 10,000원이었는데, 은행은 20,000원을 내주게 되는 대참사가 발생했습니다!
아래 코드는 두 개의 스레드가 _counter라는 공유 변수를
각각 1,000,000번씩 증가시키는 예제입니다. 결과는 당연히 2,000,000이 되어야겠죠?
[코드]
class Program
{
private static int _counter = 0;
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join(); // t1 스레드가 끝날 때까지 대기
t2.Join(); // t2 스레드가 끝날 때까지 대기
// 과연 결과는 2,000,000이 나올까요?
Console.WriteLine($"최종 결과: {_counter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 1_000_000; i++)
{
_counter++; // 여기가 바로 문제의 지점! (임계 영역)
}
}
}
[실행 결과]
최종 결과: 1623970
코드를 실행해 보면, 2,000,000이 아닌 그보다 훨씬 작은 값이 나옵니다.
_counter++는 사실 '읽기-수정-쓰기' 3단계로 이루어지는데,
이 3단계 사이에 다른 스레드가 끼어들어 값을 변경해 버리기 때문입니다.
이럴 때 공유 자원에 접근하는 코드 영역, 즉 임계 영역(Critical Section)을
한 번에 단 하나의 스레드만 실행할 수 있도록 보호해야 합니다.
마치 탈의실에 들어갈 때 문을 잠그는 것과 같아요.
C#에서 기본적이고 널리 쓰이는 동기화 방법입니다.
lock 키워드는 특정 객체(_lockObject)를 '열쇠'로 사용하여 코드 블록을 잠급니다.
[코드]
class Program
{
private static int _counter = 0;
// lock을 위한 '열쇠' 객체. 보통 private readonly object 타입으로 만든다.
private static readonly object _lockObject = new object();
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"최종 결과: {_counter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 1_000_000; i++)
{
lock (_lockObject) // 이 문이 열릴 때까지 기다린다.
{
// 이제 이 블록 안은 한 번에 하나의 스레드만 들어올 수 있다.
_counter++;
} // 블록을 벗어나면 문이 자동으로 열린다.
}
}
}
[실행 결과]
최종 결과: 2000000
이제 코드를 실행하면 정확히 2,000,000이라는 결과가 나옵니다!
lock외에도 다양한 상황에 맞는 동기화 도구들이 있습니다.
| 도구 | 비유 | 특징 |
|---|---|---|
| Monitor | lock의 수동 버전 | lock이 내부적으로 사용하는 클래스. 더 세밀한 제어가 가능 |
| Mutex | 시스템 전체의 VIP 룸 열쇠 | lock과 비슷하지만, 여러 프로세스 간의 동기화도 가능 |
| Semaphore | 스터디 카페의 좌석 (N개) | 정해진 개수(N개)의 스레드만 임계 영역에 들어오도록 허용 |
동기화는 강력하지만, 잘못 사용하면 치명적인 문제를 일으킬 수 있습니다.
비유: 철수와 영희가 좁은 외나무다리 양 끝에서 마주쳤습니다.
철수는 "영희가 비켜야 내가 간다!"라고 생각하고,
영희는 "철수가 비켜야 내가 간다!"라고 생각합니다.
- 결국 둘은 움직이지 못하는 교착 상태(Deadlock)에 빠집니다!
두 개 이상의 스레드가 서로가 가진 '열쇠(lock)'를 기다릴 때 교착 상태에 빠질 수 있습니다.
이런 상황을 피하려면 lock의 순서를 일정하게 유지하는 등 신중한 설계가 필요합니다.
lock은 가급적 짧은 시간 동안만 유지되어야 합니다.
한 스레드가 일을 마칠 때까지 다른 스레드는 아무것도 못 하고 기다려야 합니다.
이것을 블로킹(Blocking) 현상이라고 하며 자원 낭비로 이어집니다.
스레드가 안전하게 협업하기 위한 규칙, '동기화'에 대해 알아봤습니다.
lock: 임계 영역을 한 번에 한 스레드만 실행하도록 잠그는 가장 기본적인 방법