본문 바로가기

synchronized 안에서 sleep하면 멈추는 이유

synchronized 안에서 sleep으로 빈자리를 기다리는 버퍼가 멈추는 이유를 실행 결과와 스레드 덤프로 확인하고, 락을 놓고 기다리는 wait로 고치면서 wait를 while 안에서 불러야 하는 이유를 알아봅니다.

앞 글의 버퍼는 가득 차면 바로 FULL을 돌려주었습니다. 그런데 한 건도 잃으면 안 되는 상황이라면, 거절하기보다 생산자가 빈자리가 날 때까지 기다리게 하는 편이 낫습니다. 가장 먼저 떠오르는 방법은 synchronized 메서드 안에서 큐가 가득 찬 동안 Thread.sleep으로 조금씩 쉬면서 다시 확인하는 것입니다. 공유 큐는 락으로 보호되고 기다리는 동안 CPU도 쓰지 않으니 그럴듯해 보입니다.

하지만 이 버퍼는 영원히 멈춥니다. Thread.sleep은 실행을 잠시 멈출 뿐 스레드가 쥐고 있는 락을 놓지 않기 때문입니다. 생산자가 락을 쥔 채 잠들면 빈자리를 만들어 줄 소비자는 take에 들어가지 못하고, 생산자는 결코 오지 않을 빈자리를 기다리며 깨어났다 잠들기를 되풀이합니다. 이 글에서는 이 상황을 직접 재현해 스레드 덤프로 원인을 확인하고, 락을 놓고 기다리는 wait와 락 밖에서 다시 시도하는 방법으로 고쳐 봅니다.

빈자리를 기다리며 잠든 생산자

다음 프로그램의 버퍼는 용량이 1입니다. put은 큐가 가득 찬 동안 20밀리초씩 잠들며 다시 확인하고, take는 원소 하나를 꺼냅니다. 두 메서드 모두 synchronized이므로 같은 버퍼 객체의 락을 씁니다.

main이 하는 일을 순서대로 보겠습니다. 먼저 first를 넣어 버퍼를 가득 채운 뒤, stuck-producer라는 이름의 스레드에서 second를 넣게 합니다. 50밀리초 뒤에는 blocked-consumer라는 스레드에서 원소를 꺼내게 합니다. 그다음 두 스레드가 끝나기를 각각 최대 150밀리초씩 기다린 뒤 상태를 출력하고, 마지막으로 생산자에게 인터럽트를 걸어 프로그램이 끝날 수 있게 합니다.

bad/SleepWhileHoldingMonitor.java
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();
    }
}

두 줄은 join(150)으로 각각 최대 150밀리초를 기다린 뒤 읽은 두 스레드의 상태입니다. 둘 다 그 안에 일을 마쳤다면 TERMINATED가 나와야 합니다.

producer=TIMED_WAITING
consumer=BLOCKED

두 스레드 모두 150밀리초 안에 끝나지 않았습니다. 18장에서 본 것처럼 TIMED_WAITING은 시간 제한이 있는 대기 중이라는 뜻으로, 상태를 읽은 순간 생산자가 Thread.sleep(20) 안에 있었다는 것을 알려 줍니다. BLOCKED는 synchronized 메서드에 들어가려고 락을 기다리는 중이라는 뜻입니다. 즉 소비자는 take의 입구에서 생산자가 락을 놓기를 기다리고 있습니다.

이 환경에서 20번 실행했을 때 모두 같은 결과가 나왔지만, 이 결과가 보장되는 것은 아닙니다. main의 Thread.sleep(50)은 생산자가 먼저 락을 잡을 시간을 주려는 것일 뿐이라서, 컴퓨터가 몹시 바빠 생산자 스레드가 50밀리초 안에 put에 들어가지 못하면 소비자가 먼저 first를 꺼낼 수 있습니다. 그러면 생산자는 기다리지 않고 second를 넣으므로 두 줄 모두 TERMINATED가 나옵니다. 또 두 getState() 호출은 서로 다른 순간에 읽은 값이라서, 두 스레드의 상태를 한순간에 찍은 사진이 아닙니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 모니터를 쥔 채 sleep하는 조건에서 소비자는 BLOCKED, interrupt 뒤 해제 후 제거가 가능합니다. 스케줄의 가변성을 유지한 조건부 진행·락 해제 추적이 유용합니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.

보유 중 sleep · 진행 조건

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다.

보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자 take모니터 필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자take모니터필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
설명과 원래 예시 값

생산자

큐가 가득 찬 반복
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합니다.

  • 생산자 조회: TIMED_WAITING
  • 모니터: 계속 보유
  • sleep은 모니터를 반납하지 않습니다.
소비자 진입 대기

소비자는 take에 필요한 같은 모니터를 기다립니다.

  • 소비자 조회: BLOCKED
  • 큐 조건: 소비자가 변경 못 함
  • 보유 중인 생산자가 제거를 기다려도 소비자는 진입할 수 없습니다.
중단 뒤 반납

출력 뒤 interrupt가 sleep을 중단시키고 put이 반환합니다.

  • 생산자: 플래그 복구 뒤 반환
  • 소비자: 반납 뒤 first 제거 가능
  • 원 두 상태 조회는 원자적 스냅샷이 아니며 다른 획득 순서에서는 둘 다 종료할 수 있습니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 모니터를 쥔 채 sleep하는 조건에서 소비자는 BLOCKED, interrupt 뒤 해제 후 제거가 가능합니다. 스케줄의 가변성을 유지한 조건부 진행·락 해제 추적이 유용합니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.

보유 중 sleep · 진행 조건

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다.

보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자 take모니터 필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자take모니터필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
설명과 원래 예시 값

생산자

