본문 바로가기

lockInterruptibly로 락 대기 취소하기

lock()으로 기다리는 스레드는 interrupt를 받아도 멈추지 않는다는 것을 확인하고, lockInterruptibly()로 락 대기를 취소한 뒤 상태를 정리하는 방법을 배웁니다.

서비스를 종료하려는데 작업자 스레드 하나가 락을 기다리느라 멈춰 있다고 해 봅시다. 락을 쥔 쪽이 오래 걸리는 일을 하고 있다면, 종료하는 쪽은 그 스레드에게 '이제 그만 기다리고 끝내라'고 알려야 합니다. 자바에서 이런 취소 요청을 보내는 표준 방법은 interrupt 플래그와 InterruptedException 처리에서 배운 interrupt()입니다. 그런데 요청에 어떻게 반응할지는 받는 쪽 코드가 정하므로, 락을 기다리는 메서드가 interrupt를 무시하면 요청은 아무 효과가 없습니다.

앞 글에서는 ReentrantLock을 고르는 이유로 '기다리다가 포기할 수 있다'는 점을 들었습니다. 하지만 이 기능은 lock()에는 없고 lockInterruptibly()와 시간 제한이 있는 tryLock()에만 있습니다. 이 글에서는 먼저 lock()으로 기다리는 스레드가 interrupt를 받아도 멈추지 않는 것을 확인하고, lockInterruptibly()로 바꾸면 무엇이 달라지는지 봅니다. 이어서 취소된 뒤 무엇을 정리해야 하는지, interrupt로도 취소할 수 없는 것은 무엇인지 살펴봅니다.

lock()으로 기다리는 스레드는 interrupt를 받아도 멈추지 않는다

다음 프로그램에서 main은 락을 쥔 채로 plain-lock-waiter 스레드를 시작합니다. 이 스레드는 lock()을 호출하므로 main이 락을 풀 때까지 기다리게 됩니다. main은 이 스레드가 정말 기다리는 상태가 된 것을 확인한 다음 interrupt()를 보내고, join(20)으로 최대 20밀리초 동안 스레드가 끝나기를 기다립니다. interrupt로 락 대기가 취소됐다면 스레드는 그사이 끝났을 것이고, 출력 이름 wrong-cancelled의 값은 true가 됩니다.

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());
    }
}

코드에 쓰인 도구 두 가지를 먼저 짚겠습니다. CountDownLatch는 정해 둔 횟수만큼 countDown()이 호출되면 await()로 기다리던 스레드를 깨우는 도구로, 여기서는 '작업자가 lock()을 호출하기 직전까지 왔다'는 신호로 씁니다. awaitWaiting()은 작업자의 상태가 WAITING이 될 때까지 1밀리초씩 쉬며 최대 200번 확인하고, 그래도 WAITING이 아니면 IllegalStateException을 던집니다. ReentrantLock의 재진입과 공정성에서 본 것처럼 ReentrantLock을 기다리는 스레드는 WAITING 상태로 보이므로, 이 확인을 통과했다면 작업자는 실제로 락을 기다리고 있습니다.

wrong-cancelled=false
acquired-after-release flag=true

150번 실행해 모두 이 출력을 얻었고, 상태 확인이 실패하지 않는 한 결과는 달라질 수 없습니다. 첫 줄은 interrupt를 받고 20밀리초가 지난 뒤에도 작업자가 살아 있다는 뜻입니다. main이 락을 쥐고 있는 동안 작업자는 lock()에서 빠져나올 수 없으니 끝날 수도 없습니다. 둘째 줄은 main이 finally에서 락을 푼 뒤, 작업자가 마침내 락을 얻고 출력한 것입니다.

둘째 줄의 flag=true가 중요한 단서입니다. interrupt는 사라지지 않았습니다. lock()은 기다리는 동안 받은 interrupt를 기억해 두었다가, 락을 얻은 뒤 스레드의 interrupt 플래그를 다시 켜 둡니다. 다시 말해 lock()은 interrupt를 대기를 멈출 이유로 쓰지 않고, 락을 얻을 때까지 반응을 미룹니다. 그러니 interrupt를 보냈다는 사실만으로 그 스레드의 작업이 취소되었다고 판단하면 안 됩니다.

lock()이 이렇게 동작하는 이유는 메서드 선언에서 찾을 수 있습니다. lock()에는 throws InterruptedException이 없고 반환 타입도 void이므로, '기다리다 포기했다'는 사실을 호출자에게 알릴 방법이 없습니다. 반환했다면 락을 얻은 것이어야 하므로, 할 수 있는 일은 계속 기다리면서 interrupt를 잃지 않게 보관하는 것뿐입니다. 호출하기 전에 플래그가 이미 켜져 있을 때도 마찬가지여서, 락이 잡혀 있으면 그대로 기다렸다가 얻은 뒤 플래그가 켜진 채로 돌아옵니다.

더 깊이: 기다리는 동안에는 플래그가 꺼져 보일 수 있다

lock()의 API 문서는 interrupt에 대해 아무것도 말하지 않습니다. 앞에서 본 '얻은 뒤 플래그를 다시 켜는' 동작은 OpenJDK의 구현인 AbstractQueuedSynchronizer에서 나옵니다. 기다리던 스레드는 interrupt를 받으면 잠시 깨어나 Thread.interrupted()로 플래그를 확인하면서 지우고, interrupt가 있었다는 사실을 지역 변수에 기억한 채 다시 잠듭니다. 그리고 락을 얻은 뒤 스스로 interrupt()를 호출해 플래그를 되살립니다.

