잠든 스레드와 교착
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();
}
}SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.
| 원문의 지점 | 생산자 | 소비자 |
|---|---|---|
| 큐가 가득 찬 반복 | sleep 동안에도 buffer 모니터를 보유합니다. | take에 진입하려면 같은 모니터가 필요합니다. |
| 이번 실행의 조회 | TIMED_WAITING | BLOCKED |
| 출력 뒤 interrupt | sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다. | 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다. |
- 큐가 가득 찬 반복
- 생산자: sleep 동안에도 buffer 모니터를 보유합니다.소비자: take에 진입하려면 같은 모니터가 필요합니다.
- 이번 실행의 조회
- 생산자:
TIMED_WAITING소비자:BLOCKED - 출력 뒤 interrupt
- 생산자: sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.소비자: 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.
main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.
이 종료 장치는 실패를 해결한 것이 아니라 실험을 끝내기 위한 안전핀입니다.
잠금의 목적과 조건 대기의 목적 분리
모니터는 크기 검사와 큐 변경을 하나의 원자적 구간으로 묶습니다.
반면 대기는 현재 스레드가 할 일이 없을 때 다른 주체가 조건을 바꾸도록 실행권을 넘기는 행위입니다.
두 목적을 섞어서 “잠금 안에서 오래 쉬면 안전하다”고 해석하면 안전성은 얻을지 몰라도 생존성을 잃습니다.
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());
}
}OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.
| 실행 순서 | 원문의 검사·호출 | 결과와 범위 |
|---|---|---|
| 1 · 즉시 시도 | tryPut(value) | 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다. |
| 2 · 실패 뒤 중단 검사 | Thread.interrupted() | true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다. |
| 3 · 모니터 밖 대기 | parkNanos(200_000) | 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다. |
| 4 · 기한 비교 | 현재 시간이 deadline보다 이른가 | 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다. |
- 1 · 즉시 시도
- 원문의 검사·호출:
tryPut(value)결과와 범위: 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다. - 2 · 실패 뒤 중단 검사
- 원문의 검사·호출:
Thread.interrupted()결과와 범위: true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다. - 3 · 모니터 밖 대기
- 원문의 검사·호출:
parkNanos(200_000)결과와 범위: 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다. - 4 · 기한 비교
- 원문의 검사·호출: 현재 시간이 deadline보다 이른가결과와 범위: 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.
첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.
이제 소비자는 생산자가 재시도 사이에 잠금을 반납하므로 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());
}
}SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.
| 원문의 구간 | 소유 범위 | 수행하는 일 |
|---|---|---|
submit | this 모니터 안 | 큐에 항목을 추가합니다. |
claimOne | 같은 this 모니터 안 | poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다. |
slowSerialize | 모니터 밖 | 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다. |
submit- 소유 범위: this 모니터 안수행하는 일: 큐에 항목을 추가합니다.
claimOne- 소유 범위: 같은 this 모니터 안수행하는 일: poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.
slowSerialize- 소유 범위: 모니터 밖수행하는 일: 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.
같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.
직렬화 중에도 생산자는 두 번째 메시지를 넣을 수 있습니다.
claimOne이 반환하는 순간 첫 메시지는 한 소비자에게만 귀속됩니다.
처리 실패 때 재큐잉이 필요하다면 별도의 실패 정책과 시도 횟수를 추가해야 하며, 단순히 긴 작업을 다시 잠금 안으로 넣어 원자성을 흉내 내서는 안 됩니다.
정지 원인의 명시
synchronized는 스레드를 안전하게 해 주는 마법 문구가 아니라 한 번에 한 주체만 상태 전이를 수행하게 하는 문입니다.
문 안에서 조건이 저절로 바뀌기를 기다리면, 조건 변경자를 문 밖에 세워 두는 셈입니다.
어떤 코드가 잠금을 소유하고 어떤 코드가 조건을 바꾸는지 타임라인으로 그리면 이 오류를 빠르게 찾을 수 있습니다.
완성된 버퍼는 안전성, 진행성, 취소 가능성을 따로 확인합니다.
크기가 용량을 넘지 않는지, 제한 시간 안에 생산과 소비가 모두 끝나는지, 인터럽트가 작업 중단으로 이어지는지를 각각 관찰해야 합니다.
다음 절의 모니터 대기는 이 세 요구를 한 프로토콜 안에서 다룹니다.