큐가 가득 찬 반복
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합니다.

  • 생산자 조회: TIMED_WAITING
  • 모니터: 계속 보유
  • sleep은 모니터를 반납하지 않습니다.
소비자 진입 대기

소비자는 take에 필요한 같은 모니터를 기다립니다.

  • 소비자 조회: BLOCKED
  • 큐 조건: 소비자가 변경 못 함
  • 보유 중인 생산자가 제거를 기다려도 소비자는 진입할 수 없습니다.
중단 뒤 반납

출력 뒤 interrupt가 sleep을 중단시키고 put이 반환합니다.

  • 생산자: 플래그 복구 뒤 반환
  • 소비자: 반납 뒤 first 제거 가능
  • 원 두 상태 조회는 원자적 스냅샷이 아니며 다른 획득 순서에서는 둘 다 종료할 수 있습니다.

마지막 줄의 producer.interrupt()는 문제를 고치는 코드가 아니라 실험을 끝내기 위한 장치입니다. 인터럽트를 받은 생산자는 sleep에서 InterruptedException을 받고, 인터럽트 상태를 되살린 뒤 second를 넣지 않은 채 put에서 돌아옵니다. 그제야 락이 풀려 소비자가 first를 꺼내고 두 스레드가 모두 끝납니다. 이 줄을 지우고 실행하면 상태 두 줄을 출력한 뒤 프로그램이 끝나지 않습니다. 두 스레드가 모두 데몬이 아닌 스레드라서 JVM이 이들이 끝나기를 계속 기다리기 때문입니다.

sleep은 락을 놓지 않는다

두 스레드가 왜 이렇게 되었는지 정리해 보겠습니다. 생산자는 '큐에 빈자리가 생긴다'는 조건을 기다리는데, 그 조건을 만들 수 있는 것은 소비자뿐입니다. 소비자는 '락이 풀린다'는 조건을 기다리는데, 락을 풀 수 있는 것은 생산자뿐입니다. 서로가 상대의 다음 행동을 기다리므로 아무도 앞으로 나아가지 못합니다.

원인은 Thread.sleep의 동작에 있습니다. Thread.sleep 문서는 잠든 스레드가 어떤 모니터의 소유권도 잃지 않는다고 명시합니다. 여기서 모니터(monitor)는 synchronized가 쓰는, 객체마다 하나씩 있는 락을 말합니다. 생산자는 put에 들어갈 때 버퍼 객체의 모니터를 얻었고, 반복문 안에서 몇 번을 잠들고 깨어나든 put을 벗어나기 전까지는 그 모니터를 놓지 않습니다.

이 상황은 교착 상태(deadlock)와 닮았습니다. 서로 상대를 기다리며 영원히 멈춘다는 결과는 같습니다. 하지만 흔히 말하는 교착 상태는 두 스레드가 락 두 개를 서로 반대 순서로 잡으려 할 때 생기는 반면, 여기에는 락이 하나뿐입니다. 조건을 바꿔 줄 스레드를 락 바깥에 세워 둔 채 락 안에서 조건이 바뀌기를 기다리면, 락이 하나여도 프로그램은 멈춥니다.

이 버퍼를 두 가지 성질로 나눠 보면 무엇이 잘못됐는지 더 분명해집니다. 안전성(safety)은 '나쁜 일이 일어나지 않는다'는 성질로, 이 버퍼는 크기가 용량 1을 넘은 적이 없으니 안전성은 지켰습니다. 생존성(liveness)은 '해야 할 일이 결국에는 일어난다'는 성질로, 이 버퍼는 생산도 소비도 끝내지 못하니 생존성을 잃었습니다. 동시성 코드를 확인할 때 결과 값이 맞는지뿐 아니라 정해진 시간 안에 모든 스레드가 끝나는지도 봐야 하는 이유입니다.

더 깊이: 스레드 덤프로 락을 쥔 스레드 찾기

getState()가 알려 주는 것은 각 스레드가 어떤 종류의 대기 중인지뿐입니다. 누가 어떤 락을 쥐고 있고 누가 그 락을 기다리는지까지 확인하려면 스레드 덤프(thread dump)를 봅니다. main의 producer.interrupt(); 줄을 지우고 실행해 프로그램을 멈춘 상태로 두고, 다른 터미널에서 jcmd를 인자 없이 실행해 프로세스 번호(PID)를 찾은 뒤 jcmd <PID> Thread.print를 실행하면 모든 스레드의 상태와 호출 스택이 출력됩니다. 다음은 JDK 21에서 얻은 덤프 가운데 두 스레드의 앞부분만 옮긴 것입니다.

"stuck-producer" #18 [3838] prio=5 os_prio=0 cpu=5.97ms elapsed=1.90s tid=0x00007fa7b463e9b0 nid=3838 waiting on condition  [0x00007fa798efe000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
	at java.lang.Thread.sleep0([email protected]/Native Method)
	at java.lang.Thread.sleep([email protected]/Thread.java:509)
	at SleepWhileHoldingMonitor.put(SleepWhileHoldingMonitor.java:10)
	- locked <0x00000000a8519868> (a SleepWhileHoldingMonitor)
	at SleepWhileHoldingMonitor.lambda$main$0(SleepWhileHoldingMonitor.java:26)

"blocked-consumer" #19 [3842] prio=5 os_prio=0 cpu=0.28ms elapsed=1.85s tid=0x00007fa7b463fd70 nid=3842 waiting for monitor entry  [0x00007fa798dfe000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at SleepWhileHoldingMonitor.take(SleepWhileHoldingMonitor.java:20)
	- waiting to lock <0x00000000a8519868> (a SleepWhileHoldingMonitor)

