잠든 스레드와 교착
synchronized 안의 sleep 재시도가 왜 진행 가능성을 파괴하는지 상태와 타임라인으로 밝히고 잠금 밖 재시도와 올바른 대기 조건을 구분합니다.
공유 큐를 보호하려고 synchronized를 붙인 다음, 가득 차면 빈자리가 생길 때까지 sleep으로 쉬면 될 것처럼 보입니다.
문제는 잠드는 위치입니다.
Thread.sleep은 실행만 잠시 멈출 뿐 현재 소유한 모니터를 반납하지 않습니다.
생산자가 큐 잠금을 쥔 채 잠들면 빈칸을 만들 소비자는 take에 들어갈 수 없고, 생산자는 소비를 기다리며 같은 조건을 영원히 다시 확인합니다.
이 현상은 서로 다른 두 락을 반대 순서로 획득하는 고전적 교착과 모양이 다릅니다.
단 하나의 락만 있어도 조건을 바꿀 주체를 임계 영역 밖에 세워 두면 진행이 멈춥니다.
따라서 안전성만 확인해서는 부족합니다.
용량 불변식이 지켜져도 어느 스레드도 작업을 완료하지 못한다면 버퍼는 사용할 수 없습니다.
빈칸을 만들 소비자가 입구에서 막히는 실험
다음 프로그램은 용량 1 큐에 첫 값을 넣은 뒤 두 번째 생산을 시작합니다.
두 번째 생산자는 모니터 안에서 잠들며 빈자리를 기다립니다.
이어서 소비자를 실행해도 take 호출은 객체 락을 얻지 못합니다.
각 join에 제한 시간을 두고 두 스레드의 순간 상태를 출력한 뒤 생산자에게 인터럽트를 요청합니다. 정리 뒤 종료를 다시 기다리는 코드는 없습니다.
import java.util.ArrayDeque;
import java.util.Queue;
public final class SleepWhileHoldingMonitor {
private final Queue<String> queue = new ArrayDeque<>();
public synchronized void put(String value) {
while (queue.size() == 1) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
queue.add(value);
}
public synchronized String take() {
return queue.remove();
}
public static void main(String[] args) throws InterruptedException {
SleepWhileHoldingMonitor buffer = new SleepWhileHoldingMonitor();
buffer.put("first");
Thread producer = Thread.ofPlatform().name("stuck-producer").start(() -> buffer.put("second"));
Thread.sleep(50);
Thread consumer = Thread.ofPlatform().name("blocked-consumer").start(buffer::take);
producer.join(150);
consumer.join(150);
System.out.println("producer=" + producer.getState());
System.out.println("consumer=" + consumer.getState());
producer.interrupt();
}
}생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다
생산자가 모니터를 쥔 채 sleep하는 조건에서 소비자는 BLOCKED, interrupt 뒤 해제 후 제거가 가능합니다. 스케줄의 가변성을 유지한 조건부 진행·락 해제 추적이 유용합니다.
SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.
보유 중 sleep · 진행 조건
생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다
생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다.
설명과 원래 예시 값
생산자
- 큐가 가득 찬 반복
- sleep 동안에도 buffer 모니터를 보유합니다.
- 이번 실행의 조회
TIMED_WAITING- 출력 뒤 interrupt
- sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.
소비자
- 큐가 가득 찬 반복
- take에 진입하려면 같은 모니터가 필요합니다.
- 이번 실행의 조회
BLOCKED- 출력 뒤 interrupt
- 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.
main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.
이 예의 조건
- 생산자가 먼저 모니터를 획득한 경로입니다. sleep 50은 획득 순서를 보장하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다. 보유 중 sleep · 진행 조건 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다. 생산자 큐가 가득 찬 반복 sleep 동안에도 buffer 모니터를 보유합니다. 이번 실행의 조회 TIMED_WAITING 출력 뒤 interrupt sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다. 소비자 큐가 가득 찬 반복 take에 진입하려면 같은 모니터가 필요합니다. 이번 실행의 조회 BLOCKED 출력 뒤 interrupt 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다. main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 가득 찬 큐 보유 생산자가 buffer 모니터를 얻고 sleep합니다. |
|
|
| 소비자 진입 대기 소비자는 take에 필요한 같은 모니터를 기다립니다. |
|
|
| 중단 뒤 반납 출력 뒤 interrupt가 sleep을 중단시키고 put이 반환합니다. |
|
|
생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다
생산자가 모니터를 쥔 채 sleep하는 조건에서 소비자는 BLOCKED, interrupt 뒤 해제 후 제거가 가능합니다. 스케줄의 가변성을 유지한 조건부 진행·락 해제 추적이 유용합니다.
SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.
보유 중 sleep · 진행 조건
생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다
생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다.
설명과 원래 예시 값
생산자
- 큐가 가득 찬 반복
- sleep 동안에도 buffer 모니터를 보유합니다.
- 이번 실행의 조회
TIMED_WAITING- 출력 뒤 interrupt
- sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.
소비자
- 큐가 가득 찬 반복
- take에 진입하려면 같은 모니터가 필요합니다.
- 이번 실행의 조회
BLOCKED- 출력 뒤 interrupt
- 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.
main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.
이 예의 조건
- 생산자가 먼저 모니터를 획득한 경로입니다. sleep 50은 획득 순서를 보장하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다. 보유 중 sleep · 진행 조건 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다. 생산자 큐가 가득 찬 반복 sleep 동안에도 buffer 모니터를 보유합니다. 이번 실행의 조회 TIMED_WAITING 출력 뒤 interrupt sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다. 소비자 큐가 가득 찬 반복 take에 진입하려면 같은 모니터가 필요합니다. 이번 실행의 조회 BLOCKED 출력 뒤 interrupt 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다. main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 가득 찬 큐 보유 생산자가 buffer 모니터를 얻고 sleep합니다. |
|
|
| 소비자 진입 대기 소비자는 take에 필요한 같은 모니터를 기다립니다. |
|
|
| 중단 뒤 반납 출력 뒤 interrupt가 sleep을 중단시키고 put이 반환합니다. |
|
|
이 종료 장치는 실패를 해결한 것이 아니라 실험을 끝내기 위한 안전핀입니다.
잠금의 목적과 조건 대기의 목적 분리
모니터는 크기 검사와 큐 변경을 하나의 원자적 구간으로 묶습니다.
반면 대기는 현재 스레드가 할 일이 없을 때 다른 주체가 조건을 바꾸도록 실행권을 넘기는 행위입니다.
두 목적을 섞어서 “잠금 안에서 오래 쉬면 안전하다”고 해석하면 안전성은 얻을지 몰라도 생존성을 잃습니다.
sleep은 시간 경과만 기다리며 어떤 객체의 모니터도 자동으로 해제하지 않습니다.yield는 스케줄러에 양보 힌트를 줄 뿐 잠금 소유권이나 조건 신호를 다루지 않습니다.- 잠금 밖 폴링은 다른 스레드의 진입을 허용하지만 확인 주기만큼 지연과 불필요한 깨어남을 만듭니다.
wait와Condition.await는 정해진 잠금과 결합되어 대기 진입 시 소유권을 반납합니다.- 인터럽트는 취소 통로이므로 잡아서 버리지 말고 반환·전파·상태 복구 중 규칙에 맞는 처리를 해야 합니다.
- 진행성 검증은 최종 값뿐 아니라 제한 시간 안에 각 역할이 종료되는지도 확인해야 합니다.
잠금 범위는 “공유 상태를 읽고 바꾸는 데 필요한 최소 구간”으로 설명하는 편이 정확합니다.
파일 쓰기, 네트워크 호출, 인위적 지연을 그 안에 두면 큐의 불변식과 관계없는 시간이 모든 참여자를 막습니다.
조건 확인부터 변경까지는 붙여 두되, 후속 처리와 느린 작업은 원소를 꺼낸 뒤 잠금 밖에서 수행합니다.
모니터 밖 재시도 기준
정식 해법은 뒤 절의 조건 대기입니다.
여기서는 잘못의 원인이 락 보유라는 사실을 분리해 확인하기 위해 폴링 버전을 만듭니다.
tryPut과 tryTake는 한 번만 검사하고 즉시 반환합니다.
재시도 루프는 메서드 밖에 있으므로 실패 사이에 다른 역할이 같은 모니터를 얻을 수 있습니다.
LockSupport.parkNanos는 CPU 독점을 줄이지만 조건 신호가 아니므로 학습용 비교 기준으로만 사용합니다.
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.locks.LockSupport;
public final class OutsideMonitorRetryBuffer<E> {
private final Queue<E> queue = new ArrayDeque<>();
private final int capacity;
public OutsideMonitorRetryBuffer(int capacity) {
if (capacity < 1) throw new IllegalArgumentException("capacity");
this.capacity = capacity;
}
public synchronized boolean tryPut(E value) {
if (value == null) throw new NullPointerException("value");
if (queue.size() == capacity) return false;
queue.add(value);
return true;
}
public synchronized Optional<E> tryTake() {
return Optional.ofNullable(queue.poll());
}
public boolean putBefore(E value, Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
do {
if (tryPut(value)) return true;
if (Thread.interrupted()) throw new InterruptedException();
LockSupport.parkNanos(200_000);
} while (System.nanoTime() < deadline);
return false;
}
public static void main(String[] args) throws Exception {
OutsideMonitorRetryBuffer<String> buffer = new OutsideMonitorRetryBuffer<>(1);
buffer.tryPut("first");
Thread producer = Thread.ofPlatform().start(() -> {
try {
System.out.println("inserted=" + buffer.putBefore("second", Duration.ofSeconds(1)));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = Thread.ofPlatform().start(() -> {
LockSupport.parkNanos(Duration.ofMillis(30).toNanos());
System.out.println("removed=" + buffer.tryTake().orElseThrow());
});
producer.join();
consumer.join();
System.out.println("tail=" + buffer.tryTake().orElseThrow());
}
}재시도는 시도·중단·대기·기한 순서로 검사한다
즉시 tryPut→실패 시 interrupted→모니터 밖 park→기한 비교의 실제 재시도 순서입니다. 첫 시도가 중단·기한 검사보다 앞섬을 단계로 보여 줍니다.
OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.
재시도 · 검사 순서
재시도는 시도·중단·대기·기한 순서로 검사한다
첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다.
설명과 원래 예시 값
1 · 즉시 시도
- 원문의 검사·호출
tryPut(value)- 결과와 범위
- 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
2 · 실패 뒤 중단 검사
- 원문의 검사·호출
Thread.interrupted()- 결과와 범위
- true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
3 · 모니터 밖 대기
- 원문의 검사·호출
parkNanos(200_000)- 결과와 범위
- 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
4 · 기한 비교
- 원문의 검사·호출
- 현재 시간이 deadline보다 이른가
- 결과와 범위
- 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.
첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.
이 예의 조건
- 시도 횟수 제한은 없으며 true는 정확한 1초 내 완료 증거가 아닙니다.
- tryPut 성공은 즉시 반환합니다. 실패 뒤 중단 조회가 false일 때만 모니터 밖 대기와 기한 비교에 도달합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 재시도는 시도·중단·대기·기한 순서로 검사한다 재시도는 시도·중단·대기·기한 순서로 검사한다 OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다. 재시도 · 검사 순서 재시도는 시도·중단·대기·기한 순서로 검사한다 첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다. 1 · 즉시 시도 원문의 검사·호출 tryPut(value) 결과와 범위 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다. 2 · 실패 뒤 중단 검사 원문의 검사·호출 Thread.interrupted() 결과와 범위 true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다. 3 · 모니터 밖 대기 원문의 검사·호출 parkNanos(200_000) 결과와 범위 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다. 4 · 기한 비교 원문의 검사·호출 현재 시간이 deadline보다 이른가 결과와 범위 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다. 첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 즉시 시도 tryPut이 모니터 안에서 검사·추가합니다. |
|
|
| 실패 뒤 중단 분기 실패한 시도 뒤 Thread.interrupted를 조회하여 종료 또는 진행을 정합니다. |
|
|
| false 경로의 대기와 기한 중단 조회가 false일 때만 parkNanos(200000) 뒤 시간을 비교합니다. |
|
|
재시도는 시도·중단·대기·기한 순서로 검사한다
즉시 tryPut→실패 시 interrupted→모니터 밖 park→기한 비교의 실제 재시도 순서입니다. 첫 시도가 중단·기한 검사보다 앞섬을 단계로 보여 줍니다.
OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.
재시도 · 검사 순서
재시도는 시도·중단·대기·기한 순서로 검사한다
첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다.
설명과 원래 예시 값
1 · 즉시 시도
- 원문의 검사·호출
tryPut(value)- 결과와 범위
- 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
2 · 실패 뒤 중단 검사
- 원문의 검사·호출
Thread.interrupted()- 결과와 범위
- true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
3 · 모니터 밖 대기
- 원문의 검사·호출
parkNanos(200_000)- 결과와 범위
- 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
4 · 기한 비교
- 원문의 검사·호출
- 현재 시간이 deadline보다 이른가
- 결과와 범위
- 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.
첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.
이 예의 조건
- 시도 횟수 제한은 없으며 true는 정확한 1초 내 완료 증거가 아닙니다.
- tryPut 성공은 즉시 반환합니다. 실패 뒤 중단 조회가 false일 때만 모니터 밖 대기와 기한 비교에 도달합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 재시도는 시도·중단·대기·기한 순서로 검사한다 재시도는 시도·중단·대기·기한 순서로 검사한다 OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다. 재시도 · 검사 순서 재시도는 시도·중단·대기·기한 순서로 검사한다 첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다. 1 · 즉시 시도 원문의 검사·호출 tryPut(value) 결과와 범위 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다. 2 · 실패 뒤 중단 검사 원문의 검사·호출 Thread.interrupted() 결과와 범위 true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다. 3 · 모니터 밖 대기 원문의 검사·호출 parkNanos(200_000) 결과와 범위 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다. 4 · 기한 비교 원문의 검사·호출 현재 시간이 deadline보다 이른가 결과와 범위 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다. 첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 즉시 시도 tryPut이 모니터 안에서 검사·추가합니다. |
|
|
| 실패 뒤 중단 분기 실패한 시도 뒤 Thread.interrupted를 조회하여 종료 또는 진행을 정합니다. |
|
|
| false 경로의 대기와 기한 중단 조회가 false일 때만 parkNanos(200000) 뒤 시간을 비교합니다. |
|
|
이제 소비자는 생산자가 재시도 사이에 잠금을 반납하므로 first를 제거할 수 있습니다.
이번 실행은 removed=first, inserted=true, tail=second 순으로 끝났습니다. 성공 경로의 앞 두 줄 순서는 바뀔 수 있습니다. 기한이 먼저 끝나면 inserted=false 뒤 마지막 꺼내기에서 NoSuchElementException이 날 수 있습니다.
반복 조회 비용은 남으며 실제 재검사 간격과 추가 지연은 스케줄링과 모니터 획득 지연의 영향도 받습니다.
테스트의 제한 시간 관측
생존성 오류를 다루는 테스트가 join()을 제한 없이 호출하면 실패했을 때 테스트 러너도 끝나지 않습니다.
다음 감시기는 제한 시간 후 살아 있는 스레드의 상태를 기록하고 인터럽트합니다.
검사 순간의 종료 여부를 불리언으로 반환하지만, 인터럽트 요청 뒤 실제 종료를 기다리지는 않습니다.
import java.time.Duration;
import java.util.List;
public final class ThreadCompletionProbe {
static boolean awaitAll(List<Thread> workers, Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
for (Thread worker : workers) {
long remaining = deadline - System.nanoTime();
if (remaining > 0) worker.join(Duration.ofNanos(remaining));
}
boolean completed = workers.stream().noneMatch(Thread::isAlive);
if (!completed) {
workers.stream()
.filter(Thread::isAlive)
.forEach(worker -> System.err.println(worker.getName() + ":" + worker.getState()));
workers.forEach(Thread::interrupt);
}
return completed;
}
public static void main(String[] args) throws InterruptedException {
Thread shortJob = Thread.ofPlatform().name("short-job").start(() -> {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("completed=" + awaitAll(List.of(shortJob), Duration.ofSeconds(1)));
}
}상태 출력은 원인 그 자체가 아니라 단서입니다.
BLOCKED는 모니터 진입을 기다린다는 뜻이고 TIMED_WAITING은 시간 제한 동작 안에 있다는 뜻입니다.
스레드 덤프에서 잠금 소유자와 대기자를 함께 연결해야 “생산자가 모니터를 가진 채 잠들어 소비자가 막혔다”는 인과를 확정할 수 있습니다.
어떤 대기 도구를 고를지 결정하는 표
| 상황 | 락 반납 여부 | 판단 |
|---|---|---|
| 임계 영역과 무관한 지연 | 먼저 잠금 해제 | 밖에서 sleep 가능 |
| 공유 조건이 바뀔 때까지 대기 | 반드시 반납 | wait 또는 Condition |
| 단 한 번의 비차단 시도 | 즉시 반환 | try 메서드와 결괏값 |
| 제한 시간 안의 수용 | 반납하며 시간 계산 | 시간 제한 offer 계열 |
대기 시간을 짧게 줄인다고 구조적 정지가 해결되지는 않습니다.
1초 대신 1밀리초로 바꾸면 잠금 소유자가 더 자주 깨어날 뿐, 소비자가 들어올 기회는 여전히 없습니다.
반대로 잠금 밖 폴링은 진행은 가능하지만 지연과 CPU 사용이 확인 주기에 묶입니다.
버퍼 조건이 바뀌는 정확한 순간을 알고 있으므로 신호 기반 대기가 더 직접적인 모델입니다.
연습 문제
큐에서 메시지를 꺼내 JSON 문자열로 직렬화하는 소비자가 있습니다.
기존 코드는 synchronized drainOne() 안에서 40밀리초 직렬화를 수행하여 생산자를 계속 막습니다.
큐 제거만 보호하고 변환은 밖에서 실행하도록 리팩터링하세요.
동시에 두 소비자가 같은 메시지를 처리하지 않는지도 설명합니다.
정답과 해설
잠금 안에서는 poll로 소유권을 한 소비자에게 이전합니다.
제거된 큐 항목은 한 소비자에게만 넘어갑니다.
그다음 느린 변환은 지역 변수만 사용하므로 모니터가 필요하지 않습니다.
큐가 비었을 때는 Optional.empty()로 즉시 알려 줍니다.
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;
public final class SerializeOutsideLockSolution {
record Message(long id, String body) {}
private final Queue<Message> pending = new ArrayDeque<>();
synchronized void submit(Message message) {
pending.add(message);
}
private synchronized Optional<Message> claimOne() {
return Optional.ofNullable(pending.poll());
}
Optional<String> drainOne() {
Optional<Message> claimed = claimOne();
return claimed.map(this::slowSerialize);
}
private String slowSerialize(Message message) {
try {
Thread.sleep(40);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("serialization interrupted", e);
}
return "{\"id\":" + message.id() + ",\"body\":\"" + message.body() + "\"}";
}
public static void main(String[] args) throws InterruptedException {
SerializeOutsideLockSolution queue = new SerializeOutsideLockSolution();
queue.submit(new Message(7, "ready"));
Thread consumer = Thread.ofPlatform().start(() -> System.out.println(queue.drainOne().orElseThrow()));
Thread producer = Thread.ofPlatform().start(() -> queue.submit(new Message(8, "next")));
consumer.join();
producer.join();
System.out.println(queue.drainOne().isPresent());
}
}큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다
잠금 안 claimOne이 Message를 한 호출자에게 넘긴 뒤 잠금 밖 slowSerialize를 합니다. 모니터 획득·소유 이전·해제 후 변환을 순서대로 구분해 모니터 범위를 확인할 수 있습니다.
SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.
모니터 범위 · 항목 소유
큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다
큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다.
설명과 원래 예시 값
submit
- 소유 범위
- this 모니터 안
- 수행하는 일
- 큐에 항목을 추가합니다.
claimOne
- 소유 범위
- 같은 this 모니터 안
- 수행하는 일
- poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.
slowSerialize
- 소유 범위
- 모니터 밖
- 수행하는 일
- 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.
같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.
이 예의 조건
- 40ms는 sleep 요청이며 JSON 결합은 임의 body를 이스케이프하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다. 모니터 범위 · 항목 소유 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다. submit 소유 범위 this 모니터 안 수행하는 일 큐에 항목을 추가합니다. claimOne 소유 범위 같은 this 모니터 안 수행하는 일 poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다. slowSerialize 소유 범위 모니터 밖 수행하는 일 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다. 같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 항목 등록 submit이 this 모니터 안에서 Message를 큐에 추가합니다. |
|
|
| 항목 인수 claimOne이 같은 모니터 안에서 poll합니다. |
|
|
| 느린 변환 모니터 밖에서 sleep과 문자열 결합을 수행합니다. |
|
|
큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다
잠금 안 claimOne이 Message를 한 호출자에게 넘긴 뒤 잠금 밖 slowSerialize를 합니다. 모니터 획득·소유 이전·해제 후 변환을 순서대로 구분해 모니터 범위를 확인할 수 있습니다.
SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.
모니터 범위 · 항목 소유
큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다
큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다.
설명과 원래 예시 값
submit
- 소유 범위
- this 모니터 안
- 수행하는 일
- 큐에 항목을 추가합니다.
claimOne
- 소유 범위
- 같은 this 모니터 안
- 수행하는 일
- poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.
slowSerialize
- 소유 범위
- 모니터 밖
- 수행하는 일
- 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.
같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.
이 예의 조건
- 40ms는 sleep 요청이며 JSON 결합은 임의 body를 이스케이프하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다. 모니터 범위 · 항목 소유 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다. submit 소유 범위 this 모니터 안 수행하는 일 큐에 항목을 추가합니다. claimOne 소유 범위 같은 this 모니터 안 수행하는 일 poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다. slowSerialize 소유 범위 모니터 밖 수행하는 일 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다. 같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 항목 등록 submit이 this 모니터 안에서 Message를 큐에 추가합니다. |
|
|
| 항목 인수 claimOne이 같은 모니터 안에서 poll합니다. |
|
|
| 느린 변환 모니터 밖에서 sleep과 문자열 결합을 수행합니다. |
|
|
여기서 한 소비자에게 넘기는 단위는 큐에 넣은 항목 하나입니다. 같은 Message 참조나 ID를 submit으로 두 번 넣으면 항목이 두 개가 되어 각각 제거할 수 있습니다. poll의 배타적 제거는 업무 ID 중복 제거·처리 성공 보장이나 실패 뒤 재처리까지 구현한 규약이 아닙니다.
slowSerialize의 sleep(40)은 느린 변환을 흉내 낸 지연 요청이며 정확히 40ms의 실행 시간을 보장하지 않습니다. 뒤의 문자열 결합도 ready·next처럼 따옴표나 역슬래시가 없는 입력의 모양을 보여 줍니다. 임의의 body에는 올바른 JSON 이스케이프를 수행하는 직렬화 도구가 필요합니다. 큐에서 꺼낸 뒤 변환이 실패하면 원 항목은 이미 제거됐으므로, 재처리·보관·폐기 중 어떤 정책을 사용할지 별도로 정합니다.
직렬화 중에도 생산자는 두 번째 메시지를 넣을 수 있습니다.
claimOne이 반환하는 순간 첫 메시지는 한 소비자에게만 귀속됩니다.
처리 실패 때 재큐잉이 필요하다면 별도의 실패 정책과 시도 횟수를 추가해야 하며, 단순히 긴 작업을 다시 잠금 안으로 넣어 원자성을 흉내 내서는 안 됩니다.
정지 원인의 명시
synchronized는 스레드를 안전하게 해 주는 마법 문구가 아니라 한 번에 한 주체만 상태 전이를 수행하게 하는 문입니다.
문 안에서 조건이 저절로 바뀌기를 기다리면, 조건 변경자를 문 밖에 세워 두는 셈입니다.
어떤 코드가 잠금을 소유하고 어떤 코드가 조건을 바꾸는지 타임라인으로 그리면 이 오류를 빠르게 찾을 수 있습니다.
완성된 버퍼는 안전성, 진행성, 취소 가능성을 따로 확인합니다.
크기가 용량을 넘지 않는지, 제한 시간 안에 생산과 소비가 모두 끝나는지, 인터럽트가 작업 중단으로 이어지는지를 각각 관찰해야 합니다.
다음 절의 모니터 대기는 이 세 요구를 한 프로토콜 안에서 다룹니다.