interrupt 플래그와 InterruptedException 처리
interrupt가 세우는 플래그를 interrupted와 isInterrupted가 어떻게 읽고 지우는지, InterruptedException을 잡았을 때 플래그를 다시 세우거나 예외를 넘겨야 하는 이유, 큐에서 기다리는 작업자를 interrupt로 멈추는 방법을 배웁니다.
앞 글에서는 시간 제한 join이 끝났는데도 작업자가 살아 있으면 worker.interrupt()로 취소를 요청했습니다. 그런데 interrupt()는 작업자를 그 자리에서 멈추는 명령이 아닙니다. 작업자 스레드에 '멈춰 달라는 요청이 왔다'는 표시를 남길 뿐이고, 그 표시를 보고 실제로 멈추는 일은 작업자 쪽 코드가 해야 합니다. 이처럼 요청하는 쪽과 응답하는 쪽이 함께 맞춰야 동작하는 방식을 협력적 취소(cooperative cancellation)라고 합니다.
협력이 깨지는 곳은 대개 그 표시를 읽고 지우는 코드입니다. 표시를 지우는 메서드가 따로 있고, 작업자가 sleep 중에 요청을 받으면 자바는 표시를 지운 뒤 예외를 던집니다. 이 규칙을 모른 채 예외를 삼키면 아래쪽 메서드 하나가 취소 요청을 없애 버리고, 위쪽 반복문은 아무 일도 없었다는 듯 계속 일합니다. 이 글에서는 그 표시가 언제 세워지고 언제 지워지는지 확인한 다음, InterruptedException을 올바르게 처리하는 방법과 큐에서 기다리는 작업자를 멈추는 방법을 차례로 살펴봅니다.
interrupt()는 플래그 하나를 세운다
모든 스레드는 interrupt 상태(interrupt status)라는 참·거짓 값을 하나씩 가지고 있습니다. 흔히 interrupt 플래그라고 부르므로 이 글에서도 플래그라고 줄여 부르겠습니다. 처음에는 false이고, 다른 스레드가 worker.interrupt()를 호출하면 worker의 플래그가 true가 됩니다. 그다음에 일어나는 일은 요청이 도착한 순간 작업자가 무엇을 하고 있었는지에 따라 달라집니다.
| 요청이 도착한 순간 작업자는 | 일어나는 일 | 플래그 |
|---|---|---|
| 계산 같은 일반 코드를 실행 중 | 아무 일도 없이 계속 실행한다. 코드가 직접 플래그를 확인해야 멈출 수 있다 | true로 남는다 |
sleep, wait, join 같은 대기 메서드 안에서 기다리는 중 | 대기가 풀리고 그 메서드가 InterruptedException을 던진다 | 예외와 함께 false로 지워진다 |
플래그가 이미 true인 채로 대기 메서드를 새로 호출 | 기다리지 않고 바로 InterruptedException을 던진다 | 예외와 함께 false로 지워진다 |
세 번째 줄은 놓치기 쉽지만 중요합니다. 요청이 대기보다 먼저 도착해도 사라지지 않고 플래그에 남아 있다가, 작업자가 다음 대기에 들어가는 순간 예외로 전달된다는 뜻이기 때문입니다. 예를 들어 플래그가 true인 스레드가 Thread.sleep(2_000)을 호출하면 2초를 기다리지 않고 곧바로 예외가 나고, catch 블록에서 확인한 플래그는 false입니다. 요청과 대기 중 무엇이 먼저 일어나든 작업자가 요청을 놓치지 않는 것은 이 규칙 덕분입니다.
두 번째와 세 번째 줄에서 플래그가 지워진다는 점도 기억해 두세요. 예외가 요청을 전달했으니 플래그는 비운다고 이해하면 됩니다. 그 대신 예외를 잡은 코드가 아무것도 하지 않으면 요청은 그 자리에서 사라집니다. 이 문제는 뒤에서 실제 코드로 확인합니다.
더 깊이: interrupt에 반응하지 않는 대기
기다리는 호출이 모두 interrupt에 반응하지는 않습니다. synchronized 블록에 들어가려고 다른 스레드의 락을 기다리는 스레드(BLOCKED 상태)에 interrupt()를 호출하면 플래그만 true가 되고, 스레드는 락을 얻을 때까지 계속 BLOCKED로 남습니다. System.in.read()처럼 java.io 스트림에서 입력을 기다리는 호출도 interrupt로 깨어나지 않습니다. 두 경우 모두 직접 실행해 보면 interrupt()를 호출한 뒤에도 스레드 상태가 그대로입니다.
락을 기다리는 동안에도 취소에 응답해야 한다면 ReentrantLock의 lockInterruptibly()를 씁니다. 이 메서드는 lockInterruptibly로 락 대기 취소하기에서 만납니다. 입출력은 시간 제한을 두거나 NIO의 InterruptibleChannel을 쓸 수 있습니다. InterruptibleChannel에서 기다리던 스레드에 interrupt를 걸면 채널이 닫히고 ClosedByInterruptException이 납니다.
interrupted()와 isInterrupted(): 읽기만 할까, 읽고 지울까
플래그를 읽는 메서드는 두 개이고, 이름은 비슷하지만 하는 일이 다릅니다. 인스턴스 메서드 t.isInterrupted()는 스레드 t의 플래그를 읽기만 합니다. 정적 메서드 Thread.interrupted()는 지금 이 코드를 실행하는 스레드의 플래그를 읽고, 읽은 즉시 false로 지웁니다. 다음 프로그램은 main 스레드가 자기 자신에게 interrupt()를 한 번 건 뒤 Thread.interrupted()를 두 번 호출합니다. 두 번 다 true가 나올 것이라고 예상했다면 결과를 확인해 보세요.
public final class InterruptedFlagClearedBug {
public static void main(String[] args) {
Thread.currentThread().interrupt();
boolean first = Thread.interrupted();
boolean second = Thread.interrupted();
System.out.println("first=" + first + ", second=" + second);
System.out.println("wrong-assumption=" + (first == second));
}
}first=true, second=false
wrong-assumption=false첫 호출은 interrupt()가 세운 true를 돌려주면서 플래그를 지웁니다. 그 사이에 새 요청이 없었으므로 두 번째 호출은 지워진 플래그를 읽어 false를 돌려줍니다. 마지막 줄의 wrong-assumption=false는 '두 번 읽어도 같은 값이 나온다'는 가정이 틀렸다는 뜻입니다. 이 프로그램에는 스레드가 main 하나뿐이라 다른 스레드가 끼어들 수 없으므로, 몇 번을 실행해도 출력이 같습니다.
같은 상황에서 isInterrupted()를 두 번 먼저 호출한 다음 Thread.interrupted()를 두 번 호출하면 플래그는 다음과 같이 변합니다. 표의 me는 Thread.currentThread()입니다.
| 순서 | 호출 | 반환값 | 호출 뒤 플래그 |
|---|---|---|---|
| 0 | me.interrupt() | 없음 | true |
| 1 | me.isInterrupted() | true | true |
| 2 | me.isInterrupted() | true | true |
| 3 | Thread.interrupted() | true | false |
| 4 | Thread.interrupted() | false | false |
읽기만 하는 isInterrupted()는 몇 번을 불러도 상태를 바꾸지 않고, 요청을 꺼내 없애는 것은 3번 호출입니다. 그래서 반복문 조건처럼 '요청이 왔는지 확인만 하고 처리는 바깥에 맡길' 때는 isInterrupted()를, '요청을 여기서 처리하겠다'고 정했을 때는 Thread.interrupted()를 씁니다. Thread.interrupted()로 요청을 꺼냈다면 예외를 던지든 작업을 끝내든 그 자리에서 요청에 맞는 처리를 해야 합니다.
Thread.interrupted()가 정적 메서드라는 점 때문에 함정이 하나 생깁니다. 자바는 정적 메서드를 worker.interrupted()처럼 객체 변수로 호출하는 것도 허용하는데, 이렇게 써도 대상은 worker가 아니라 지금 실행 중인 스레드입니다.
public final class StaticInterruptedTrap {
public static void main(String[] args) {
Thread worker = new Thread(() -> {}, "worker");
Thread.currentThread().interrupt();
boolean result = worker.interrupted();
System.out.println("result=" + result);
System.out.println("main=" + Thread.currentThread().isInterrupted());
System.out.println("worker=" + worker.isInterrupted());
}
}result=true
main=false
worker=falseworker.interrupted()는 worker의 플래그가 아니라 이 코드를 실행한 main의 플래그를 읽고 지웠습니다. 그래서 result는 main의 true이고, 호출 뒤 main의 플래그는 false가 됐습니다. worker는 시작하지도 않았고 아무도 interrupt하지 않았으니 처음부터 false입니다. javac는 기본 설정에서 이 코드에 아무 경고도 내지 않고, -Xlint:static 옵션을 켜야 'static method should be qualified by type name' 경고를 보여 줍니다. 다른 스레드의 상태를 볼 때는 worker.isInterrupted()를 쓰고, 현재 스레드의 요청을 꺼낼 때는 언제나 Thread.interrupted()처럼 클래스 이름으로 적어야 읽는 사람이 헷갈리지 않습니다. 두 메서드의 공식 설명은 Thread API 문서에서 비교해 볼 수 있습니다.
InterruptedException을 잡았다면 플래그를 다시 세운다
이번에는 대기 중에 요청을 받는 경우입니다. 다음 프로그램의 작업자는 입력을 기다리는 대신 5초 동안 sleep하고, main은 10ms 뒤 작업자에게 interrupt()를 겁니다. 작업자의 waitForInput()은 InterruptedException을 잡아 false를 돌려주는데, 돌려주기 전에 Thread.currentThread().interrupt()로 자기 플래그를 다시 세웁니다.
public final class InterruptedExceptionRestoration {
public static void main(String[] args) throws InterruptedException {
Thread worker =
new Thread(
() -> {
boolean completed = waitForInput();
System.out.println(
"completed="
+ completed
+ ", restored="
+ Thread.currentThread().isInterrupted());
},
"restore-worker");
worker.start();
Thread.sleep(10);
worker.interrupt();
worker.join();
}
private static boolean waitForInput() {
try {
Thread.sleep(5_000);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}completed=false, restored=truesleep(5_000)이 예외로 끝났으므로 completed는 false입니다. 예외가 나는 순간 플래그는 지워졌지만 catch 블록이 다시 세웠기 때문에, 호출한 쪽에서 읽은 restored는 true입니다. 이렇게 해 두면 waitForInput()이 boolean 하나만 돌려주더라도 '취소 요청이 있었다'는 사실이 위쪽 코드까지 전달됩니다.
이 출력은 실행할 때마다 같습니다. 그렇다고 main의 sleep(10)이 '작업자가 먼저 sleep(5_000)에 들어간다'는 순서를 정해 주는 것은 아닙니다. 작업자 스레드가 늦게 출발해서 interrupt()가 대기보다 먼저 도착해도, 앞의 표에서 본 대로 플래그가 남아 있다가 sleep에 들어가는 순간 바로 예외가 나므로 결과가 같을 뿐입니다. 결과가 달라지려면 main이 5초 넘게 늦어져서 작업자의 대기가 먼저 정상적으로 끝나야 합니다. 그때는 completed=true가 나오고, restored도 interrupt가 언제 도착했느냐에 따라 달라집니다. 코드가 그 순서를 막지는 않지만 보통 환경에서 볼 일은 거의 없습니다.
아래 단계 그림은 자기 자신을 interrupt하고 두 번 읽은 첫 프로그램과, sleep 중에 요청을 받고 catch에서 플래그를 다시 세운 이 프로그램을 나란히 놓고 플래그의 변화를 따라갑니다.
플래그 조회·소거·복원은 서로 다른 동작이다
interrupted 조회가 true 플래그를 지우고 다음 조회 false, 별도 대기 중단 catch가 복원합니다. 같은 플래그의 상태 변화를 순서대로 확인할 수 있습니다.
자기 interrupt를 두 번 소비하는 원문과 InterruptedException 처리 예제의 조건을 구분합니다.
INTERRUPT · 조회 / 소거 / 복원
플래그 조회·소거·복원은 서로 다른 동작이다
isInterrupted는 그대로 조회하고, interrupted는 현재 플래그를 지웁니다. 중단 예외 처리 뒤 복원 여부는 정책입니다.
t.isInterrupted()
- 플래그에 미치는 영향
- 지정한 스레드의 값을 지우지 않고 조회
- 이 문서의 근거
- API 규칙 · 상대 스레드를 종료시키지 않음
Thread.interrupted() 두 번
- 플래그에 미치는 영향
- 현재 플래그를 읽고 매번 지움
- 이 문서의 근거
- 자기 interrupt 예제:
first=true,second=false
sleep 대기에서 중단 예외
- 플래그에 미치는 영향
InterruptedException과 함께 플래그 소거- 이 문서의 근거
- 예외를 처리하는 경로의 규칙
catch에서 다시 interrupt()
- 플래그에 미치는 영향
- 현재 스레드의 플래그를 복원
- 이 문서의 근거
- 대기 중 중단된 경우
completed=false,restored=true
복원 예제의 main sleep 10ms는 작업자 sleep 5000ms와의 순서를 강제하지 않습니다. 작업자가 먼저 정상 대기를 끝내면 completed=true도 가능하며, restored 값도 interrupt 시점에 따라 달라집니다. 첫 자기 interrupt 예제의 두 값은 고정됩니다.
이 예의 조건
- 자기 interrupt 조회와 sleep 중단 복원은 독립 실행입니다. 각 장면에서 같은 관찰 지점을 비교합니다.
- isInterrupted는 조회로 플래그를 소거하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 자기 interrupt 뒤 첫 interrupted는 true/소거, 둘째는 false이며 대기 중 중단 예외에서는 소거 뒤 정책에 따라 복원합니다. isInterrupted는 소거하지 않고 두 예제의 조건을 분리합니다. |
| 7 | true 반환 |
| 8 | 소거 |
| 10 | false |
| 11 | 새 요청 없을 때 |
| 구분 | 내용 |
|---|---|
| 설명 | 자기 interrupt 뒤 첫 interrupted는 true/소거, 둘째는 false이며 대기 중 중단 예외에서는 소거 뒤 정책에 따라 복원합니다. isInterrupted는 소거하지 않고 두 예제의 조건을 분리합니다. |
| 전체 읽기 정보 | 플래그 조회·소거·복원은 서로 다른 동작이다 플래그 조회·소거·복원은 서로 다른 동작이다 자기 interrupt를 두 번 소비하는 원문과 InterruptedException 처리 예제의 조건을 구분합니다. INTERRUPT · 조회 / 소거 / 복원 플래그 조회·소거·복원은 서로 다른 동작이다 isInterrupted는 그대로 조회하고, interrupted는 현재 플래그를 지웁니다. 중단 예외 처리 뒤 복원 여부는 정책입니다. t.isInterrupted() 플래그에 미치는 영향 지정한 스레드의 값을 지우지 않고 조회 이 문서의 근거 API 규칙 · 상대 스레드를 종료시키지 않음 Thread.interrupted() 두 번 플래그에 미치는 영향 현재 플래그를 읽고 매번 지움 이 문서의 근거 자기 interrupt 예제: first=true , second=false sleep 대기에서 중단 예외 플래그에 미치는 영향 InterruptedException 과 함께 플래그 소거 이 문서의 근거 예외를 처리하는 경로의 규칙 catch에서 다시 interrupt() 플래그에 미치는 영향 현재 스레드의 플래그를 복원 이 문서의 근거 대기 중 중단된 경우 completed=false , restored=true 복원 예제의 main sleep 10ms는 작업자 sleep 5000ms와의 순서를 강제하지 않습니다. 작업자가 먼저 정상 대기를 끝내면 completed=true도 가능하며, restored 값도 interrupt 시점에 따라 달라집니다. 첫 자기 interrupt 예제의 두 값은 고정됩니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 요청과 대기 조건 자기 조회 실행과 sleep 중단 실행의 시작 조건을 나란히 둡니다. |
|
|
| 소거가 일어나는 지점 각 독립 실행에서 플래그가 소거되는 경계를 비교합니다. |
|
|
| 후속 조회와 복원 소거 뒤 새 요청 없는 조회와 catch의 복원 정책을 대조합니다. |
|
|
플래그 조회·소거·복원은 서로 다른 동작이다
interrupted 조회가 true 플래그를 지우고 다음 조회 false, 별도 대기 중단 catch가 복원합니다. 같은 플래그의 상태 변화를 순서대로 확인할 수 있습니다.
자기 interrupt를 두 번 소비하는 원문과 InterruptedException 처리 예제의 조건을 구분합니다.
INTERRUPT · 조회 / 소거 / 복원
플래그 조회·소거·복원은 서로 다른 동작이다
isInterrupted는 그대로 조회하고, interrupted는 현재 플래그를 지웁니다. 중단 예외 처리 뒤 복원 여부는 정책입니다.
t.isInterrupted()
- 플래그에 미치는 영향
- 지정한 스레드의 값을 지우지 않고 조회
- 이 문서의 근거
- API 규칙 · 상대 스레드를 종료시키지 않음
Thread.interrupted() 두 번
- 플래그에 미치는 영향
- 현재 플래그를 읽고 매번 지움
- 이 문서의 근거
- 자기 interrupt 예제:
first=true,second=false
sleep 대기에서 중단 예외
- 플래그에 미치는 영향
InterruptedException과 함께 플래그 소거- 이 문서의 근거
- 예외를 처리하는 경로의 규칙
catch에서 다시 interrupt()
- 플래그에 미치는 영향
- 현재 스레드의 플래그를 복원
- 이 문서의 근거
- 대기 중 중단된 경우
completed=false,restored=true
복원 예제의 main sleep 10ms는 작업자 sleep 5000ms와의 순서를 강제하지 않습니다. 작업자가 먼저 정상 대기를 끝내면 completed=true도 가능하며, restored 값도 interrupt 시점에 따라 달라집니다. 첫 자기 interrupt 예제의 두 값은 고정됩니다.
이 예의 조건
- 자기 interrupt 조회와 sleep 중단 복원은 독립 실행입니다. 각 장면에서 같은 관찰 지점을 비교합니다.
- isInterrupted는 조회로 플래그를 소거하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 자기 interrupt 뒤 첫 interrupted는 true/소거, 둘째는 false이며 대기 중 중단 예외에서는 소거 뒤 정책에 따라 복원합니다. isInterrupted는 소거하지 않고 두 예제의 조건을 분리합니다. |
| 7 | true 반환 |
| 8 | 소거 |
| 10 | false |
| 11 | 새 요청 없을 때 |
| 구분 | 내용 |
|---|---|
| 설명 | 자기 interrupt 뒤 첫 interrupted는 true/소거, 둘째는 false이며 대기 중 중단 예외에서는 소거 뒤 정책에 따라 복원합니다. isInterrupted는 소거하지 않고 두 예제의 조건을 분리합니다. |
| 전체 읽기 정보 | 플래그 조회·소거·복원은 서로 다른 동작이다 플래그 조회·소거·복원은 서로 다른 동작이다 자기 interrupt를 두 번 소비하는 원문과 InterruptedException 처리 예제의 조건을 구분합니다. INTERRUPT · 조회 / 소거 / 복원 플래그 조회·소거·복원은 서로 다른 동작이다 isInterrupted는 그대로 조회하고, interrupted는 현재 플래그를 지웁니다. 중단 예외 처리 뒤 복원 여부는 정책입니다. t.isInterrupted() 플래그에 미치는 영향 지정한 스레드의 값을 지우지 않고 조회 이 문서의 근거 API 규칙 · 상대 스레드를 종료시키지 않음 Thread.interrupted() 두 번 플래그에 미치는 영향 현재 플래그를 읽고 매번 지움 이 문서의 근거 자기 interrupt 예제: first=true , second=false sleep 대기에서 중단 예외 플래그에 미치는 영향 InterruptedException 과 함께 플래그 소거 이 문서의 근거 예외를 처리하는 경로의 규칙 catch에서 다시 interrupt() 플래그에 미치는 영향 현재 스레드의 플래그를 복원 이 문서의 근거 대기 중 중단된 경우 completed=false , restored=true 복원 예제의 main sleep 10ms는 작업자 sleep 5000ms와의 순서를 강제하지 않습니다. 작업자가 먼저 정상 대기를 끝내면 completed=true도 가능하며, restored 값도 interrupt 시점에 따라 달라집니다. 첫 자기 interrupt 예제의 두 값은 고정됩니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 요청과 대기 조건 자기 조회 실행과 sleep 중단 실행의 시작 조건을 나란히 둡니다. |
|
|
| 소거가 일어나는 지점 각 독립 실행에서 플래그가 소거되는 경계를 비교합니다. |
|
|
| 후속 조회와 복원 소거 뒤 새 요청 없는 조회와 catch의 복원 정책을 대조합니다. |
|
|
예외를 삼키면 생기는 일
catch 블록의 interrupt() 한 줄을 지우면 어떻게 될까요? 앞의 프로그램에서 그 줄만 빼고 실행하면 출력은 completed=false, restored=false가 됩니다. 예외가 플래그를 지웠고 아무도 다시 세우지 않았으니, 위쪽 코드는 취소 요청이 있었는지 알 방법이 없습니다. 이것이 실제로 어떤 결과를 낳는지 반복문으로 확인해 보겠습니다.
public final class SwallowedInterruptBug {
public static void main(String[] args) throws InterruptedException {
Thread worker =
new Thread(
() -> {
int rounds = 0;
while (!Thread.currentThread().isInterrupted() && rounds < 10) {
pause(20);
rounds++;
}
System.out.println(
"rounds="
+ rounds
+ ", flag="
+ Thread.currentThread().isInterrupted());
},
"swallow-worker");
worker.start();
Thread.sleep(50);
worker.interrupt();
worker.join();
}
private static void pause(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
// 아무것도 하지 않는다
}
}
}작업자는 요청이 올 때까지 한 번에 20ms씩 쉬면서 최대 10번 반복합니다. main은 50ms 뒤 취소를 요청하므로, 작업자가 세 번째 휴식 도중에 멈추기를 기대한 코드입니다. 그런데 실행하면 10번을 모두 채웁니다.
rounds=10, flag=false요청은 pause() 안의 sleep에서 InterruptedException으로 도착했습니다. 이때 플래그가 지워졌고 catch 블록이 아무것도 하지 않았으므로 요청은 그대로 사라졌습니다. 반복문 조건의 isInterrupted()는 그 뒤로 계속 false를 읽고, 작업자는 아무 요청도 받지 않은 것처럼 남은 반복을 끝까지 수행합니다. 이 예제는 10번에서 멈추도록 상한을 두었지만, 상한이 없는 반복문이었다면 작업자는 끝나지 않고 main의 join()도 영원히 기다렸을 것입니다. 아주 드물게 interrupt가 두 휴식 사이의 짧은 순간에 도착하면 반복문 조건이 요청을 보고 일찍 멈출 수도 있지만, 그 순간은 20ms 휴식에 비해 아주 짧아서 거의 언제나 위와 같은 출력이 나옵니다.
catch 블록의 주석 자리에 Thread.currentThread().interrupt();를 넣고 다시 실행하면 결과가 바뀝니다.
rounds=3, flag=true50ms 시점은 대개 세 번째 휴식(40ms부터 60ms까지) 도중입니다. pause()가 플래그를 다시 세우고 돌아오면 rounds가 3이 되고, 반복문 조건이 요청을 보고 멈춥니다. 다만 3이라는 숫자는 스케줄링에 따라 달라집니다. 작업자 스레드가 늦게 출발하거나 휴식이 길어지면 더 작은 값이 나오고, main이 늦게 깨어나면 더 큰 값이 나올 수 있습니다. 실제로 CPU를 바쁘게 만든 상태에서 실행하면 1이나 2도 나왔습니다. 어느 경우든 flag=true이고 작업자가 10번을 채우기 전에 멈춘다는 점은 같습니다.
넘길 수 있으면 넘기고, 넘길 수 없으면 다시 세운다
InterruptedException을 처리하는 올바른 방법은 두 가지입니다. 첫째는 예외를 잡지 않고 메서드 선언에 throws InterruptedException을 붙여 호출한 쪽에 그대로 넘기는 것입니다. 요청이 예외 형태 그대로 올라가므로 가장 단순하고, 무엇을 할지는 요청을 받은 위쪽 코드가 정합니다. 둘째는 Runnable.run()처럼 검사 예외를 선언할 수 없어서 넘길 수 없을 때 쓰는 방법입니다. 예외를 잡아 필요한 정리를 하고, Thread.currentThread().interrupt()로 플래그를 다시 세운 뒤 돌아갑니다. 앞의 waitForInput()이 이 방식입니다.
피해야 할 것은 앞에서 본 빈 catch 블록, 그리고 로그 한 줄만 남기고 아무 일 없었던 듯 계속 진행하는 코드입니다. 두 경우 모두 요청이 사라집니다. 한 가지 예외는 스레드를 직접 만든 코드가 그 스레드의 run() 가장 바깥에서 예외를 잡고 곧바로 끝나는 경우입니다. 그 뒤에 플래그를 읽을 코드가 없으므로 다시 세우지 않아도 결과는 같습니다. 그래도 다시 세워 두면 나중에 그 코드가 다른 메서드 안으로 옮겨졌을 때도 안전하므로, 습관처럼 세우는 편이 낫습니다.
큐에서 기다리는 작업자를 interrupt로 멈추기
이제 이 규칙을 프린터 작업자에 적용해 보겠습니다. 문서가 들어오면 출력하고, 문서가 없으면 기다리는 작업자입니다. 큐가 비었는지 계속 확인하다가 비어 있으면 잠깐 sleep하는 식으로 만들 수도 있지만, 그러면 문서가 들어와도 휴식이 끝날 때까지 출력이 늦어지고 문서가 없는 동안에도 쓸데없이 깨어납니다. java.util.concurrent 패키지의 BlockingQueue가 이 문제를 대신 풀어 줍니다. take()는 항목이 있으면 바로 꺼내고, 없으면 항목이 들어올 때까지 기다립니다. 그리고 sleep처럼 기다리는 동안 interrupt를 받으면 InterruptedException을 던집니다.
다음 InterruptiblePrinter는 LinkedBlockingQueue에 문서를 쌓고, 작업자 스레드 하나가 take()로 꺼내 출력합니다. new Thread(this::loop, ...)는 이 객체의 loop() 메서드를 작업자 스레드가 실행할 작업으로 넘깁니다. stop()은 작업자에게 interrupt()를 걸고 join()으로 끝날 때까지 기다립니다.
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public final class InterruptiblePrinter {
private final BlockingQueue<String> jobs = new LinkedBlockingQueue<>();
private final Thread worker = new Thread(this::loop, "printer-worker");
public void start() {
worker.start();
}
public void submit(String document) {
jobs.add(document);
}
public void stop() throws InterruptedException {
worker.interrupt();
worker.join();
}
private void loop() {
try {
while (true) {
String job = jobs.take();
System.out.println("printed=" + job);
}
} catch (InterruptedException e) {
System.out.println("printer-cancelled pending=" + jobs.size());
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
InterruptiblePrinter printer = new InterruptiblePrinter();
printer.start();
printer.submit("post-18");
while (!printer.jobs.isEmpty()) {
Thread.onSpinWait();
}
printer.stop();
}
}printed=post-18
printer-cancelled pending=0main은 문서 하나를 넣은 뒤 큐가 빌 때까지 기다렸다가 stop()을 호출합니다. 큐가 비었다는 것은 작업자가 take()로 문서를 꺼냈다는 뜻일 뿐, 출력까지 마쳤다는 뜻은 아닙니다. 그래서 stop()의 interrupt()는 두 시점 중 하나에 도착합니다. 작업자가 문서를 꺼낸 뒤 println을 하기 전이라면, println은 interrupt에 반응하지 않으므로 출력은 그대로 일어납니다. 그리고 반복문이 다음 take()를 부르는 순간 이미 세워진 플래그 때문에 바로 예외가 납니다. 작업자가 출력을 마치고 다시 take()에서 기다리는 중이라면 그 대기가 예외로 풀립니다.
어느 쪽이든 두 줄은 같은 작업자 스레드가 차례로 출력하므로, printed=post-18이 먼저 나오고 printer-cancelled가 나중에 나옵니다. 실행할 때마다 달라지는 것은 interrupt가 어느 시점에 도착했느냐뿐이고, 그 차이는 출력에 드러나지 않습니다. pending=0은 넣은 문서가 하나뿐이었고 작업자가 그것을 이미 꺼냈기 때문입니다. main의 Thread.onSpinWait() 반복은 예제의 순서를 맞추려고 둔 바쁜 대기이고, 실제 코드에서 다른 스레드의 완료를 기다릴 때는 래치나 join()을 씁니다. 용량을 따로 정하지 않은 LinkedBlockingQueue는 Integer.MAX_VALUE개까지 담을 수 있으므로 submit()의 add가 실패할 일은 사실상 없습니다. 용량을 정한 큐라면 가득 찼을 때 기다리는 put이나 실패를 false로 알려 주는 offer를 고릅니다.
아래 그림은 문서 하나가 큐에 들어가고, 작업자가 꺼내 출력하고, 다음 take()에서 취소를 받아 끝나기까지를 단계별로 보여 줍니다.
큐가 빈 시점과 출력이 끝난 시점은 다르다
한 항목 submit→take로 큐 밖 처리→다음 take 중단→join 회수의 실제 수명입니다. 큐 empty와 출력 완료를 단계별로 구분해 처리 완료 시점을 확인할 수 있습니다.
InterruptiblePrinter의 post-18 하나를 넣고 큐가 빈 것을 확인한 뒤 interrupt와 join하는 실제 흐름을 읽습니다.
PRINTER · 큐와 완료 경계
큐가 빈 시점과 출력이 끝난 시점은 다르다
isEmpty는 항목을 꺼냈다는 사실입니다. 이 예제는 콘솔 로그 뒤 다음 take에서 중단되고 join으로 회수됩니다.
실선: 항목 처리와 마지막 종료의 관계isEmpty는 출력 완료 신호 아님interrupt즉시 종료를 뜻하지 않음
실선: 항목 처리와 마지막종료의 관계isEmpty는 출력 완료 신호아님interrupt즉시 종료를 뜻하지않음
submit("post-18")- 확인된 사실
- 항목 하나를 큐에 넣음
- 아직 뜻하지 않는 것
- 출력 완료
jobs.isEmpty()확인- 확인된 사실
- 소비자가 항목을 꺼냄
- 아직 뜻하지 않는 것
- 콘솔 출력 완료
interrupt()요청- 확인된 사실
- 다음 중단 가능한 대기에 취소를 전달
- 아직 뜻하지 않는 것
- 요청 즉시 작업자 종료
join()반환- 확인된 사실
- 작업자 종료까지 회수
- 아직 뜻하지 않는 것
- 새 submit을 거부하는 수용 정책
이 원문은 printed=post-18 다음 printer-cancelled pending=0을 남깁니다. 꺼낸 항목의 콘솔 로그 뒤 다음 take가 중단됩니다. 큐 확인 반복에는 자체 시간 제한이 없고, drain·running 플래그·Outcome은 이 클래스에 없습니다.
이 예의 조건
- main의 interrupt와 콘솔 출력의 앞뒤는 고정되지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | take 뒤 빈 큐와 처리 중 post-18을 구별합니다. 원래 코드는 콘솔 로그 뒤 다음 take에서 취소를 받고 join으로 회수하며 main의 interrupt와 콘솔 로그 앞뒤는 고정하지 않습니다. |
| 6 | 큐 빈 상태 |
| 7 | 처리 중 항목 |
| 18 | interrupt 즉시 종료를 뜻하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | take 뒤 빈 큐와 처리 중 post-18을 구별합니다. 원래 코드는 콘솔 로그 뒤 다음 take에서 취소를 받고 join으로 회수하며 main의 interrupt와 콘솔 로그 앞뒤는 고정하지 않습니다. |
| 전체 읽기 정보 | 큐가 빈 시점과 출력이 끝난 시점은 다르다 큐가 빈 시점과 출력이 끝난 시점은 다르다 InterruptiblePrinter의 post-18 하나를 넣고 큐가 빈 것을 확인한 뒤 interrupt와 join하는 실제 흐름을 읽습니다. PRINTER · 큐와 완료 경계 큐가 빈 시점과 출력이 끝난 시점은 다르다 isEmpty는 항목을 꺼냈다는 사실입니다. 이 예제는 콘솔 로그 뒤 다음 take에서 중단되고 join으로 회수됩니다. submit("post-18") 확인된 사실 항목 하나를 큐에 넣음 아직 뜻하지 않는 것 출력 완료 jobs.isEmpty() 확인 확인된 사실 소비자가 항목을 꺼냄 아직 뜻하지 않는 것 콘솔 출력 완료 interrupt() 요청 확인된 사실 다음 중단 가능한 대기에 취소를 전달 아직 뜻하지 않는 것 요청 즉시 작업자 종료 join() 반환 확인된 사실 작업자 종료까지 회수 아직 뜻하지 않는 것 새 submit을 거부하는 수용 정책 이 원문은 printed=post-18 다음 printer-cancelled pending=0 을 남깁니다. 꺼낸 항목의 콘솔 로그 뒤 다음 take가 중단됩니다. 큐 확인 반복에는 자체 시간 제한이 없고, drain·running 플래그·Outcome은 이 클래스에 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 항목 등록 submit(post-18)이 항목을 큐에 넣습니다. |
|
|
| 처리 중 take 뒤 큐는 비어 있고 worker가 출력할 항목을 보유합니다. |
|
|
| 취소 회수 다음 take가 중단 예외를 받고 worker가 종료합니다. |
|
|
큐가 빈 시점과 출력이 끝난 시점은 다르다
한 항목 submit→take로 큐 밖 처리→다음 take 중단→join 회수의 실제 수명입니다. 큐 empty와 출력 완료를 단계별로 구분해 처리 완료 시점을 확인할 수 있습니다.
InterruptiblePrinter의 post-18 하나를 넣고 큐가 빈 것을 확인한 뒤 interrupt와 join하는 실제 흐름을 읽습니다.
PRINTER · 큐와 완료 경계
큐가 빈 시점과 출력이 끝난 시점은 다르다
isEmpty는 항목을 꺼냈다는 사실입니다. 이 예제는 콘솔 로그 뒤 다음 take에서 중단되고 join으로 회수됩니다.
실선: 항목 처리와 마지막 종료의 관계isEmpty는 출력 완료 신호 아님interrupt즉시 종료를 뜻하지 않음
실선: 항목 처리와 마지막종료의 관계isEmpty는 출력 완료 신호아님interrupt즉시 종료를 뜻하지않음
submit("post-18")- 확인된 사실
- 항목 하나를 큐에 넣음
- 아직 뜻하지 않는 것
- 출력 완료
jobs.isEmpty()확인- 확인된 사실
- 소비자가 항목을 꺼냄
- 아직 뜻하지 않는 것
- 콘솔 출력 완료
interrupt()요청- 확인된 사실
- 다음 중단 가능한 대기에 취소를 전달
- 아직 뜻하지 않는 것
- 요청 즉시 작업자 종료
join()반환- 확인된 사실
- 작업자 종료까지 회수
- 아직 뜻하지 않는 것
- 새 submit을 거부하는 수용 정책
이 원문은 printed=post-18 다음 printer-cancelled pending=0을 남깁니다. 꺼낸 항목의 콘솔 로그 뒤 다음 take가 중단됩니다. 큐 확인 반복에는 자체 시간 제한이 없고, drain·running 플래그·Outcome은 이 클래스에 없습니다.
이 예의 조건
- main의 interrupt와 콘솔 출력의 앞뒤는 고정되지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | take 뒤 빈 큐와 처리 중 post-18을 구별합니다. 원래 코드는 콘솔 로그 뒤 다음 take에서 취소를 받고 join으로 회수하며 main의 interrupt와 콘솔 로그 앞뒤는 고정하지 않습니다. |
| 6 | 큐 빈 상태 |
| 7 | 처리 중 항목 |
| 18 | interrupt 즉시 종료를 뜻하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | take 뒤 빈 큐와 처리 중 post-18을 구별합니다. 원래 코드는 콘솔 로그 뒤 다음 take에서 취소를 받고 join으로 회수하며 main의 interrupt와 콘솔 로그 앞뒤는 고정하지 않습니다. |
| 전체 읽기 정보 | 큐가 빈 시점과 출력이 끝난 시점은 다르다 큐가 빈 시점과 출력이 끝난 시점은 다르다 InterruptiblePrinter의 post-18 하나를 넣고 큐가 빈 것을 확인한 뒤 interrupt와 join하는 실제 흐름을 읽습니다. PRINTER · 큐와 완료 경계 큐가 빈 시점과 출력이 끝난 시점은 다르다 isEmpty는 항목을 꺼냈다는 사실입니다. 이 예제는 콘솔 로그 뒤 다음 take에서 중단되고 join으로 회수됩니다. submit("post-18") 확인된 사실 항목 하나를 큐에 넣음 아직 뜻하지 않는 것 출력 완료 jobs.isEmpty() 확인 확인된 사실 소비자가 항목을 꺼냄 아직 뜻하지 않는 것 콘솔 출력 완료 interrupt() 요청 확인된 사실 다음 중단 가능한 대기에 취소를 전달 아직 뜻하지 않는 것 요청 즉시 작업자 종료 join() 반환 확인된 사실 작업자 종료까지 회수 아직 뜻하지 않는 것 새 submit을 거부하는 수용 정책 이 원문은 printed=post-18 다음 printer-cancelled pending=0 을 남깁니다. 꺼낸 항목의 콘솔 로그 뒤 다음 take가 중단됩니다. 큐 확인 반복에는 자체 시간 제한이 없고, drain·running 플래그·Outcome은 이 클래스에 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 항목 등록 submit(post-18)이 항목을 큐에 넣습니다. |
|
|
| 처리 중 take 뒤 큐는 비어 있고 worker가 출력할 항목을 보유합니다. |
|
|
| 취소 회수 다음 take가 중단 예외를 받고 worker가 종료합니다. |
|
|
그림이 짚는 것처럼 큐가 빈 시점과 출력이 끝난 시점은 다릅니다. 게다가 이 예제의 println은 실제 장치 출력이 아니라 콘솔 로그일 뿐입니다. 진짜 프린터라면 장치가 출력을 마쳤다는 응답을 따로 받아야 출력이 끝났다고 말할 수 있습니다.
interrupt를 받은 순간 큐에 문서가 남아 있었다면 어떻게 될까요? LinkedBlockingQueue의 take()는 플래그가 세워져 있으면 큐에 항목이 있어도 꺼내지 않고 바로 InterruptedException을 던집니다. 그래서 남은 문서는 큐에 그대로 남고, 그 문서를 버릴지, 저장했다가 나중에 다시 처리할지는 이 클래스가 정해야 할 정책입니다. 남은 문서를 모두 출력하고 끝내는 방법은 다음 글에서 다룹니다.
플래그에는 요청 횟수도 이유도 없다
플래그는 참·거짓 하나뿐이라서 담을 수 있는 정보가 매우 적습니다. 플래그를 지우지 않은 채 interrupt()를 두 번 호출해도 요청이 두 개 쌓이지 않고 그냥 true입니다. 사용자가 취소 버튼을 눌렀는지, 기한이 지났는지, 프로그램 전체가 종료 중인지 같은 이유도 플래그에는 남지 않습니다.
그래서 이유와 결과는 작업을 맡긴 쪽이 따로 기록해야 합니다. 예를 들어 작업 결과를 완료, 취소, 실패로 나눠 돌려주면, 호출한 쪽은 취소된 작업을 다시 시도할지 실패 원인을 보고할지 정할 수 있습니다. 모든 경우에 false 하나만 돌려주면 이런 판단을 할 수 없고, 실패했다면 그 원인이 된 예외도 함께 넘겨야 합니다. 로그도 마찬가지입니다. 종료 과정에서 예상대로 받은 interrupt까지 오류 수준의 스택 트레이스로 남기면 정상 종료가 장애처럼 보이고, 반대로 아무 기록도 없이 끝내면 처리하지 못한 작업을 나중에 찾기 어렵습니다. 종료를 요청하는 쪽이 '지금은 종료 중'이라는 상태를 기록해 두면, 작업자는 interrupt를 받았을 때 그 상태와 비교해 예상한 취소인지 예상 밖의 중단인지 구분해서 기록할 수 있습니다.
다음 CancellableDocumentConsumer는 그중 '무엇을 완료했는가'를 기록합니다. 작업자가 처리한 문서를 completed 목록에 모으고, 바깥에서는 그 목록의 복사본을 받아 볼 수 있습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public final class CancellableDocumentConsumer {
private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();
private final List<String> completed = new ArrayList<>();
private final Thread thread = new Thread(this::consume, "document-consumer");
public void start() {
thread.start();
}
public void add(String value) {
queue.add(value);
}
public void cancel() throws InterruptedException {
thread.interrupt();
thread.join();
}
private void consume() {
try {
while (!Thread.currentThread().isInterrupted()) {
String value = queue.take();
synchronized (completed) {
completed.add(value);
}
System.out.println("consumed=" + value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public List<String> completed() {
synchronized (completed) {
return List.copyOf(completed);
}
}
public static void main(String[] args) throws InterruptedException {
CancellableDocumentConsumer c = new CancellableDocumentConsumer();
c.start();
c.add("thread");
while (c.completed().isEmpty()) {
Thread.onSpinWait();
}
c.cancel();
System.out.println("snapshot=" + c.completed());
}
}consumed=thread
snapshot=[thread]main은 완료 목록이 비어 있지 않게 될 때까지 기다린 뒤 cancel()로 작업자를 멈추고 목록을 출력합니다. 작업자 스레드는 목록에 추가하고 main 스레드는 목록을 읽으므로, 두 스레드가 같은 ArrayList를 동시에 다루지 않도록 둘 다 synchronized (completed) 블록 안에서만 목록에 접근합니다. 바깥에 원본이 아니라 List.copyOf로 만든 복사본을 돌려주는 이유도 같습니다. 원본을 넘기면 호출한 쪽이 락 밖에서 목록을 읽는 동안 작업자가 그 목록을 바꿀 수 있기 때문입니다. cancel()은 join()까지 마친 뒤 돌아오므로, 그 뒤에 얻은 복사본에는 더 이상 새 항목이 추가되지 않습니다.
두 줄의 순서도 실행할 때마다 같습니다. consumed=thread는 작업자가 끝나기 전에 출력하고, snapshot은 main이 그 작업자를 join()으로 기다린 뒤에 출력하기 때문입니다. 작업자의 반복문은 두 곳에서 요청을 알아챕니다. 요청이 take() 대기 중에 오면 예외로 알아채고 catch 블록이 플래그를 다시 세웁니다. 문서를 처리하는 도중에 오면 다음 반복의 조건 검사에서 멈추는데, isInterrupted()는 플래그를 지우지 않으므로 이때도 플래그는 true로 남습니다.
이 클래스에는 아직 빈틈이 하나 있습니다. cancel()은 작업자를 멈추지만 add()는 그 뒤에도 문서를 받아 큐에 넣습니다. 받은 문서를 처리할 작업자가 없으니, 그 문서는 아무도 모르게 큐에 쌓이기만 합니다. 작업자를 멈추는 일과 새 입력을 그만 받는 일은 따로 구현해야 하고, 접수 중인지 한 번 확인한 뒤 나중에 큐에 넣는 식으로 짜면 종료와 등록이 엇갈릴 수 있습니다. 다음 글에서 접수 확인과 큐 등록을 같은 락 안에서 처리해 이 문제를 해결합니다.
여러 메서드를 거쳐 InterruptedException 넘기기
실제 코드에서는 take() 같은 대기 호출이 여러 메서드 깊숙이 들어 있곤 합니다. 이때 가장 안쪽 메서드가 '요청을 받으면 작업을 버리고 끝낸다' 같은 정책을 스스로 정해 버리면 그 메서드를 다른 곳에서 다시 쓰기 어렵습니다. 어떤 호출자는 바로 끝내고 싶고, 어떤 호출자는 정리 작업을 하고 끝내고 싶을 수 있기 때문입니다. 그래서 안쪽 메서드는 throws InterruptedException으로 예외를 그대로 넘기고, 작업 전체를 맡은 가장 바깥 반복문이 어떻게 끝낼지를 정합니다. 앞의 프린터를 이렇게 나누면 다음과 같은 모양이 됩니다.
private String nextJob() throws InterruptedException {
return jobs.take(); // 안쪽: 정책을 정하지 않고 넘긴다
}
private void loop() { // 바깥: 작업자 스레드가 실행하는 맨 위
try {
while (true) {
print(nextJob());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// 취소로 기록하고 끝낸다
}
}Runnable.run()처럼 검사 예외를 선언할 수 없는 메서드가 가장 바깥이 되는 경우가 많습니다. 그곳에서 예외를 잡고, 최종 결과를 기록하고, 플래그를 다시 세웁니다. 어쩔 수 없이 InterruptedException을 RuntimeException 같은 비검사 예외로 감싸서 던져야 한다면, 감싸기 전에 플래그를 다시 세우고 원래 예외를 원인(cause)으로 넣으세요. 그래야 위쪽 코드가 플래그로 요청을 알아채거나 원인을 확인할 수 있습니다. 22장에서 배울 실행기(Executor)의 Future는 취소를 요청하는 cancel과 결과를 받는 get을 서로 다른 메서드로 제공하므로, 스레드를 직접 다룰 때보다 이 구분이 분명해집니다. 자세한 내용은 Future로 작업을 취소하는 글에서 다룹니다.
정리
interrupt()는 대상 스레드의 플래그를true로 세울 뿐 스레드를 멈추지 않습니다. 작업자는 플래그를 확인하거나 대기 메서드가 던지는InterruptedException을 받아 스스로 멈춥니다.sleep,wait,join,BlockingQueue의take()같은 대기 메서드는 기다리는 중에 요청을 받거나 플래그가 이미 세워진 채로 호출되면InterruptedException을 던지고 플래그를 지웁니다.t.isInterrupted()는 플래그를 읽기만 하고, 정적 메서드Thread.interrupted()는 현재 스레드의 플래그를 읽고 지웁니다.worker.interrupted()라고 써도 대상은 현재 스레드입니다.InterruptedException은throws로 넘기거나, 넘길 수 없으면 잡은 뒤Thread.currentThread().interrupt()로 플래그를 다시 세웁니다. 빈catch블록은 요청을 없앱니다.- 큐가 비었다고 처리가 끝난 것은 아닙니다. 플래그에는 요청 횟수와 이유가 없으므로 취소 이유와 완료 결과는 따로 기록합니다.
연습 문제
-
현재 스레드가 자기 자신에게
interrupt()를 두 번 호출한 뒤isInterrupted()를 한 번,Thread.interrupted()를 두 번 차례로 호출합니다. 세 반환값을 쓰고, 이 값들로interrupt()가 두 번 호출됐다는 사실을 알아낼 수 있는지 설명하세요. -
다음 작업자는
interrupt()를 받은 뒤에도 끝나지 않습니다. 이유를 설명하고, 고치는 방법 두 가지를 쓰세요.jobs는 앞의InterruptiblePrinter처럼LinkedBlockingQueue이고, 큐는 비어 있다고 가정합니다.private String nextJobOrNull() { try { return jobs.take(); } catch (InterruptedException e) { return null; } } private void loop() { while (!Thread.currentThread().isInterrupted()) { String job = nextJobOrNull(); if (job != null) System.out.println("printed=" + job); } } -
CancellableDocumentConsumer의 작업자가 문서 하나를take()로 꺼내 처리하는 도중이라 큐는 비어 있습니다.main이 큐가 빈 것만 보고 '모든 문서를 처리했다'고 판단해도 될까요? 또cancel()이 돌아온 뒤add("late")를 호출하면 이 문서는 어떻게 될까요? -
작업자가 20ms마다 heartbeat 횟수를 1씩 늘리며
sleep하게 만드세요.main은 첫 heartbeat가 일어난 것을 확인한 뒤interrupt()를 걸고join()합니다. 작업자는catch블록에서 플래그를 다시 세운 뒤 종료 이유를 출력해야 합니다.
해설 보기
1번. true, true, false입니다. 두 번째 interrupt()는 이미 true인 플래그를 다시 true로 만들 뿐이라 요청이 두 개 쌓이지 않습니다. isInterrupted()는 읽기만 하므로 true를 돌려주고 플래그를 그대로 두고, 첫 Thread.interrupted()가 true를 돌려주면서 플래그를 지우며, 두 번째는 false를 돌려줍니다. interrupt()를 한 번 호출했을 때와 결과가 완전히 같으므로 몇 번 호출됐는지는 알아낼 수 없습니다. 횟수나 이유가 필요하다면 플래그 밖에 따로 기록해야 합니다.
2번. 작업자가 take()에서 기다리는 동안 요청이 오면 InterruptedException이 나면서 플래그가 지워집니다. nextJobOrNull()은 그 예외를 삼키고 null을 돌려주므로, 반복문 조건의 isInterrupted()는 false를 읽고 다시 take()를 호출합니다. 큐가 비어 있으니 작업자는 다시 기다리고, 요청은 이미 사라졌으므로 영원히 깨어나지 않습니다. 실제로 실행해 보면 interrupt() 뒤 0.5초가 지나도 작업자는 살아 있고 상태는 WAITING입니다.
첫 번째 방법은 catch 블록에 Thread.currentThread().interrupt();를 넣어 플래그를 다시 세우는 것입니다. 그러면 null을 받은 반복문이 조건 검사에서 요청을 보고 멈춥니다. 두 번째 방법은 예외를 잡지 않고 넘기는 것입니다. 본문의 nextJob()처럼 throws InterruptedException을 선언하고, loop()가 반복문 전체를 try로 감싸 catch에서 끝내도록 바꿉니다. 두 방법 모두 실행해 보면 작업자가 TERMINATED 상태로 끝납니다. 넘길 수 있는 상황이라면 두 번째 방법이 더 단순하고, null이 '문서 없음'인지 '취소됨'인지 헷갈릴 일도 없습니다.
3번. 판단할 수 없습니다. 큐가 비었다는 것은 작업자가 문서를 꺼냈다는 뜻일 뿐이고, 꺼낸 문서는 처리 중이라 큐에도 완료 목록에도 아직 없을 수 있습니다. 완료 여부는 completed()처럼 작업자가 처리를 마친 뒤 기록한 결과로 확인해야 합니다. cancel() 뒤의 add("late")는 예외 없이 큐에 들어갑니다. add()에 접수 상태를 확인하는 코드가 없기 때문입니다. 하지만 작업자는 이미 끝났으므로 이 문서를 처리할 스레드가 없고, 문서는 큐에 남은 채 잊힙니다. 작업자를 멈추는 것과 별도로 접수를 닫는 기능과 남은 문서를 처리할 정책이 필요합니다.
4번. 래치(CountDownLatch)로 첫 heartbeat를 기다리면, main이 heartbeat가 한 번도 일어나기 전에 interrupt를 거는 일을 막을 수 있습니다.
import java.util.concurrent.CountDownLatch;
public final class InterruptibleHeartbeatSolution {
public static void main(String[] args) throws InterruptedException {
CountDownLatch first = new CountDownLatch(1);
int[] beats = {0};
Thread worker =
new Thread(
() -> {
try {
while (true) {
beats[0]++;
first.countDown();
Thread.sleep(20);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(
"reason=interrupt, flag="
+ Thread.currentThread().isInterrupted());
}
},
"heartbeat");
worker.start();
first.await();
worker.interrupt();
worker.join();
System.out.println("terminated=" + !worker.isAlive() + ", beats=" + beats[0]);
}
}reason=interrupt, flag=true
terminated=true, beats=1첫 줄의 flag=true는 catch 블록이 플래그를 다시 세웠기 때문이고, 둘째 줄의 terminated=true는 join()이 작업자가 끝날 때까지 기다렸기 때문입니다. 두 줄의 순서도 join() 때문에 항상 같습니다. 반면 beats는 실행할 때마다 달라질 수 있습니다. 보통은 main이 첫 heartbeat 직후 깨어나 작업자의 첫 휴식 도중에 interrupt를 걸므로 1이 나오지만, 시스템이 바빠 main이 20ms 넘게 늦게 깨어나면 2 이상이 나옵니다. 그러니 이 프로그램이 확실히 말할 수 있는 것은 '1 이상'뿐입니다. beats[0]을 volatile 없이 읽어도 되는 이유는 main이 join()으로 작업자의 종료를 기다린 뒤에 읽기 때문입니다. 작업자가 끝나기 전에 한 쓰기는 join()이 돌아온 뒤의 읽기에 보입니다.
다음 글
작업자 스레드 안전하게 종료하기에서는 volatile 플래그만으로 작업자를 멈추려 할 때 생기는 문제를 확인하고, 남은 문서를 모두 출력한 뒤 끝내는 종료와 interrupt로 즉시 멈추는 종료를 직접 만들어 비교합니다.