---
title: "조건 변수는 락을 놓고 기다렸다가 다시 잡는다"
description: "락을 쥔 채로 조건을 기다리면 아무도 그 조건을 바꿔 줄 수 없습니다. 조건 변수는 락을 놓는 것과 기다리는 것을 한 번에 합니다. Monitor.Wait 과 Pulse 가 그것이고, if 로 검사하면 100번 중 100번 깨집니다."
author: "유니얼"
pubDate: 2026-09-15
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-condition-variable/
---
큐가 빌 때까지 기다렸다가 항목을 꺼내는 코드를 짭니다.

큐는 공유 자원이니 락으로 지켜야 합니다. 그런데 락을 잡고 보니 큐가 비었습니다.
기다려야 하는데, 락을 쥔 채로 기다리면 아무도 항목을 넣어 줄 수 없습니다.
그렇다고 락을 놓으면 그 사이에 큐 상태가 바뀝니다.

**락을 놓는 것과 기다리는 것을 따로 하면 안 됩니다.** 조건 변수는 그 둘을 한 번에 해 줍니다.

## 락을 쥔 채로는 기다릴 수 없다

말이 아니라 실제로 멈춥니다. 소비자가 락을 쥔 채로 큐를 계속 확인하고,
생산자는 항목을 넣으려고 락을 기다립니다.

```csharp
// 소비자
lock (gate)
{
    while (q.Count == 0) Thread.Sleep(1);   // 락을 쥔 채로 기다립니다
    q.Dequeue();
}

// 생산자
lock (gate) { q.Enqueue(1); }               // 여기서 영원히 못 들어갑니다
```

500ms 를 주고 20번 돌렸더니 **20번 다 안 끝났습니다.**

데드락인데 자물쇠는 하나뿐입니다. 소비자는 조건이 바뀌기를 기다리고,
조건을 바꿀 수 있는 유일한 사람은 그 락을 기다립니다.
**서로를 기다리는 것이 아니라, 한쪽이 다른 쪽의 입구를 막고 서 있는 것**입니다.

## 락을 놓고 돌면 되긴 합니다

그러면 락을 놓고 바깥에서 확인하면 됩니다. 확인할 때만 잠깐 잡습니다.

```csharp
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` 에 쓴 그 객체를 그대로 씁니다.

```csharp
// 소비자
lock (gate)
{
    while (q.Count == 0) Monitor.Wait(gate);   // 락을 놓고 잠듭니다
    q.Dequeue();
}

// 생산자
lock (gate)
{
    q.Enqueue(1);
    Monitor.Pulse(gate);                       // 기다리는 사람 하나를 깨웁니다
}
```

`Monitor.Wait` 이 하는 일이 이 글의 전부입니다. **락을 놓는 것과 조건 대기열에 들어가는 것이
한 번에 일어납니다.** 그 사이에 다른 스레드가 끼어들 틈이 없습니다.

<x-condvar>
<script type="application/json">
{
  "label": "락이 넘어가는가",
  "mode": "both",
  "note": "락을 쥔 채로 기다리면 락이 한 번도 안 넘어가고 거기서 끝납니다."
}
</script>
</x-condvar>