두 스레드에 나오는 락 주소 <0x00000000a8519868>이 같다는 점이 핵심입니다. 생산자는 put 안의 Thread.sleep에서 잠든 채(TIMED_WAITING (sleeping)) 버퍼 객체를 잠그고 있고(- locked), 소비자는 take에 들어가려고 같은 객체의 락을 기다립니다(- waiting to lock). 이렇게 락을 쥔 스레드와 기다리는 스레드를 같은 락으로 이어 봐야 '생산자가 락을 쥔 채 잠들어서 소비자가 막혔다'고 확정할 수 있습니다. 주소와 스레드 번호, 시간 값은 실행할 때마다 달라집니다.

JVM은 교착 상태를 찾아내면 덤프 끝에 Found one Java-level deadlock으로 시작하는 단락을 붙이는데, 이 프로그램의 덤프에는 그런 단락이 없습니다. JVM은 스레드들이 서로가 쥔 락을 기다리며 고리를 이룰 때만 교착 상태로 판단하는데, 생산자는 락을 기다리는 것이 아니라 잠들어 있을 뿐이기 때문입니다. 교착 탐지에 아무것도 나오지 않았다고 해서 멈춤 문제가 없다는 뜻은 아닙니다. 스레드 덤프를 읽는 방법은 BLOCKED와 WAITING 상태를 다루는 글에서 더 자세히 다룹니다.

락을 놓고 기다리는 wait로 고치기

문제는 기다리는 동안에도 락을 쥐고 있다는 것이므로, 기다리는 동안에는 락을 놓으면 됩니다. 모든 자바 객체가 가진 wait() 메서드가 바로 그 일을 합니다. wait()를 호출한 스레드는 그 객체의 락을 놓고 잠들었다가, 다른 스레드가 같은 객체에서 notify()나 notifyAll()을 호출하면 깨어납니다. 깨어난 스레드는 락을 다시 얻은 뒤에야 wait()에서 돌아옵니다.

다음 WaitInsteadOfSleep은 앞의 버퍼에서 두 곳을 바꾼 것입니다. put의 Thread.sleep(20)을 wait()로 바꾸고, take가 원소를 꺼낸 뒤 notifyAll()로 기다리는 스레드를 깨웁니다. wait()는 InterruptedException을 던질 수 있는데, 이것을 잡아서 삼키지 않고 put의 throws로 호출한 쪽에 넘겼습니다. 결과를 확인하려고 큐 내용을 보여 주는 toString()도 추가했습니다.

src/WaitInsteadOfSleep.java
import java.util.ArrayDeque;
import java.util.Queue;

public final class WaitInsteadOfSleep {
    private final Queue<String> queue = new ArrayDeque<>();

    public synchronized void put(String value) throws InterruptedException {
        while (queue.size() == 1) {
            wait();
        }
        queue.add(value);
    }

    public synchronized String take() {
        String value = queue.remove();
        notifyAll();
        return value;
    }

    @Override
    public synchronized String toString() {
        return queue.toString();
    }

