잠든 스레드와 교착
`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();
}
}관찰 시점에 생산자는 대개 TIMED_WAITING, 소비자는 BLOCKED입니다.
생산자가 깨어나도 큐 크기는 그대로이므로 다시 잠듭니다.
소비자는 조건을 바꿀 유일한 주체지만 모니터 문턱을 넘지 못합니다.
인터럽트를 보내면 생산자가 잠금에서 빠져나오고 나서야 소비자가 실행됩니다.
이 종료 장치는 실패를 해결한 것이 아니라 실험을 끝내기 위한 안전핀입니다.
잠금의 목적과 조건 대기의 목적 분리
모니터는 크기 검사와 큐 변경을 하나의 원자적 구간으로 묶습니다. 반면 대기는 현재 스레드가 할 일이 없을 때 다른 주체가 조건을 바꾸도록 실행권을 넘기는 행위입니다. 두 목적을 섞어서 “잠금 안에서 오래 쉬면 안전하다”고 해석하면 안전성은 얻을지 몰라도 생존성을 잃습니다.
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());
}
}이제 소비자는 생산자가 재시도 사이에 잠금을 반납하므로 first를 제거할 수 있습니다.
생산자는 다음 시도에서 빈칸을 보고 second를 추가합니다.
정상 출력의 핵심은 정확한 줄 순서가 아니라 inserted=true, 제거된 두 값, 두 스레드의 종료입니다.
다만 0.2밀리초마다 조건을 확인하는 비용과 최대 추가 지연이 남아 있어 실무 기본 구현으로 채택할 이유는 약합니다.
테스트의 유한 대기 보장
생존성 오류를 다루는 테스트가 join()을 제한 없이 호출하면 실패했을 때 테스트 러너도 끝나지 않습니다.
다음 감시기는 제한 시간 후 살아 있는 스레드의 상태를 기록하고 인터럽트합니다.
종료 여부를 불리언으로 반환하므로 CI나 작은 실습에서도 실패를 판단할 수 있습니다.
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이 반환하는 순간 첫 메시지는 한 소비자에게만 귀속됩니다.
처리 실패 때 재큐잉이 필요하다면 별도의 실패 정책과 시도 횟수를 추가해야 하며, 단순히 긴 작업을 다시 잠금 안으로 넣어 원자성을 흉내 내서는 안 됩니다.
정지 원인의 명시
synchronized는 스레드를 안전하게 해 주는 마법 문구가 아니라 한 번에 한 주체만 상태 전이를 수행하게 하는 문입니다.
문 안에서 조건이 저절로 바뀌기를 기다리면, 조건 변경자를 문 밖에 세워 두는 셈입니다.
어떤 코드가 잠금을 소유하고 어떤 코드가 조건을 바꾸는지 타임라인으로 그리면 이 오류를 빠르게 찾을 수 있습니다.
완성된 버퍼는 안전성, 진행성, 취소 가능성을 따로 확인합니다. 크기가 용량을 넘지 않는지, 제한 시간 안에 생산과 소비가 모두 끝나는지, 인터럽트가 작업 중단으로 이어지는지를 각각 관찰해야 합니다. 다음 절의 모니터 대기는 이 세 요구를 한 프로토콜 안에서 다룹니다.