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

안동민 개발노트

본문 시작
19장 : 가시성과 동기화

lockInterruptibly와 대기 중단

락이 `interrupt`를 무시하고 계속 기다리는 결과를 재현한 뒤 중단 가능 획득과 정리 정책을 구현합니다.

ReentrantLock을 선택하는 중요한 이유 중 하나는 락을 기다리는 스레드를 중단할 수 있다는 점입니다. 그러나 lock() 자체는 interrupt를 취소 요청으로 받아 획득을 포기하지 않습니다. 중단 가능성이 요구되면 호출 시점부터 lockInterruptibly()를 선택해야 합니다.

락 대기 스레드의 인터럽트 오해

main이 락을 보유한 상태에서 대기자가 lock()을 호출합니다. interrupt 뒤 20ms를 기다려도 대기자는 계속 살아 있어 실제 출력은 wrong-cancelled=false입니다. main이 락을 풀고 나서야 대기자가 획득하며 interrupt 플래그가 보존돼 있습니다.

lab/LockIgnoresInterruptAssumption.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;

public final class LockIgnoresInterruptAssumption {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        CountDownLatch attempting = new CountDownLatch(1);
        Thread waiter =
                new Thread(
                        () -> {
                            attempting.countDown();
                            lock.lock();
                            try {
                                System.out.println(
                                        "acquired-after-release flag="
                                                + Thread.currentThread().isInterrupted());
                            } finally {
                                lock.unlock();
                            }
                        },
                        "plain-lock-waiter");
        lock.lock();
        try {
            waiter.start();
            attempting.await();
            awaitWaiting(waiter);
            waiter.interrupt();
            waiter.join(20);
            System.out.println("wrong-cancelled=" + !waiter.isAlive());
        } finally {
            lock.unlock();
        }
        waiter.join();
    }

    private static void awaitWaiting(Thread t) throws InterruptedException {
        for (int i = 0; i < 200 && t.getState() != Thread.State.WAITING; i++) {
            Thread.sleep(1);
        }
        if (t.getState() != Thread.State.WAITING)
            throw new IllegalStateException(t.getState().toString());
    }
}

lock() 내부는 interrupt 순간 잠깐 실행 가능해도 다시 큐에서 기다립니다. 획득을 완수한 뒤 interrupt 상태를 다시 설정할 수 있습니다. 따라서 플래그를 보냈다는 로그와 실제 업무 취소를 동일시하면 안 됩니다.

lockInterruptibly와 취소

중단 가능 획득은 대기 중 interrupt가 오면 InterruptedException을 던지고 락 큐에서 빠져나옵니다. 락을 얻지 못했으므로 finally에서 unlock하면 안 됩니다. boolean 획득됨 또는 try 블록 위치로 소유 여부를 분명하게 표현합니다.

src/InterruptibleLockWait.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;

public final class InterruptibleLockWait {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        CountDownLatch attempting = new CountDownLatch(1);
        String[] outcome = new String[1];
        Thread waiter =
                new Thread(
                        () -> {
                            attempting.countDown();
                            try {
                                lock.lockInterruptibly();
                                try {
                                    outcome[0] = "acquired";
                                } finally {
                                    lock.unlock();
                                }
                            } catch (InterruptedException e) {
                                outcome[0] = "interrupted-before-acquire";
                                Thread.currentThread().interrupt();
                            }
                        },
                        "interruptible-waiter");
        lock.lock();
        try {
            waiter.start();
            attempting.await();
            awaitWaiting(waiter);
            waiter.interrupt();
            waiter.join();
            System.out.println(
                    "outcome=" + outcome[0] + ", locked-by-main=" + lock.isHeldByCurrentThread());
        } finally {
            lock.unlock();
        }
    }

    private static void awaitWaiting(Thread t) throws InterruptedException {
        for (int i = 0; i < 200 && t.getState() != Thread.State.WAITING; i++) {
            Thread.sleep(1);
        }
        if (t.getState() != Thread.State.WAITING) throw new IllegalStateException();
    }
}

대기자는 main이 락을 풀기 전에 종료하며 결과는 interrupted-before-acquire입니다. catch에서 플래그를 복원해 상위 스레드 구분점의 진단 상태를 유지했습니다. 작업자가 이 catch에서 완전히 종료된다면 별도의 취소 결과가 더 중요합니다.

세 acquisition API의 취소·시간 특성

lock()은 무기한 기다리고 interrupt로 포기하지 않습니다. lockInterruptibly()는 시간 제한은 없지만 interrupt에 응합니다. 시간 제한 tryLock은 시간 만료와 interrupt 모두 처리하며 boolean false 또는 예외로 원인을 구분합니다.

즉시 tryLock()은 기다리지 않아 스레드 interrupt 상태를 획득 예외로 바꾸지 않습니다. 호출 전에 플래그가 true일 때 어떤 정책으로 처리할지 호출자가 판단합니다. API를 섞을 때 “false는 바쁜 대기, 예외는 취소됨”처럼 결과 mapping을 명시합니다.

