ReentrantLock과 Condition으로 생산자·소비자 큐를 만들어 보고, notEmpty.signal()을 부르는 순간 대기 중인 소비자 스레드가 곧장 깨어나 값을 꺼낼 거라 생각했다. 그런데 신호를 보낸 직후 jstack을 떠 보면 소비자 스레드는 여전히 WAITING (parking) 상태로 남아 있다. signal()이 스레드를 깨우는 게 아니라면, 이 호출은 정확히 무슨 일을 하는 걸까.
AbstractQueuedSynchronizer(AQS) 소스에서 ConditionObject의 await/signal 구현을 따라가 보면, 답은 락 하나가 큐를 두 개 갖고 있다는 데 있다.
AQS 기반 락(ReentrantLock, ReentrantReadWriteLock 등)에는 성격이 다른 대기 큐가 두 종류 존재한다.
Condition 인스턴스를 기다리는 스레드들의 큐. 단일 연결 리스트이고, 항상 락을 들고 있는 상태에서만 접근하므로 CAS가 필요 없다.sync queue: head <-> node <-> tail (CAS로 삽입, 락 대기자)
condition queue: first -> node -> last (락 보유 중에만 접근, condition 대기자)
await()를 부른 스레드는 sync queue가 아니라 condition queue에 들어가 있는 상태이고, 이 시점엔 이미 락을 반납했다. 이 둘을 별개의 자료구조로 나눠 놓은 덕분에, "락을 기다리는 일"과 "조건을 기다리는 일"이 서로 다른 규칙(CAS 필요 여부, 큐잉 순서)으로 처리된다.
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()가 실행될 때다.
용량 1짜리 버퍼를 소비자 두 스레드(C1, C2)가 while (queue.isEmpty()) notEmpty.await();로 공유한다고 하자.
notEmpty.signal()을 호출한다. condition queue의 첫 대기자였던 C1의 노드가 sync queue 꼬리로 옮겨진다. C2는 그대로 condition queue에 남는다.unlock()한다 → signalNext(head)가 C1을 unpark한다. 그런데 바로 이 순간, take()를 새로 호출한 스레드 D가 lock()으로 진입한다. ReentrantLock의 기본(nonfair) 정책에서는 이렇게 갓 도착한 스레드가 큐에서 기다리던 대기자보다 먼저 CAS로 락을 채갈 수 있다고 알려져 있다.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(); }
}
signal()은 깨우는 함수가 아니라 옮기는 함수다. 실제 unpark는 신호를 보낸 스레드가 락을 놓는 순간 일어나고, 그 틈에 다른 스레드가 먼저 락을 잡아 조건을 다시 깨뜨릴 수 있다는 게 while 관용구의 근거다.
더 파고들 만한 지점 하나는 ConditionNode가 ForkJoinPool.ManagedBlocker를 구현한다는 것이다. 공용 풀 안에서 조건 대기가 워커 스레드를 그냥 묶어 두지 않도록, 풀이 임시로 워커를 보충해 고갈을 막는 메커니즘인데, 이건 work-stealing deque가 풀 워커를 다루는 방식과 맞닿아 있다.
openjdk/jdk(master), openjdk/jdk21u — java.util.concurrent.locks.AbstractQueuedSynchronizer