    public static void main(String[] args) throws InterruptedException {
        WaitInsteadOfSleep buffer = new WaitInsteadOfSleep();
        buffer.put("first");
        Thread producer = Thread.ofPlatform().name("waiting-producer").start(() -> {
            try {
                buffer.put("second");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread.sleep(50);
        Thread consumer = Thread.ofPlatform().name("consumer").start(buffer::take);
        producer.join(150);
        consumer.join(150);
        System.out.println("producer=" + producer.getState());
        System.out.println("consumer=" + consumer.getState());
        System.out.println("queue=" + buffer);
    }
}

main의 흐름은 앞의 SleepWhileHoldingMonitor와 같고, 마지막에 큐 내용을 한 줄 더 출력합니다. 두 스레드의 상태를 앞의 결과와 비교해 보세요.

producer=TERMINATED
consumer=TERMINATED
queue=[second]

이번에는 두 스레드가 150밀리초 안에 모두 끝났고, 큐에는 생산자가 넣은 second가 들어 있습니다. 이 환경에서 30번 실행했을 때도 결과는 모두 같았습니다. 흐름을 순서대로 따라가면 다음과 같습니다.

순서생산자소비자큐
1put("second")에서 큐가 가득 찬 것을 보고 wait(), 락을 놓고 잠듦[first]
2잠든 채 기다림 (WAITING)take()에 들어가 락을 얻음[first]
3깨어나지만 락을 얻을 때까지 기다림first를 꺼내고 notifyAll() 호출[]
4take()를 마치며 락을 놓음[]
5락을 다시 얻고 while 조건을 다시 검사, 자리가 있으므로 second를 넣음[second]

3단계에서 소비자가 notifyAll()을 호출해도 생산자가 곧바로 실행되지는 않습니다. 소비자가 아직 락을 쥐고 있으므로, 생산자는 소비자가 take를 마치고 락을 놓은 뒤에야 wait()에서 돌아옵니다. 소비자를 시작하기 전에 producer.getState()를 출력해 보면 TIMED_WAITING이 아니라 WAITING이 나오는데, 시간 제한 없는 wait() 안에서 락을 놓고 기다리는 중이기 때문입니다.

앞의 예제처럼 소비자가 먼저 락을 잡는 실행도 가능하지만, 그 경우에도 출력은 같습니다. 소비자가 먼저 first를 꺼내면 생산자는 기다릴 필요 없이 바로 second를 넣기 때문입니다. 어느 쪽이 먼저 실행되든 first가 꺼내지고 second가 들어갑니다.

이 예제는 sleep과의 차이를 보여 주려고 생산자 쪽만 기다리게 만든 것입니다. 지금의 take는 큐가 비어 있으면 NoSuchElementException을 던지므로, 실제 버퍼라면 소비자도 큐가 비어 있는 동안 wait()로 기다리고 생산자도 값을 넣은 뒤 notifyAll()을 호출해야 합니다. 양쪽이 모두 기다리는 버퍼는 다음 글에서 만듭니다. wait()와 notifyAll()은 그 객체의 락을 쥔 상태, 즉 synchronized 안에서만 호출할 수 있다는 규칙도 그때 함께 다룹니다.

wait()는 왜 while 안에서 부를까

WaitInsteadOfSleep의 put은 wait()를 if가 아니라 while 안에서 부릅니다. 깨어났다고 해서 '이제 자리가 있다'는 뜻은 아니므로, 깨어난 뒤에 조건을 한 번 더 검사하기 위해서입니다. 깨어났는데도 조건이 여전히 거짓일 수 있는 경우는 세 가지입니다.

첫째, notifyAll()은 기다리는 스레드를 모두 깨웁니다. 생산자 둘이 빈자리를 기다리는데 소비자가 하나를 꺼내면 둘 다 깨어나지만 자리는 하나뿐입니다. 먼저 락을 얻은 생산자가 자리를 채우고 나면, 나중에 락을 얻은 생산자는 다시 가득 찬 큐를 만납니다. if였다면 이 생산자는 조건을 다시 보지 않고 다음 줄로 넘어가 용량을 넘겨서 넣었을 것입니다.

둘째, 깨어난 스레드가 락을 다시 얻기 전에 다른 스레드가 먼저 끼어들 수 있습니다. notify 문서는 깨어난 스레드가 락을 두고 다른 스레드와 평소처럼 경쟁하며, 다음에 락을 얻는 데 아무런 우선권도 불이익도 없다고 설명합니다. 그래서 기다리지 않고 막 도착한 다른 생산자가 먼저 락을 얻어 빈자리를 채울 수 있습니다.

셋째, 아무도 깨우지 않았는데 깨어날 수도 있습니다. 이를 가짜 깨움(spurious wakeup)이라고 합니다. Object.wait 문서는 스레드가 알림도, 인터럽트도, 시간 초과도 없이 깨어날 수 있다고 명시하고, 실제로는 드물지만 응용 프로그램이 대비해야 한다고 적고 있습니다. POSIX의 pthread_cond_wait처럼 운영체제가 제공하는 대기 기능에도 같은 단서가 붙어 있습니다. 조건 변수에서 깨어난 뒤 조건을 다시 확인하는 규칙이 생긴 배경은 운영체제 교재의 모니터와 고전적 동기화 문제에서 다룹니다.

세 경우 모두 while이면 문제가 없습니다. wait()에서 돌아올 때마다 조건을 다시 검사해서, 참일 때만 반복을 빠져나오고 거짓이면 다시 wait()로 돌아가기 때문입니다. 그래서 왜 깨어났는지는 따질 필요가 없습니다. 이 반복은 바쁜 대기도 아닙니다. 조건이 거짓이면 wait()가 락을 놓고 다시 잠들므로, 깨어날 일이 없는 동안에는 CPU를 쓰지 않습니다.

Object.wait 문서가 권하는 형태도 이와 같습니다. 일반적인 모양으로 쓰면 다음과 같습니다.

synchronized (lock) {
    while (!condition()) {   // 조건이 참이 될 때까지
        lock.wait();         // 락을 놓고 기다리다가, 깨어나면 다시 검사한다
    }
    // 여기서는 condition()이 참이고, 락도 쥐고 있다
}

그러고 보면 처음의 SleepWhileHoldingMonitor도 while을 쓰고 있었습니다. 문제는 반복문이 아니라, 반복문 안에서 락을 쥔 채 잠든 데 있었습니다. 여러 소비자가 함께 깨어날 때 if가 실제로 어떻게 틀리는지는 조건을 다시 검사하는 이유를 다루는 글에서 실행해 봅니다.

락 밖에서 다시 시도하는 방법

wait를 쓰지 않고 고치는 방법도 있습니다. 락을 쥔 채 기다리는 것이 문제였으니, 시도할 때만 락을 잡고 실패하면 락을 놓은 상태에서 잠깐 쉬었다가 다시 시도하는 것입니다. 실제로 쓰기에는 아쉬운 방법이지만, 멈춤의 원인이 '기다리는 동안 락을 쥐고 있었던 것'이라는 점을 확인하기에 좋습니다.

OutsideMonitorRetryBuffer의 tryPut과 tryTake는 한 번만 시도하고 결과를 바로 돌려줍니다. 다시 시도하는 반복은 synchronized가 아닌 putBefore에 있으므로, 시도와 시도 사이에는 락이 풀려 있어 소비자가 들어올 수 있습니다. putBefore는 정해진 시간 timeout 안에 넣으면 true를, 시간이 다 지나면 false를 돌려줍니다. main에서는 생산자가 1초 안에 second를 넣으려 하고, 소비자는 30밀리초 뒤에 first를 꺼냅니다.

src/OutsideMonitorRetryBuffer.java
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());
    }
}

removed= 줄은 소비자가, inserted= 줄은 생산자가 출력하고, 마지막 tail= 줄은 두 스레드가 끝난 뒤 main이 출력합니다. 생산자가 기다리는 동안에도 소비자가 버퍼에 들어올 수 있었는지가 이 출력으로 드러납니다.

removed=first
inserted=true
tail=second

