안동민 개발노트

본문 시작

interrupt 플래그와 취소

interrupted와 isInterrupted의 차이, InterruptedException의 플래그 소거, 큐 기반 printer 취소를 실행으로 확인합니다.

interrupt는 Thread 안에 있는 상태와 중단 가능 API의 예외 경로가 함께 만드는 프로토콜입니다.

플래그를 읽는 메서드마다 소거 여부가 다르고, sleep·wait·join이 중단되어 던지는 InterruptedException도 플래그를 지웁니다.

이 규칙을 모르면 한 계층에서 취소 요청을 소비해 상위 코드가 계속 일하게 됩니다.


interrupted 플래그 소비

정적 Thread.interrupted()는 current 스레드의 플래그를 반환한 뒤 false로 지웁니다.

아래 프로그램은 자기 자신을 interrupt하고 두 번 읽으므로 실제 출력은 first=true, second=false입니다.

두 번째도 true일 것이라는 기대가 잘못됐습니다.

lab/InterruptedFlagClearedBug.java
public final class InterruptedFlagClearedBug {
    public static void main(String[] args) {
        Thread.currentThread().interrupt();
        boolean first = Thread.interrupted();
        boolean second = Thread.interrupted();
        System.out.println("first=" + first + ", second=" + second);
        System.out.println("wrong-assumption=" + (first == second));
    }
}

반면 current.isInterrupted()는 지정한 Thread의 플래그를 지우지 않고 조회합니다.

반복문 조건에서 상태를 유지하며 보고 싶으면 인스턴스 메서드가 맞고, 현재 계층에서 요청을 소비하기로 했다면 static 메서드의 소거 의미를 의도적으로 사용할 수 있습니다.


interrupt 요청이 전달되는 세 경로

RUNNABLE 계산 스레드에 interrupt하면 플래그가 true가 되고 코드는 확인할 때까지 계속 실행합니다.

sleep·wait·join 중이면 대기가 풀리고 InterruptedException이 발생하며 플래그는 false로 정리됩니다.

이미 플래그가 true인 상태에서 중단 가능 블로킹 호출로 들어가도 즉시 예외가 날 수 있습니다.

catch에서 작업을 끝내고 반환한다면 플래그 복원이 반드시 필요한 것은 아닙니다.

하지만 라이브러리 계층이 정리만 하고 예외를 던질 수 없어서 반환한다면 Thread.currentThread().interrupt()로 상위에 신호를 보존하는 관례가 중요합니다.

아무 처리 없이 catch를 비우는 것이 가장 위험합니다.

src/InterruptedExceptionRestoration.java
public final class InterruptedExceptionRestoration {
    public static void main(String[] args) throws InterruptedException {
        Thread worker =
                new Thread(
                        () -> {
                            boolean completed = waitForInput();
                            System.out.println(
                                    "completed="
                                            + completed
                                            + ", restored="
                                            + Thread.currentThread().isInterrupted());
                        },
                        "restore-worker");
        worker.start();
        Thread.sleep(10);
        worker.interrupt();
        worker.join();
    }

