조건 변수는 락을 놓고 기다렸다가 다시 잡는다
큐가 빌 때까지 기다렸다가 항목을 꺼내는 코드를 짭니다.
큐는 공유 자원이니 락으로 지켜야 합니다. 그런데 락을 잡고 보니 큐가 비었습니다. 기다려야 하는데, 락을 쥔 채로 기다리면 아무도 항목을 넣어 줄 수 없습니다. 그렇다고 락을 놓으면 그 사이에 큐 상태가 바뀝니다.
락을 놓는 것과 기다리는 것을 따로 하면 안 됩니다. 조건 변수는 그 둘을 한 번에 해 줍니다.
락을 쥔 채로는 기다릴 수 없다
말이 아니라 실제로 멈춥니다. 소비자가 락을 쥔 채로 큐를 계속 확인하고, 생산자는 항목을 넣으려고 락을 기다립니다.
// 소비자
lock (gate)
{
while (q.Count == 0) Thread.Sleep(1); // 락을 쥔 채로 기다립니다
q.Dequeue();
}
// 생산자
lock (gate) { q.Enqueue(1); } // 여기서 영원히 못 들어갑니다
500ms 를 주고 20번 돌렸더니 20번 다 안 끝났습니다.
데드락인데 자물쇠는 하나뿐입니다. 소비자는 조건이 바뀌기를 기다리고, 조건을 바꿀 수 있는 유일한 사람은 그 락을 기다립니다. 서로를 기다리는 것이 아니라, 한쪽이 다른 쪽의 입구를 막고 서 있는 것입니다.
락을 놓고 돌면 되긴 합니다
그러면 락을 놓고 바깥에서 확인하면 됩니다. 확인할 때만 잠깐 잡습니다.
while (true)
{
lock (gate) { if (q.Count > 0) { q.Dequeue(); break; } }
// 락을 놓은 상태로 다시 봅니다
}
이건 돕니다. 대신 아무 일도 안 하면서 코어 하나를 통째로 태웁니다. 항목 200개를 5ms 간격으로 넣으면서 재보니 벽시계 1024ms 동안 CPU 를 1030ms 썼습니다. 사용률로 100.6% 입니다.
Thread.Sleep(1) 을 끼우면 CPU 는 2.9% 로 떨어집니다. 대신 항목이 들어온 뒤
꺼내기까지 걸리는 시간이 평균 534.8µs 가 됩니다.
1ms 단위로만 눈을 뜨니 그만큼 늦게 봅니다.
CPU 를 태우거나, 늦게 알거나 둘 중 하나입니다. 둘 다 안 하려면 다른 방법이 필요합니다.
조건 변수는 두 가지를 한 번에 합니다
조건 변수는 이렇게 씁니다. C# 에서는 모든 객체가 락과 조건 변수를 한 쌍으로 가지고 있어서
lock 에 쓴 그 객체를 그대로 씁니다.
// 소비자
lock (gate)
{
while (q.Count == 0) Monitor.Wait(gate); // 락을 놓고 잠듭니다
q.Dequeue();
}
// 생산자
lock (gate)
{
q.Enqueue(1);
Monitor.Pulse(gate); // 기다리는 사람 하나를 깨웁니다
}
Monitor.Wait 이 하는 일이 이 글의 전부입니다. 락을 놓는 것과 조건 대기열에 들어가는 것이
한 번에 일어납니다. 그 사이에 다른 스레드가 끼어들 틈이 없습니다.
락이 넘어간 횟수가 0 과 2 로 갈립니다. 그 차이가 전부입니다.
틈이 없어야 하는 이유는 이렇습니다. 만약 “락을 놓는다” 와 “기다린다” 가 두 동작이라면, 그 사이에 생산자가 들어와서 항목을 넣고 깨우기 신호를 쏠 수 있습니다. 소비자는 아직 대기열에 없으니 그 신호를 못 받고, 그 뒤에 잠들어서 영원히 깨지 않습니다. 둘을 나눌 수 없기 때문에 하나의 연산으로 묶어 준 것이 조건 변수입니다.
다른 언어에서 기다리는 함수가 뮤텍스를 인자로 받는 것도 같은 이유입니다.
POSIX 의 pthread_cond_wait(cond, mutex) 가 뮤텍스를 받고,
C++ 의 std::condition_variable::wait(lock, pred) 가 락을 받습니다.
놓을 락이 무엇인지 알아야 원자적으로 놓을 수 있기 때문입니다.
C# 은 객체 하나가 락과 조건 변수를 겸해서 인자가 하나로 보일 뿐입니다.
깨운다고 바로 도는 것이 아닙니다
Monitor.Pulse 는 깨우라는 신호일 뿐 락을 넘기지 않습니다.
신호를 쏜 쪽은 여전히 락을 쥐고 있고, 자기 lock 블록이 끝나야 놓습니다.
깬 스레드는 조건 대기열에서 락 대기열로 옮겨 갈 뿐입니다.
락을 다시 잡은 뒤에야 Wait 다음 줄로 넘어갑니다.
이 방식을 Mesa 방식이라고 부릅니다. 깨우는 쪽이 락을 바로 양보하는 방식도 있는데 (Hoare 방식) 실제 구현은 거의 전부 Mesa 입니다.
여기서 중요한 결과가 나옵니다. 깨어난 것과 조건이 맞는 것은 다릅니다. 깬 뒤 락을 다시 잡기까지 시간이 걸리고, 그 사이에 제3의 스레드가 먼저 락을 잡아 조건을 도로 무너뜨릴 수 있습니다.
깨우는 함수는 둘입니다.
| 무엇을 하나 | 언제 쓰나 | |
|---|---|---|
Monitor.Pulse |
기다리는 사람 하나를 깨웁니다 | 기다리는 사람들이 전부 같은 조건을 보고, 자원이 하나 늘었을 때 |
Monitor.PulseAll |
기다리는 사람 전부를 깨웁니다 | 기다리는 조건이 서로 다를 때, 또는 종료를 알릴 때 |
Pulse 가 싸지만 누가 깰지 고를 수 없습니다. 조건이 서로 다른 사람들이 같이 기다리고 있으면
엉뚱한 사람만 깨우고 정작 조건이 맞는 사람은 계속 자는 일이 생깁니다.
헷갈리면 PulseAll 이 안전합니다.
그래서 if 가 아니라 while 입니다
조건 검사를 if 로 쓰면 깨어난 순간 조건이 맞다고 믿는 것입니다. 안 맞습니다.
소비자 넷이 기다리는데 항목을 하나만 넣고 PulseAll 로 전부 깨웠습니다.
항목은 하나뿐인데 넷이 다 깼습니다.
if 쪽은 100번 중 100번 깨졌습니다. 확률이 아니라 구조입니다.
넷이 깼는데 항목은 하나니까 셋은 반드시 빈 큐를 꺼냅니다.
while 로 쓰면 깬 뒤에 조건을 다시 봅니다. 안 맞으면 다시 잠듭니다.
Wait 에서 돌아왔다는 것은 “조건이 맞다” 가 아니라 “한 번 다시 확인해 봐라” 입니다.
POSIX 조건 변수에는 신호가 없어도 깨어나는 경우(가짜 깨움)가 명시적으로 허용되어 있기도 합니다.
그것까지 가지 않아도 위 이유만으로 while 이 필요합니다.
조건 변수를 쓰는 코드에서 if 는 거의 언제나 버그입니다.
조건을 안 보고 기다리면 신호를 놓칩니다
while 이 필요한 이유가 하나 더 있습니다. 신호가 먼저 오는 경우입니다.
깨우기 신호는 저장되지 않습니다. 쏘는 순간 기다리는 사람이 없으면 그냥 사라집니다. 생산자가 항목을 넣고 신호를 쏜 뒤에 소비자가 도착하면, 소비자는 이미 지나간 신호를 기다립니다.
// 나쁜 쪽: 조건을 안 보고 무조건 기다립니다
lock (gate) { Monitor.Wait(gate); }
// 좋은 쪽: 이미 준비됐으면 아예 안 기다립니다
lock (gate) { while (!ready) Monitor.Wait(gate); }
신호를 먼저 쏜 뒤 소비자를 띄우는 상황을 20번 만들었더니,
조건을 안 보는 쪽은 20번 다 안 끝났고 while 로 조건을 보는 쪽은 한 번도 안 멈췄습니다.
while (!ready) 는 두 가지를 같이 해 줍니다. 이미 조건이 맞으면 안 기다리고,
잘못 깨면 다시 기다립니다. 신호를 놓치는 문제와 헛되이 깨는 문제가 같은 한 줄로 막힙니다.
세 방식을 나란히 재면
항목 200개를 5ms 간격으로 넣고, 소비자가 꺼낼 때까지의 지연과 CPU 시간을 쟀습니다.
코어 두 개짜리 x86-64 리눅스, .NET 8 입니다.
조건 변수는 CPU 를 바쁜 기다림의 50분의 1만 쓰면서 지연은 Sleep 폴링의 7분의 1입니다.
둘 중 하나를 고르는 게 아니라 양쪽을 다 가져옵니다.
되는 이유는 간단합니다. 잠든 스레드는 스케줄러가 아예 안 돌립니다. 그래서 CPU 를 안 씁니다.
그리고 깨우는 시점이 정해진 주기가 아니라 조건이 바뀌는 순간입니다. 그래서 늦지 않습니다.
Sleep 폴링은 다음 눈 뜰 때까지 기다려야 하니 평균적으로 주기의 절반을 손해 봅니다.
최대 지연은 조건 변수도 1080.4µs 가 한 번 나왔습니다. 코어가 둘뿐이라 깨우고 나서 실제로 스케줄될 때까지 밀린 경우입니다. 깨우기가 곧 실행은 아닙니다. 평균이 실제 성격을 보여줍니다.
실제로는 직접 안 씁니다
Monitor.Wait 과 Pulse 를 손으로 쓰는 코드는 요즘 드뭅니다.
같은 일을 하는 것들이 이미 이 위에 얹혀 있기 때문입니다.
| 쓰는 것 | 안에서 하는 일 |
|---|---|
BlockingCollection<T> |
큐가 비면 잠들고, 항목이 들어오면 깨웁니다 |
Channel<T> |
같은 일을 하면서 스레드를 안 잡고 await 로 기다립니다 |
SemaphoreSlim |
자원 수를 세면서 0 이면 잠듭니다 |
TaskCompletionSource |
결과 하나가 정해질 때까지 기다립니다 |
그래도 원리를 알아야 하는 이유가 셋입니다.
하나. 이 API 들이 왜 그렇게 생겼는지가 보입니다. TryTake(out item, timeout) 처럼
타임아웃을 받는 모양이 어디서 나왔는지, 왜 “꺼내기” 와 “기다리기” 가 한 메서드로 묶여 있는지가
같은 이유입니다. 나눠 놓으면 그 사이에 틈이 생깁니다.
둘. 감싸는 것이 없는 조건에는 직접 써야 합니다. “큐에 항목이 세 개 이상 쌓였을 때”, “플레이어가 전부 준비됐을 때” 같은 조건은 기성품이 없습니다.
셋. 면접에서 묻습니다. 락과 조건 변수가 왜 짝으로 다니는지, while 인 이유가 무엇인지는
동시성 질문의 단골입니다.
타임아웃을 주고 싶으면 Monitor.Wait(gate, TimeSpan.FromSeconds(1)) 을 씁니다.
시간이 지나 돌아오면 false 를 돌려주는데, 돌아올 때 락은 다시 잡은 상태입니다.
그래서 이 경우에도 조건을 다시 확인해야 하는 것은 같습니다.
직접 재보기
하네스 핵심입니다. 콘솔 프로젝트에 그대로 붙여도 돕니다.
// if 로 검사했을 때 깨지는지 보는 재현
static int BrokenRuns(bool useWhile, int runs)
{
int broken = 0;
for (int r = 0; r < runs; r++)
{
var q = new Queue<int>();
var gate = new object();
int failures = 0;
var ts = new Thread[4];
var ready = new CountdownEvent(4);
for (int i = 0; i < 4; i++)
{
ts[i] = new Thread(() =>
{
lock (gate)
{
ready.Signal();
if (useWhile) { while (q.Count == 0) Monitor.Wait(gate); }
else { if (q.Count == 0) Monitor.Wait(gate); }
if (q.Count == 0) Interlocked.Increment(ref failures);
else q.Dequeue();
}
}) { IsBackground = true }; // 없으면 프로그램이 안 끝납니다
ts[i].Start();
}
ready.Wait();
Thread.Sleep(20); // 넷이 다 Wait 에 들어가도록
lock (gate)
{
q.Enqueue(1); // 항목은 하나뿐
Monitor.PulseAll(gate); // 그런데 넷을 다 깨웁니다
}
foreach (var t in ts) t.Join(300);
if (Volatile.Read(ref failures) > 0) broken++;
lock (gate) { for (int i = 0; i < 4; i++) q.Enqueue(1); Monitor.PulseAll(gate); }
foreach (var t in ts) t.Join(300);
}
return broken;
}
함정이 셋 있습니다.
ready.Signal() 을 lock 안에서 불러야 합니다. 바깥에서 부르면 신호만 보내 놓고
아직 락을 못 잡은 스레드가 생깁니다. 그러면 PulseAll 을 쏠 때 대기열에 넷이 다 없어서
재현이 안 됩니다.
Thread.Sleep(20) 을 빼면 안 됩니다. 넷이 전부 Wait 에 들어간 뒤에 신호를 쏴야
“넷이 동시에 깼는데 항목은 하나” 상황이 만들어집니다.
끝에 남은 대기자를 풀어 줘야 합니다. while 쪽은 셋이 다시 잠들어서 안 끝납니다.
그대로 두고 100번 돌리면 스레드가 300개 쌓입니다.
CPU 시간은 Process.GetCurrentProcess().TotalProcessorTime 의 차이로 쟀습니다.
프로세스 전체 값이라 다른 스레드도 섞이는데, 생산자는 대부분 자고 있어서
소비자가 태운 시간이 그대로 드러납니다.
정리
- 락을 쥔 채로 조건을 기다리면 멈춥니다. 조건을 바꿔 줄 사람이 그 락을 기다리기 때문입니다. 자물쇠가 하나뿐인데도 데드락이 됩니다
- 조건 변수는 락을 놓는 것과 대기열에 들어가는 것을 한 번에 합니다. 둘로 나누면 그 사이에 신호가 지나가 버립니다. 나눌 수 없어서 하나의 연산인 것입니다
- 기다리는 함수가 뮤텍스를 인자로 받는 이유가 이것입니다. 놓을 락을 알아야 원자적으로 놓습니다
Pulse는 깨우기만 합니다. 락을 넘기지 않습니다. 깬 스레드는 조건 대기열에서 락 대기열로 옮겨 갈 뿐이고, 락을 다시 잡아야 이어집니다- 그래서 깨어난 것과 조건이 맞는 것은 다릅니다. 조건 검사는
if가 아니라while입니다. 소비자 넷에 항목 하나로 재보니if는 100번 중 100번 깨졌고while은 0번이었습니다 - 신호는 저장되지 않습니다. 조건을 안 보고 기다리면 이미 지나간 신호를 기다립니다.
20번 중 20번 안 끝났습니다.
while (!조건)한 줄이 이것까지 같이 막습니다 - 성능은 양쪽을 다 가져옵니다. 바쁜 기다림이 CPU 100.6%,
Sleep폴링이 평균 지연 534.8µs 인데 조건 변수는 CPU 1.9% 에 평균 지연 76.8µs 였습니다 - 실무에서는
BlockingCollection이나Channel이 이걸 감싸고 있습니다. 다만 그 API 들의 모양이 왜 그런지가 여기서 나옵니다