0.2밀리초 간격으로 다시 시도하던 생산자는 소비자가 first를 꺼낸 뒤의 첫 시도에서 second를 넣고 true를 받습니다. 마지막 줄은 두 스레드가 끝난 뒤 main이 꺼낸 값입니다. 이 환경에서 50번 실행했을 때 모두 이 순서로 나왔지만, 앞의 두 줄은 순서가 바뀔 수 있습니다. 소비자가 first를 꺼내고 그 결과를 출력하기 전에, 생산자가 먼저 넣고 출력할 수도 있기 때문입니다.

드물지만 다른 결과도 가능합니다. 컴퓨터가 몹시 바빠 소비자가 1초 안에 first를 꺼내지 못하면 생산자는 inserted=false를 출력하고, 큐가 빈 채로 끝나므로 main의 마지막 orElseThrow()에서 NoSuchElementException이 납니다. 또 마감 시각을 비교한 직후 스레드가 늦게 실행되면 마감이 조금 지난 뒤에 넣고 true를 돌려줄 수도 있으므로, true가 '정확히 1초 안에 넣었다'는 뜻은 아닙니다.

putBefore의 반복은 다음 순서로 진행됩니다. 먼저 tryPut으로 시도하고, 성공하면 바로 true를 돌려줍니다. 실패하면 Thread.interrupted()로 인터럽트 요청이 왔는지 확인해, 왔다면 InterruptedException을 던져 취소 요청을 따릅니다. 이 메서드는 확인과 동시에 인터럽트 상태를 지우므로 예외로 바꿔 알리는 것입니다(18장). 요청이 없으면 LockSupport.parkNanos로 약 0.2밀리초 쉰 뒤, 마감 시각 전이면 다시 시도하고 지났으면 false를 돌려줍니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

즉시 tryPut→실패 시 interrupted→모니터 밖 park→기한 비교의 실제 재시도 순서입니다. 첫 시도가 중단·기한 검사보다 앞섬을 단계로 보여 줍니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.

재시도 · 검사 순서

재시도는 시도·중단·대기·기한 순서로 검사한다

첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다.

즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그 제거예외 종료중단 falsepark 0.2ms기한 전?기한 종료false 반환
즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그제거예외 종료중단 falsepark0.2ms기한 전?기한 종료false 반환
설명과 원래 예시 값
  1. 1 · 즉시 시도

    원문의 검사·호출
    tryPut(value)
    결과와 범위
    검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
  2. 2 · 실패 뒤 중단 검사

    원문의 검사·호출
    Thread.interrupted()
    결과와 범위
    true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
  3. 3 · 모니터 밖 대기

    원문의 검사·호출
    parkNanos(200_000)
    결과와 범위
    소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
  4. 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이 모니터 안에서 검사·추가합니다.

  • 성공: 즉시 true · 뒤 검사 없음
  • 실패: 중단 조회로 진행
  • 순서: 중단·기한 검사보다 먼저
  • 첫 시도가 성공하면 뒤의 중단·대기·기한 검사를 실행하지 않습니다.
실패 뒤 중단 분기

실패한 시도 뒤 Thread.interrupted를 조회하여 종료 또는 진행을 정합니다.

  • 조회 true: 플래그 소거 · InterruptedException 종료
  • 조회 false: 대기·기한 비교로 진행
  • 진입 조건: tryPut 실패 뒤
  • 중단 조회 true의 경로는 예외로 끝나며 false인 경로만 뒤 대기에 도달합니다.
false 경로의 대기와 기한

중단 조회가 false일 때만 parkNanos(200000) 뒤 시간을 비교합니다.

  • 진입: tryPut 실패 · 중단 조회 false
  • 대기 요청: 0.2ms · 모니터 밖
  • 이른 시각 / 만료: 다음 시도 / false 반환
  • 기한 전 비교 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 완료 시각으로 해석하지 않습니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

즉시 tryPut→실패 시 interrupted→모니터 밖 park→기한 비교의 실제 재시도 순서입니다. 첫 시도가 중단·기한 검사보다 앞섬을 단계로 보여 줍니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.

재시도 · 검사 순서

재시도는 시도·중단·대기·기한 순서로 검사한다

첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다.

즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그 제거예외 종료중단 falsepark 0.2ms기한 전?기한 종료false 반환
즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그제거예외 종료중단 falsepark0.2ms기한 전?기한 종료false 반환
설명과 원래 예시 값
  1. 1 · 즉시 시도

    원문의 검사·호출
    tryPut(value)
    결과와 범위
    검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
  2. 2 · 실패 뒤 중단 검사

    원문의 검사·호출
    Thread.interrupted()
    결과와 범위
    true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
  3. 3 · 모니터 밖 대기

    원문의 검사·호출
    parkNanos(200_000)
    결과와 범위
    소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
  4. 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이 모니터 안에서 검사·추가합니다.

  • 성공: 즉시 true · 뒤 검사 없음
  • 실패: 중단 조회로 진행
  • 순서: 중단·기한 검사보다 먼저
  • 첫 시도가 성공하면 뒤의 중단·대기·기한 검사를 실행하지 않습니다.
실패 뒤 중단 분기

실패한 시도 뒤 Thread.interrupted를 조회하여 종료 또는 진행을 정합니다.

  • 조회 true: 플래그 소거 · InterruptedException 종료
  • 조회 false: 대기·기한 비교로 진행
  • 진입 조건: tryPut 실패 뒤
  • 중단 조회 true의 경로는 예외로 끝나며 false인 경로만 뒤 대기에 도달합니다.
false 경로의 대기와 기한

중단 조회가 false일 때만 parkNanos(200000) 뒤 시간을 비교합니다.

  • 진입: tryPut 실패 · 중단 조회 false
  • 대기 요청: 0.2ms · 모니터 밖
  • 이른 시각 / 만료: 다음 시도 / false 반환
  • 기한 전 비교 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 완료 시각으로 해석하지 않습니다.

