Condition.signal()을 불러도 대기 스레드가 즉시 깨어나지 않는 이유 — AQS의 두 큐

seonwoo_jung·약 20시간 전

1. 도입

ReentrantLockCondition으로 생산자·소비자 큐를 만들어 보고, notEmpty.signal()을 부르는 순간 대기 중인 소비자 스레드가 곧장 깨어나 값을 꺼낼 거라 생각했다. 그런데 신호를 보낸 직후 jstack을 떠 보면 소비자 스레드는 여전히 WAITING (parking) 상태로 남아 있다. signal()이 스레드를 깨우는 게 아니라면, 이 호출은 정확히 무슨 일을 하는 걸까.

AbstractQueuedSynchronizer(AQS) 소스에서 ConditionObjectawait/signal 구현을 따라가 보면, 답은 락 하나가 큐를 두 개 갖고 있다는 데 있다.

2. 핵심 개념 — 두 개의 큐

AQS 기반 락(ReentrantLock, ReentrantReadWriteLock 등)에는 성격이 다른 대기 큐가 두 종류 존재한다.

  • sync queue: 락 자체를 기다리는 스레드들의 큐. 양방향 연결이고, 여러 스레드가 동시에 진입할 수 있어 CAS로 노드를 이어 붙인다.
  • condition queue: 특정 Condition 인스턴스를 기다리는 스레드들의 큐. 단일 연결 리스트이고, 항상 락을 들고 있는 상태에서만 접근하므로 CAS가 필요 없다.
sync queue:      head <-> node <-> tail   (CAS로 삽입, 락 대기자)
condition queue: first -> node -> last    (락 보유 중에만 접근, condition 대기자)

await()를 부른 스레드는 sync queue가 아니라 condition queue에 들어가 있는 상태이고, 이 시점엔 이미 락을 반납했다. 이 둘을 별개의 자료구조로 나눠 놓은 덕분에, "락을 기다리는 일"과 "조건을 기다리는 일"이 서로 다른 규칙(CAS 필요 여부, 큐잉 순서)으로 처리된다.

3. 내부 동작 — await가 옮기고, signal이 다시 옮긴다

await()가 하는 일은 순서대로 이렇다: 새 노드를 만들어 상태를 COND | WAITING으로 설정하고 condition queue의 꼬리에 매단 뒤(enableWait), release()로 락을 완전히 반납하고, status에서 COND 비트가 벗겨질 때까지 park한다.

signal()의 실체는 이렇다(doSignal, 단순화):

private void doSignal(ConditionNode first, boolean all) {
    while (first != null) {
        ConditionNode next = first.nextWaiter;
        firstWaiter = next;
        if ((first.getAndUnsetStatus(COND) & COND) != 0) {
            enqueue(first);   // condition queue에서 떼어 sync queue 꼬리로 이동
            if (!all) break;
        }
        first = next;
    }
}

LockSupport.unpark는 어디에도 없다. signal()이 하는 일은 딱 여기까지 — condition queue의 첫 대기자를 sync queue의 꼬리로 옮기는 것뿐이다. 실제 깨움은 신호를 보낸 스레드가 나중에 락을 놓을 때 일어난다.

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        signalNext(head);   // 여기서만 실제 LockSupport.unpark 발생
        return true;
    }
    return false;
}

signal()은 스레드를 깨우는 함수가 아니라, condition queue에서 sync queue로 전학시키는 함수다. 옮겨진 노드가 실제로 unpark되는 시점은 signal을 호출한 스레드가 unlock()해서 release()가 실행될 때다.

4. 예시 — while이 필요한 이유를 타임라인으로

용량 1짜리 버퍼를 소비자 두 스레드(C1, C2)가 while (queue.isEmpty()) notEmpty.await();로 공유한다고 하자.

  1. 생산자 P가 아이템 1개를 넣고 notEmpty.signal()을 호출한다. condition queue의 첫 대기자였던 C1의 노드가 sync queue 꼬리로 옮겨진다. C2는 그대로 condition queue에 남는다.
  2. P가 unlock()한다 → signalNext(head)가 C1을 unpark한다. 그런데 바로 이 순간, take()를 새로 호출한 스레드 D가 lock()으로 진입한다. ReentrantLock의 기본(nonfair) 정책에서는 이렇게 갓 도착한 스레드가 큐에서 기다리던 대기자보다 먼저 CAS로 락을 채갈 수 있다고 알려져 있다.
  3. D가 락을 얻어 큐가 비어 있지 않음을 보고 아이템을 가져간다. 큐는 다시 비었다.
  4. 그제야 C1이 락을 재획득한다. 이 시점에 조건을 다시 확인하지 않으면(if였다면) 빈 큐에서 꺼내려다 NoSuchElementException을 던진다. while이었다면 조건을 재확인하고 얌전히 재대기한다.

signal과 실제 unpark 사이의 이 간격, 그리고 그 사이 누구든 먼저 락을 잡아 조건을 다시 깨뜨릴 수 있다는 사실이 Mesa 모니터 시맨틱의 전형적인 모습이고, if가 아니라 while로 조건을 감싸야 하는 이유다.

void put(int v) {
    lock.lock();
    try {
        queue.addLast(v);
        notEmpty.signal();          // 전학만 수행, unpark는 아직 안 함
    } finally { lock.unlock(); }    // signalNext(head) 실행 지점
}

int take() throws InterruptedException {
    lock.lock();
    try {
        while (queue.isEmpty())     // 재검사 필수 — if로 바꾸면 위 레이스에 노출
            notEmpty.await();
        return queue.removeFirst();
    } finally { lock.unlock(); }
}

5. 정리

signal()은 깨우는 함수가 아니라 옮기는 함수다. 실제 unpark는 신호를 보낸 스레드가 락을 놓는 순간 일어나고, 그 틈에 다른 스레드가 먼저 락을 잡아 조건을 다시 깨뜨릴 수 있다는 게 while 관용구의 근거다.

더 파고들 만한 지점 하나는 ConditionNodeForkJoinPool.ManagedBlocker를 구현한다는 것이다. 공용 풀 안에서 조건 대기가 워커 스레드를 그냥 묶어 두지 않도록, 풀이 임시로 워커를 보충해 고갈을 막는 메커니즘인데, 이건 work-stealing deque가 풀 워커를 다루는 방식과 맞닿아 있다.

참고 자료

  • OpenJDK GitHub: openjdk/jdk(master), openjdk/jdk21ujava.util.concurrent.locks.AbstractQueuedSynchronizer
  • Doug Lea, "The java.util.concurrent Synchronizer Framework", PODC 2004

0개의 댓글