그래서 기다리는 동안 다른 스레드가 waiter.isInterrupted()를 호출하면 false가 나올 수 있습니다. JDK 21에서 앞 예제를 고쳐 interrupt를 보내고 100밀리초 뒤에 확인해 보니, 작업자는 여전히 WAITING이었고 isInterrupted()는 40번 모두 false였습니다. interrupt 직후에 바로 확인하면 깨어난 스레드가 플래그를 지우기 전인지 후인지에 따라 true와 false가 섞여 나왔습니다. 다른 스레드의 플래그 값은 요청이 전달됐는지 확인하는 수단으로 믿을 수 없습니다.

lockInterruptibly()로 기다리면 interrupt에 응답한다

이번에는 작업자가 lock() 대신 lockInterruptibly()로 락을 기다립니다. 이 메서드는 기다리는 도중 interrupt를 받으면 대기를 멈추고 InterruptedException을 던집니다. 작업자는 락을 얻었을 때 결과를 acquired로, 예외를 받았을 때 interrupted-before-acquire로 기록합니다. 구조를 잘 보세요. lockInterruptibly() 호출은 unlock()하는 try-finally의 바깥에 있고, 그 전체를 InterruptedException을 잡는 try-catch가 감싸고 있습니다.

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();
    }
}
outcome=interrupted-before-acquire, locked-by-main=true

이 출력도 150번 모두 같았습니다. 작업자는 main이 락을 쥐고 있는 동안 InterruptedException으로 대기에서 빠져나와 catch로 갔고, 결과를 기록한 뒤 끝났습니다. 그래서 main은 락을 쥔 채로 시간 제한 없는 join()을 호출했는데도 기다림이 끝났습니다. 앞 예제에서 join(20) 대신 join()을 썼다면, 작업자는 main이 락을 풀기를, main은 작업자가 끝나기를 서로 기다리느라 프로그램이 끝나지 않았을 것입니다.

locked-by-main=true는 isHeldByCurrentThread()의 결과로, 이 시점에도 락은 여전히 main의 것이라는 뜻입니다. 작업자는 락을 얻지 못했으니 풀 것도 없고, main이 얻은 락은 main의 finally가 풉니다. 또 main이 join() 뒤에 읽은 outcome[0]에 작업자가 쓴 값이 보이는 것은, 한 스레드의 모든 동작이 그 스레드에 대한 join()의 반환보다 먼저 일어난다(happens-before)는 규칙 덕분입니다.

아래 그림은 두 예제를 나란히 놓고, main이 락을 쥔 동안 보낸 같은 interrupt가 lock()과 lockInterruptibly()에서 어떻게 다른 결과로 이어지는지 단계별로 보여 줍니다.

같은 interrupt도 획득 API에 따라 회수 결과가 다르다

락 보유 중 두 획득 API의 interrupt 결과와 해제 뒤 진행이 다릅니다. 요청·중단 관찰·실제 획득/미획득·반납을 단계로 추적합니다.

같은 interrupt도 획득 API에 따라 회수 결과가 다르다

LockIgnoresInterruptAssumption과 InterruptibleLockWait에서 main이 락을 보유한 동안 요청한 interrupt의 서로 다른 결과를 비교합니다.

취소 비교 · 두 획득 API

같은 interrupt도 획득 API에 따라 회수 결과가 다르다

main이 락을 가진 동안 보낸 interrupt에 lock은 계속 기다리고, lockInterruptibly는 첫 획득 전에 종료합니다.

보유 중인 락에서 갈리는 중단 결과 main이계속보유하는조건에서lock대기자는interrupt후에도살아있고반납뒤획득합니다. lockInterruptibly대기자는첫획득없이예외종료해main이보유한채join을끝냅니다. lock() 대기자 main이 락을 보유 waiter에 interrupt 요청 join(20) 반환 wrong-cancelled=false 대기자는 계속 기다림 main의 반납 뒤 획득 / flag=true 출력 finally로 자기 획득 반납 lockInterruptibly 대기자 main이 락을 보유 waiter에 interrupt 요청 예외 경로로 종료 interrupted-before- acquire worker의 첫 획득 없음 main의 정상 join locked-by-main=true main 책임은 아직 남음 실선: 각API의 요청과 회수 결과 원WAITING관측 성공 경로 조건 플래그복원과 업무취소는 구별
보유 중인 락에서 갈리는 중단 결과 main이계속보유하는조건에서lock대기자는interrupt후에도살아있고반납뒤획득합니다. lockInterruptibly대기자는첫획득없이예외종료해main이보유한채join을끝냅니다. lock() 대기자 main이 락을 보유 waiter에 interrupt 요청 join(20) 반환 wrong-cancelled=false 대기자는 계속 기다림 main의 반납 뒤 획득 / flag=true 출력 finally로 자기 획득 반납 lockInterruptibly 대기자 main이 락을 보유 waiter에 interrupt 요청 예외 경로로 종료 interrupted-before- acquire worker의 첫 획득 없음 main의 정상 join locked-by-main=true main 책임은 아직 남음 실선: 각API의 요청과 회수 결과 원WAITING관측 성공 경로 조건 플래그복원과 업무취소는 구별