이 방법은 멈추지는 않지만 한계가 분명합니다. 생산자는 자리가 언제 나는지 알지 못하고 0.2밀리초마다 확인할 뿐이라서, 자리가 난 뒤 다음 확인까지 지연이 생기고 자리가 없는 동안에도 계속 깨어나 시도합니다. 확인 간격을 줄이면 지연은 줄지만 헛된 깨어남과 CPU 사용이 늘고, 간격을 늘리면 그 반대가 됩니다. 반면 버퍼는 자리가 나는 정확한 순간, 즉 소비자가 원소를 꺼낸 순간을 알고 있습니다. 그 순간에 기다리는 스레드를 깨우는 wait·notifyAll 방식이 더 직접적인 이유입니다.

멈출 수 있는 코드는 제한 시간을 두고 시험한다

생존성 문제를 다루는 테스트에서 join()을 시간 제한 없이 호출하면, 버그가 있을 때 테스트 자체도 끝나지 않습니다. 실패를 알려 줘야 할 테스트가 멈춰 버리면 어디가 문제인지도 알 수 없습니다. 다음 awaitAll은 여러 스레드를 하나의 마감 시각까지만 기다리고, 그때까지 끝나지 않은 스레드의 이름과 상태를 표준 오류에 출력한 뒤 인터럽트를 겁니다.

src/ThreadCompletionProbe.java
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)));
    }
}

main의 작업은 20밀리초 동안 잠들 뿐이므로 1초 안에 끝나고, 실행하면 다음 한 줄이 나옵니다. 끝나지 않은 스레드가 없으니 awaitAll이 표준 오류로 내보내는 줄도 없습니다.

completed=true

마감 시각을 하나만 정해 두고 스레드마다 남은 시간만큼만 기다리는 점을 보세요. 스레드마다 1초씩 기다리면 스레드가 열 개일 때 최대 10초를 기다리게 됩니다. join(Duration)은 JDK 19에 추가된 메서드로, 최대 주어진 시간까지 기다리다가 그 안에 스레드가 끝나면 바로 돌아옵니다. 돌려주는 값은 그 스레드가 끝났으면 true, 아직 살아 있으면 false입니다. awaitAll은 이 값을 쓰지 않고, 반복을 마친 뒤 isAlive()로 모든 스레드를 다시 확인해 그 순간 모두 끝났는지를 돌려줍니다. 또 인터럽트를 건 뒤에 스레드가 실제로 끝나기를 기다리지는 않습니다.

이 감시기는 멈춘 스레드에 써 봐야 쓸모가 드러납니다. 앞의 SleepWhileHoldingMonitor에서 main의 producer.join(150);부터 마지막 줄까지를 다음 두 줄로 바꾸고, 파일 맨 위에 import java.time.Duration;과 import java.util.List;를 추가합니다.

boolean completed = ThreadCompletionProbe.awaitAll(List.of(producer, consumer), Duration.ofMillis(300));
System.out.println("completed=" + completed);

그다음 ThreadCompletionProbe.java와 함께 컴파일해 실행합니다. 바꾼 main이 ThreadCompletionProbe.awaitAll을 호출하므로 javac에는 두 소스를 함께 넘기고, 실행하는 클래스는 main이 있는 SleepWhileHoldingMonitor입니다.

javac -d out src/ThreadCompletionProbe.java bad/SleepWhileHoldingMonitor.java
java -cp out SleepWhileHoldingMonitor
stuck-producer:TIMED_WAITING
blocked-consumer:BLOCKED
completed=false

300밀리초가 지나도 두 스레드가 살아 있으므로 처음 두 줄이 표준 오류에 찍히고, awaitAll은 false를 돌려줍니다. 감시기가 건 인터럽트로 생산자가 put에서 돌아오면 소비자도 first를 꺼내고 끝나므로, 프로그램도 멈추지 않고 끝납니다. 이렇게 하면 테스트는 멈추는 대신 실패로 끝나고, 어느 스레드가 어떤 상태였는지도 알 수 있습니다.

다만 상태는 원인을 짐작하게 하는 단서일 뿐입니다. BLOCKED는 락에 들어가려고 기다린다는 뜻이고 TIMED_WAITING은 시간 제한이 있는 대기 중이라는 뜻일 뿐, 누가 누구를 막고 있는지까지 알려 주지는 않습니다. 그것을 확정하려면 앞의 '더 깊이'에서처럼 스레드 덤프에서 락을 쥔 스레드와 기다리는 스레드를 이어 봐야 합니다.

무엇으로 기다릴지 고르기

지금까지 본 방법을 기다리는 동안 락이 어떻게 되는지를 기준으로 정리하면 다음과 같습니다.

상황기다리는 동안 락은알맞은 방법
공유 상태와 상관없는 지연(예: 재시도 간격)먼저 놓는다락 밖에서 sleep이나 parkNanos
공유 상태의 조건이 바뀔 때까지 기다림반드시 놓는다wait·notifyAll, 또는 Condition의 await·signal
한 번만 시도하고 결과를 바로 받음잡았다가 곧바로 놓는다tryPut처럼 결과를 돌려주는 메서드
정해진 시간 안에서만 기다림놓은 채 남은 시간을 계산한다시간 제한이 있는 offer·poll

Condition은 ReentrantLock과 함께 쓰는 대기 도구이고, 시간 제한이 있는 offer·poll은 BlockingQueue가 제공합니다. 어느 쪽이든 기다리는 동안 락을 놓는다는 점은 wait와 같습니다.

잠드는 시간을 줄이는 것은 해결책이 아닙니다. Thread.sleep(20)을 Thread.sleep(1)로 바꿔 실행해도 결과는 그대로 producer=TIMED_WAITING, consumer=BLOCKED입니다. 생산자가 더 자주 깨어날 뿐 깨어난 순간에도 락을 쥐고 있으므로, 소비자가 들어올 틈은 생기지 않습니다.