    private static boolean waitForInput() {
        try {
            Thread.sleep(5_000);
            return true;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }
}
플래그 조회·소거·복원은 서로 다른 동작이다

자기 interrupt를 두 번 소비하는 원문과 InterruptedException 처리 예제의 조건을 구분합니다.

플래그 조회·소거·복원은 서로 다른 동작이다
동작플래그에 미치는 영향이 문서의 근거
t.isInterrupted()지정한 스레드의 값을 지우지 않고 조회API 규칙 · 상대 스레드를 종료시키지 않음
Thread.interrupted() 두 번현재 플래그를 읽고 매번 지움자기 interrupt 예제: first=true, second=false
sleep 대기에서 중단 예외InterruptedException과 함께 플래그 소거예외를 처리하는 경로의 규칙
catch에서 다시 interrupt()현재 스레드의 플래그를 복원대기 중 중단된 경우 completed=false, restored=true
t.isInterrupted()
플래그에 미치는 영향: 지정한 스레드의 값을 지우지 않고 조회
이 문서의 근거: API 규칙 · 상대 스레드를 종료시키지 않음
Thread.interrupted() 두 번
플래그에 미치는 영향: 현재 플래그를 읽고 매번 지움
이 문서의 근거: 자기 interrupt 예제: first=true, second=false
sleep 대기에서 중단 예외
플래그에 미치는 영향: InterruptedException과 함께 플래그 소거
이 문서의 근거: 예외를 처리하는 경로의 규칙
catch에서 다시 interrupt()
플래그에 미치는 영향: 현재 스레드의 플래그를 복원
이 문서의 근거: 대기 중 중단된 경우 completed=false, restored=true

복원 예제의 main sleep 10ms는 작업자 sleep 5000ms와의 순서를 강제하지 않습니다. 작업자가 먼저 정상 대기를 끝내면 completed=true도 가능하며, restored 값도 interrupt 시점에 따라 달라집니다. 첫 자기 interrupt 예제의 두 값은 고정됩니다.

도우미가 검사 예외를 시그니처에 올릴 수 있다면 catch하지 않고 호출자에게 전달하는 쪽이 더 단순할 수 있습니다.


블로킹 큐의 interrupt 대기

printer가 큐를 계속 확인하며 비어 있으면 sleep하는 구조는 지연과 불필요한 wake-up을 만듭니다.

이 예제의 LinkedBlockingQueue.take()는 항목이 없으면 기다리고 interrupt에 응답합니다.

생산자는 put 또는 offer로 작업을 전달합니다.

src/InterruptiblePrinter.java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public final class InterruptiblePrinter {
    private final BlockingQueue<String> jobs = new LinkedBlockingQueue<>();
    private final Thread worker = new Thread(this::loop, "printer-worker");

    public void start() {
        worker.start();
    }

    public void submit(String document) {
        jobs.add(document);
    }

    public void stop() throws InterruptedException {
        worker.interrupt();
        worker.join();
    }

    private void loop() {
        try {
            while (true) {
                String job = jobs.take();
                System.out.println("printed=" + job);
            }
        } catch (InterruptedException e) {
            System.out.println("printer-cancelled pending=" + jobs.size());
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        InterruptiblePrinter printer = new InterruptiblePrinter();
        printer.start();
        printer.submit("post-18");
        while (!printer.jobs.isEmpty()) {
            Thread.onSpinWait();
        }
        printer.stop();
    }
}
큐가 빈 시점과 출력이 끝난 시점은 다르다

InterruptiblePrinter의 post-18 하나를 넣고 큐가 빈 것을 확인한 뒤 interrupt와 join하는 실제 흐름을 읽습니다.

큐가 빈 시점과 출력이 끝난 시점은 다르다
원문의 지점확인된 사실아직 뜻하지 않는 것
submit("post-18")항목 하나를 큐에 넣음출력 완료
jobs.isEmpty() 확인소비자가 항목을 꺼냄콘솔 출력 완료
interrupt() 요청다음 중단 가능한 대기에 취소를 전달요청 즉시 작업자 종료
join() 반환작업자 종료까지 회수새 submit을 거부하는 수용 정책
submit("post-18")
확인된 사실: 항목 하나를 큐에 넣음
아직 뜻하지 않는 것: 출력 완료
jobs.isEmpty() 확인
확인된 사실: 소비자가 항목을 꺼냄
아직 뜻하지 않는 것: 콘솔 출력 완료
interrupt() 요청
확인된 사실: 다음 중단 가능한 대기에 취소를 전달
아직 뜻하지 않는 것: 요청 즉시 작업자 종료
join() 반환
확인된 사실: 작업자 종료까지 회수
아직 뜻하지 않는 것: 새 submit을 거부하는 수용 정책

이 원문은 printed=post-18 다음 printer-cancelled pending=0을 남깁니다. 꺼낸 항목의 콘솔 로그 뒤 다음 take가 중단됩니다. 큐 확인 반복에는 자체 시간 제한이 없고, drain·running 플래그·Outcome은 이 클래스에 없습니다.

interrupt 순간 큐에 남은 작업을 폐기할지 저장할지 재처리할지는 기능 정책입니다.


interrupt와 정상 완료 구분

취소는 사용자 요청, 기한 만료, 프로세스 shutdown 등 원인이 다를 수 있습니다.

모든 경우를 false로 반환하면 재시도 여부를 정할 수 없습니다.

완료·취소·실패를 separate 결과로 표현하고 예외 원인을 보존합니다.

interrupt를 업무 오류로 로깅할 때 스택 추적을 매번 오류 수준으로 남기면 정상 shutdown이 장애처럼 보입니다.

요청된 shutdown인지 예상 밖 interrupt인지 소유자 상태와 비교합니다.

반대로 아무 로그도 없이 종료하면 누락 작업을 찾기 어렵습니다.

app/CancellableDocumentConsumer.java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public final class CancellableDocumentConsumer {
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
    private final List<String> completed = new ArrayList<>();
    private final Thread thread = new Thread(this::consume, "document-consumer");

    public void start() {
        thread.start();
    }

    public void add(String value) {
        queue.add(value);
    }

    public void cancel() throws InterruptedException {
        thread.interrupt();
        thread.join();
    }

    private void consume() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                String value = queue.take();
                synchronized (completed) {
                    completed.add(value);
                }
                System.out.println("consumed=" + value);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public List<String> completed() {
        synchronized (completed) {
            return List.copyOf(completed);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CancellableDocumentConsumer c = new CancellableDocumentConsumer();
        c.start();
        c.add("thread");
        while (c.completed().isEmpty()) {
            Thread.onSpinWait();
        }
        c.cancel();
        System.out.println("snapshot=" + c.completed());
    }
}

완료 목록을 락 아래 갱신하고 스냅샷으로 반환합니다.

취소가 join까지 수행하므로 호출 이후에는 새 완료가 추가되지 않습니다.

큐에 남은 입력은 별도 보상 처리 대상으로 볼 수 있습니다.


중첩 메서드의 interrupt 책임

가장 안쪽 블로킹 메서드가 정책을 모두 결정하면 재사용이 어렵습니다.

가능하면 InterruptedException을 올려 소유자 반복문이 종료·재시도·무시 중 하나를 택하게 합니다.

Runnable.run처럼 검사 예외를 선언할 수 없는 최상위 접점에서 catch하고 최종 결과를 기록합니다.

InterruptedException을 RuntimeException으로 감쌀 때도 원 원인과 플래그 정책을 문서화합니다.

실행기 Future는 interrupt 요청과 작업 결과를 별도 API로 표현하므로 직접 Thread보다 선택지가 선명합니다.


연습 문제

작업자가 20ms마다 heartbeat 번호를 올리며 sleep하게 만드세요.

main은 첫 heartbeat를 확인한 뒤 interrupt하고 join합니다.

작업자는 catch에서 플래그를 복원한 후 종료 이유를 기록해야 합니다.

정답과 경합 방지

래치로 첫 heartbeat 증가를 확인한 뒤 interrupt를 요청합니다.

heartbeat 증가 횟수는 스케줄러에 따라 달라 규칙에 넣지 않습니다.

exercise/InterruptibleHeartbeatSolution.java
import java.util.concurrent.CountDownLatch;

public final class InterruptibleHeartbeatSolution {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch first = new CountDownLatch(1);
        int[] beats = {0};
        Thread worker =
                new Thread(
                        () -> {
                            try {
                                while (true) {
                                    beats[0]++;
                                    first.countDown();
                                    Thread.sleep(20);
                                }
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                                System.out.println(
                                        "reason=interrupt, flag="
                                                + Thread.currentThread().isInterrupted());
                            }
                        },
                        "heartbeat");
        worker.start();
        first.await();
        worker.interrupt();
        worker.join();
        System.out.println("terminated=" + !worker.isAlive() + ", beats=" + beats[0]);
    }
}

reason의 플래그는 true이고 terminated도 true입니다.

beats는 1 이상이라는 조건만 안정적입니다.

interrupt를 올바르게 쓰려면 플래그를 지우는 연산, 블로킹 예외 경로, 복원할 계층, 남은 업무 정책을 함께 정해야 합니다.

단순히 interrupt() 한 줄을 호출하는 것으로 shutdown이 완성되지 않습니다.