lock() 대기자

interrupt 뒤 회수
join(20) 뒤에도 살아 있음 · wrong-cancelled=false
작업자의 락 획득
main이 해제한 뒤 획득함
작업자의 정리
획득 후 finally에서 해제 · 출력 flag=true

lockInterruptibly() 대기자

interrupt 뒤 회수
join이 끝나 종료됨 · interrupted-before-acquire
작업자의 락 획득
첫 획득 없이 catch로 이동
작업자의 정리
작업자 unlock 없음 · main이 계속 보유하여 locked-by-main=true

두 예제의 WAITING 확인이 성공한 경로입니다. 상태 탐색은 200회로 제한돼 실패하면 예외로 끝납니다. lockInterruptibly 예제의 catch는 플래그를 복원하지만 그 값은 출력하지 않습니다.

이 예의 조건

  • main이 락을 보유하고 WAITING 관측이 성공한 경로의 두 독립 API 실행입니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2main이 계속 보유하는 조건에서 lock 대기자는 interrupt 후에도 살아 있고 반납 뒤 획득합니다. lockInterruptibly 대기자는 첫 획득 없이 예외로 종료해 main이 보유한 채 join을 끝냅니다.
10획득
11flag=true 출력
23실선: 각 API의 요청과 회수 결과
24원래 WAITING 관측 성공 경로 조건
25플래그 복원과 업무 취소는 구별
원 도표의 설명과 주석
구분내용
설명main이 계속 보유하는 조건에서 lock 대기자는 interrupt 후에도 살아 있고 반납 뒤 획득합니다. lockInterruptibly 대기자는 첫 획득 없이 예외로 종료해 main이 보유한 채 join을 끝냅니다.
전체 읽기 정보같은 interrupt도 획득 API에 따라 회수 결과가 다르다 같은 interrupt도 획득 API에 따라 회수 결과가 다르다 LockIgnoresInterruptAssumption과 InterruptibleLockWait에서 main이 락을 보유한 동안 요청한 interrupt의 서로 다른 결과를 비교합니다. 취소 비교 · 두 획득 API 같은 interrupt도 획득 API에 따라 회수 결과가 다르다 main이 락을 가진 동안 보낸 interrupt에 lock은 계속 기다리고, lockInterruptibly는 첫 획득 전에 종료합니다. lock() 대기자 interrupt 뒤 회수 join(20) 뒤에도 살아 있음 · wrong-cancelled=false 작업자의 락 획득 main이 해제한 뒤 획득함 작업자의 정리 획득 후 finally에서 해제 · 출력 flag=true lockInterruptibly() 대기자 interrupt 뒤 회수 join이 끝나 종료됨 · interrupted-before-acquire 작업자의 락 획득 첫 획득 없이 catch로 이동 작업자의 정리 작업자 unlock 없음 · main이 계속 보유하여 locked-by-main=true 두 예제의 WAITING 확인이 성공한 경로입니다. 상태 탐색은 200회로 제한돼 실패하면 예외로 끝납니다. lockInterruptibly 예제의 catch는 플래그를 복원하지만 그 값은 출력하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
보유와 중단 요청

각 실행에서 main이 락을 계속 가진 채 waiter에 interrupt합니다.

  • lock 실행: 진입 대기자에 요청
  • lockInterruptibly 실행: 중단 가능한 진입에 요청
  • 같은 보유 조건에서도 API의 중단 응답이 다릅니다.
획득 또는 예외

lock은 반납까지 기다리고 lockInterruptibly는 미획득 예외로 끝납니다.

  • lock: join(20) 뒤 wrong-cancelled=false, main 반납 뒤 획득
  • lockInterruptibly: interrupted-before-acquire, 첫 획득 없음
  • 중단 요청은 lock 대기자에게 소유권을 넘기거나 대기를 취소하지 않습니다.
각 회수와 책임

자기 획득 여부에 따라 반납과 조회를 구별합니다.

  • lock: 획득 후 flag=true, finally 자기 획득 반납
  • lockInterruptibly: main 정상 join 뒤 locked-by-main=true
  • worker의 미획득이 main의 기존 반납 책임을 없애지 않습니다.

같은 interrupt도 획득 API에 따라 회수 결과가 다르다

락 보유 중 두 획득 API의 interrupt 결과와 해제 뒤 진행이 다릅니다. 요청·중단 관찰·실제 획득/미획득·반납을 단계로 추적합니다.

같은 interrupt도 획득 API에 따라 회수 결과가 다르다

LockIgnoresInterruptAssumption과 InterruptibleLockWait에서 main이 락을 보유한 동안 요청한 interrupt의 서로 다른 결과를 비교합니다.

취소 비교 · 두 획득 API

같은 interrupt도 획득 API에 따라 회수 결과가 다르다

main이 락을 가진 동안 보낸 interrupt에 lock은 계속 기다리고, lockInterruptibly는 첫 획득 전에 종료합니다.