try-catch를 지우고 Thread.yield(); 한 줄로 바꿔도 마찬가지입니다. yield는 다른 스레드에 CPU를 양보할 수 있다는 힌트를 스케줄러에 줄 뿐 락을 놓지 않으므로, 이 경우 출력은 producer=RUNNABLE, consumer=BLOCKED입니다. 게다가 yield는 인터럽트에 반응하지 않기 때문에 마지막의 interrupt()로도 생산자를 멈출 수 없어서, 프로그램이 끝나지 않습니다.

락은 짧게 잡고 느린 일은 밖에서

이 글의 문제는 '락을 쥔 채 오래 머무르면 같은 락을 쓰는 다른 스레드가 모두 멈춘다'는 더 일반적인 규칙의 한 예입니다. 그래서 락은 공유 상태를 읽고 바꾸는 데 꼭 필요한 동안만 잡는 것이 원칙입니다. 락 안에서 파일을 쓰거나 네트워크를 호출하거나 일부러 지연을 두면, 큐와 아무 상관 없는 시간 동안 다른 스레드들이 줄줄이 기다려야 합니다. 조건 검사부터 상태 변경까지는 한 임계 영역에 묶되, 꺼낸 원소를 가공하는 느린 일은 락을 놓은 뒤에 합니다. 연습 문제 3에서 이 방법을 직접 적용해 봅니다.

정리

  • Thread.sleep은 스레드가 쥔 락을 놓지 않습니다. synchronized 안에서 조건이 바뀌기를 sleep으로 기다리면 조건을 바꿀 스레드가 들어오지 못해 프로그램이 멈춥니다. 잠드는 시간을 줄이거나 yield로 바꿔도 마찬가지입니다.
  • 이런 멈춤은 락이 하나뿐이어도 생기며, JVM의 교착 탐지에도 잡히지 않습니다. 스레드 덤프에서 locked와 waiting to lock이 가리키는 락을 이어 보면 원인을 확정할 수 있습니다.
  • 조건을 기다릴 때는 락을 놓고 기다리는 wait()를 쓰고, 조건을 바꾼 쪽은 notifyAll()로 알립니다.
  • wait()는 언제나 while 안에서 부릅니다. 함께 깨어난 다른 스레드가 먼저 자리를 가져갔거나, 다른 스레드가 끼어들었거나, 가짜 깨움이 일어났을 수 있으므로 깨어날 때마다 조건을 다시 검사해야 합니다.
  • 락 밖에서 다시 시도하는 방법도 멈추지는 않지만, 확인 간격만큼 지연과 헛된 깨어남이 생깁니다.
  • 멈출 수 있는 코드는 제한 시간을 두고 시험하고, 락은 공유 상태를 다루는 동안만 짧게 잡습니다.

연습 문제

  1. WaitInsteadOfSleep의 take에서 notifyAll(); 줄을 지우고 실행하면 어떤 결과가 나올지 예상해 보세요. 프로그램은 끝날까요?
  2. OutsideMonitorRetryBuffer.putBefore의 do-while을 다음처럼 조건을 먼저 검사하는 while로 바꿨습니다. 큐에 자리가 있는데 Duration.ZERO를 넘기면 원래 코드와 결과가 어떻게 달라지나요?
while (System.nanoTime() < deadline) {
    if (tryPut(value)) return true;
    if (Thread.interrupted()) throw new InterruptedException();
    LockSupport.parkNanos(200_000);
}
return false;
  1. 큐에서 메시지를 꺼내 JSON 문자열로 바꾸는 소비자가 있습니다. 지금 코드는 synchronized 메서드 drainOne() 안에서 40밀리초가 걸리는 변환까지 하므로, 그동안 생산자가 새 메시지를 넣지 못합니다. 큐에서 꺼내는 일만 락으로 보호하고 변환은 락 밖에서 하도록 고치세요. 소비자 두 명이 동시에 drainOne()을 호출해도 같은 메시지를 두 번 처리하지 않는 이유도 설명하세요.
정답과 해설

1번. 출력은 다음과 같고, 프로그램은 끝나지 않습니다.

producer=WAITING
consumer=TERMINATED
queue=[]

소비자는 first를 꺼내고 끝났지만 아무도 생산자를 깨우지 않았기 때문에, 생산자는 큐에 자리가 생긴 것을 모른 채 wait() 안에서 계속 기다립니다. sleep 버전과 달리 락을 놓았으므로 소비자는 진행할 수 있었지만, 이번에는 알림이 빠져서 생산자가 멈췄습니다. wait는 조건을 바꾼 쪽이 반드시 알림을 보내야만 동작합니다. 그래서 조건을 바꾸는 모든 곳에서 notifyAll()을 빠뜨리지 않았는지 확인해야 합니다.

2번. 원래 코드는 do-while이라 조건을 검사하기 전에 반복 본문을 한 번 실행하므로, 자리가 있으면 첫 tryPut에서 넣고 true를 돌려줍니다. 바꾼 코드는 반복에 들어가기 전에 조건부터 검사하는데, Duration.ZERO면 마감 시각이 바로 지금이므로 System.nanoTime() < deadline이 처음부터 거짓입니다. 그래서 한 번도 시도하지 않고 false를 돌려주는데, 자리가 있는데도 '시간 안에 넣지 못했다'고 답하는 셈입니다. 실제로 두 버전에 Duration.ZERO를 넘겨 보면 원래 코드는 true, 바꾼 코드는 false를 돌려줍니다. BlockingQueue의 시간 제한 offer도 시간이 0이면 기다리지는 않지만 자리가 있으면 넣으므로, 제한 시간이 0이어도 최소 한 번은 시도하는 쪽이 자연스럽습니다.

