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하지 않습니다. 재진입 전에 이미 보유한 획득분의 해제 책임은 별도로 남습니다.
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();
}
}LockIgnoresInterruptAssumption과 InterruptibleLockWait에서 main이 락을 보유한 동안 요청한 interrupt의 서로 다른 결과를 비교합니다.
| 비교 지점 | lock() 대기자 | lockInterruptibly() 대기자 |
|---|---|---|
| interrupt 뒤 회수 | join(20) 뒤에도 살아 있음 · wrong-cancelled=false | join이 끝나 종료됨 · interrupted-before-acquire |
| 작업자의 락 획득 | main이 해제한 뒤 획득함 | 첫 획득 없이 catch로 이동 |
| 작업자의 정리 | 획득 후 finally에서 해제 · 출력 flag=true | 작업자 unlock 없음 · main이 계속 보유하여 locked-by-main=true |
- interrupt 뒤 회수
lock()대기자:join(20)뒤에도 살아 있음 ·wrong-cancelled=falselockInterruptibly()대기자: join이 끝나 종료됨 ·interrupted-before-acquire- 작업자의 락 획득
lock()대기자: main이 해제한 뒤 획득함lockInterruptibly()대기자: 첫 획득 없이 catch로 이동- 작업자의 정리
lock()대기자: 획득 후 finally에서 해제 · 출력flag=truelockInterruptibly()대기자: 작업자 unlock 없음 · main이 계속 보유하여locked-by-main=true
두 예제의 WAITING 확인이 성공한 경로입니다. 상태 탐색은 200회로 제한돼 실패하면 예외로 끝납니다. lockInterruptibly 예제의 catch는 플래그를 복원하지만 그 값은 출력하지 않습니다.
작업자가 이 catch에서 완전히 종료된다면 별도의 취소 결과가 더 중요합니다.
세 acquisition 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입니다. 취소를 반환하는 분기는 이 실행에서 거치지 않습니다.
중단 시 수행 완료 작업 구분
락 획득 전 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로 락을 풀기 전에 상태를 유효하게 만들고 나가야 합니다.
무차별적으로 애플리케이션의 모든 Thread를 interrupt하면 라이브러리 백그라운드 스레드와 무관한 연산까지 영향을 받습니다.
구성 요소가 생성한 작업자 참조만 소유하고 생명 주기 API로 요청합니다.
연습 문제
main이 목록화 락을 보유한 동안 작업자가 수량 증가를 기다리게 한 뒤 interrupt하세요.
작업자는 갱신을 수행하지 않고 취소됨을 기록하며, main이 락을 풀기 전 종료해야 합니다.
정답과 소유 여부 확인
작업자는 lockInterruptibly에서 예외를 받아 임계 영역에 들어가지 않습니다.
unlock은 successful 획득 뒤 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이 됩니다.