보유 중인 락에서 갈리는 중단 결과 main이계속보유하는조건에서lock대기자는interrupt후에도살아있고반납뒤획득합니다. lockInterruptibly대기자는첫획득없이예외종료해main이보유한채join을끝냅니다. lock() 대기자 main이 락을 보유 waiter에 interrupt 요청 join(20) 반환 wrong-cancelled=false 대기자는 계속 기다림 main의 반납 뒤 획득 / flag=true 출력 finally로 자기 획득 반납 lockInterruptibly 대기자 main이 락을 보유 waiter에 interrupt 요청 예외 경로로 종료 interrupted-before- acquire worker의 첫 획득 없음 main의 정상 join locked-by-main=true main 책임은 아직 남음 실선: 각API의 요청과 회수 결과 원WAITING관측 성공 경로 조건 플래그복원과 업무취소는 구별
보유 중인 락에서 갈리는 중단 결과 main이계속보유하는조건에서lock대기자는interrupt후에도살아있고반납뒤획득합니다. lockInterruptibly대기자는첫획득없이예외종료해main이보유한채join을끝냅니다. lock() 대기자 main이 락을 보유 waiter에 interrupt 요청 join(20) 반환 wrong-cancelled=false 대기자는 계속 기다림 main의 반납 뒤 획득 / flag=true 출력 finally로 자기 획득 반납 lockInterruptibly 대기자 main이 락을 보유 waiter에 interrupt 요청 예외 경로로 종료 interrupted-before- acquire worker의 첫 획득 없음 main의 정상 join locked-by-main=true main 책임은 아직 남음 실선: 각API의 요청과 회수 결과 원WAITING관측 성공 경로 조건 플래그복원과 업무취소는 구별

lock() 대기자

interrupt 뒤 회수
join(20) 뒤에도 살아 있음 · wrong-cancelled=false
작업자의 락 획득
main이 해제한 뒤 획득함
작업자의 정리
획득 후 finally에서 해제 · 출력 flag=true

lockInterruptibly() 대기자

interrupt 뒤 회수
join이 끝나 종료됨 · interrupted-before-acquire
작업자의 락 획득
첫 획득 없이 catch로 이동
작업자의 정리
작업자 unlock 없음 · main이 계속 보유하여 locked-by-main=true

두 예제의 WAITING 확인이 성공한 경로입니다. 상태 탐색은 200회로 제한돼 실패하면 예외로 끝납니다. lockInterruptibly 예제의 catch는 플래그를 복원하지만 그 값은 출력하지 않습니다.

이 예의 조건

  • main이 락을 보유하고 WAITING 관측이 성공한 경로의 두 독립 API 실행입니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2main이 계속 보유하는 조건에서 lock 대기자는 interrupt 후에도 살아 있고 반납 뒤 획득합니다. lockInterruptibly 대기자는 첫 획득 없이 예외로 종료해 main이 보유한 채 join을 끝냅니다.
10획득
11flag=true 출력
23실선: 각 API의 요청과 회수 결과
24원래 WAITING 관측 성공 경로 조건
25플래그 복원과 업무 취소는 구별
원 도표의 설명과 주석
구분내용
설명main이 계속 보유하는 조건에서 lock 대기자는 interrupt 후에도 살아 있고 반납 뒤 획득합니다. lockInterruptibly 대기자는 첫 획득 없이 예외로 종료해 main이 보유한 채 join을 끝냅니다.
전체 읽기 정보같은 interrupt도 획득 API에 따라 회수 결과가 다르다 같은 interrupt도 획득 API에 따라 회수 결과가 다르다 LockIgnoresInterruptAssumption과 InterruptibleLockWait에서 main이 락을 보유한 동안 요청한 interrupt의 서로 다른 결과를 비교합니다. 취소 비교 · 두 획득 API 같은 interrupt도 획득 API에 따라 회수 결과가 다르다 main이 락을 가진 동안 보낸 interrupt에 lock은 계속 기다리고, lockInterruptibly는 첫 획득 전에 종료합니다. lock() 대기자 interrupt 뒤 회수 join(20) 뒤에도 살아 있음 · wrong-cancelled=false 작업자의 락 획득 main이 해제한 뒤 획득함 작업자의 정리 획득 후 finally에서 해제 · 출력 flag=true lockInterruptibly() 대기자 interrupt 뒤 회수 join이 끝나 종료됨 · interrupted-before-acquire 작업자의 락 획득 첫 획득 없이 catch로 이동 작업자의 정리 작업자 unlock 없음 · main이 계속 보유하여 locked-by-main=true 두 예제의 WAITING 확인이 성공한 경로입니다. 상태 탐색은 200회로 제한돼 실패하면 예외로 끝납니다. lockInterruptibly 예제의 catch는 플래그를 복원하지만 그 값은 출력하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
보유와 중단 요청

각 실행에서 main이 락을 계속 가진 채 waiter에 interrupt합니다.

  • lock 실행: 진입 대기자에 요청
  • lockInterruptibly 실행: 중단 가능한 진입에 요청
  • 같은 보유 조건에서도 API의 중단 응답이 다릅니다.
획득 또는 예외

lock은 반납까지 기다리고 lockInterruptibly는 미획득 예외로 끝납니다.

  • lock: join(20) 뒤 wrong-cancelled=false, main 반납 뒤 획득
  • lockInterruptibly: interrupted-before-acquire, 첫 획득 없음
  • 중단 요청은 lock 대기자에게 소유권을 넘기거나 대기를 취소하지 않습니다.
