lockInterruptibly와 대기 중단
락이 interrupt를 무시하고 계속 기다리는 결과를 재현한 뒤 중단 가능한 획득과 정리 정책을 구현합니다.
ReentrantLock을 선택하는 중요한 이유 중 하나는 락을 기다리는 스레드를 중단할 수 있다는 점입니다.
그러나 lock() 자체는 interrupt를 취소 요청으로 받아 획득을 포기하지 않습니다.
중단 가능성이 요구되면 호출 시점부터 lockInterruptibly()를 선택해야 합니다.
락 대기 스레드의 인터럽트 오해
main이 락을 보유한 상태에서 대기자가 lock()을 호출합니다.
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을 던지고 락 큐에서 빠져나옵니다.
처음 얻으려던 락을 획득하지 못한 경로에서는 unlock하지 않습니다. 재진입 전에 이미 보유한 획득분의 해제 책임은 별도로 남습니다.
interrupt()는 강제 종료가 아니라 협력 요청입니다. lockInterruptibly()는 이 요청을 감지하면 예외로 반환하고 중단 플래그를 지웁니다. catch의 interrupt()는 상위 호출자가 요청을 놓치지 않도록 플래그를 복원하는 별도 동작이며, 이미 수행한 업무를 되돌리지는 않습니다.
예제의 attempting.countDown()은 작업자가 획득 호출 직전까지 도착했다는 뜻입니다. 그 신호만으로 실제 대기 상태를 증명하지는 않습니다. 다만 main이 계속 락을 보유하고 작업자가 첫 획득을 시도한다면, 신호 직후 중단이 호출 전 또는 대기 중 어느 쪽에 도착해도 중단 가능한 획득은 실패합니다. 이 계약을 검사하려고 Thread.State.WAITING을 무한히 기다릴 필요는 없습니다.
예시
호출 전에 플래그가 설정돼 있으면 락이 비어 있는 경우에도 중단 가능한 획득이 예외로 끝날 수 있습니다. 다음은 이미 한 번 보유한 락에서 중첩 획득이 거절되는 별도 예제입니다.
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입니다. 실패한 중첩 호출에서 새 획득분을 반납하지 않고, 바깥 finally가 원래 한 획득분만 해제합니다. ReentrantLock의 중단 가능 계약은 재진입보다 중단을 우선하는 이 동작도 설명합니다.
획득 여부를 나타내는 boolean 변수 또는 try 블록 위치로 소유 여부를 분명하게 표현합니다.
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();
}
}같은 interrupt도 획득 API에 따라 회수 결과가 다르다
락 보유 중 두 획득 API의 interrupt 결과와 해제 뒤 진행이 다릅니다. 요청·중단 관찰·실제 획득/미획득·반납을 단계로 추적합니다.
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이 락을 보유하고 WAITING 관측이 성공한 경로의 두 독립 API 실행입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | main이 계속 보유하는 조건에서 lock 대기자는 interrupt 후에도 살아 있고 반납 뒤 획득합니다. lockInterruptibly 대기자는 첫 획득 없이 예외로 종료해 main이 보유한 채 join을 끝냅니다. |
| 10 | 획득 |
| 11 | flag=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는 미획득 예외로 끝납니다. |
|
|
| 각 회수와 책임 자기 획득 여부에 따라 반납과 조회를 구별합니다. |
|
|
같은 interrupt도 획득 API에 따라 회수 결과가 다르다
락 보유 중 두 획득 API의 interrupt 결과와 해제 뒤 진행이 다릅니다. 요청·중단 관찰·실제 획득/미획득·반납을 단계로 추적합니다.
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이 락을 보유하고 WAITING 관측이 성공한 경로의 두 독립 API 실행입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | main이 계속 보유하는 조건에서 lock 대기자는 interrupt 후에도 살아 있고 반납 뒤 획득합니다. lockInterruptibly 대기자는 첫 획득 없이 예외로 종료해 main이 보유한 채 join을 끝냅니다. |
| 10 | 획득 |
| 11 | flag=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는 미획득 예외로 끝납니다. |
|
|
| 각 회수와 책임 자기 획득 여부에 따라 반납과 조회를 구별합니다. |
|
|
작업자가 이 catch에서 완전히 종료된다면 별도의 취소 결과가 더 중요합니다.
세 획득 API의 취소·시간 특성
lock()은 무기한 기다리고 interrupt로 포기하지 않습니다.
lockInterruptibly()는 시간 제한은 없지만 interrupt에 응합니다.
시간 제한 tryLock은 시간 만료와 interrupt 모두 처리하며 boolean false 또는 예외로 원인을 구분합니다.
즉시 tryLock()은 기다리지 않아 스레드 interrupt 상태를 획득 예외로 바꾸지 않습니다.
호출 전에 플래그가 true일 때 어떤 정책으로 처리할지 호출자가 판단합니다.
API를 섞을 때 즉시 획득 실패, 시간 만료, 중단 예외가 어떤 업무 결과에 대응하는지 명시합니다.
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());
}
}제시한 main은 경합과 중단 요청 없이 실행하므로 executed=true, completed=1입니다. 취소를 반환하는 분기는 이 실행에서 거치지 않습니다.
현재 스레드의 플래그를 먼저 설정해 같은 서비스의 execute()를 호출하면 false이고 completed는 0입니다. 메서드가 플래그를 복원하므로 처리 없이 다시 호출해도 취소 경로를 탑니다. 호출자는 이 결과를 받아 종료·상위 전파 등 정책을 정해야 하며, 성공시키려고 중단 요청을 무조건 지워서는 안 됩니다. 정상 실행과 이 별도 취소 입력을 구별해 검산하세요.
중단 시 수행 완료 작업 구분
락 획득 전 interrupt라면 임계 영역은 시작하지 않았습니다.
락을 얻은 뒤 내부 블로킹 호출에서 interrupt가 발생하면 일부 상태가 변경됐을 수 있습니다.
롤백 가능한 지역 스테이징을 사용하거나 커밋 시점을 분명히 둡니다.
예를 들어 문서 발행이 락 안에서 메타데이터를 PUBLISHING으로 바꾼 뒤 네트워크 I/O 중 중단되면 finally unlock만으로 업무 상태가 복구되지 않습니다.
catch 또는 트랜잭션 계층이 FAILED·RETRYABLE 상태를 기록해야 합니다.
락 안전성과 도메인 일관성은 별도 책임입니다.
중단할 수 없는 대기
synchronized 모니터 진입의 BLOCKED 상태는 interrupt로 포기할 수 없습니다.
락 대기 취소가 서비스의 shutdown 요구라면 내장 모니터를 ReentrantLock으로 바꿀 실질적인 근거가 됩니다.
반대로 대기 시간이 짧고 상한이 있는 모니터라면 별도 취소 결과 없이 기다리는 편이 단순합니다.
네이티브 라이브러리나 외부 드라이버가 interrupt를 무시하면 lockInterruptibly로 락 대기만 취소돼도 임계 영역 진입 후의 I/O는 남습니다.
모든 블로킹 계층에 시간 제한과 취소 지원이 있는지 끝까지 추적합니다.
interrupt와 unlock 순서
shutdown 스레드는 대기자에 interrupt를 보내고 join으로 종료를 회수합니다.
락 소유자도 중단해야 하는지는 별도 선택입니다.
소유자가 불변식 중간에서 interrupt를 받더라도 finally로 락을 풀기 전에 상태를 유효하게 만들고 나가야 합니다.
무차별적으로 애플리케이션의 모든 Thread를 interrupt하면 라이브러리 백그라운드 스레드와 무관한 연산까지 영향을 받습니다.
구성 요소가 생성한 작업자 참조만 소유하고 생명 주기 API로 요청합니다.
연습 문제
main이 재고를 보호하는 락을 보유한 동안 작업자가 수량 증가를 기다리게 한 뒤 interrupt하세요.
작업자는 갱신을 수행하지 않고 취소됨을 기록하며, main이 락을 풀기 전 종료해야 합니다.
해설을 열기 전 판정표를 적어 보세요. main이 계속 보유하므로 작업자의 첫 획득은 불가능하고, lockInterruptibly가 예외를 내면 stock++를 건너뜁니다. 작업자에는 unlock 책임이 생기지 않지만 main의 책임은 남습니다. 같은 자리에 lock()을 쓰면 중단만으로 작업자가 종료하지 않으므로, main이 락을 가진 채 join하는 절차도 완료할 수 없습니다. 원 WAITING 폴링을 새 실습의 종료 보장으로 사용하지 마세요.
정답과 소유 여부 확인
작업자는 lockInterruptibly에서 예외를 받아 임계 영역에 들어가지 않습니다.
unlock은 성공한 획득 뒤 try-finally에만 있습니다.
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();
}
}
}이 main의 결과는 outcome=cancelled, stock=10입니다. 갱신에 성공하는 분기는 실행하지 않습니다.
main이 락을 보유한 상태에서 join이 끝나므로 작업자는 첫 획득을 하지 않았습니다. 다만 WAITING을 기다리는 반복에는 자체 제한 시간이 없습니다.
ReentrantLock 대기를 중단하려면 interrupt() 호출뿐 아니라 획득 API 자체가 중단 가능해야 합니다.
획득 전·후 취소를 나누고 성공한 소유자만 unlock하며 도메인 롤백까지 정의해야 안전한 shutdown이 됩니다.
취소 시점과 책임을 나누는 연습
아래 세 상황은 서로 독립적이며 외부 중단 요청은 명시한 것만 있습니다.
- main이 락을 계속 보유하고 작업자는 첫
lockInterruptibly()호출 직전에attempt.countDown()을 실행합니다. main은 신호를 받은 뒤 곧바로 작업자를 중단합니다.WAITING관측 없이도 갱신 0회·작업자 해제 0회를 설명할 수 있나요? 같은 코드를lock()으로 바꾸고 main이 보유한 채 작업자join()을 무기한 기다리면 어떤 문제가 생기나요? - 새
InterruptibleCriticalSection에서 현재 스레드를 먼저 중단한 뒤execute()를 두 번 연속 호출합니다. 두 반환값·completed·플래그를 답하세요. 이후 호출자가 테스트 목적상Thread.interrupted()로 요청을 소비하고 정상 호출을 한 번 수행한 경우도 별도로 구하세요. execute()가 이미 락을 얻은 직후,completed++전에 중단 요청이 도착했다고 가정합니다. 이후 코드에는 블로킹 호출과 플래그 검사가 없으며 다른 예외도 없습니다. 이 요청만으로 증가가 취소되거나 이전 상태로 복구되나요?
정답과 진입 전·후 경계
- main이 계속 소유해 첫 획득은 불가능합니다. 요청이 호출 전에 도착하면 진입 검사에서, 대기 중 도착하면 대기에서
InterruptedException이 발생해 갱신을 건너뜁니다. 작업자의 새 획득분은 없고 main의 해제 책임은 남습니다.lock()으로 바꾸면 중단으로 획득을 포기하지 않으므로, main은 join을 기다리고 작업자는 main의 해제를 기다리는 순환 대기가 됩니다. - 두 번 모두
false, completed는 0, 플래그는 복원되어true입니다. 별도 정상 호출 전에Thread.interrupted()는 기존true를 반환하며 지웁니다. 그 뒤 실행은true, completed는 1입니다. 요청을 지우는 단계는 이 실험의 명시적 입력이며 일반 취소 정책으로 자동 적용하지 않습니다. - 이 경로에서는 증가가 실행되어
true를 반환하고finally에서 획득분을 해제합니다. 획득 완료 뒤 중단은 명령을 되감거나 코드 사이에 자동으로 예외를 삽입하지 않습니다. 취소 점검·커밋·복구 정책은 별도로 설계해야 합니다.