본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
18장 : 스레드 생성·제어·생명 주기

시간 제한 join과 interrupt

시간 제한 `join`이 완료를 보장하지 않는 실패를 확인하고 기한, 결과 폐기, `interrupt` 요청을 조합합니다.

인자 없는 join은 대상이 끝날 때까지 기다리지만 join(millis)는 최대 대기 시간만 제한합니다. 시간이 지나 정상 반환해도 작업자가 종료됐다는 뜻은 아닙니다. 반환 직후 isAlive()와 결과 상태를 확인하고, 시간 제한이면 계속 기다릴지 interrupt할지 결과를 버릴지 결정해야 합니다.

시간 제한 join의 성공 오판

작업자는 게이트가 열릴 때까지 기다립니다. main은 20ms 시간 제한 join 뒤 결과를 읽으므로 실제로 wrong-complete=false, result=null이 출력됩니다. join 메서드가 예외 없이 돌아왔다는 사실을 업무 완료로 오해한 것입니다.

lab/TimedJoinCompletionBug.java
import java.util.concurrent.CountDownLatch;

public final class TimedJoinCompletionBug {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch gate = new CountDownLatch(1);
        String[] result = new String[1];
        Thread worker =
                new Thread(
                        () -> {
                            try {
                                gate.await();
                                result[0] = "ready";
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                        },
                        "delayed-result");
        worker.start();
        worker.join(20);
        System.out.println("wrong-complete=" + !worker.isAlive() + ", result=" + result[0]);
        gate.countDown();
        worker.join();
        System.out.println("recovered=" + result[0]);
    }
}

첫 단계의 null은 가시성 문제를 volatile로 덮을 상황이 아닙니다. write가 아직 실행되지 않았습니다. 시간 제한 join 뒤 생존이면 결과 컨테이너를 읽지 않고 시간 제한 결과를 만들어야 합니다.

시간 제한 join의 상태 해석

join(0)은 즉시 반환이 아니라 무기한 join과 같습니다. 양수 millis를 주면 호출자는 최대 그 시간 동안 TIMED_WAITING이 될 수 있습니다. 대상이 먼저 끝나면 일찍 깨어납니다. 허용 범위 밖 nanos나 음수 millis는 예외가 될 수 있으므로 기간 입력을 확인합니다.

시간 제한은 작업자 중단 명령이 아닙니다. main이 반환된 뒤에도 대상은 RUNNABLE, WAITING, BLOCKED 가운데 하나일 수 있습니다. 따라서 백그라운드에서 계속 작업해도 되는지, 공유 자원에 늦은 write를 해도 되는지 명시합니다.

여러 작업자 각각에 같은 시간 제한을 주면 총 대기가 작업자 수만큼 늘어납니다. 전체 예산 100ms라면 기한을 하나 정하고 join마다 남은 시간을 계산합니다.

src/DeadlineJoinGroup.java
import java.time.Duration;

public final class DeadlineJoinGroup {
    public static void main(String[] args) throws InterruptedException {
        Thread[] workers = {sleeping("fast", 10), sleeping("medium", 25), sleeping("slow", 200)};
        for (Thread worker : workers) {
            worker.start();
        }
        boolean complete = joinUntil(workers, Duration.ofMillis(80));
        System.out.println("all-within-budget=" + complete);
        for (Thread worker : workers) {
            if (worker.isAlive()) worker.interrupt();
        }
        for (Thread worker : workers) {
            worker.join();
        }
    }

    private static Thread sleeping(String name, long millis) {
        return new Thread(
                () -> {
                    try {
                        Thread.sleep(millis);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                },
                name);
    }

    private static boolean joinUntil(Thread[] workers, Duration budget)
            throws InterruptedException {
        long deadline = System.nanoTime() + budget.toNanos();
        for (Thread worker : workers) {
            long remaining = deadline - System.nanoTime();
            if (remaining <= 0) return false;
            worker.join(Math.max(1, remaining / 1_000_000));
            if (worker.isAlive()) return false;
        }
        return true;
    }
}

slow 작업자 때문에 false가 출력되고 정리에서 interrupt 후 모든 작업자를 회수합니다. millisecond 절삭으로 0을 전달하면 무한 대기가 되므로 최소 1ms로 올렸습니다. 더 정밀한 API가 필요하면 Future의 시간 제한 get이나 structured 동시성 정책을 검토합니다.

interrupt의 협력적 취소

worker.interrupt()는 대상의 interrupt 상태를 설정합니다. sleep, wait, join 같은 중단 가능 블로킹 연산에서 대기 중이면 InterruptedException을 발생시켜 일찍 깨웁니다. 일반 계산 반복문은 플래그를 확인하고 스스로 빠져나와야 합니다.

Thread.stop처럼 임의 지점에서 강제로 죽이면 불변식이 깨진 채 락이나 상태가 남을 수 있습니다. interrupt를 받는 코드가 임시 파일 삭제, 트랜잭션 롤백, 큐 상태 정리 후 반환하도록 프로토콜을 구성합니다. interrupt가 업무 실패인지 정상 취소인지도 결과에서 구분합니다.

src/InterruptibleCounter.java
import java.util.concurrent.CountDownLatch;

public final class InterruptibleCounter {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch started = new CountDownLatch(1);
        long[] count = new long[1];
        Thread worker =
                new Thread(
                        () -> {
                            started.countDown();
                            long local = 0;
                            while (!Thread.currentThread().isInterrupted()) {
                                local++;
                            }
                            count[0] = local;
                        },
                        "count-until-cancel");
        worker.start();
        started.await();
        Thread.sleep(5);
        worker.interrupt();
        worker.join();
        System.out.println(
                "terminated=" + !worker.isAlive() + ", count-positive=" + (count[0] > 0));
    }
}