각 회수와 책임

자기 획득 여부에 따라 반납과 조회를 구별합니다.

  • lock: 획득 후 flag=true, finally 자기 획득 반납
  • lockInterruptibly: main 정상 join 뒤 locked-by-main=true
  • worker의 미획득이 main의 기존 반납 책임을 없애지 않습니다.

두 예제는 모두 awaitWaiting()으로 작업자가 기다리는 상태가 된 것을 확인한 다음 interrupt를 보냈습니다. 하지만 이 확인이 없어도 결과는 같습니다. interrupt가 lockInterruptibly() 호출보다 먼저 도착하면 이 메서드는 들어오자마자 플래그를 보고 예외를 던지고, 대기 중에 도착하면 대기 중에 예외를 던집니다. 어느 쪽이든 main이 락을 쥐고 있으니 작업자가 먼저 락을 얻는 경우는 없습니다. 예제가 상태를 확인하는 것은 '기다리는 도중에 받은 interrupt'를 보여 주기 위해서일 뿐, 결과를 정하기 위해서가 아닙니다.

InterruptedException을 잡은 뒤 플래그를 되살리는 이유

lockInterruptibly()는 InterruptedException을 던지면서 스레드의 interrupt 플래그를 지웁니다. 예외 자체가 'interrupt가 왔다'는 소식을 전하므로 플래그를 남겨 둘 필요가 없다는 설계입니다. 그런데 예외를 잡아서 처리하고 끝내 버리면, 이 코드를 호출한 바깥 코드는 취소 요청이 있었다는 사실을 알 수 없습니다. 그래서 예제의 catch는 Thread.currentThread().interrupt()로 플래그를 다시 켭니다.

플래그를 되살린다고 해서 이미 한 일이 되돌려지거나 스레드가 멈추지는 않습니다. 요청을 바깥으로 전달할 뿐입니다. 이 예제의 작업자는 catch 다음에 바로 끝나므로 되살린 플래그를 읽는 코드가 없고, 실제로 의미 있는 것은 outcome[0]에 남긴 기록입니다. 그래도 실무 코드는 그 메서드가 어디서 호출될지 모르므로, InterruptedException을 다시 던지지 않는다면 플래그를 되살려 두는 것이 기본입니다.

얻지 못한 락은 풀지 않는다

lockInterruptibly()가 예외를 던졌다면 이번 호출로 얻은 락은 없습니다. 그러니 앞 글의 규칙대로 lockInterruptibly()는 unlock()하는 try-finally의 바깥, 바로 앞에 두어야 합니다. try 안에 두면 예외가 났을 때 finally가 얻지 않은 락을 풀려고 합니다. 락을 전혀 갖지 않은 스레드라면 앞 글에서 본 것처럼 IllegalMonitorStateException이 나서 원래 예외를 가립니다.

더 위험한 것은 스레드가 이미 같은 락을 갖고 있을 때입니다. ReentrantLock의 API 문서에 따르면, lockInterruptibly()는 호출할 때 플래그가 이미 켜져 있으면 락이 비어 있든 스레드가 이미 갖고 있든 락을 얻지 않고 InterruptedException을 던집니다. 문서는 이 메서드가 명시적인 중단 지점이므로 일반 획득이나 재진입 획득보다 interrupt에 응답하는 쪽을 우선한다고 설명합니다. 다음 예제는 락을 이미 한 번 얻은 스레드가 플래그를 켠 채로 lockInterruptibly()를 다시 호출할 때 무슨 일이 생기는지 보여 줍니다.

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

public final class InterruptedReentryBalance {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        try {
            Thread.currentThread().interrupt();
            try {
                lock.lockInterruptibly();
                try { System.out.println("unexpected acquisition"); }
                finally { lock.unlock(); }
            } catch (InterruptedException e) {
                System.out.println("rejected-hold=" + lock.getHoldCount()
                        + ", flag-in-catch=" + Thread.currentThread().isInterrupted());
                Thread.currentThread().interrupt();
            }
        } finally {
            lock.unlock();
        }
        System.out.println("locked-after=" + lock.isLocked()
                + ", flag-restored=" + Thread.currentThread().isInterrupted());
    }
}
rejected-hold=1, flag-in-catch=false
locked-after=false, flag-restored=true

첫 줄의 rejected-hold=1은 안쪽 lockInterruptibly()가 획득 횟수를 늘리지 않고 예외를 던졌다는 뜻입니다. 이미 갖고 있는 락인데도 재진입하지 않은 것입니다. flag-in-catch=false는 예외를 던지면서 플래그를 지웠다는 뜻이고, catch는 그 플래그를 다시 켭니다. 둘째 줄을 보면 바깥 finally가 처음 얻은 한 번을 풀어 락이 완전히 풀렸고(locked-after=false), 되살린 플래그도 남아 있습니다(flag-restored=true).