3번. 락 안에서는 큐에서 메시지를 꺼내는 일만 하고, 느린 변환은 꺼낸 뒤 락 밖에서 합니다. 아래 claimOne은 synchronized 메서드로 poll을 호출해 메시지 하나를 꺼내고, drainOne은 락 없이 claimOne을 부른 뒤 결과를 slowSerialize로 변환합니다. 큐가 비었으면 Optional.empty()를 돌려줘 꺼낼 것이 없다는 것을 바로 알립니다.

exercise/SerializeOutsideLockSolution.java
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());
    }
}

실행 결과:

{"id":7,"body":"ready"}
true

첫 줄은 소비자 스레드가 메시지 7을 꺼내 변환한 결과입니다. 소비자가 40밀리초 동안 변환하는 사이에도 락은 풀려 있으므로, 생산자는 기다리지 않고 메시지 8을 넣을 수 있습니다. 두 스레드가 끝난 뒤 main이 drainOne()을 한 번 더 부르면 메시지 8이 나오므로 둘째 줄은 true입니다. 메시지 7은 스레드를 시작하기 전에 넣었으므로 소비자는 언제나 7을 먼저 꺼내고, main은 두 스레드가 끝난 뒤에 8을 꺼내므로 이 출력은 실행 순서와 상관없이 같습니다.

두 소비자가 같은 메시지를 처리하지 않는 이유는 poll이 락 안에서 실행되기 때문입니다. 한 소비자가 claimOne으로 메시지를 꺼내는 동안 다른 소비자는 같은 락을 기다려야 하고, 차례가 왔을 때 그 메시지는 이미 큐에 없습니다. 꺼낸 메시지는 그 소비자의 지역 변수에만 있으므로 이후의 변환에는 락이 필요 없습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

잠금 안 claimOne이 Message를 한 호출자에게 넘긴 뒤 잠금 밖 slowSerialize를 합니다. 모니터 획득·소유 이전·해제 후 변환을 순서대로 구분해 모니터 범위를 확인할 수 있습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.

모니터 범위 · 항목 소유

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다.

모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐MessageclaimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐큐 항목claimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
설명과 원래 예시 값

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를 큐에 추가합니다.

  • 소유: 큐 항목
  • 잠금: this
  • 같은 참조를 여러 번 등록하면 항목도 여러 개입니다.
항목 인수

claimOne이 같은 모니터 안에서 poll합니다.

  • 큐: 항목 하나 제거
  • 소유: 한 호출자가 Message 인수
  • 보호된 제거가 두 호출자에게 같은 큐 항목을 넘기는 것을 막습니다.
느린 변환

모니터 밖에서 sleep과 문자열 결합을 수행합니다.

  • 잠금: 미보유
  • 출력 한계: 따옴표 이스케이프 없음
  • 느린 작업 동안 다른 호출자가 큐 모니터에 진입할 수 있습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

잠금 안 claimOne이 Message를 한 호출자에게 넘긴 뒤 잠금 밖 slowSerialize를 합니다. 모니터 획득·소유 이전·해제 후 변환을 순서대로 구분해 모니터 범위를 확인할 수 있습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.

모니터 범위 · 항목 소유

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다.

모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐MessageclaimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐큐 항목claimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
설명과 원래 예시 값

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를 큐에 추가합니다.

  • 소유: 큐 항목
  • 잠금: this
  • 같은 참조를 여러 번 등록하면 항목도 여러 개입니다.
항목 인수

claimOne이 같은 모니터 안에서 poll합니다.

  • 큐: 항목 하나 제거
  • 소유: 한 호출자가 Message 인수
  • 보호된 제거가 두 호출자에게 같은 큐 항목을 넘기는 것을 막습니다.
느린 변환

모니터 밖에서 sleep과 문자열 결합을 수행합니다.

  • 잠금: 미보유
  • 출력 한계: 따옴표 이스케이프 없음
  • 느린 작업 동안 다른 호출자가 큐 모니터에 진입할 수 있습니다.

다만 여기서 한 소비자에게만 넘어가는 단위는 큐에 넣은 항목 하나입니다. 같은 Message나 같은 ID를 submit으로 두 번 넣으면 항목이 두 개가 되어 서로 다른 소비자가 하나씩 꺼낼 수 있으므로, 업무 ID 기준의 중복 제거는 따로 구현해야 합니다. 또 꺼낸 뒤 변환이 실패하면 원래 항목은 이미 큐에서 사라졌으므로, 다시 넣을지, 따로 보관할지, 버릴지를 정해 두어야 합니다. 실패가 걱정된다고 느린 변환을 다시 락 안으로 넣으면 이 연습에서 풀려던 문제가 되돌아옵니다.

slowSerialize의 Thread.sleep(40)은 느린 변환을 흉내 낸 것이고, 정확히 40밀리초를 보장하지는 않습니다. 문자열을 직접 이어 붙인 JSON도 ready, next처럼 따옴표나 역슬래시가 없는 값에서만 올바르므로, 임의의 문자열을 다룬다면 이스케이프를 처리해 주는 JSON 라이브러리를 써야 합니다.

다음 글

다음 글에서는 이 글에서 처음 써 본 wait와 notifyAll을 자세히 살펴봅니다. 락을 쥐지 않고 wait를 호출하면 무슨 일이 생기는지, 깨어난 스레드가 언제 다시 실행되는지 확인하고, 생산자와 소비자가 모두 기다리는 한 칸짜리 버퍼를 만듭니다.