src/InterruptibleCriticalSection.java
import java.util.concurrent.locks.ReentrantLock;

public final class InterruptibleCriticalSection {
    private final ReentrantLock lock = new ReentrantLock();
    private int completed;

    boolean execute() {
        try {
            lock.lockInterruptibly();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
        try {
            completed++;
            return true;
        } finally {
            lock.unlock();
        }
    }

    int completed() {
        lock.lock();
        try {
            return completed;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        InterruptibleCriticalSection service = new InterruptibleCriticalSection();
        Thread worker =
                new Thread(
                        () -> System.out.println("executed=" + service.execute()),
                        "critical-worker");
        worker.start();
        worker.join();
        System.out.println("completed=" + service.completed());
    }
}

정상 경로는 executed=true와 완료된=1입니다. 취소 test에서는 다른 스레드가 락을 잡은 상태를 만들고 작업자를 interrupt해야 합니다. 시간 sleep만으로 경쟁 순서를 추정하지 않습니다.

중단 시 수행 완료 작업 구분

락 획득 전 interrupt라면 임계 영역은 시작하지 않았습니다. 락을 얻은 뒤 내부 블로킹 호출에서 interrupt가 발생하면 일부 상태가 변경됐을 수 있습니다. 롤백 가능한 지역 스테이징을 사용하거나 커밋 point를 분명히 둡니다.

예를 들어 document publish가 락 안에서 메타데이터를 PUBLISHING으로 바꾼 뒤 네트워크 I/O 중 중단되면 finally unlock만으로 업무 상태가 복구되지 않습니다. catch 또는 트랜잭션 계층이 FAILED·RETRYABLE 상태를 기록해야 합니다. 락 안전성과 도메인 consistency는 별도 책임입니다.

중단할 수 없는 대기

synchronized 모니터 진입의 BLOCKED 상태는 interrupt로 포기할 수 없습니다. 락 대기 취소가 서비스의 shutdown 요구라면 내장 모니터를 ReentrantLock으로 바꿀 실질적인 근거가 됩니다. 반대로 대기 시간이 짧고 상한이 있는 모니터라면 별도 취소 결과 없이 기다리는 편이 단순합니다.

네이티브 라이브러리나 외부 드라이버가 interrupt를 무시하면 lockInterruptibly로 락 대기만 취소돼도 임계 영역 진입 후의 I/O는 남습니다. 모든 블로킹 계층에 시간 제한과 취소 지원이 있는지 끝까지 추적합니다.

interrupt와 unlock 순서

shutdown 스레드는 대기자에 interrupt를 보내고 join으로 종료를 회수합니다. 락 소유자도 중단해야 하는지는 별도 선택입니다. 소유자가 불변식 중간에서 interrupt를 받더라도 finally로 락을 풀기 전에 상태를 유효하게 만들고 나가야 합니다.

무차별적으로 애플리케이션의 모든 Threadinterrupt하면 라이브러리 백그라운드 스레드와 무관한 연산까지 영향을 받습니다. 구성 요소가 생성한 작업자 참조만 소유하고 생명 주기 API로 요청합니다.

연습 문제

main이 목록화 락을 보유한 동안 작업자가 수량 증가를 기다리게 한 뒤 interrupt하세요. 작업자는 갱신을 수행하지 않고 취소됨을 기록하며, main이 락을 풀기 전 종료해야 합니다.

정답과 소유 여부 확인

작업자는 lockInterruptibly에서 예외를 받아 임계 영역에 들어가지 않습니다. unlock은 successful 획득 뒤 try-finally에만 있습니다.

exercise/CancelledInventoryUpdateSolution.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;

public final class CancelledInventoryUpdateSolution {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        CountDownLatch attempt = new CountDownLatch(1);
        int[] stock = {10};
        String[] outcome = new String[1];
        Thread worker =
                new Thread(
                        () -> {
                            attempt.countDown();
                            try {
                                lock.lockInterruptibly();
                                try {
                                    stock[0]++;
                                    outcome[0] = "updated";
                                } finally {
                                    lock.unlock();
                                }
                            } catch (InterruptedException e) {
                                outcome[0] = "cancelled";
                                Thread.currentThread().interrupt();
                            }
                        },
                        "inventory-update");
        lock.lock();
        try {
            worker.start();
            attempt.await();
            while (worker.getState() != Thread.State.WAITING) {
                Thread.onSpinWait();
            }
            worker.interrupt();
            worker.join();
            System.out.println("outcome=" + outcome[0] + ", stock=" + stock[0]);
        } finally {
            lock.unlock();
        }
    }
}

결과는 취소됨과 stock=10입니다. main이 락을 보유한 상태에서 join이 끝나므로 작업자가 락을 얻지 않았다는 점도 확인됩니다.

ReentrantLock 대기를 중단하려면 interrupt() 호출뿐 아니라 획득 API 자체가 중단 가능해야 합니다. 획득 전·후 취소를 나누고 성공한 소유자만 unlock하며 도메인 롤백까지 정의해야 안전한 shutdown이 됩니다.