이 코드에서 안쪽 lockInterruptibly()를 unlock()하는 try 안으로 옮기면 결과가 크게 달라집니다. 예외가 난 뒤 안쪽 finally가 바깥에서 얻어 둔 한 번을 대신 풀어 버려 획득 횟수가 0이 됩니다. 그 순간부터 바깥 블록의 나머지 코드는 락 없이 실행되고, 마지막에 바깥 finally의 unlock()이 IllegalMonitorStateException을 던집니다. 실제로 그렇게 바꿔 실행하면 이 순서대로 일어납니다. 락을 얻었는지는 코드 위치로 분명하게 드러내는 것이 가장 안전합니다. try에 들어왔다면 얻은 것이고, 그 앞에서 예외가 났다면 얻지 못한 것입니다.

획득 방법마다 interrupt와 시간 제한에 반응하는 방식

ReentrantLock에는 락을 얻는 메서드가 네 가지 있습니다. 지금까지 본 두 가지에 다음 글에서 자세히 다룰 tryLock()의 두 형태를 더해, interrupt와 시간 제한에 어떻게 반응하는지 정리하면 다음과 같습니다.

메서드기다리는 시간기다리는 중 interrupt를 받으면호출 전에 플래그가 켜져 있으면얻지 못했을 때
lock()얻을 때까지계속 기다리고, 얻은 뒤 플래그를 다시 켬무시하고 진행, 플래그 유지반환하지 않음
lockInterruptibly()얻을 때까지InterruptedException락이 비어 있어도 InterruptedException예외
tryLock()기다리지 않음해당 없음무시하고 진행, 플래그 유지false
tryLock(time, unit)최대 지정한 시간InterruptedException락이 비어 있어도 InterruptedException시간이 지나면 false

InterruptedException을 던지는 두 메서드는 예외를 던지면서 플래그를 지웁니다. 표에서 tryLock()과 tryLock(0, unit)의 차이도 눈여겨볼 만합니다. 둘 다 기다리지 않지만, 플래그가 켜져 있을 때 앞의 것은 락이 비어 있으면 그냥 얻고 플래그를 그대로 두는 반면, 뒤의 것은 예외를 던지고 플래그를 지웁니다. 그래서 tryLock()을 쓰는 코드는 취소 요청이 와 있는지 스스로 확인해야 하고, 그때 어떻게 할지도 호출하는 쪽이 정해야 합니다.

여러 획득 방법을 섞어 쓸 때는 즉시 실패, 시간 초과, 중단이 각각 업무에서 어떤 결과에 해당하는지 미리 정해 두어야 합니다. 예를 들어 즉시 실패는 '지금은 바쁘니 나중에 다시 요청하라'로, 중단은 '서비스가 종료 중이니 작업을 포기한다'로 처리할 수 있습니다. 다음 InterruptibleCriticalSection의 execute()는 중단을 예외 대신 반환값으로 알려 주는 방식입니다. 락을 기다리다 중단되면 플래그를 되살리고 false를 반환하며, 락을 얻었다면 처리 횟수 completed를 하나 늘리고 true를 반환합니다.

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
completed=1

lockInterruptibly()를 감싼 try-catch와 unlock()하는 try-finally가 완전히 나뉘어 있어서, 락을 얻지 못한 경로는 finally에 들어가지 않습니다. main은 경쟁하는 스레드도 interrupt도 없이 작업자 하나만 실행하므로, execute()는 락을 바로 얻고 true를 반환합니다. 이 실행에서는 false를 반환하는 분기를 지나지 않습니다.

중단 분기는 이렇게 확인할 수 있습니다. 현재 스레드의 플래그를 먼저 켜고 같은 객체의 execute()를 두 번 호출하면 둘 다 false이고 completed()는 0입니다. execute()가 catch에서 플래그를 되살리므로, 두 번째 호출도 들어오자마자 다시 중단되는 것입니다. 이 결과를 받은 호출자는 작업을 끝낼지, 바깥으로 취소를 전달할지 정해야 합니다. 성공시키려고 Thread.interrupted()로 플래그를 몰래 지우고 다시 호출하면, 서비스를 종료하려던 쪽의 요청을 무시하는 셈이 됩니다.

락을 얻은 뒤에 온 interrupt는 작업을 되돌리지 않는다

interrupt가 락을 얻기 전에 도착해 lockInterruptibly()가 예외를 던졌다면, 임계 영역은 시작조차 하지 않았으므로 되돌릴 것이 없습니다. 하지만 락을 얻은 뒤에 도착한 interrupt는 다릅니다. interrupt는 실행 중인 코드를 강제로 멈추지 않고 플래그만 켭니다. 그래서 임계 영역 안의 코드가 플래그를 확인하지 않고 interrupt에 반응하는 블로킹 호출도 하지 않는다면, 작업은 아무 일 없었던 듯 끝까지 실행됩니다.

문제는 임계 영역 안에서 interrupt에 반응하는 호출을 하다가 중간에 예외가 날 때입니다. 다음 코드는 문서를 발행하면서 상태를 PUBLISHING으로 바꾸고 파일을 올린 다음 PUBLISHED로 바꿉니다. 업로드 도중 InterruptedException이 나면 finally가 락은 풀어 주지만, 따로 처리하지 않으면 문서는 PUBLISHING 상태로 남습니다. 락을 푸는 것과 상태를 올바르게 정리하는 것은 별개의 일이기 때문입니다. 그래서 catch에서 문서를 '다시 시도할 수 있음' 상태로 바꾼 다음 예외를 다시 던집니다.

