조건 변수는 락을 놓고 기다렸다가 다시 잡는다
Esc

    조건 변수는 락을 놓고 기다렸다가 다시 잡는다

    –C#동시성

    큐가 빌 때까지 기다렸다가 항목을 꺼내는 코드를 짭니다.

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

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

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

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

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