반복문은 플래그를 소비하지 않는 isInterrupted()로 관찰합니다. mainjoin한 뒤 count를 읽어 write 완료를 보장합니다. 실제 반복 횟수는 장비마다 달라 검증에 사용하지 않습니다.

시간 제한 이후 부수 효과별 선택

읽기 전용 계산은 늦은 결과를 버리고 백그라운드 완료를 허용할 수 있습니다. 외부 결제나 파일 갱신은 호출자가 시간 제한됐어도 이미 커밋됐을 수 있어 단순 재시도가 중복 부작용을 만듭니다. 멱등성 키와 상태 조회가 필요합니다.

취소 가능한 작업은 interrupt에 응답하는 모든 블로킹 API를 사용하고 계산 구간도 주기적으로 플래그를 봅니다. 취소 불가능한 네이티브 호출이나 외부 서비스 대기는 별도 프로세스 격리, 소켓 시간 제한, circuit breaker를 검토합니다. join 시간 제한 하나로 하위 수집기 시간 제한이 생기지는 않습니다.

완료 결과를 나타내는 Outcome

다음 작은 실행기는 시간 안에 끝나면 DONE, 살아 있으면 interrupt 후 TIMED_OUT, 기다리는 호출자 자신이 중단되면 CALLER_INTERRUPTED를 반환합니다. 문자열 결과를 읽는 시점은 DONE일 때뿐입니다.

app/TimedDocumentTask.java
public final class TimedDocumentTask {
    private enum Status {
        DONE,
        TIMED_OUT,
        CALLER_INTERRUPTED
    }

    private record Outcome(Status status, String value) {}

    public static void main(String[] args) {
        System.out.println(run(() -> "indexed", 100));
        System.out.println(
                run(
                        () -> {
                            try {
                                Thread.sleep(200);
                            } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                            }
                            return "late";
                        },
                        10));
    }

    private static Outcome run(Task task, long timeoutMillis) {
        String[] value = new String[1];
        Thread worker = new Thread(() -> value[0] = task.execute(), "timed-document");
        worker.start();
        try {
            worker.join(timeoutMillis);
            if (!worker.isAlive()) return new Outcome(Status.DONE, value[0]);
            worker.interrupt();
            worker.join();
            return new Outcome(Status.TIMED_OUT, null);
        } catch (InterruptedException e) {
            worker.interrupt();
            Thread.currentThread().interrupt();
            return new Outcome(Status.CALLER_INTERRUPTED, null);
        }
    }

    @FunctionalInterface
    private interface Task {
        String execute();
    }
}

제품 구현에서는 예외 결과도 분리하고 스레드를 매 호출마다 직접 만들지 않습니다. 여기서는 join 반환 조건을 분명히 보여 주는 데 초점을 둡니다.

연습 문제

첫 작업자는 15ms, 둘째는 100ms sleep합니다. 둘을 동시에 start한 뒤 전체 기한 60ms 안에 회수하고, 살아 있는 작업자는 interruptjoin으로 정리하세요.

정답과 시간 예산 계산

join 전에 같은 기한에서 현재 시간을 빼야 합니다. 두 번째 작업자에 새 60ms를 주지 않습니다.

exercise/SharedDeadlineJoinSolution.java
public final class SharedDeadlineJoinSolution {
    public static void main(String[] args) throws InterruptedException {
        Thread first = task("first", 15), second = task("second", 100);
        Thread[] all = {first, second};
        for (Thread t : all) {
            t.start();
        }
        long deadline = System.nanoTime() + 60_000_000L;
        for (Thread t : all) {
            long left = deadline - System.nanoTime();
            if (left > 0) t.join(Math.max(1, left / 1_000_000));
        }
        for (Thread t : all) {
            if (t.isAlive()) {
                t.interrupt();
            }
        }
        for (Thread t : all) {
            t.join();
        }
        System.out.println("cleaned=" + (!first.isAlive() && !second.isAlive()));
    }

    private static Thread task(String name, long millis) {
        return new Thread(
                () -> {
                    try {
                        Thread.sleep(millis);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                },
                name);
    }
}

최종 출력은 cleaned=true입니다. 둘째의 정상 완료 여부는 스케줄러 지연에 따라 달라질 수 있어 규칙에 넣지 않고, 기한 이후 살아 있는 스레드가 없다는 정리만 확인합니다.

시간 제한 join은 성공 신호가 아니라 관찰 기회입니다. 반환 뒤 생존 여부를 확인하고 기한을 공유하며, 시간 제한 부작용과 interrupt 가능한 지점을 함께 설계해야 안전한 시간 제한이 됩니다.