void publish(Document doc) throws InterruptedException {
    lock.lockInterruptibly();           // 여기서 중단되면 아무것도 바뀌지 않았다
    try {
        doc.status = Status.PUBLISHING;
        uploader.upload(doc);           // 여기서 중단되면 PUBLISHING이 남는다
        doc.status = Status.PUBLISHED;
    } catch (InterruptedException e) {
        doc.status = Status.RETRYABLE;  // 다시 시도할 수 있는 상태로 정리한다
        throw e;
    } finally {
        lock.unlock();
    }
}

catch가 예외를 삼키지 않고 다시 던지므로 interrupt 요청도 호출자에게 그대로 전달됩니다. 앞 글에서 말했듯 업로드처럼 오래 걸리는 I/O는 락 밖으로 빼는 편이 좋습니다. 하지만 I/O를 락 밖으로 옮겨도 '상태를 바꾼 뒤 작업이 끊기는' 문제는 그대로 남으므로, 어느 구조든 중간에 끊겼을 때 어떤 상태로 정리할지를 정해 두어야 합니다. 다른 방법은 계산을 지역 변수에서 모두 끝낸 뒤 마지막에 공유 상태를 한 번에 바꾸는 것입니다. 그러면 중간에 끊겨도 공유 상태는 이전 그대로입니다.

synchronized로 기다리는 스레드는 중단할 수 없다

synchronized 블록에 들어가려고 기다리는 스레드는 BLOCKED 상태가 되는데, 이 대기는 interrupt로 끝낼 수 없습니다. park와 interrupt, BLOCKED와 WAITING에서 BLOCKED인 스레드에 interrupt를 보내도 상태가 그대로인 것을 확인했습니다. 시간 제한을 걸 방법도 없으므로, 모니터를 쥔 스레드가 블록을 벗어나지 않으면 기다리는 스레드도 계속 기다립니다.

그래서 '종료할 때 락 대기를 취소할 수 있어야 한다'는 요구가 있다면, 그것이 synchronized를 ReentrantLock으로 바꿀 분명한 이유가 됩니다. 반대로 임계 영역이 짧고 대기 시간에 상한이 있다면, 취소 처리 없이 잠깐 기다리는 synchronized가 더 단순합니다.

lockInterruptibly()가 취소할 수 있게 해 주는 것은 락을 기다리는 구간뿐이라는 점도 기억해야 합니다. 일단 락을 얻어 임계 영역에 들어간 스레드가 interrupt에 반응하지 않는 호출, 예를 들어 interrupt를 확인하지 않는 네이티브 라이브러리나 외부 드라이버의 호출을 하고 있다면 interrupt를 보내도 그 호출은 끝나지 않습니다. 그동안 락도 계속 잡혀 있으니 다른 스레드의 대기도 길어집니다. 종료가 확실히 이루어지려면 락 대기뿐 아니라 임계 영역 안의 블로킹 호출마다 시간 제한이나 취소 방법이 있는지 확인해야 합니다.

종료할 때 interrupt를 보내는 쪽이 지킬 것

취소를 요청하는 쪽에도 규칙이 있습니다. 종료를 맡은 코드는 자기가 만든 작업자 스레드에 interrupt()를 보내고, join()으로 그 스레드가 실제로 끝나기를 기다립니다. interrupt는 요청일 뿐이므로, 보냈다고 끝난 것으로 치지 않고 정말 끝났는지 확인하는 것입니다. 이때 락을 쥐고 일하는 스레드까지 interrupt할지는 따로 정할 문제입니다. 그 스레드도 interrupt한다면, 앞에서 본 것처럼 상태를 바꾸는 도중에 중단되더라도 올바른 상태로 정리한 뒤 finally에서 락을 풀도록 만들어 두어야 합니다.

애플리케이션의 모든 스레드에 interrupt를 보내는 식의 방법은 쓰지 않습니다. 라이브러리가 내부에서 쓰는 백그라운드 스레드처럼, 내 코드가 상태를 알지 못하는 스레드까지 영향을 받기 때문입니다. 구성 요소는 자기가 만든 작업자 스레드의 참조만 갖고 있다가, close()나 shutdown() 같은 종료 메서드에서 그 스레드들에게만 요청을 보냅니다. 스레드 풀을 쓴다면 ExecutorService의 shutdownNow()가 보통 작업자 스레드에 interrupt를 보내는 방식으로 이 일을 대신하며, 이는 ExecutorService를 안전하게 종료하기에서 다룹니다.

정리

  • lock()으로 기다리는 스레드는 interrupt를 받아도 계속 기다립니다. 락을 얻은 뒤 플래그가 켜진 채로 돌아올 뿐이므로, interrupt를 보낸 것만으로 작업이 취소되었다고 보면 안 됩니다.
  • lockInterruptibly()는 호출할 때나 기다리는 도중에 interrupt가 있으면 락을 얻지 않고 InterruptedException을 던지며 플래그를 지웁니다. 이미 갖고 있는 락을 다시 얻는 경우에도 interrupt가 우선합니다.
  • lockInterruptibly()는 unlock()하는 try의 바로 앞에 둡니다. 예외로 끝났다면 얻은 것이 없으니 풀지 않습니다.
  • InterruptedException을 잡아 처리했다면 Thread.currentThread().interrupt()로 플래그를 되살려 바깥 코드가 취소 요청을 알 수 있게 합니다.
  • 락을 얻은 뒤의 interrupt는 작업을 되돌리지 않습니다. 중간에 끊길 수 있는 작업에는 끊겼을 때의 상태를 정리하는 코드를 둡니다.
  • synchronized의 대기는 취소할 수 없습니다. 락 대기를 취소해야 한다면 lockInterruptibly()나 시간 제한이 있는 tryLock()을 씁니다.

