notify와 잘못된 깨움
생산자와 소비자가 한 Object 대기 집합을 공유할 때 발생하는 무의미한 각성과 기아 위험을 분석하고 `notifyAll`의 안전 조건과 비용을 확인합니다.
객체 모니터에는 조건별 대기실이 여러 개 생기지 않습니다.
같은 객체에서 wait()를 호출한 생산자와 소비자는 하나의 대기 집합에 섞입니다.
큐가 비어서 소비자를 깨워야 하는 순간 notify()를 호출해도 JVM은 소비자를 골라 준다고 약속하지 않습니다.
이미 가득 찬 큐에서 기다리는 생산자를 깨우면 그 스레드는 잠금을 다시 얻고 조건을 확인한 뒤 아무 일도 못 한 채 되돌아갑니다.
잘못된 역할이 한 번 깨어나는 것은 성능 낭비로 끝날 수 있습니다.
그러나 이어질 상태 변경자가 더 없고 필요한 역할이 계속 잠들어 있다면 전체 작업이 멈춥니다.
notifyAll()은 모든 대기자에게 재검사 기회를 주어 이 생존성 구멍을 막습니다.
대신 다수의 스레드가 같은 잠금을 두고 경쟁한 뒤 대부분 다시 잠드는 각성 폭풍 비용이 생깁니다.
신호 한 번에 역할 선택을 기대하는 취약한 코드
다음 큐는 생산과 소비 양쪽에서 notify()만 사용합니다.
코드만 보면 상태가 바뀔 때마다 한 명을 깨우므로 효율적으로 보입니다.
하지만 대기 집합에서 어떤 역할이 선택되는지는 구현 의존입니다.
실행기는 제한 시간 후 남은 작업을 취소하고 완료 개수를 출력합니다.
여러 번 반복하면 환경에 따라 작업이 덜 끝나는 라운드를 관찰할 수 있습니다.
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
public final class SingleNotifyMixedWaiters {
private final Queue<Integer> queue = new ArrayDeque<>();
private final int capacity = 1;
synchronized void put(int value) throws InterruptedException {
while (queue.size() == capacity) wait();
queue.add(value);
notify();
}
synchronized int take() throws InterruptedException {
while (queue.isEmpty()) wait();
int value = queue.remove();
notify();
return value;
}
public static void main(String[] args) throws InterruptedException {
SingleNotifyMixedWaiters buffer = new SingleNotifyMixedWaiters();
boolean[] completed = new boolean[8];
List<Thread> workers = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int value = i;
int producerSlot = i * 2;
int consumerSlot = producerSlot + 1;
workers.add(Thread.ofPlatform().start(() -> {
try { buffer.put(value); completed[producerSlot] = true; }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}));
workers.add(Thread.ofPlatform().start(() -> {
try { buffer.take(); completed[consumerSlot] = true; }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}));
}
for (Thread worker : workers) {
worker.join(300);
}
for (Thread worker : workers) {
if (worker.isAlive()) worker.interrupt();
}
for (Thread worker : workers) worker.join();
int completedCount = 0;
for (boolean done : completed) if (done) completedCount++;
System.out.println("completed=" + completedCount + "/" + workers.size());
}
}이 실패는 반드시 매 실행에서 재현된다고 주장하면 안 됩니다.
notify의 선택 자체가 명시되지 않았기 때문입니다.
바로 그 비결정성이 결함입니다.
테스트가 운 좋게 여덟 작업 완료를 출력하더라도 코드가 올바른 역할을 선택한다는 보장은 생기지 않습니다.
명세가 주지 않은 순서를 프로그램의 전제로 삼지 않는 것이 판단 기준입니다.
notifyAll과 조건 재검사
전체 알림을 받으면 생산자와 소비자가 모두 대기 집합에서 나옵니다.
현재 큐가 비었다면 소비자 중 하나가 잠금을 얻어 진행하고, 생산자는 자신의 while 조건을 확인한 뒤 다시 대기합니다.
큐가 가득 찬 상황에서는 반대 역할이 움직입니다.
올바른 역할을 직접 지정하지 못하는 단일 대기 집합에서 안전성을 확보하는 방식입니다.
- 알림 대상 선택을 JVM의 우연한 대기 순서에 맡겨야 한다면
notifyAll이 보수적인 기본값입니다. - 모든 복귀 경로는
while술어로 보호되어 잘못 깨어난 역할이 공유 상태를 훼손하지 않게 합니다. - 상태 변경 없이 알림만 보내면 깨어난 참여자가 전부 같은 거짓 조건을 보고 다시 잠들므로 효과가 없습니다.
- 각성 횟수, 재대기 횟수, 잠금 경쟁 시간은 처리량이 커질 때 별도 성능 지표가 됩니다.
- 대기 역할이 많아 비용이 커지면 알림을 다시
notify로 줄이기보다 조건 대기실을 분리합니다. - 표준 동시성 큐가 요구를 충족한다면 직접 신호 정책을 유지보수하지 않는 편이 검증 비용이 낮습니다.
notifyAll을 정확성 장치로 채택한 뒤 실제 측정에서 병목이 확인되면 ReentrantLock의 여러 Condition으로 이동할 수 있습니다.
생산자는 소비자 조건만, 소비자는 생산자 조건만 신호하도록 역할을 지정합니다.
최적화 순서는 “잘못된 역할도 안전하게 재대기”를 먼저 확보하고 “불필요한 역할은 깨우지 않기”를 나중에 적용하는 방식이 좋습니다.
전체 알림으로 생존성을 우선한 유한 큐
아래 구현은 단일 모니터의 한계를 숨기지 않습니다. 상태 전이마다 모든 대기자를 깨우고, 깨어난 작업자는 자신의 조건을 다시 검사합니다. 통계 필드는 임계 영역 안에서 갱신하여 대기 진입과 복귀 수를 관찰합니다. 통계가 정확성에 영향을 주지는 않지만 부하 실험에서 각성 비용을 비교할 수 있습니다.
import java.util.ArrayDeque;
import java.util.Queue;
public final class NotifyAllBoundedQueue<E> {
private final Queue<E> queue = new ArrayDeque<>();
private final int capacity;
private int waits;
private int wakeups;
public NotifyAllBoundedQueue(int capacity) {
if (capacity < 1) throw new IllegalArgumentException("capacity");
this.capacity = capacity;
}
public synchronized void put(E value) throws InterruptedException {
if (value == null) throw new NullPointerException("value");
while (queue.size() == capacity) {
waits++;
wait();
wakeups++;
}
queue.add(value);
notifyAll();
}
public synchronized E take() throws InterruptedException {
while (queue.isEmpty()) {
waits++;
wait();
wakeups++;
}
E value = queue.remove();
notifyAll();
return value;
}
public synchronized int waitCount() { return waits; }
public synchronized int wakeupCount() { return wakeups; }
public static void main(String[] args) throws InterruptedException {
NotifyAllBoundedQueue<Integer> buffer = new NotifyAllBoundedQueue<>(2);
Thread[] consumers = new Thread[3];
for (int i = 0; i < consumers.length; i++) {
consumers[i] = Thread.ofPlatform().name("consumer-" + i).start(() -> {
try { System.out.println("value=" + buffer.take()); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
Thread.sleep(30);
for (int value : new int[]{11, 22, 33}) {
buffer.put(value);
}
for (Thread consumer : consumers) {
consumer.join();
}
System.out.printf("waits=%d wakeups=%d%n", buffer.waitCount(), buffer.wakeupCount());
}
}값 11, 22, 33은 각각 한 번 출력되지만 소비자 이름과 값의 조합은 정해지지 않습니다.
wakeupCount가 처리 건수보다 클 수 있는 이유는 전체 알림을 받은 작업자가 조건 경쟁에서 지고 다시 잠들기 때문입니다.
이 수치를 실패로 보지 말고 단일 대기 집합 설계의 비용으로 해석합니다.
알림 폭풍을 수치로 기록하는 부하 탐침
다음 코드는 여러 생산자와 소비자가 정해진 총량을 교환한 뒤 합계와 대기 통계를 검사합니다. 독약 값이나 강제 종료 대신 역할별 반복 횟수를 같게 맞춰 모든 스레드가 자연스럽게 끝나게 합니다. 합계 기대값은 0부터 199까지의 합인 19,900입니다.
import java.util.ArrayList;
import java.util.List;
public final class NotifyAllLoadProbe {
public static void main(String[] args) throws InterruptedException {
NotifyAllBoundedQueue<Integer> queue = new NotifyAllBoundedQueue<>(4);
List<Thread> threads = new ArrayList<>();
long[] partial = new long[4];
for (int producerId = 0; producerId < 4; producerId++) {
int start = producerId * 50;
threads.add(Thread.ofPlatform().start(() -> {
try {
for (int value = start; value < start + 50; value++) {
queue.put(value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
for (int consumerId = 0; consumerId < 4; consumerId++) {
int slot = consumerId;
threads.add(Thread.ofPlatform().start(() -> {
try {
long local = 0;
for (int count = 0; count < 50; count++) {
local += queue.take();
}
partial[slot] = local;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
for (Thread thread : threads) {
thread.join();
}
long sum = 0;
for (long value : partial) sum += value;
System.out.println("sum=" + sum);
System.out.println("extraWakeups=" + Math.max(0, queue.wakeupCount() - 200));
}
}합계가 맞고 모든 스레드가 종료하면 기능 규칙은 만족합니다.
추가 각성 수는 머신과 스케줄에 따라 달라지므로 고정값을 단언하지 않습니다.
같은 부하를 두 Condition 버전과 비교해 각성 및 처리 시간을 측정하면 최적화 근거를 얻을 수 있습니다.
작은 큐에서 수치 차이가 업무 지연으로 이어지지 않는다면 단순한 표준 구현을 유지하는 선택도 합리적입니다.
단일 알림과 전체 알림의 선택 기준
| 대기 구조 | 안전한 신호 | 이유 |
|---|---|---|
| 한 역할·한 조건만 존재 | notify 검토 가능 | 어느 대기자를 골라도 같은 술어 |
| 생산·소비가 같은 집합 공유 | notifyAll 우선 | 올바른 역할의 재검사 보장 |
| 역할별 Condition 분리 | 반대 조건 신호 | 대상 대기실을 지정 가능 |
| 라이브러리 큐 사용 | 내부 구현에 위임 | 검증된 정책과 취소 지원 |
대기자 수가 하나라는 가정은 코드 주석으로 끝내지 말고 생성 경로에서 강제해야 합니다.
유지보수자가 두 번째 작업자를 추가하는 순간 notify의 전제가 깨질 수 있습니다.
구조적으로 단일 대기임을 보장할 수 없다면 전체 알림이나 조건 분리를 선택하는 편이 안전합니다.
연습 문제
여러 작업자가 현재 설정 버전을 기억하고 다음 버전이 게시될 때까지 기다립니다.
게시자는 버전을 1씩 올리고 설정 문자열을 교체합니다.
한 번의 게시가 현재 대기 중인 모든 구독자에게 보여야 하므로 notify가 아니라 notifyAll을 사용하세요.
작업자는 자신이 본 버전보다 큰지 반복 검사해야 합니다.
정답과 해설
구독자가 기다리는 술어는 publishedVersion <= observedVersion입니다.
새 설정과 버전은 같은 모니터 안에서 바꾼 뒤 전체 알림을 보냅니다.
반환값에 버전을 포함해야 작업자가 다음 호출에서 정확한 기준을 전달할 수 있습니다.
import java.util.ArrayList;
import java.util.List;
public final class VersionedConfigSignalSolution {
record Snapshot(long version, String value) {}
private long version;
private String value = "initial";
synchronized Snapshot awaitNewerThan(long observed) throws InterruptedException {
while (version <= observed) wait();
return new Snapshot(version, value);
}
synchronized void publish(String next) {
if (next == null || next.isBlank()) throw new IllegalArgumentException("next");
value = next;
version++;
notifyAll();
}
public static void main(String[] args) throws InterruptedException {
VersionedConfigSignalSolution source = new VersionedConfigSignalSolution();
List<Snapshot> received = java.util.Collections.synchronizedList(new ArrayList<>());
Thread a = subscriber(source, received, "a");
Thread b = subscriber(source, received, "b");
while (a.getState() != Thread.State.WAITING || b.getState() != Thread.State.WAITING) {
Thread.onSpinWait();
}
source.publish("dark-mode-on");
a.join();
b.join();
System.out.println("subscribers=" + received.size());
}
private static Thread subscriber(VersionedConfigSignalSolution source,
List<Snapshot> received,
String name) {
return Thread.ofPlatform().name(name).start(() -> {
try { received.add(source.awaitNewerThan(0)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
}두 구독자 모두 버전 1 스냅샷을 받아 목록 크기는 2가 됩니다. 이 패턴은 이벤트를 모두 보존하는 큐가 아닙니다. 게시가 연속으로 두 번 일어나기 전에 구독자가 실행되지 않으면 최신 버전만 관찰할 수 있으므로 변경 이력 전달이 필요할 때는 별도 로그 구조를 사용해야 합니다.
생존성·효율 판단 단계
단일 대기 집합에서 notifyAll은 올바른 역할이 반드시 조건을 다시 볼 기회를 제공합니다.
불필요한 각성이 있다는 이유로 이를 notify로 바꾸면 측정 가능한 비용을 명세되지 않은 운에 교환하는 셈입니다.
먼저 모든 실행 순서에서 완료 가능한 구조를 만들고, 이후 프로파일로 각성 비용이 실제 병목인지 확인합니다.
역할 선택이 필요해졌다면 다음 단계는 신호 한 번을 영리하게 만드는 것이 아니라 대기 공간 자체를 나누는 것입니다.
두 Condition은 생산 가능과 소비 가능을 타입 대신 구조로 분리하여 신호 대상을 명시합니다.