park와 interrupt, BLOCKED와 WAITING
interrupt된 스레드에서 park가 어떻게 돌아오는지, parkNanos로 남은 시간을 계산하며 기다리는 방법, 모니터를 기다리는 BLOCKED와 사건을 기다리는 WAITING의 차이를 배웁니다.
앞 글에서는 unpark가 허가증을 주면 park가 돌아온다는 것을 보았습니다. 그런데 park가 돌아오는 이유는 그것만이 아닙니다. 다른 스레드가 기다리는 스레드를 interrupt해도 돌아오고, 아무 이유 없이 돌아올 수도 있습니다. 이 글은 그중 interrupt를 자세히 봅니다. park는 Thread.sleep이나 Object.wait와 달리 InterruptedException을 던지지 않고 interrupt 플래그도 지우지 않기 때문에, 이 차이를 모르고 대기 반복문을 짜면 스레드가 쉬지도 끝나지도 않고 헛돌게 됩니다.
뒤에서는 parkNanos로 기다리는 시간에 상한을 두는 방법과, 스레드 덤프에서 비슷해 보이는 두 대기 상태 BLOCKED와 WAITING의 차이를 다룹니다. 둘 다 스레드가 멈춰 있다는 점은 같지만 기다리는 대상이 다르고 interrupt에 반응하는 방식도 달라서, 문제를 풀 때 써야 할 도구가 달라집니다. interrupt 플래그 자체의 동작은 interrupt 플래그와 취소를 다룬 글에서 설명했습니다.
interrupt를 받은 park는 예외 없이 돌아온다
먼저 sleep과 park를 나란히 놓고 interrupt에 어떻게 반응하는지 보겠습니다. 아래 프로그램에서 main 스레드는 스스로 interrupt 플래그를 세운 뒤 Thread.sleep을 한 번 호출하고, 다시 플래그를 세운 뒤 LockSupport.park를 한 번 호출합니다. 다른 스레드를 쓰지 않으므로 실행할 때마다 같은 결과가 나옵니다.
import java.util.concurrent.locks.LockSupport;
public final class SleepVersusPark {
public static void main(String[] args) {
Thread.currentThread().interrupt();
try {
Thread.sleep(1_000);
System.out.println("sleep: returned normally");
} catch (InterruptedException e) {
System.out.println(
"sleep: InterruptedException, flag=" + Thread.currentThread().isInterrupted());
}
Thread.currentThread().interrupt();
LockSupport.park();
System.out.println("park: returned, flag=" + Thread.currentThread().isInterrupted());
}
}sleep: InterruptedException, flag=false
park: returned, flag=true첫 줄에서 sleep은 플래그가 이미 서 있는 것을 보고 1초를 기다리지 않은 채 곧바로 InterruptedException을 던졌습니다. 예외를 던지면서 플래그를 지웠기 때문에 catch 안에서 읽은 값은 false입니다. 두 번째 줄의 park도 기다리지 않고 바로 돌아왔지만, 예외를 던지지 않았고 플래그도 true 그대로입니다. 즉 park는 interrupt 요청이 오면 돌아오기만 하고, 그 요청을 어떻게 처리할지는 호출한 코드에 맡깁니다.
park가 플래그를 지우지 않는다는 것은 바로 다음 park도 기다리지 않는다는 뜻입니다. 아래 작업자는 스스로 interrupt한 뒤 park를 두 번 호출합니다. 첫 번째 park가 interrupt 요청을 써 버린다고 잘못 생각하면 두 번째 park에서는 작업자가 멈출 것이라고 예상하게 되는데, 출력의 wrong-second-wait가 바로 이 잘못된 예상을 나타냅니다. 첫 park 뒤에 플래그가 지워져 있었다면 !afterFirst가 true가 되어 예상이 맞았다는 뜻이 됩니다.
import java.util.concurrent.locks.LockSupport;
public final class ParkInterruptFlagAssumption {
public static void main(String[] args) throws InterruptedException {
Thread worker =
new Thread(
() -> {
Thread.currentThread().interrupt();
LockSupport.park();
boolean afterFirst = Thread.currentThread().isInterrupted();
LockSupport.park();
System.out.println(
"wrong-second-wait="
+ !afterFirst
+ ", flag="
+ Thread.currentThread().isInterrupted());
},
"flagged-parker");
worker.start();
worker.join();
}
}실행하면 wrong-second-wait=false, flag=true 한 줄을 출력하고 바로 끝납니다. 첫 park 뒤에 읽은 플래그가 true였으므로 !afterFirst는 false이고, 두 번째 park도 기다리지 않고 돌아와서 마지막에 읽은 플래그도 true입니다. 작업자가 스스로 플래그를 세우므로 결과가 스레드 실행 순서에 좌우되지 않고, 200번 실행했을 때도 매번 같은 줄이 나왔습니다.
대기 방법마다 interrupt에 반응하는 방식을 모아 보면 다음과 같습니다. 아래쪽 세 줄은 이 글 뒤쪽과 다음 글들에서 다시 다루는데, 비교하기 좋도록 미리 넣었습니다.
| 대기 방법 | interrupt를 받으면 | interrupt 플래그 |
|---|---|---|
Thread.sleep, Object.wait, Thread.join | InterruptedException을 던진다 | 예외를 던지면서 지운다 |
LockSupport.park, parkNanos, parkUntil | 예외 없이 돌아온다 | 그대로 둔다 |
synchronized 진입 대기(BLOCKED) | 계속 기다린다 | 모니터를 얻은 뒤에도 남아 있다 |
ReentrantLock.lock() | 계속 기다린다 | 락을 얻은 뒤에도 남아 있다 |
ReentrantLock.lockInterruptibly() | InterruptedException을 던진다 | 예외를 던지면서 지운다 |
플래그를 확인하지 않는 park 반복문은 헛돈다
park는 대개 조건이 참이 될 때까지 기다리는 반복문 안에서 씁니다. 앞 글에서 본 것처럼 park는 이유 없이 돌아올 수도 있으므로, 돌아올 때마다 조건을 다시 확인해야 하기 때문입니다. 그런데 이 반복문이 interrupt 플래그를 확인하지 않으면 문제가 생깁니다. 플래그는 한 번 서면 누군가 지우기 전까지 남아 있으므로 그 뒤의 park는 모두 기다리지 않고 돌아오고, 반복문은 조건을 확인하고 park를 부르는 일을 쉬지 않고 되풀이합니다.
아래 프로그램은 그 상황을 100ms 동안 재현합니다. 플래그를 세운 뒤 조건 대신 시간을 보며 park를 계속 부르고, 그동안 park가 몇 번 돌아왔는지 셉니다.
import java.util.concurrent.locks.LockSupport;
public final class InterruptedParkSpin {
public static void main(String[] args) {
Thread.currentThread().interrupt();
long end = System.nanoTime() + 100_000_000L; // 100ms
long parks = 0;
while (System.nanoTime() - end < 0) {
LockSupport.park();
parks++;
}
System.out.println("parks=" + parks + ", flag=" + Thread.currentThread().isInterrupted());
}
}한 번 실행한 결과는 다음과 같습니다. parks는 100ms 동안 park가 돌아온 횟수이고, flag는 반복이 끝난 뒤의 인터럽트 상태입니다. park가 한 번이라도 제대로 기다렸다면 횟수는 아주 작아야 합니다.
parks=421381, flag=true횟수는 실행할 때마다 크게 달라집니다. 이 글을 쓰며 2코어 환경에서 40번 실행했을 때 적게는 68,340번, 많게는 1,105,618번이었고, 플래그는 매번 true였습니다. 중요한 것은 정확한 값이 아니라, 기다려야 할 스레드가 0.1초 동안 수만 번에서 백만 번 넘게 깨어났다는 사실입니다. 실제 서비스에서 이런 반복문은 아무 일도 하지 않으면서 CPU 코어 하나를 계속 차지합니다. 게다가 park는 검사 예외를 던지지 않으므로 컴파일러도 이 실수를 알려 주지 않습니다.
해결하려면 park가 돌아온 뒤 플래그를 확인하고, 그 interrupt를 어떻게 처리할지 정해야 합니다. 대개 두 가지 중 하나를 고릅니다. interrupt를 그만 기다리라는 취소 요청으로 받아들여 sleep이나 wait처럼 InterruptedException을 던지거나, 끝까지 기다려야 하는 대기라면 요청이 왔다는 사실만 기억해 두었다가 대기가 끝난 뒤 플래그를 다시 세우는 것입니다. 아래 클래스의 두 메서드가 각각 이 두 정책입니다.
import java.util.concurrent.locks.LockSupport;
public final class ParkWaitPolicies {
private volatile boolean ready;
// 정책 1: interrupt를 취소 요청으로 받아들여 sleep·wait처럼 예외를 던진다
void awaitReady() throws InterruptedException {
while (!ready) {
LockSupport.park(this);
if (Thread.interrupted()) {
throw new InterruptedException();
}
}
}
// 정책 2: 끝까지 기다리되, 받은 interrupt 요청은 대기가 끝난 뒤 되돌려 놓는다
void awaitReadyUninterruptibly() {
boolean interrupted = false;
while (!ready) {
LockSupport.park(this);
if (Thread.interrupted()) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
ParkWaitPolicies cancellable = new ParkWaitPolicies();
Thread.currentThread().interrupt();
try {
cancellable.awaitReady();
} catch (InterruptedException e) {
System.out.println(
"awaitReady: InterruptedException, flag="
+ Thread.currentThread().isInterrupted());
}
ParkWaitPolicies stubborn = new ParkWaitPolicies();
Thread waiter =
new Thread(
() -> {
stubborn.awaitReadyUninterruptibly();
System.out.println(
"awaitReadyUninterruptibly: returned, flag="
+ Thread.currentThread().isInterrupted());
},
"stubborn-waiter");
waiter.start();
waiter.interrupt();
stubborn.ready = true;
LockSupport.unpark(waiter);
waiter.join();
}
}awaitReady: InterruptedException, flag=false
awaitReadyUninterruptibly: returned, flag=trueawaitReady는 Thread.interrupted()로 플래그를 읽으면서 지운 뒤 예외를 던집니다. 플래그를 지우고 예외를 던지는 것은 sleep이나 wait와 같은 규칙이므로, 호출하는 쪽은 이 메서드를 다른 블로킹 메서드와 똑같이 다룰 수 있습니다. main은 플래그를 미리 세워 둔 채 이 메서드를 부르므로, 첫 park가 곧바로 돌아오고 예외가 던져져 첫 줄의 flag=false가 나옵니다.
awaitReadyUninterruptibly도 Thread.interrupted()로 플래그를 지우지만 목적이 다릅니다. 플래그를 지워야 다음 park가 다시 기다리므로, 앞에서 본 헛도는 반복을 막을 수 있습니다. 대신 지역 변수 interrupted에 요청이 왔다는 사실을 적어 두었다가, 조건이 참이 되어 반복문을 빠져나온 뒤 Thread.currentThread().interrupt()로 플래그를 다시 세웁니다. 이렇게 해야 이 메서드를 부른 상위 코드가 취소 요청을 잃지 않습니다. 앞의 표에서 ReentrantLock.lock()이 락을 얻은 뒤에도 플래그를 남겨 두는 것도 안에서 이와 같은 일을 하기 때문입니다.
main의 두 번째 부분은 작업자를 시작하자마자 interrupt하고, 이어서 ready를 true로 만든 뒤 unpark합니다. 작업자가 반복문에 들어가기 전에 이 일이 모두 끝날 수도 있고, 반복문 안에서 interrupt를 먼저 받을 수도 있습니다. 앞의 경우에는 플래그를 지운 적이 없으므로 그대로 남아 있고, 뒤의 경우에는 지웠다가 반복문이 끝난 뒤 다시 세우므로, 어느 순서로 실행되든 두 번째 줄은 flag=true가 됩니다. 실제로 200번 실행했을 때 두 줄 모두 매번 같았습니다.
더 깊이: park가 예외를 던지지 않는 이유
park는 응용 코드보다는 동기화 도구를 만드는 데 쓰라고 만든 저수준 부품입니다. ReentrantLock, Semaphore, CountDownLatch 같은 도구는 내부의 AbstractQueuedSynchronizer(AQS)로 대기 큐를 관리하고, 큐에 들어간 스레드를 park로 재웁니다. 그런데 이 도구들은 interrupt를 다루는 정책이 서로 다릅니다. lock()은 interrupt를 받아도 계속 기다렸다가 락을 얻은 뒤 플래그를 다시 세워 두고, lockInterruptibly()는 곧바로 InterruptedException을 던집니다.
park가 예외를 던지거나 플래그를 지워 버린다면 앞의 정책은 만들 수 없습니다. park는 돌아오기만 하고 플래그는 건드리지 않기 때문에, 그 위에 올라가는 코드가 원하는 정책을 고를 수 있습니다. 앞에서 본 ParkWaitPolicies의 두 메서드가 이 두 정책을 아주 작게 줄인 모습입니다.
다른 스레드가 기다리는 스레드를 interrupt할 때
지금까지는 스레드가 스스로 interrupt했지만, 실제로는 다른 스레드가 기다리는 스레드를 interrupt하는 경우가 대부분입니다. 아래 프로그램에서 작업자는 래치 entered를 연 뒤 park에 들어가고, main은 작업자가 WAITING이 된 것을 확인한 다음 interrupt하고 작업자가 끝나기를 기다립니다. park에 넘긴 문자열은 앞 글에서 본 blocker로, 스레드 덤프에서 무엇 때문에 기다리는지 보여 주는 용도입니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.LockSupport;
public final class ParkInterruptObservation {
public static void main(String[] args) throws InterruptedException {
CountDownLatch entered = new CountDownLatch(1);
Thread worker =
new Thread(
() -> {
entered.countDown();
LockSupport.park("cancel-observation");
System.out.println(
"returned flag=" + Thread.currentThread().isInterrupted());
},
"park-observer");
worker.start();
entered.await();
while (worker.getState() != Thread.State.WAITING) {
Thread.onSpinWait();
}
System.out.println("before=" + worker.getState());
worker.interrupt();
worker.join();
System.out.println("after=" + worker.getState());
}
}이 글을 쓰며 200번 실행했을 때 출력은 매번 다음과 같았습니다. 첫 줄과 마지막 줄은 main이, 가운데 줄은 작업자가 출력한 것입니다.
before=WAITING
returned flag=true
after=TERMINATEDmain이 래치만 기다리지 않고 상태까지 확인하는 이유는, 래치가 park 호출보다 먼저 열리기 때문입니다. 래치가 열렸다는 것은 작업자가 park 직전까지 왔다는 뜻일 뿐, 이미 기다리기 시작했다는 뜻은 아닙니다. 그래서 getState()가 WAITING을 돌려줄 때까지 Thread.onSpinWait()로 짧게 반복한 뒤 before를 찍고 interrupt를 보냅니다. 그러면 작업자의 park가 돌아오고, 작업자는 플래그를 읽어 returned flag=true를 찍은 뒤 끝납니다. join이 정상적으로 돌아온 뒤 읽은 상태는 TERMINATED입니다.
하지만 세 줄 가운데 언제나 성립하는 것은 마지막 줄뿐입니다. getState()는 그 순간의 상태를 찍은 사진이고, before를 출력할 때는 상태를 한 번 더 조회하므로 그 값이 여전히 WAITING이라는 보장도 없습니다. park가 이유 없이 먼저 돌아왔다면 작업자는 interrupt를 받기 전에 returned flag=false를 찍고 끝나며, 이 줄이 before보다 먼저 나올 수도 있습니다. 작업자가 WAITING에 머문 순간을 main이 한 번도 보지 못한 채 작업자가 끝나 버리면, 상한이 없는 확인 반복은 끝나지 않습니다. 그래서 상태 조회는 실험에서 들여다보는 용도로만 쓰고, 실제 코드에서 스레드끼리 진행을 맞출 때는 volatile 조건이나 래치 같은 동기화 수단을 써야 합니다.
인터럽트 플래그와 상태 관측은 보장 범위가 다르다
자기 interrupt 후 두 park 반환에서도 플래그 유지, 별도 상태 폴링 경로의 순간 관측을 보여 줍니다. 각 프로그램의 조회·중단·종료를 순차 강조하되 관측 보장을 만들지 않습니다.
ParkInterruptFlagAssumption의 자기 인터럽트 결과와 ParkInterruptObservation의 세 출력 지점을 구분합니다.
PARK · 플래그와 상태
인터럽트 플래그와 상태 관측은 보장 범위가 다르다
park는 인터럽트 플래그를 지우지 않습니다. 플래그 계약과 before·after 상태 조회의 보장 범위를 구분합니다.
자기 interrupt 예제
- 읽을 수 있는 결과
- 두 park 뒤에도
flag=true입니다. - 그 결과의 범위
- 플래그를 지우지 않으므로
wrong-second-wait=false이며 대기 시간을 측정한 값은 아닙니다.
관측 예제의 before
- 읽을 수 있는 결과
- WAITING 폴링 뒤 상태를 새로 읽습니다.
- 그 결과의 범위
- 새 조회도 WAITING이라고 고정할 수 없습니다.
관측 예제의 작업자 로그
- 읽을 수 있는 결과
- park 뒤
isInterrupted()를 읽습니다. - 그 결과의 범위
- 임의 반환이 앞서면 false일 수 있고 before 로그와의 순서도 바뀔 수 있습니다.
관측 예제의 after
- 읽을 수 있는 결과
- 성공한 join 뒤 상태를 읽습니다.
- 그 결과의 범위
- 이 지점까지 완료했을 때는 TERMINATED입니다.
entered 래치는 park 호출보다 먼저 해제됩니다. main이 WAITING 순간을 놓치면, 작업자가 임의 반환 후 종료했어도 상태 폴링에서 계속 기다릴 수 있습니다.
이 예의 조건
- 자기 interrupt 두 park 실행과 entered/WAITING 폴링 실행은 독립 프로그램입니다.
- park는 플래그를 소거하지 않으며 상태 관측 반복에는 상한이 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 자기 interrupt 실험의 두 park는 flag true를 유지합니다. 별도의 entered-before park/상태 폴링/정상 join의 조회는 다른 보장 범위이며 false flag/관측 실패 가능성을 숨기지 않습니다. |
| 9 | 소거 안 함 |
| 구분 | 내용 |
|---|---|
| 설명 | 자기 interrupt 실험의 두 park는 flag true를 유지합니다. 별도의 entered-before park/상태 폴링/정상 join의 조회는 다른 보장 범위이며 false flag/관측 실패 가능성을 숨기지 않습니다. |
| 전체 읽기 정보 | 인터럽트 플래그와 상태 관측은 보장 범위가 다르다 인터럽트 플래그와 상태 관측은 보장 범위가 다르다 ParkInterruptFlagAssumption의 자기 인터럽트 결과와 ParkInterruptObservation의 세 출력 지점을 구분합니다. PARK · 플래그와 상태 인터럽트 플래그와 상태 관측은 보장 범위가 다르다 park는 인터럽트 플래그를 지우지 않습니다. 플래그 계약과 before·after 상태 조회의 보장 범위를 구분합니다. 자기 interrupt 예제 읽을 수 있는 결과 두 park 뒤에도 flag=true 입니다. 그 결과의 범위 플래그를 지우지 않으므로 wrong-second-wait=false 이며 대기 시간을 측정한 값은 아닙니다. 관측 예제의 before 읽을 수 있는 결과 WAITING 폴링 뒤 상태를 새로 읽습니다. 그 결과의 범위 새 조회도 WAITING이라고 고정할 수 없습니다. 관측 예제의 작업자 로그 읽을 수 있는 결과 park 뒤 isInterrupted() 를 읽습니다. 그 결과의 범위 임의 반환이 앞서면 false일 수 있고 before 로그와의 순서도 바뀔 수 있습니다. 관측 예제의 after 읽을 수 있는 결과 성공한 join 뒤 상태를 읽습니다. 그 결과의 범위 이 지점까지 완료했을 때는 TERMINATED입니다. entered 래치는 park 호출보다 먼저 해제됩니다. main이 WAITING 순간을 놓치면, 작업자가 임의 반환 후 종료했어도 상태 폴링에서 계속 기다릴 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 독립 시작 조건 자기 요청 프로그램은 플래그를 세우고 상태 관측 프로그램은 entered를 알립니다. |
|
|
| 대기와 조회 범위 각 독립 프로그램의 park와 관측을 같은 시점에서 비교합니다. |
|
|
| 후속 확인 각 프로그램이 확인할 수 있는 마지막 사실을 비교합니다. |
|
|
인터럽트 플래그와 상태 관측은 보장 범위가 다르다
자기 interrupt 후 두 park 반환에서도 플래그 유지, 별도 상태 폴링 경로의 순간 관측을 보여 줍니다. 각 프로그램의 조회·중단·종료를 순차 강조하되 관측 보장을 만들지 않습니다.
ParkInterruptFlagAssumption의 자기 인터럽트 결과와 ParkInterruptObservation의 세 출력 지점을 구분합니다.
PARK · 플래그와 상태
인터럽트 플래그와 상태 관측은 보장 범위가 다르다
park는 인터럽트 플래그를 지우지 않습니다. 플래그 계약과 before·after 상태 조회의 보장 범위를 구분합니다.
자기 interrupt 예제
- 읽을 수 있는 결과
- 두 park 뒤에도
flag=true입니다. - 그 결과의 범위
- 플래그를 지우지 않으므로
wrong-second-wait=false이며 대기 시간을 측정한 값은 아닙니다.
관측 예제의 before
- 읽을 수 있는 결과
- WAITING 폴링 뒤 상태를 새로 읽습니다.
- 그 결과의 범위
- 새 조회도 WAITING이라고 고정할 수 없습니다.
관측 예제의 작업자 로그
- 읽을 수 있는 결과
- park 뒤
isInterrupted()를 읽습니다. - 그 결과의 범위
- 임의 반환이 앞서면 false일 수 있고 before 로그와의 순서도 바뀔 수 있습니다.
관측 예제의 after
- 읽을 수 있는 결과
- 성공한 join 뒤 상태를 읽습니다.
- 그 결과의 범위
- 이 지점까지 완료했을 때는 TERMINATED입니다.
entered 래치는 park 호출보다 먼저 해제됩니다. main이 WAITING 순간을 놓치면, 작업자가 임의 반환 후 종료했어도 상태 폴링에서 계속 기다릴 수 있습니다.
이 예의 조건
- 자기 interrupt 두 park 실행과 entered/WAITING 폴링 실행은 독립 프로그램입니다.
- park는 플래그를 소거하지 않으며 상태 관측 반복에는 상한이 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 자기 interrupt 실험의 두 park는 flag true를 유지합니다. 별도의 entered-before park/상태 폴링/정상 join의 조회는 다른 보장 범위이며 false flag/관측 실패 가능성을 숨기지 않습니다. |
| 9 | 소거 안 함 |
| 구분 | 내용 |
|---|---|
| 설명 | 자기 interrupt 실험의 두 park는 flag true를 유지합니다. 별도의 entered-before park/상태 폴링/정상 join의 조회는 다른 보장 범위이며 false flag/관측 실패 가능성을 숨기지 않습니다. |
| 전체 읽기 정보 | 인터럽트 플래그와 상태 관측은 보장 범위가 다르다 인터럽트 플래그와 상태 관측은 보장 범위가 다르다 ParkInterruptFlagAssumption의 자기 인터럽트 결과와 ParkInterruptObservation의 세 출력 지점을 구분합니다. PARK · 플래그와 상태 인터럽트 플래그와 상태 관측은 보장 범위가 다르다 park는 인터럽트 플래그를 지우지 않습니다. 플래그 계약과 before·after 상태 조회의 보장 범위를 구분합니다. 자기 interrupt 예제 읽을 수 있는 결과 두 park 뒤에도 flag=true 입니다. 그 결과의 범위 플래그를 지우지 않으므로 wrong-second-wait=false 이며 대기 시간을 측정한 값은 아닙니다. 관측 예제의 before 읽을 수 있는 결과 WAITING 폴링 뒤 상태를 새로 읽습니다. 그 결과의 범위 새 조회도 WAITING이라고 고정할 수 없습니다. 관측 예제의 작업자 로그 읽을 수 있는 결과 park 뒤 isInterrupted() 를 읽습니다. 그 결과의 범위 임의 반환이 앞서면 false일 수 있고 before 로그와의 순서도 바뀔 수 있습니다. 관측 예제의 after 읽을 수 있는 결과 성공한 join 뒤 상태를 읽습니다. 그 결과의 범위 이 지점까지 완료했을 때는 TERMINATED입니다. entered 래치는 park 호출보다 먼저 해제됩니다. main이 WAITING 순간을 놓치면, 작업자가 임의 반환 후 종료했어도 상태 폴링에서 계속 기다릴 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 독립 시작 조건 자기 요청 프로그램은 플래그를 세우고 상태 관측 프로그램은 entered를 알립니다. |
|
|
| 대기와 조회 범위 각 독립 프로그램의 park와 관측을 같은 시점에서 비교합니다. |
|
|
| 후속 확인 각 프로그램이 확인할 수 있는 마지막 사실을 비교합니다. |
|
|
위 단계 그림은 앞의 두 프로그램을 나란히 놓고, 각각에서 확실히 말할 수 있는 결과와 실행마다 달라질 수 있는 결과를 나눕니다. ParkInterruptFlagAssumption의 flag=true는 park가 플래그를 지우지 않는다는 규칙에서 나오는 결과입니다. 반면 ParkInterruptObservation의 before와 returned flag는 스케줄링에 따라 달라질 수 있는 순간의 조회입니다.
parkNanos로 기다리는 시간에 상한 두기
기다림에 시간 제한이 필요하면 parkNanos(nanos)를 씁니다. 허가증이 없고 interrupt도 없으면 최대 nanos 나노초 동안 기다리지만, 그 전에 unpark나 interrupt를 받거나 이유 없이 돌아올 수도 있습니다. 그리고 park와 마찬가지로 왜 돌아왔는지는 알려 주지 않습니다. 그래서 시간 제한 대기도 반복문으로 쓰고, 돌아올 때마다 조건과 interrupt와 남은 시간을 직접 확인해야 합니다.
아래 프로그램의 await는 ready 플래그가 참이 되기를 최대 30ms 동안 기다립니다. 이 예제에는 플래그를 바꾸는 스레드가 없으므로 시간 초과로 끝납니다.
import java.time.Duration;
import java.util.concurrent.locks.LockSupport;
public final class ParkNanosDeadline {
private static final class ReadyFlag {
volatile boolean ready;
}
public static void main(String[] args) throws InterruptedException {
ReadyFlag flag = new ReadyFlag();
Thread waiter =
new Thread(
() -> System.out.println("outcome=" + await(flag, Duration.ofMillis(30))),
"timed-parker");
waiter.start();
waiter.join();
}
private static String await(ReadyFlag flag, Duration timeout) {
long deadline = System.nanoTime() + timeout.toNanos();
while (!flag.ready) {
if (Thread.currentThread().isInterrupted()) return "interrupted";
long remaining = deadline - System.nanoTime();
if (remaining <= 0) return "timed-out";
LockSupport.parkNanos(remaining);
}
return "ready";
}
}실행하면 outcome=timed-out이 출력되고, 200번 실행에서도 모두 같았습니다. 반복문은 매번 세 가지를 순서대로 확인합니다. ready가 참이면 성공, interrupt 플래그가 서 있으면 취소, 기한이 지났으면 시간 초과입니다. 셋 다 아니면 남은 시간만큼만 parkNanos로 기다린 뒤 처음으로 돌아가고, 30ms가 지나면 remaining이 0 이하가 되어 timed-out을 돌려줍니다.
여기서 핵심은 기한 deadline을 반복문 밖에서 한 번만 계산한다는 점입니다. 매번 parkNanos에 처음 받은 30ms를 그대로 넘기면, park가 일찍 돌아올 때마다 기다리는 시간이 새로 시작되어 전체 대기가 얼마든지 길어질 수 있습니다. 예산이 100ms이고 park가 두 번 일찍 돌아온 경우를 따라가 보겠습니다.
| 시점 | 시작 후 지난 시간 | 다음 parkNanos에 넘길 값 |
|---|---|---|
| 처음 | 0ms | 100,000,000ns |
| 첫 번째로 일찍 돌아옴 | 32ms | 68,000,000ns |
| 두 번째로 일찍 돌아옴 | 52ms | 48,000,000ns |
| 그 뒤 48ms가 더 지나 돌아옴 | 100ms | 남은 시간이 0 이하이므로 timed-out |
시간을 System.nanoTime()으로 재는 데도 이유가 있습니다. nanoTime은 경과 시간을 재기 위한 값이라서 벽시계 시각과 관계가 없고, 시스템 시계를 조정해도 경과 시간 계산이 흔들리지 않습니다. 대신 값 자체는 날짜와 아무 관련이 없어서, 같은 JVM 안에서 잰 두 값의 차이만 의미가 있습니다.
비교도 now >= deadline이 아니라 deadline - now <= 0처럼 뺄셈으로 해야 합니다. nanoTime의 기준점은 정해져 있지 않아서 deadline을 구하는 덧셈이 long 범위를 넘칠 수 있는데, 넘치더라도 두 값의 차이는 뺄셈으로 정확히 구해지기 때문입니다. LockSupport에는 기한을 절대 시각으로 받는 parkUntil(deadline)도 있지만, 이 인자는 1970년 1월 1일부터 센 밀리초, 즉 벽시계 시각입니다. 시스템 시계가 바뀌면 실제로 기다리는 시간도 달라질 수 있으므로, 최대 몇 밀리초까지 기다린다는 대기에는 nanoTime과 parkNanos의 조합이 맞습니다.
남은 시간이 0 이하인지를 parkNanos를 부르기 전에 확인하는 것도 의미가 있습니다. 문서에 따르면 parkNanos는 0이나 음수를 받으면 아무것도 하지 않고 바로 돌아옵니다. 그래서 이 확인이 없으면 기한이 지난 뒤로는 parkNanos가 곧바로 돌아오기만 하고, 반복문은 끝나지 않은 채 헛돕니다. 또 남은 시간이 아주 짧을 때는 park 자체의 비용과 운영체제 스케줄러의 시간 단위가 더 크기 때문에, 넘긴 값만큼만 정확히 기다리고 돌아온다고 기대할 수 없습니다.
이 예제에서 ready를 true로 바꾸는 쪽이 있다면 무엇을 해야 할까요? flag.ready = true로 조건을 바꾸고, 대기자의 Thread 참조를 보관해 두었다가 LockSupport.unpark도 호출해야 합니다. unpark를 빼먹어도 대기자는 언젠가 조건을 보지만, 그 시점은 parkNanos가 시간을 다 채우고 돌아온 뒤입니다. 반대로 unpark만 하고 조건을 바꾸지 않으면 대기자는 깨어나 조건을 확인한 뒤 다시 잠듭니다.
더 깊이: interrupt와 unpark가 보장하는 가시성
Java 메모리 모델에는 interrupt에 관한 순서 규칙이 하나 있습니다. JLS 17.4.4에 따르면 스레드 T1이 T2를 interrupt하면, 그 interrupt는 어느 스레드든 T2가 interrupt되었음을 알아내는 지점과 synchronizes-with 관계를 맺습니다. 알아내는 지점이란 InterruptedException이 던져지거나 Thread.interrupted 또는 isInterrupted를 호출한 곳입니다. 그래서 T1이 interrupt() 전에 쓴 값은, T2가 플래그를 확인해 true를 읽은 뒤라면 보입니다. 다만 park가 돌아온 것만으로는 이 순서가 생기지 않고, 플래그를 직접 읽어야 합니다.
unpark에는 이런 규칙이 없습니다. LockSupport 문서는 언제 park하고 unpark할지를 volatile(또는 원자) 변수로 정해야 안정적으로 쓸 수 있다고 하고, 이 메서드 호출들의 순서는 volatile 변수 접근에 대해서는 지켜지지만 일반 변수 접근에 대해서는 그렇지 않을 수 있다고 적고 있습니다. 즉 unpark 전에 일반 필드에 쓴 값이 깨어난 스레드에 보인다는 보장이 없습니다. ParkNanosDeadline의 ready나 연습 문제의 open을 volatile로 선언한 것이 이 때문입니다. volatile 변수에 쓴 값을 다른 스레드가 읽으면 두 동작 사이에 happens-before 관계가 생기므로, ready가 true인 것을 본 대기자는 그보다 앞서 쓰인 값도 볼 수 있습니다.
직접 만든 대기 반복문의 네 갈래
지금까지 본 대기 반복문은 모두 같은 뼈대를 가집니다. 반복할 때마다 조건이 참이면 성공으로, interrupt 플래그가 서 있으면 취소로, 기한이 지났으면 시간 초과로 끝내고, 어느 것도 아니면 다시 park합니다. park가 돌아왔다는 사실 자체는 이 네 갈래 어디에도 들어가지 않습니다. 허가증은 다시 확인해 보라는 신호일 뿐 대기 조건이 아니기 때문입니다.
여러 갈래가 동시에 참이면 확인 순서가 결과를 정합니다. ParkNanosDeadline은 조건을 가장 먼저 보므로, 조건이 참이 된 순간 interrupt도 와 있었다면 취소가 아니라 성공을 돌려줍니다. 취소를 무엇보다 먼저 존중해야 하는 작업이라면 interrupt 확인을 맨 앞으로 옮기면 됩니다. 어느 쪽이 맞는지는 업무가 정할 일이고, 중요한 것은 이 순서를 한곳에 모아 일부러 정해 두는 것입니다. 대기 코드를 도우미 메서드 하나에 모아 두면 호출하는 곳마다 park의 반환을 제각각 해석하는 실수도 줄어듭니다.
BLOCKED는 모니터를, WAITING은 사건을 기다린다
Thread.State에서 스레드가 멈춰 있는 상태는 세 가지입니다. BLOCKED는 synchronized 블록이나 메서드에 들어가려고 모니터 락을 기다리는 상태이고, wait에서 깨어난 스레드가 모니터를 다시 얻으려고 기다릴 때도 BLOCKED입니다. WAITING은 다른 스레드가 어떤 일을 해 주기를 시간 제한 없이 기다리는 상태로, 시간 제한 없는 Object.wait, 시간 제한 없는 Thread.join, LockSupport.park가 이 상태를 만듭니다. TIMED_WAITING은 같은 기다림에 시간 제한이 붙은 경우로 Thread.sleep, 시간 제한이 있는 wait와 join, parkNanos, parkUntil이 여기에 해당합니다.
아래 프로그램은 두 작업자를 각각 WAITING과 BLOCKED로 만든 뒤, BLOCKED인 작업자를 interrupt해 봅니다. plain-waiting은 park를 호출하고, monitor-blocked는 main이 쥐고 있는 monitor에 들어가려다 막힙니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.LockSupport;
public final class BlockedAndWaitingComparison {
public static void main(String[] args) throws InterruptedException {
Object monitor = new Object();
CountDownLatch enteredPark = new CountDownLatch(1);
Thread waiting =
new Thread(
() -> {
enteredPark.countDown();
LockSupport.park();
},
"plain-waiting");
Thread blocked =
new Thread(
() -> {
synchronized (monitor) {
System.out.println("monitor-acquired");
}
},
"monitor-blocked");
waiting.start();
enteredPark.await();
synchronized (monitor) {
blocked.start();
awaitState(waiting, Thread.State.WAITING);
awaitState(blocked, Thread.State.BLOCKED);
System.out.println("states=" + waiting.getState() + "," + blocked.getState());
blocked.interrupt();
System.out.println("blocked-after-interrupt=" + blocked.getState());
}
LockSupport.unpark(waiting);
waiting.join();
blocked.join();
}
private static void awaitState(Thread t, Thread.State state) throws InterruptedException {
for (int i = 0; i < 200 && t.getState() != state; i++) {
Thread.sleep(1);
}
if (t.getState() != state) throw new IllegalStateException(t.getName());
}
}200번 실행에서 출력은 매번 다음과 같았습니다. 앞의 두 줄은 main이 monitor를 쥔 채 출력하고, 마지막 줄 monitor-acquired는 blocked 작업자가 모니터에 들어간 뒤에 출력합니다.
states=WAITING,BLOCKED
blocked-after-interrupt=BLOCKED
monitor-acquiredmain은 synchronized (monitor) 블록 안에서 blocked를 시작하므로 blocked는 블록 입구에서 멈춥니다. awaitState로 두 작업자가 기대한 상태가 된 것을 확인한 뒤 첫 줄을 찍고, 이어서 blocked를 interrupt합니다. 그래도 두 번째 줄처럼 blocked는 여전히 BLOCKED입니다. 모니터를 기다리는 스레드는 interrupt를 받아도 기다림을 포기하지 않고 예외도 받지 않습니다.
main이 블록을 빠져나가 모니터를 놓은 뒤에야 blocked가 들어가 monitor-acquired를 찍는데, 이때도 blocked의 interrupt 플래그는 true로 남아 있습니다. 마지막으로 main은 unpark(waiting)으로 park 중인 작업자를 깨우고 두 작업자를 join합니다.
awaitState는 1ms씩 쉬면서 최대 200번 상태를 확인하고, 그 안에 기대한 상태를 보지 못하면 IllegalStateException을 던집니다. 이 예외로 synchronized 블록을 빠져나가면 blocked는 결국 모니터를 얻고 끝나지만, 블록 뒤의 unpark(waiting)은 실행되지 않습니다. 그러면 waiting은 계속 park에 머물 수 있고, 데몬 스레드가 아니므로 JVM도 끝나지 않을 수 있습니다. 실제 코드라면 이런 정리 작업은 finally에 두어야 합니다.
같은 interrupt라도 모니터 진입 대기를 해제하지 않는다
blocked interrupt는 모니터를 획득시키지 않고, 별도 parked worker는 다른 unpark로 깨웁니다. 요청·실제 진행 조건 해제·join을 단계로 구분해 대기 해제의 조건을 확인할 수 있습니다.
BlockedAndWaitingComparison의 main 모니터 보유 구간, blocked 인터럽트, 모니터 해제 뒤 정리를 두 작업자로 비교합니다.
대기 비교 · 모니터 / PARK
같은 interrupt라도 모니터 진입 대기를 해제하지 않는다
BLOCKED 작업자는 모니터가 풀려야 진입합니다. 다른 작업자의 park 상태 조회는 순간 관측입니다.
blocked 작업자
- 상태 관측 성공·main이 모니터 보유
- 모니터를 얻지 못해 BLOCKED입니다.
- 같은 블록 안의 interrupt
- 인터럽트 후 조회도 BLOCKED입니다.
- 모니터 해제 뒤 정리
- 모니터를 얻어
monitor-acquired를 출력합니다.
waiting 작업자
- 상태 관측 성공·main이 모니터 보유
- WAITING 관측은 순간값이며 이후 상태를 고정하지 않습니다.
- 같은 블록 안의 interrupt
- 이 interrupt의 대상이 아닙니다.
- 모니터 해제 뒤 정리
- main이 unpark를 요청한 뒤 두 작업자를 join합니다.
waiting의 park는 임의로 반환할 수 있습니다. 상태 관측 도우미가 실패하면 뒤의 unpark와 join을 건너뛰므로 대기자가 남아 JVM 종료를 막을 수도 있습니다.
이 예의 조건
- 원 WAITING 관측이 성공한 경로를 추적합니다. park 임의 반환은 가능합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | interrupt 대상은 blocked이며 main의 모니터 반납 전 진입을 포기하지 않습니다. 별도 waiting 작업자는 해제 뒤 unpark로 정리하며 원래 관측 실패와 임의 반환의 한계를 보존합니다. |
| 16 | unpark |
| 17 | 두 join으로 회수 |
| 19 | 관측 실패 시 원 정리가 빠질 수 있음 |
| 구분 | 내용 |
|---|---|
| 설명 | interrupt 대상은 blocked이며 main의 모니터 반납 전 진입을 포기하지 않습니다. 별도 waiting 작업자는 해제 뒤 unpark로 정리하며 원래 관측 실패와 임의 반환의 한계를 보존합니다. |
| 전체 읽기 정보 | 같은 interrupt라도 모니터 진입 대기를 해제하지 않는다 같은 interrupt라도 모니터 진입 대기를 해제하지 않는다 BlockedAndWaitingComparison의 main 모니터 보유 구간, blocked 인터럽트, 모니터 해제 뒤 정리를 두 작업자로 비교합니다. 대기 비교 · 모니터 / PARK 같은 interrupt라도 모니터 진입 대기를 해제하지 않는다 BLOCKED 작업자는 모니터가 풀려야 진입합니다. 다른 작업자의 park 상태 조회는 순간 관측입니다. blocked 작업자 상태 관측 성공·main이 모니터 보유 모니터를 얻지 못해 BLOCKED입니다. 같은 블록 안의 interrupt 인터럽트 후 조회도 BLOCKED입니다. 모니터 해제 뒤 정리 모니터를 얻어 monitor-acquired 를 출력합니다. waiting 작업자 상태 관측 성공·main이 모니터 보유 WAITING 관측은 순간값이며 이후 상태를 고정하지 않습니다. 같은 블록 안의 interrupt 이 interrupt의 대상이 아닙니다. 모니터 해제 뒤 정리 main이 unpark를 요청한 뒤 두 작업자를 join합니다. waiting의 park는 임의로 반환할 수 있습니다. 상태 관측 도우미가 실패하면 뒤의 unpark와 join을 건너뛰므로 대기자가 남아 JVM 종료를 막을 수도 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 서로 다른 대기 main은 monitor를 보유하고 blocked는 진입을 기다립니다. |
|
|
| blocked 중단 blocked에 interrupt를 요청해도 main 보유 중 진입 대기를 포기하지 않습니다. |
|
|
| 반납과 회수 main이 monitor를 반납하고 waiting을 unpark합니다. |
|
|
같은 interrupt라도 모니터 진입 대기를 해제하지 않는다
blocked interrupt는 모니터를 획득시키지 않고, 별도 parked worker는 다른 unpark로 깨웁니다. 요청·실제 진행 조건 해제·join을 단계로 구분해 대기 해제의 조건을 확인할 수 있습니다.
BlockedAndWaitingComparison의 main 모니터 보유 구간, blocked 인터럽트, 모니터 해제 뒤 정리를 두 작업자로 비교합니다.
대기 비교 · 모니터 / PARK
같은 interrupt라도 모니터 진입 대기를 해제하지 않는다
BLOCKED 작업자는 모니터가 풀려야 진입합니다. 다른 작업자의 park 상태 조회는 순간 관측입니다.
blocked 작업자
- 상태 관측 성공·main이 모니터 보유
- 모니터를 얻지 못해 BLOCKED입니다.
- 같은 블록 안의 interrupt
- 인터럽트 후 조회도 BLOCKED입니다.
- 모니터 해제 뒤 정리
- 모니터를 얻어
monitor-acquired를 출력합니다.
waiting 작업자
- 상태 관측 성공·main이 모니터 보유
- WAITING 관측은 순간값이며 이후 상태를 고정하지 않습니다.
- 같은 블록 안의 interrupt
- 이 interrupt의 대상이 아닙니다.
- 모니터 해제 뒤 정리
- main이 unpark를 요청한 뒤 두 작업자를 join합니다.
waiting의 park는 임의로 반환할 수 있습니다. 상태 관측 도우미가 실패하면 뒤의 unpark와 join을 건너뛰므로 대기자가 남아 JVM 종료를 막을 수도 있습니다.
이 예의 조건
- 원 WAITING 관측이 성공한 경로를 추적합니다. park 임의 반환은 가능합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | interrupt 대상은 blocked이며 main의 모니터 반납 전 진입을 포기하지 않습니다. 별도 waiting 작업자는 해제 뒤 unpark로 정리하며 원래 관측 실패와 임의 반환의 한계를 보존합니다. |
| 16 | unpark |
| 17 | 두 join으로 회수 |
| 19 | 관측 실패 시 원 정리가 빠질 수 있음 |
| 구분 | 내용 |
|---|---|
| 설명 | interrupt 대상은 blocked이며 main의 모니터 반납 전 진입을 포기하지 않습니다. 별도 waiting 작업자는 해제 뒤 unpark로 정리하며 원래 관측 실패와 임의 반환의 한계를 보존합니다. |
| 전체 읽기 정보 | 같은 interrupt라도 모니터 진입 대기를 해제하지 않는다 같은 interrupt라도 모니터 진입 대기를 해제하지 않는다 BlockedAndWaitingComparison의 main 모니터 보유 구간, blocked 인터럽트, 모니터 해제 뒤 정리를 두 작업자로 비교합니다. 대기 비교 · 모니터 / PARK 같은 interrupt라도 모니터 진입 대기를 해제하지 않는다 BLOCKED 작업자는 모니터가 풀려야 진입합니다. 다른 작업자의 park 상태 조회는 순간 관측입니다. blocked 작업자 상태 관측 성공·main이 모니터 보유 모니터를 얻지 못해 BLOCKED입니다. 같은 블록 안의 interrupt 인터럽트 후 조회도 BLOCKED입니다. 모니터 해제 뒤 정리 모니터를 얻어 monitor-acquired 를 출력합니다. waiting 작업자 상태 관측 성공·main이 모니터 보유 WAITING 관측은 순간값이며 이후 상태를 고정하지 않습니다. 같은 블록 안의 interrupt 이 interrupt의 대상이 아닙니다. 모니터 해제 뒤 정리 main이 unpark를 요청한 뒤 두 작업자를 join합니다. waiting의 park는 임의로 반환할 수 있습니다. 상태 관측 도우미가 실패하면 뒤의 unpark와 join을 건너뛰므로 대기자가 남아 JVM 종료를 막을 수도 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 서로 다른 대기 main은 monitor를 보유하고 blocked는 진입을 기다립니다. |
|
|
| blocked 중단 blocked에 interrupt를 요청해도 main 보유 중 진입 대기를 포기하지 않습니다. |
|
|
| 반납과 회수 main이 monitor를 반납하고 waiting을 unpark합니다. |
|
|
대기가 끝나는 조건을 정리하면 다음과 같습니다. 같은 interrupt라도 WAITING인 스레드는 깨우지만, BLOCKED인 스레드는 플래그만 세워진 채 계속 모니터를 기다린다는 점이 가장 큰 차이입니다.
| 대기 | 끝나는 조건 |
|---|---|
BLOCKED(synchronized 진입) | 쥐고 있던 스레드가 블록을 빠져나가거나 wait를 호출해 모니터가 풀린 뒤, 이 스레드가 모니터를 얻을 때 |
LockSupport.park | unpark, interrupt, 가짜 깨움 |
Object.wait | notify나 notifyAll, interrupt, 가짜 깨움(spurious wakeup) |
Thread.join | 대상 스레드의 종료, interrupt |
park는 쥐고 있는 모니터를 놓지 않는다
이 차이가 실제 문제로 이어지는 대표적인 경우가 synchronized 블록 안에서 park하는 코드입니다. 앞에서 본 것처럼 park로 기다리는 스레드는 WAITING이고, 모니터에 들어가지 못한 스레드는 BLOCKED입니다. 다음 코드에서 이 두 상태가 어떻게 맞물리는지 보세요.
synchronized (lock) {
while (!ready) {
LockSupport.park(); // lock을 쥔 채로 잠든다
}
}park는 이미 쥐고 있는 모니터를 놓지 않습니다. 그래서 이 스레드를 깨워 줄 스레드가 ready = true를 쓰려고 같은 lock에 들어가려 하면, 그 스레드는 BLOCKED가 되어 들어가지 못합니다. 한쪽은 WAITING으로 신호를, 다른 쪽은 BLOCKED로 모니터를 기다리면서 둘 다 진행하지 못하는 것입니다. 이 글을 쓰며 이 구조를 실행해 보니 0.3초가 지나도 두 스레드가 각각 WAITING과 BLOCKED에 머물러 있었고, ready는 여전히 false였습니다.
Object.wait는 이 점이 다릅니다. wait는 호출한 객체의 모니터를 내려놓고 기다리다가, 돌아오기 전에 그 모니터를 다시 얻습니다. 조건을 기다리는 동안 다른 스레드가 같은 모니터 안에서 조건을 바꿀 수 있는 것은 이 때문입니다. wait와 notify는 wait·notify와 모니터를 다룬 글에서 자세히 봅니다.
다만 wait가 놓는 것은 호출한 객체의 모니터 하나뿐이고, 그 스레드가 함께 쥐고 있던 다른 객체의 모니터는 쥔 채로 기다립니다. 그래서 대기 문제를 볼 때는 상태 이름만 보지 말고, 기다리는 동안 그 스레드가 어떤 락을 쥐고 있는지를 함께 확인해야 합니다.
ReentrantLock을 기다리는 스레드가 BLOCKED가 아니라 WAITING으로 보인다는 점도 기억해 둘 만합니다. ReentrantLock은 모니터가 아니라 대기 큐와 park로 만들어졌기 때문입니다. 스레드 덤프에서 어떻게 보이는지는 다음 글에서 봅니다.
기다림을 취소할 수 있어야 한다면
synchronized 진입을 기다리는 스레드는 interrupt로 멈출 수 없습니다. 사용자가 요청을 취소했거나 서버가 종료되는 중이라서 락을 기다리던 작업을 그만두게 해야 한다면, synchronized 대신 ReentrantLock의 lockInterruptibly()를 씁니다. 그러면 기다리는 중에 interrupt를 받았을 때 InterruptedException과 함께 빠져나올 수 있습니다. 일정 시간까지만 기다리고 싶다면 시간 제한이 있는 tryLock(time, unit)이 있습니다. 반대로 이런 요구가 없는 단순한 임계 영역이라면, 블록을 벗어날 때 자동으로 풀리는 synchronized가 더 읽기 쉽고 실수할 여지도 적습니다.
park로 동기화 도구를 직접 만드는 것은 마지막 수단입니다. 대기자가 여럿이면 대기 큐를 관리해야 하고, 시간 초과나 취소로 빠져나간 대기자를 큐에서 지우는 일과 놓친 신호를 처리하는 일까지 모두 직접 해야 합니다. 대기자 하나로 돌려 본 예제가 잘 동작했다고 해서 이런 경우까지 맞다는 뜻은 아닙니다. 대부분은 ReentrantLock, Semaphore, CountDownLatch, BlockingQueue 같은 java.util.concurrent의 도구로 충분합니다.
정리
park는 interrupt를 받으면 예외 없이 돌아오고 플래그를 지우지 않습니다. 반면sleep,wait,join은InterruptedException을 던지면서 플래그를 지웁니다.- 플래그를 확인하지 않는
park반복문은 헛돌며 CPU를 씁니다.park가 돌아올 때마다 플래그를 확인해서 예외로 취소하거나, 기억해 두었다가 대기가 끝난 뒤 다시 세웁니다. - 시간 제한 대기는 기한을 한 번만 계산하고, 돌아올 때마다
deadline - System.nanoTime()으로 남은 시간을 다시 구합니다. - BLOCKED는 모니터를, WAITING은
unpark,notify, 스레드 종료 같은 사건을 기다립니다. 모니터 대기는 interrupt로 끝낼 수 없고,park는 쥐고 있는 모니터를 놓지 않습니다. getState()는 순간의 조회일 뿐이므로 스레드끼리 진행을 맞추는 데 쓰지 않습니다.
연습 문제
volatile boolean조건과 대기자Thread참조를 써서, 최대 100ms 동안 열리기를 기다리는 게이트를 작성하세요.await(timeoutNanos)는"opened","interrupted","timed-out"중 하나를 돌려주고,open()은 조건을 먼저true로 만든 뒤unpark해야 합니다.- 1번 답안의
await를 같은 스레드에서 세 번 호출합니다. A는 이미open()한 게이트에서 interrupt 플래그를true로 세운 뒤await(0)을 부릅니다. B는 새 게이트에서 플래그가true인 채로, C는 새 게이트에서 플래그가false인 채로await(0)을 부릅니다. 각 호출의 반환값과 호출 뒤의 플래그를 구하세요. - 100ms 예산으로 기다리는 중에
parkNanos가 시작 후 32ms에, 그리고 다시 20ms 뒤에 일찍 돌아왔습니다. 두 번 모두 조건과 interrupt 플래그는false였습니다. 각 재확인에서 다음parkNanos에 넘길 값을 나노초로 구하세요. 그 뒤 48ms가 더 지나 돌아왔다면 결과는 무엇인가요? - 작업자 A는
synchronized (lock)안에서while (!ready) LockSupport.park();로 기다리고, 작업자 B는 같은lock안에서ready = true를 쓴 뒤 A를unpark하려 합니다. 다른 이유로park가 돌아오는 일은 없다고 할 때 두 스레드는 어떻게 되나요? 또BlockedAndWaitingComparison의main이synchronized (monitor)블록 안에서blocked.join()까지 호출하도록 바꾸면 어떻게 될까요?
1번 해설
조건, interrupt, 남은 시간을 차례로 확인하는 반복문은 ParkNanosDeadline과 같습니다. 여기에 깨워 줄 대상을 알 수 있도록 waiter 필드를 두고, open()이 조건을 쓴 뒤 그 스레드를 unpark하게 합니다.
import java.util.concurrent.locks.LockSupport;
public final class TimedParkGateSolution {
private volatile boolean open;
private volatile Thread waiter;
String await(long timeoutNanos) {
waiter = Thread.currentThread();
long deadline = System.nanoTime() + timeoutNanos;
while (!open) {
if (Thread.currentThread().isInterrupted()) return "interrupted";
long left = deadline - System.nanoTime();
if (left <= 0) return "timed-out";
LockSupport.parkNanos(this, left);
}
return "opened";
}
void open() {
open = true;
Thread target = waiter;
if (target != null) LockSupport.unpark(target);
}
public static void main(String[] args) throws InterruptedException {
TimedParkGateSolution gate = new TimedParkGateSolution();
Thread worker =
new Thread(() -> System.out.println(gate.await(100_000_000L)), "timed-gate");
worker.start();
while (gate.waiter == null) {
Thread.onSpinWait();
}
gate.open();
worker.join();
}
}await가 조건을 확인하기 전에 waiter부터 등록하고, open은 조건을 먼저 쓴 뒤 waiter를 읽는 순서가 중요합니다. open이 등록보다 먼저 실행되면 waiter가 null이라 unpark를 건너뛰지만, 작업자는 등록 뒤 첫 확인에서 이미 true인 open을 보고 기다리지 않습니다. 반대로 작업자가 open을 false로 읽은 직후에 open이 실행되면, 이미 등록된 작업자에게 unpark가 전달됩니다. 어느 순서든 신호를 놓치지 않습니다. main이 waiter가 등록될 때까지 기다리는 반복은 unpark 경로를 시험해 보려는 것일 뿐, 정확성은 이 반복에 기대지 않습니다.
이 글을 쓰며 200번 실행했을 때는 매번 opened가 출력되었습니다. 다만 main이 어떤 이유로 100ms 넘게 실행되지 못하면 timed-out이 나올 수도 있습니다.
반환값은 각 반복의 검사 순서로 결정된다
open→interrupt→remaining 검사 순서와 parkNanos 뒤 재검사가 반환 결과를 정합니다. 현재 검사와 도달하지 않은 조건을 단계로 보여 주며 반환 조건을 확인할 수 있습니다.
ParkNanosDeadline과 TimedParkGateSolution의 조건, 인터럽트, 남은 시간 검사에 따른 반환값을 비교합니다.
기한 검사 · 조건 먼저
반환값은 각 반복의 검사 순서로 결정된다
각 반복은 업무 조건, 인터럽트, 남은 시간 순서로 검사합니다. 모두 해당하지 않으면 parkNanos 뒤 다시 검사합니다.
업무 조건이 true
- 30ms 대기 예제
ready반환- 100ms 게이트 답안
opened반환
조건은 false·인터럽트는 true
- 30ms 대기 예제
interrupted반환- 100ms 게이트 답안
interrupted반환
조건·인터럽트가 false·남은 시간 ≤ 0
- 30ms 대기 예제
timed-out반환- 100ms 게이트 답안
timed-out반환
그 밖에는 parkNanos 뒤 다시 검사합니다. 30ms 원문은 timed-out이고 답안은 opened 또는 timed-out입니다. opened도 100ms 안에 열렸다는 증거는 아니며, 전체 실행 시간은 예산과 같지 않습니다.
이 예의 조건
- 검사 우선순서는 open→interrupt→remaining입니다. 반환 시각 보장은 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 코드의 검사 우선순서에 따라 열림/중단/시간 만료/parkNanos 재검사로 나뉩니다. 각 분기는 상호 배타적이며 열림 반환은 기한 내 개방 시각을 증명하지 않습니다. |
| 4 | open |
| 5 | interrupt |
| 20 | opened는 기한 내 개방 시각의 증거가 아니며 예산이 정확한 복귀 시각을 보장하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 코드의 검사 우선순서에 따라 열림/중단/시간 만료/parkNanos 재검사로 나뉩니다. 각 분기는 상호 배타적이며 열림 반환은 기한 내 개방 시각을 증명하지 않습니다. |
| 전체 읽기 정보 | 반환값은 각 반복의 검사 순서로 결정된다 반환값은 각 반복의 검사 순서로 결정된다 ParkNanosDeadline과 TimedParkGateSolution의 조건, 인터럽트, 남은 시간 검사에 따른 반환값을 비교합니다. 기한 검사 · 조건 먼저 반환값은 각 반복의 검사 순서로 결정된다 각 반복은 업무 조건, 인터럽트, 남은 시간 순서로 검사합니다. 모두 해당하지 않으면 parkNanos 뒤 다시 검사합니다. 업무 조건이 true 30ms 대기 예제 ready 반환 100ms 게이트 답안 opened 반환 조건은 false·인터럽트는 true 30ms 대기 예제 interrupted 반환 100ms 게이트 답안 interrupted 반환 조건·인터럽트가 false·남은 시간 ≤ 0 30ms 대기 예제 timed-out 반환 100ms 게이트 답안 timed-out 반환 그 밖에는 parkNanos 뒤 다시 검사합니다. 30ms 원문은 timed-out 이고 답안은 opened 또는 timed-out 입니다. opened도 100ms 안에 열렸다는 증거는 아니며, 전체 실행 시간은 예산과 같지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 열림 우선 open=true이면 중단·기한 검사 전에 반환합니다. |
|
|
| 중단 또는 만료 open=false에서 interrupt를 먼저 보고 남은 시간을 검사합니다. |
|
|
| 대기와 반복 나머지 경우 parkNanos 뒤 처음부터 재검사합니다. |
|
|
반환값은 각 반복의 검사 순서로 결정된다
open→interrupt→remaining 검사 순서와 parkNanos 뒤 재검사가 반환 결과를 정합니다. 현재 검사와 도달하지 않은 조건을 단계로 보여 주며 반환 조건을 확인할 수 있습니다.
ParkNanosDeadline과 TimedParkGateSolution의 조건, 인터럽트, 남은 시간 검사에 따른 반환값을 비교합니다.
기한 검사 · 조건 먼저
반환값은 각 반복의 검사 순서로 결정된다
각 반복은 업무 조건, 인터럽트, 남은 시간 순서로 검사합니다. 모두 해당하지 않으면 parkNanos 뒤 다시 검사합니다.
업무 조건이 true
- 30ms 대기 예제
ready반환- 100ms 게이트 답안
opened반환
조건은 false·인터럽트는 true
- 30ms 대기 예제
interrupted반환- 100ms 게이트 답안
interrupted반환
조건·인터럽트가 false·남은 시간 ≤ 0
- 30ms 대기 예제
timed-out반환- 100ms 게이트 답안
timed-out반환
그 밖에는 parkNanos 뒤 다시 검사합니다. 30ms 원문은 timed-out이고 답안은 opened 또는 timed-out입니다. opened도 100ms 안에 열렸다는 증거는 아니며, 전체 실행 시간은 예산과 같지 않습니다.
이 예의 조건
- 검사 우선순서는 open→interrupt→remaining입니다. 반환 시각 보장은 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 코드의 검사 우선순서에 따라 열림/중단/시간 만료/parkNanos 재검사로 나뉩니다. 각 분기는 상호 배타적이며 열림 반환은 기한 내 개방 시각을 증명하지 않습니다. |
| 4 | open |
| 5 | interrupt |
| 20 | opened는 기한 내 개방 시각의 증거가 아니며 예산이 정확한 복귀 시각을 보장하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 코드의 검사 우선순서에 따라 열림/중단/시간 만료/parkNanos 재검사로 나뉩니다. 각 분기는 상호 배타적이며 열림 반환은 기한 내 개방 시각을 증명하지 않습니다. |
| 전체 읽기 정보 | 반환값은 각 반복의 검사 순서로 결정된다 반환값은 각 반복의 검사 순서로 결정된다 ParkNanosDeadline과 TimedParkGateSolution의 조건, 인터럽트, 남은 시간 검사에 따른 반환값을 비교합니다. 기한 검사 · 조건 먼저 반환값은 각 반복의 검사 순서로 결정된다 각 반복은 업무 조건, 인터럽트, 남은 시간 순서로 검사합니다. 모두 해당하지 않으면 parkNanos 뒤 다시 검사합니다. 업무 조건이 true 30ms 대기 예제 ready 반환 100ms 게이트 답안 opened 반환 조건은 false·인터럽트는 true 30ms 대기 예제 interrupted 반환 100ms 게이트 답안 interrupted 반환 조건·인터럽트가 false·남은 시간 ≤ 0 30ms 대기 예제 timed-out 반환 100ms 게이트 답안 timed-out 반환 그 밖에는 parkNanos 뒤 다시 검사합니다. 30ms 원문은 timed-out 이고 답안은 opened 또는 timed-out 입니다. opened도 100ms 안에 열렸다는 증거는 아니며, 전체 실행 시간은 예산과 같지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 열림 우선 open=true이면 중단·기한 검사 전에 반환합니다. |
|
|
| 중단 또는 만료 open=false에서 interrupt를 먼저 보고 남은 시간을 검사합니다. |
|
|
| 대기와 반복 나머지 경우 parkNanos 뒤 처음부터 재검사합니다. |
|
|
단계 그림처럼 반환값은 각 반복의 확인 순서로 정해집니다. 조건이 참이면 interrupt와 기한을 보기 전에 opened를 돌려주므로, opened라는 결과가 100ms 안에 열렸다는 증거는 아닙니다. 작업자가 기한을 넘겨서야 다시 실행되었더라도 그때 조건이 참이면 opened입니다. 또 이미 timed-out으로 돌아간 호출의 결과는 나중에 open()이 불려도 바뀌지 않습니다.
세 가지 반환을 모두 확인해 보고 싶다면, 다른 스레드의 상태를 기다리지 않고 현재 스레드에서 세 경우를 하나씩 만들어 보면 됩니다. 아래 프로그램의 Gate는 1번 답안과 같은 반복문에 예산 검사만 더한 것으로, 0보다 크고 1초 이하인 예산만 받습니다.
import java.util.concurrent.locks.LockSupport;
public final class ConditionResultChecks {
private static final class Gate {
private volatile boolean open;
private volatile Thread waiter;
void open() {
open = true;
Thread target = waiter;
if (target != null) LockSupport.unpark(target);
}
String await(long budgetNanos) {
if (budgetNanos <= 0 || budgetNanos > 1_000_000_000L)
throw new IllegalArgumentException("budget");
waiter = Thread.currentThread();
long deadline = System.nanoTime() + budgetNanos;
while (!open) {
if (Thread.currentThread().isInterrupted()) return "interrupted";
long remaining = deadline - System.nanoTime();
if (remaining <= 0) return "timed-out";
LockSupport.parkNanos(this, remaining);
}
return "opened";
}
}
public static void main(String[] args) {
Gate early = new Gate();
early.open();
System.out.println("early=" + early.await(50_000_000L));
System.out.println("closed=" + new Gate().await(1_000_000L));
Thread.currentThread().interrupt();
System.out.println("cancelled=" + new Gate().await(50_000_000L));
System.out.println("flag-kept=" + Thread.currentThread().isInterrupted());
}
}early=opened
closed=timed-out
cancelled=interrupted
flag-kept=trueearly는 이미 열린 게이트라서 기한이나 interrupt를 확인하기도 전에 opened를 돌려줍니다. closed는 1ms 예산 동안 아무도 열지 않으므로 timed-out이고, 여기서 1ms는 parkNanos에 넘기는 예산일 뿐 이 줄이 찍히기까지 걸린 정확한 시간이 아닙니다. cancelled는 플래그를 세운 뒤 닫힌 게이트를 기다리므로 interrupted이고, isInterrupted()는 플래그를 지우지 않으므로 마지막 줄은 flag-kept=true입니다. 200번 실행에서 네 줄은 매번 같았습니다.
2번부터 4번까지 해설
2번에서 A는 open을 가장 먼저 확인하므로 opened를 돌려주고, 플래그는 true로 남습니다. B는 open이 false이므로 다음으로 interrupt를 확인해 interrupted를 돌려주고, 이때도 isInterrupted()만 읽으므로 플래그는 true입니다. C는 조건도 interrupt도 없고, 예산이 0이라 deadline - System.nanoTime()이 0 이하이므로 timed-out을 돌려주며 플래그는 false 그대로입니다. 1번 답안은 0을 받아들이므로 이 세 호출이 가능하지만, 양수 예산만 받는 ConditionResultChecks의 Gate라면 세 호출 모두 IllegalArgumentException으로 끝납니다. 이 세 경우를 그대로 실행해 보면 A=opened flag=true, B=interrupted flag=true, C=timed-out flag=false가 나옵니다.
3번의 첫 재확인에서는 (100 − 32) × 1,000,000 = 68,000,000ns를, 두 번째 재확인에서는 (100 − 32 − 20) × 1,000,000 = 48,000,000ns를 넘깁니다. 그 뒤 48ms가 더 지나면 남은 시간이 0 이하이므로 조건과 플래그가 여전히 false라면 timed-out입니다. 매번 100ms를 새로 넘겼다면 park가 일찍 돌아온 횟수만큼 전체 대기가 늘어났을 것입니다. 여기서 경과 시간은 문제를 위해 정한 값일 뿐이고, 실제로는 스레드가 다시 실행되기까지 걸리는 시간 때문에 반환이 넘긴 값보다 늦어질 수 있습니다.
4번에서 A는 park하면서도 lock을 놓지 않으므로, B는 synchronized (lock) 입구에서 BLOCKED가 되어 ready = true를 쓰지도 unpark를 부르지도 못합니다. A는 WAITING으로, B는 BLOCKED로 서로를 기다리며 영원히 멈춥니다. 조건을 기다리는 동안 모니터를 내려놓는 wait나 Condition을 쓰거나, 모니터를 쥐지 않은 상태에서 park하도록 구조를 바꿔야 합니다.
두 번째 질문도 같은 모양입니다. main이 모니터를 쥔 채 blocked.join()으로 blocked가 끝나기를 기다리면, blocked는 main이 쥔 모니터를 기다리므로 둘 다 진행하지 못합니다. 원래 코드처럼 모니터를 쥔 구간을 먼저 끝낸 뒤 신호를 보내고 join해야 합니다.
다음 글
다음 글에서는 park와 대기 큐로 만든 락인 ReentrantLock을 봅니다. 이 글에서 본 interrupt 처리 방식이 lock()과 lockInterruptibly()의 차이로 그대로 이어지고, 같은 스레드가 락을 여러 번 얻을 때와 대기 순서를 정하는 공정성 설정도 함께 다룹니다.