연습 문제

  1. main이 재고를 보호하는 락을 쥔 동안, 작업자 스레드가 재고를 하나 늘리려고 락을 기다리게 한 뒤 interrupt()하세요. 작업자는 재고를 바꾸지 않고 '취소됨'을 기록한 뒤, main이 락을 풀기 전에 끝나야 합니다. 작업자가 기다리는 상태가 되었는지 확인하지 않아도 결과가 항상 같은지 설명하고, 같은 자리에 lock()을 쓰면 어떻게 되는지도 답하세요.
  2. 새 InterruptibleCriticalSection에서 현재 스레드의 플래그를 먼저 켠 뒤 execute()를 두 번 연속 호출합니다. 두 반환값과 completed(), 플래그를 답하세요. 이어서 테스트 목적으로 Thread.interrupted()를 호출해 플래그를 지우고 execute()를 한 번 더 호출하면 결과는 어떻게 되나요?
  3. execute()가 락을 얻은 직후, completed++를 실행하기 전에 interrupt가 도착했다고 가정합니다. 그 뒤의 코드에는 블로킹 호출도 플래그 검사도 없고 다른 예외도 나지 않습니다. 이 interrupt 때문에 증가가 취소되거나 이전 상태로 돌아가나요? 메서드가 끝난 뒤 플래그는 어떤 상태인가요?
해설

1번은 lockInterruptibly()를 써야 합니다. 작업자는 lockInterruptibly()에서 예외를 받아 임계 영역에 들어가지 않고, unlock()은 락을 얻은 경우의 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();
            worker.interrupt();
            worker.join();
            System.out.println("outcome=" + outcome[0] + ", stock=" + stock[0]);
        } finally {
            lock.unlock();
        }
    }
}
outcome=cancelled, stock=10

300번 실행해 모두 같은 결과를 얻었고, 이 결과는 작업자의 상태를 확인하지 않아도 달라지지 않습니다. main은 attempt.await()가 끝난 뒤, 즉 작업자가 countDown()을 실행한 뒤에 interrupt를 보냅니다. 이 interrupt가 lockInterruptibly() 호출 전에 도착하면 메서드는 들어오자마자 예외를 던지고, 대기 중에 도착하면 대기 중에 예외를 던집니다. main은 join()이 끝날 때까지 락을 쥐고 있으므로 작업자가 먼저 락을 얻는 경우는 없습니다. 그래서 재고는 바뀌지 않고, 작업자는 락을 얻지 않았으니 풀 것도 없으며, main의 finally가 main이 얻은 락을 풉니다.

같은 자리에 lock()을 쓰면 interrupt로는 대기가 끝나지 않습니다. main은 작업자가 끝나기를 join()으로 기다리고 작업자는 main이 락을 풀기를 기다리므로, 두 스레드가 서로를 기다리며 프로그램이 멈춥니다. join()을 join(500)으로 바꿔 실행해 보면 500밀리초 뒤에도 작업자는 WAITING 상태로 살아 있고 재고는 10입니다. 그 뒤 main이 락을 풀면 작업자가 락을 얻어 재고를 11로 바꿉니다.

2번에서 두 호출은 모두 false이고 completed()는 0, 플래그는 true입니다. 첫 호출에서 lockInterruptibly()가 들어오자마자 예외를 던지며 플래그를 지우지만, catch가 다시 켜 두므로 두 번째 호출도 같은 이유로 실패합니다. 이어서 Thread.interrupted()는 지금 플래그 값인 true를 반환하면서 플래그를 지웁니다. 그 뒤의 execute()는 락을 얻어 true를 반환하고 completed()는 1이 됩니다. 플래그를 지운 것은 이 실험에서 일부러 한 일이고, 실제 코드에서 취소 요청을 이렇게 지우면 종료하려던 쪽의 요청을 무시하게 됩니다.

3번에서 증가는 취소되지 않습니다. interrupt는 실행 중인 코드에 끼어들어 예외를 일으키지 않고 플래그만 켜므로, completed++가 실행되고 execute()는 true를 반환하며 finally가 락을 풉니다. 메서드가 끝난 뒤에도 플래그는 켜진 채로 남아 있어서, 다음에 interrupt에 반응하는 호출을 하거나 플래그를 확인하는 코드가 그 요청을 보게 됩니다. 락을 얻은 뒤에도 작업을 멈춰야 한다면, 적절한 지점에서 Thread.currentThread().isInterrupted()를 확인하는 코드를 직접 넣어야 합니다.

다음 글

tryLock과 시간 제한 락 획득에서는 기다리지 않거나 정해진 시간만 기다리는 tryLock()을 다룹니다. 획득에 실패했는데도 unlock()하는 실수를 고치고, 락을 얻지 못한 상황을 '지금은 바쁨'이라는 정상적인 결과로 돌려주는 방법을 봅니다.