락이 넘어간 횟수가 **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` 로 전부 깨웠습니다.
항목은 하나뿐인데 넷이 다 깼습니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "if 로 검사", "code": "lock (gate)\n{\n    if (q.Count == 0) Monitor.Wait(gate);\n    q.Dequeue();          // 빈 큐를 꺼냅니다\n}", "stats": [["100회 중 깨진 실행", "100회"], ["빈 큐를 꺼낸 스레드", "매번 있음"]], "tone": "bad" },
    { "label": "while 로 검사", "code": "lock (gate)\n{\n    while (q.Count == 0) Monitor.Wait(gate);\n    q.Dequeue();          // 조건이 맞을 때만 옵니다\n}", "stats": [["100회 중 깨진 실행", "0회"], ["빈 큐를 꺼낸 스레드", "없음"]], "tone": "good" }
  ]
}
</script>
</x-compare>

`if` 쪽은 **100번 중 100번** 깨졌습니다. 확률이 아니라 구조입니다.
넷이 깼는데 항목은 하나니까 셋은 반드시 빈 큐를 꺼냅니다.

`while` 로 쓰면 깬 뒤에 조건을 다시 봅니다. 안 맞으면 다시 잠듭니다.
`Wait` 에서 돌아왔다는 것은 **"조건이 맞다" 가 아니라 "한 번 다시 확인해 봐라" 입니다.**

POSIX 조건 변수에는 신호가 없어도 깨어나는 경우(가짜 깨움)가 명시적으로 허용되어 있기도 합니다.
그것까지 가지 않아도 위 이유만으로 `while` 이 필요합니다.
**조건 변수를 쓰는 코드에서 `if` 는 거의 언제나 버그입니다.**

## 조건을 안 보고 기다리면 신호를 놓칩니다

`while` 이 필요한 이유가 하나 더 있습니다. **신호가 먼저 오는 경우**입니다.

깨우기 신호는 저장되지 않습니다. 쏘는 순간 기다리는 사람이 없으면 그냥 사라집니다.
생산자가 항목을 넣고 신호를 쏜 뒤에 소비자가 도착하면, 소비자는 이미 지나간 신호를 기다립니다.

```csharp
// 나쁜 쪽: 조건을 안 보고 무조건 기다립니다
lock (gate) { Monitor.Wait(gate); }

// 좋은 쪽: 이미 준비됐으면 아예 안 기다립니다
lock (gate) { while (!ready) Monitor.Wait(gate); }
```

신호를 먼저 쏜 뒤 소비자를 띄우는 상황을 20번 만들었더니,
조건을 안 보는 쪽은 **20번 다 안 끝났고** `while` 로 조건을 보는 쪽은 **한 번도 안 멈췄습니다.**

`while (!ready)` 는 두 가지를 같이 해 줍니다. **이미 조건이 맞으면 안 기다리고,
잘못 깨면 다시 기다립니다.** 신호를 놓치는 문제와 헛되이 깨는 문제가 같은 한 줄로 막힙니다.

## 세 방식을 나란히 재면

항목 200개를 5ms 간격으로 넣고, 소비자가 꺼낼 때까지의 지연과 CPU 시간을 쟀습니다.
코어 두 개짜리 x86-64 리눅스, `.NET 8` 입니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    { "label": "바쁜 기다림", "code": "while (true)\n{\n    lock (gate) { if (q.Count > 0) { ...; break; } }\n}", "stats": [["CPU 사용률", "100.6%"], ["평균 지연", "1.6us"], ["최대 지연", "43.0us"]], "tone": "bad" },
    { "label": "Sleep 폴링", "code": "while (true)\n{\n    lock (gate) { if (q.Count > 0) { ...; break; } }\n    Thread.Sleep(1);\n}", "stats": [["CPU 사용률", "2.9%"], ["평균 지연", "534.8us"], ["최대 지연", "1089.5us"]], "tone": "bad" },
    { "label": "조건 변수", "code": "lock (gate)\n{\n    while (q.Count == 0) Monitor.Wait(gate);\n    ...\n}", "stats": [["CPU 사용률", "1.9%"], ["평균 지연", "76.8us"], ["최대 지연", "1080.4us"]], "tone": "good" }
  ]
}
</script>
</x-compare>

조건 변수는 **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` 를 돌려주는데, **돌아올 때 락은 다시 잡은 상태**입니다.
그래서 이 경우에도 조건을 다시 확인해야 하는 것은 같습니다.

## 직접 재보기

하네스 핵심입니다. 콘솔 프로젝트에 그대로 붙여도 돕니다.

```csharp
// 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 들의 모양이 왜 그런지가 여기서 나옵니다