join으로 스레드가 끝날 때까지 기다리기
작업자 스레드의 결과를 너무 일찍 읽는 문제를 재현하고, join으로 작업자가 끝날 때까지 기다린 뒤 결과를 읽는 방법과 sleep으로 기다리면 안 되는 이유를 배웁니다.
start()는 새 스레드가 실행될 수 있게 준비해 두고 곧바로 돌아옵니다. 그 스레드의 작업이 끝나기는커녕 시작됐는지도 기다리지 않습니다. 덕분에 main은 작업자가 계산하는 동안 다른 일을 할 수 있습니다. 그런데 작업자가 계산한 결과를 써야 하는 순간에는 이 점이 문제가 됩니다. main이 결과를 읽으려는 시점에 작업자는 아직 계산 중일 수도 있고, 실행을 시작하지 않았을 수도 있기 때문입니다.
이 글에서는 이 문제를 먼저 재현한 뒤 Thread의 join() 메서드로 해결합니다. join()은 호출한 스레드가 대상 스레드가 끝날 때까지 기다리게 하는 메서드입니다. 이어서 join()이 돌아온 뒤에는 작업자가 기록한 값이 왜 반드시 보이는지, 작업자 여러 개는 어떤 순서로 시작하고 기다려야 하는지, Thread.sleep()으로 기다리면 왜 안 되는지, 기다리는 도중 중단 요청을 받으면 어떻게 해야 하는지를 차례로 살펴봅니다.
결과를 너무 일찍 읽는 프로그램
아래 프로그램에서 작업자는 ResultTask의 result 필드에 42를 기록합니다. 다만 바로 기록하지 않고, 래치(CountDownLatch)가 열릴 때까지 기다렸다가 기록합니다. Runnable과 데몬 스레드에서 본 것처럼 CountDownLatch(1)은 누군가 countDown()을 한 번 호출할 때까지 await()를 호출한 스레드를 멈춰 두는 문입니다. main은 작업자를 시작하자마자 결과를 읽고, 그다음에야 문을 엽니다.
래치를 쓴 이유는 실수를 매번 똑같이 재현하기 위해서입니다. 래치가 없으면 main이 읽는 순간 작업자가 이미 42를 기록했는지는 운영체제가 두 스레드를 어떤 순서로 실행하느냐에 달려 있어서, 실행할 때마다 결과가 달라질 수 있습니다. 래치가 닫혀 있는 동안에는 작업자가 result = 42에 도달할 수 없으므로, 첫 번째 읽기가 너무 이르다는 사실이 항상 드러납니다.
import java.util.concurrent.CountDownLatch;
public final class ReadBeforeJoinBug {
private static final class ResultTask implements Runnable {
private final CountDownLatch gate;
int result;
ResultTask(CountDownLatch gate) {
this.gate = gate;
}
public void run() {
try {
gate.await();
result = 42;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch gate = new CountDownLatch(1);
ResultTask task = new ResultTask(gate);
Thread worker = new Thread(task, "result-worker");
worker.start();
System.out.println("wrong-read=" + task.result);
gate.countDown();
worker.join();
System.out.println("after-join=" + task.result);
}
}JDK 21에서 수십 번 실행해 보면 출력은 매번 다음과 같습니다. 첫 줄은 main이 join() 전에 읽은 값이고, 둘째 줄은 join()이 돌아온 뒤에 읽은 값입니다. 두 줄 사이에 main이 하는 일은 래치를 여는 countDown()과 join() 두 가지뿐입니다.
wrong-read=0
after-join=42첫 줄의 0은 int 필드의 기본값입니다. main이 이 값을 읽을 때 작업자는 gate.await()에서 기다리고 있거나 아직 실행을 시작하지도 않았으므로, 42가 기록됐을 가능성이 없습니다. 이어서 main이 gate.countDown()으로 문을 열면 작업자가 await()를 빠져나와 42를 기록하고 run()을 마칩니다. worker.join()은 바로 이 종료를 기다렸다가 돌아오므로, 그 뒤에 읽은 값은 42입니다. 두 줄 모두 스레드 실행 순서에 따라 달라지지 않습니다. 첫 줄의 순서는 닫힌 래치가, 둘째 줄의 순서는 join()이 정해 주기 때문입니다.
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 gate에서 0 읽기→countDown→worker의 42 쓰기→join 뒤 42 읽기의 필요한 순서입니다. 읽기/쓰기/대기 경계를 같은 영역에서 따라가기 좋습니다.
ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다.
JOIN · 쓰기 전 / 종료 후
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다.
실선: 게이트·쓰기·읽기의 필요한 순서join은 main의 대기 / worker중지 아님
실선: 게이트·쓰기·읽기의필요한 순서join은 main의 대기 /worker중지 아님
시작 직후,
countDown()전- 작업자가 기록할 수 있는 조건
- 래치가 닫혀 있어
result = 42에 도달하지 못함 - 원문의 출력
wrong-read=0
countDown()·worker.join()뒤- 작업자가 기록할 수 있는 조건
- 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음
- 원문의 출력
after-join=42
첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join()은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다.
이 예의 조건
- 첫 0은 닫힌 gate로 보장하며 우연한 빠른 읽기 관측이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 4 | worker가 42를 못 씀 |
| 14 | join은 main의 대기 |
| 15 | worker 중지 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 전체 읽기 정보 | 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에 42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다. JOIN · 쓰기 전 / 종료 후 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다. 시작 직후, countDown() 전 작업자가 기록할 수 있는 조건 래치가 닫혀 있어 result = 42 에 도달하지 못함 원문의 출력 wrong-read=0 countDown() · worker.join() 뒤 작업자가 기록할 수 있는 조건 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음 원문의 출력 after-join=42 첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join() 은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 게이트 닫힘 worker가 42를 쓰기 전에 기다립니다. |
|
|
| 게이트 해제 countDown 후 worker가 42를 저장하고 끝납니다. |
|
|
| join 후 읽기 main이 종료를 회수하고 다시 읽습니다. |
|
|
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 gate에서 0 읽기→countDown→worker의 42 쓰기→join 뒤 42 읽기의 필요한 순서입니다. 읽기/쓰기/대기 경계를 같은 영역에서 따라가기 좋습니다.
ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다.
JOIN · 쓰기 전 / 종료 후
닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다
닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다.
실선: 게이트·쓰기·읽기의 필요한 순서join은 main의 대기 / worker중지 아님
실선: 게이트·쓰기·읽기의필요한 순서join은 main의 대기 /worker중지 아님
시작 직후,
countDown()전- 작업자가 기록할 수 있는 조건
- 래치가 닫혀 있어
result = 42에 도달하지 못함 - 원문의 출력
wrong-read=0
countDown()·worker.join()뒤- 작업자가 기록할 수 있는 조건
- 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음
- 원문의 출력
after-join=42
첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join()은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다.
이 예의 조건
- 첫 0은 닫힌 gate로 보장하며 우연한 빠른 읽기 관측이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 4 | worker가 42를 못 씀 |
| 14 | join은 main의 대기 |
| 15 | worker 중지 아님 |
| 구분 | 내용 |
|---|---|
| 설명 | 첫 0은 게이트가 닫혀 worker가 42를 쓰지 못하는 조건으로 고정됩니다. countDown 뒤 쓰기·종료·정상 join 뒤 읽기의 필요한 앞뒤 관계를 연결합니다. |
| 전체 읽기 정보 | 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 ReadBeforeJoinBug는 main의 첫 출력 뒤에 래치를 해제합니다. 작업자는 그 뒤 result에 42를 쓰며 main은 작업자를 join한 다음 결과를 다시 읽습니다. JOIN · 쓰기 전 / 종료 후 닫힌 게이트 앞의 0과 join 뒤의 42를 구분한다 닫힌 게이트에서는 result=42에 도달할 수 없습니다. 게이트를 열고 종료를 회수한 뒤 42를 읽습니다. 시작 직후, countDown() 전 작업자가 기록할 수 있는 조건 래치가 닫혀 있어 result = 42 에 도달하지 못함 원문의 출력 wrong-read=0 countDown() · worker.join() 뒤 작업자가 기록할 수 있는 조건 게이트를 통과한 작업자의 종료까지 기다린 뒤 읽음 원문의 출력 after-join=42 첫 읽기가 0인 이유는 게이트를 아직 열지 않았기 때문입니다. join() 은 호출자인 main이 작업자 종료를 기다리게 하며, 작업자를 멈추는 호출이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 게이트 닫힘 worker가 42를 쓰기 전에 기다립니다. |
|
|
| 게이트 해제 countDown 후 worker가 42를 저장하고 끝납니다. |
|
|
| join 후 읽기 main이 종료를 회수하고 다시 읽습니다. |
|
|
실제 코드에는 이런 래치가 없으니 상황이 더 나쁩니다. start() 직후에 결과를 읽으면 어떤 실행에서는 0이, 어떤 실행에서는 42가 나올 수 있고, 개발 PC에서는 늘 맞다가 서버에서만 가끔 틀릴 수도 있습니다. 이 예제처럼 문제를 항상 재현할 수 있게 만들어 두면, 고친 코드가 정말 고쳐졌는지도 확인할 수 있습니다.
result 필드에 volatile을 붙이면 해결될 것이라고 생각하기 쉽지만 그렇지 않습니다. volatile은 다른 스레드가 이미 기록한 값을 바로 볼 수 있게 해 줄 뿐, 아직 기록되지 않은 값을 기록된 것으로 만들 수는 없습니다. 여기에는 두 가지 문제가 겹쳐 있습니다. 하나는 작업자가 기록을 마치기 전에 main이 읽었다는 순서 문제이고, 다른 하나는 작업자가 기록한 값이 main에게 보이느냐는 가시성(visibility) 문제입니다. join()은 이 두 문제를 함께 해결합니다.
join은 호출한 스레드를 기다리게 한다
worker.join()을 읽을 때 헷갈리기 쉬운 점은 누가 기다리느냐입니다. 메서드는 worker 객체에 대고 호출하지만, 기다리는 쪽은 이 코드를 실행하는 스레드, 앞의 예제에서는 main입니다. main은 worker가 끝날 때까지 다음 줄로 넘어가지 않고 WAITING 상태로 멈춰 있습니다. 반대로 worker에게는 아무 일도 일어나지 않습니다. join()은 대상 스레드를 멈추거나 앞당기지 않고, 끝내라고 요청하지도 않습니다.
인자 없는 join()에는 시간 제한이 없어서, 대상이 끝나지 않으면 호출한 스레드도 계속 기다립니다. 기다리는 도중 누군가 기다리고 있는 스레드에 중단을 요청(interrupt)하면 join()은 기다리기를 그만두고 검사 예외인 InterruptedException을 던집니다. 앞의 main이 throws InterruptedException을 선언한 것은 이 때문이며, 이 예외를 다루는 방법은 이 글의 마지막 절에서 봅니다.
join()이 실제로 기다리는지는 호출하는 순간 대상 스레드의 상태에 따라 달라집니다.
| 대상 스레드의 상태 | join()을 호출하면 |
|---|---|
아직 start()하지 않음(NEW) | 기다리지 않고 바로 돌아온다 |
| 실행 중 | 대상이 끝날 때까지 호출한 스레드가 기다린다 |
| 이미 끝남(TERMINATED) | 기다리지 않고 바로 돌아온다 |
첫 번째 줄이 특히 중요합니다. 인자 없는 join()은 join(0)과 같고, Thread API 문서에 따르면 시작하지 않은 스레드에 호출하면 기다리지 않고 바로 돌아옵니다. 그래서 worker.join(); worker.start();처럼 순서를 뒤집으면 join()은 나중에 시작할 작업을 미리 기다려 주지 않고 그냥 지나갑니다. join()이 기다리는 것은 '이 작업의 완료'가 아니라 '이미 시작한 이 스레드의 종료'입니다. 세 번째 줄 덕분에 이미 끝난 스레드를 다시 join()해도 문제가 없습니다.
작업자 여러 개는 모두 시작한 뒤에 기다린다
작업을 여러 스레드로 나누는 목적은 동시에 실행하는 것입니다. 그런데 join()을 넣는 위치를 잘못 고르면, 스레드를 여러 개 만들고도 한 번에 하나씩 실행하게 됩니다.
for (Thread worker : workers) {
worker.start();
worker.join(); // 이 작업자가 끝나야 다음 작업자를 시작한다
}이 반복문은 첫 작업자를 시작하고, 그 작업자가 끝날 때까지 기다린 다음에야 두 번째 작업자를 시작합니다. 작업마다 1초가 걸린다면 세 작업에 3초가 걸리므로, 스레드 없이 차례로 호출한 것과 다를 바가 없습니다. 올바른 방법은 반복문을 둘로 나눠 첫 반복문에서 모두 시작하고 두 번째 반복문에서 모두 기다리는 것입니다. 그러면 세 작업이 겹쳐 실행되고, 코어가 충분하다면 전체 시간은 가장 오래 걸리는 작업 하나의 시간에 가까워집니다.
아래 프로그램은 1부터 3000까지의 합을 1000개씩 세 구간으로 나눠 계산합니다. sum-0은 1부터 1000까지, sum-1은 1001부터 2000까지, sum-2는 2001부터 3000까지를 더해 values 배열의 자기 칸에 기록합니다.
public final class ParallelStartThenJoin {
public static void main(String[] args) throws InterruptedException {
long[] values = new long[3];
Thread[] workers = new Thread[3];
for (int i = 0; i < workers.length; i++) {
int slot = i;
workers[i] =
new Thread(
() -> values[slot] = sum(slot * 1000 + 1, (slot + 1) * 1000),
"sum-" + slot);
}
for (Thread worker : workers) {
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
long total = 0;
for (long value : values) {
total += value;
}
System.out.println("total=" + total);
}
private static long sum(int from, int to) {
long result = 0;
for (int n = from; n <= to; n++) {
result += n;
}
return result;
}
}출력은 다음 한 줄이고, 여러 번 실행해도 같습니다. main은 세 작업자를 모두 시작한 다음에 차례로 join()하므로 세 구간이 동시에 계산되고, 세 칸이 다 채워진 뒤에야 합계를 구합니다.
total=4501500세 칸에는 각각 500500, 1500500, 2500500이 기록되고, main이 이를 더해 4501500을 출력합니다. 1부터 3000까지의 합인 3000×3001÷2와 같은 값입니다. 실행할 때마다 달라질 수 있는 것은 세 작업자가 끝나는 순서인데, 이 순서는 출력에 영향을 주지 않습니다. 각 작업자가 자기 칸에만 쓰고, main은 세 작업자를 모두 기다린 뒤에 더하기 때문입니다.
join()을 부르는 순서가 작업자가 끝나는 순서와 같을 필요도 없습니다. 예를 들어 sum-2가 가장 먼저 끝났다면, main이 sum-0과 sum-1을 기다리는 동안 sum-2는 이미 끝나 있고 마지막 join()은 기다리지 않고 바로 돌아옵니다. 어떤 순서로 끝나든 세 번째 join()이 돌아왔을 때는 세 작업자가 모두 끝나 있습니다.
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
세 작업자의 전용 배열 슬롯과 모두 join 뒤 total 집계입니다. 병렬 기록과 완료 경계를 구별하면 배열 자체가 가시성을 만든다는 오해를 피합니다.
ParallelStartThenJoin의 sum-0, sum-1, sum-2는1부터3000까지 서로 다른1000개 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total4501500을 출력합니다.
JOIN · 슬롯별 기록
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다.
sum-0
- 계산 범위·읽거나 쓰는 곳
1..1000values[0]에 기록- 소스 계산값 또는 최종 출력
500500
sum-1
- 계산 범위·읽거나 쓰는 곳
1001..2000values[1]에 기록- 소스 계산값 또는 최종 출력
1500500
sum-2
- 계산 범위·읽거나 쓰는 곳
2001..3000values[2]에 기록- 소스 계산값 또는 최종 출력
2500500
main
- 계산 범위·읽거나 쓰는 곳
- 모든
start()뒤 모든join()을 기다리고values를 합산 - 소스 계산값 또는 최종 출력
total=4501500
각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다.
이 예의 조건
- 작업자 간 실행 사슬이 아닌 독립 슬롯 기록이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 전체 읽기 정보 | 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 ParallelStartThenJoin의 sum-0, sum-1, sum-2는 1부터 3000까지를 나눠 각각 1000개의 정수를 포함하는 서로 다른 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total 4501500을 출력합니다. JOIN · 슬롯별 기록 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다. sum-0 계산 범위·읽거나 쓰는 곳 1..1000 values[0] 에 기록 소스 계산값 또는 최종 출력 500500 sum-1 계산 범위·읽거나 쓰는 곳 1001..2000 values[1] 에 기록 소스 계산값 또는 최종 출력 1500500 sum-2 계산 범위·읽거나 쓰는 곳 2001..3000 values[2] 에 기록 소스 계산값 또는 최종 출력 2500500 main 계산 범위·읽거나 쓰는 곳 모든 start() 뒤 모든 join() 을 기다리고 values 를 합산 소스 계산값 또는 최종 출력 total=4501500 각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 세 범위 분배 각 작업자에게 다른 슬롯을 줍니다. |
|
|
| 부분합 기록 자기 범위 결과를 저장합니다. |
|
|
| 모두 회수 후 main이 세 join 뒤 합을 읽습니다. |
|
|
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
세 작업자의 전용 배열 슬롯과 모두 join 뒤 total 집계입니다. 병렬 기록과 완료 경계를 구별하면 배열 자체가 가시성을 만든다는 오해를 피합니다.
ParallelStartThenJoin의 sum-0, sum-1, sum-2는1부터3000까지 서로 다른1000개 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total4501500을 출력합니다.
JOIN · 슬롯별 기록
세 구간은 각자 values 슬롯에 쓰고 main이 합친다
작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다.
sum-0
- 계산 범위·읽거나 쓰는 곳
1..1000values[0]에 기록- 소스 계산값 또는 최종 출력
500500
sum-1
- 계산 범위·읽거나 쓰는 곳
1001..2000values[1]에 기록- 소스 계산값 또는 최종 출력
1500500
sum-2
- 계산 범위·읽거나 쓰는 곳
2001..3000values[2]에 기록- 소스 계산값 또는 최종 출력
2500500
main
- 계산 범위·읽거나 쓰는 곳
- 모든
start()뒤 모든join()을 기다리고values를 합산 - 소스 계산값 또는 최종 출력
total=4501500
각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다.
이 예의 조건
- 작업자 간 실행 사슬이 아닌 독립 슬롯 기록이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 1..1000/1001..2000/2001..3000의 각 슬롯 500500/1500500/2500500을 모두 join의 정상 반환 뒤 4501500으로 합합니다. 각 슬롯에 기록자 한 명이며 작업자 사이의 실행 사슬이 아닙니다. |
| 전체 읽기 정보 | 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 ParallelStartThenJoin의 sum-0, sum-1, sum-2는 1부터 3000까지를 나눠 각각 1000개의 정수를 포함하는 서로 다른 구간을 계산합니다. main은 모두 시작하고 모두 join한 뒤 total 4501500을 출력합니다. JOIN · 슬롯별 기록 세 구간은 각자 values 슬롯에 쓰고 main이 합친다 작업자 셋은 겹치지 않는 구간을 각자 values 슬롯에 씁니다. main은 모두 시작하고 모두 회수한 뒤 합칩니다. sum-0 계산 범위·읽거나 쓰는 곳 1..1000 values[0] 에 기록 소스 계산값 또는 최종 출력 500500 sum-1 계산 범위·읽거나 쓰는 곳 1001..2000 values[1] 에 기록 소스 계산값 또는 최종 출력 1500500 sum-2 계산 범위·읽거나 쓰는 곳 2001..3000 values[2] 에 기록 소스 계산값 또는 최종 출력 2500500 main 계산 범위·읽거나 쓰는 곳 모든 start() 뒤 모든 join() 을 기다리고 values 를 합산 소스 계산값 또는 최종 출력 total=4501500 각 슬롯의 기록자는 하나입니다. 부분합은 소스에서 계산한 값이며 원문은 최종 합계 한 줄만 출력합니다. 표의 작업자 순서는 실행·완료 순서가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 세 범위 분배 각 작업자에게 다른 슬롯을 줍니다. |
|
|
| 부분합 기록 자기 범위 결과를 저장합니다. |
|
|
| 모두 회수 후 main이 세 join 뒤 합을 읽습니다. |
|
|
join 뒤에는 작업자가 기록한 값이 보인다
values는 평범한 long[] 배열입니다. volatile도 synchronized도 쓰지 않았는데, main은 어떻게 세 작업자가 기록한 값을 정확히 읽을 수 있을까요? 다른 스레드가 기록한 값이 언제 보이는지는 자바 메모리 모델(Java Memory Model)이 정합니다. JLS의 happens-before 규칙에는 '한 스레드의 모든 동작은, 다른 스레드가 그 스레드에 대한 join()에서 정상적으로 돌아오기 전에 일어난다'는 규칙이 있습니다. 그래서 작업자가 끝나기 전에 한 기록은 그 작업자에 대한 join()이 정상적으로 돌아온 뒤의 읽기에 반드시 보입니다. 값이 보이는 이유는 배열의 종류가 아니라 이 규칙입니다.
다만 이 규칙이 해 주는 일은 '끝난 작업자'에서 'join()에서 돌아온 스레드'로 가는 한 방향의 순서와 가시성뿐입니다. 이 예제가 안전한 것은 다음 세 조건이 모두 맞기 때문입니다.
- 각 칸에 기록하는 스레드가 하나뿐이라서, 작업자끼리 같은 칸을 두고 경쟁하지 않습니다.
- main은
join()이 돌아오기 전에는 배열을 읽지 않습니다. - 세
join()이 모두 예외 없이 정상적으로 돌아왔습니다.
하나라도 어긋나면 이 설명을 그대로 적용할 수 없습니다. 예를 들어 세 작업자가 모두 values[0] += ...처럼 한 칸에 더하도록 바꾸면, join()은 main과 각 작업자 사이의 순서만 정할 뿐 작업자끼리 동시에 하는 갱신을 하나로 묶어 주지 않습니다. 이런 경쟁을 다루는 방법은 가시성·원자성·임계 영역에서 배웁니다. 또 main이 join() 전에 배열을 읽는다면 어떤 값이 나올지 알 수 없습니다.
더 깊이: join이 가시성을 주는 규칙의 사슬
happens-before는 '앞의 동작이 한 기록을 뒤의 동작이 반드시 본다'는 관계입니다. JLS 17.4.5는 몇 가지 기본 관계를 정하고, 관계가 이어지면 처음과 끝 사이에도 성립한다고 정합니다. 이 글의 예제에 필요한 기본 관계는 세 가지입니다.
- 같은 스레드 안에서는 코드 순서상 앞선 동작이 뒤의 동작보다 먼저 일어납니다(program order).
- 어떤 스레드에 대한
start()호출은 그 스레드 안의 모든 동작보다 먼저 일어납니다. - 한 스레드의 모든 동작은 다른 스레드가 그 스레드의
join()에서 정상적으로 돌아오는 것보다 먼저 일어납니다. JLS 17.4.4는 이를 '스레드 T1의 마지막 동작은 T1이 끝났음을 알아낸 다른 스레드 T2의 동작과 동기화된다(synchronizes-with)'고 표현하고, 끝났음을 알아내는 방법으로T1.join()과T1.isAlive()를 듭니다.
sum-0의 경우를 이어 보면, values[0] 기록에서 1번 관계로 sum-0의 마지막 동작까지, 3번 관계로 main의 join() 반환까지, 다시 1번 관계로 main의 values[0] 읽기까지 하나의 사슬이 됩니다. 반대 방향도 같은 방식으로 설명됩니다. main이 start() 전에 만든 배열과 람다가 붙잡은 slot 값은 2번 관계 덕분에 작업자에게 보입니다. 3번의 isAlive()는 다음 글에서 중요해집니다. 시간 제한을 둔 join 뒤에 isAlive()가 false인 것을 확인했다면, 그 뒤의 읽기에도 같은 규칙이 적용됩니다.
작업자가 기록한 값은 어디에 있을까: this와 currentThread
앞의 ReadBeforeJoinBug로 돌아가 보겠습니다. 작업자 스레드는 run() 안에서 result = 42를 실행하고, main은 task.result를 읽습니다. 두 스레드가 값을 주고받을 수 있는 것은 같은 객체를 보고 있기 때문입니다. result = 42는 this.result = 42를 줄여 쓴 것이고, 작업자 스레드는 생성자로 받은 task의 run()을 호출하므로 이때 this는 main이 만든 바로 그 ResultTask 객체입니다. 스레드마다 스택은 따로 있지만 객체는 모두가 공유하는 힙에 있으므로, 작업자는 그 객체의 필드에 쓰고 main은 같은 필드를 읽습니다. 결과가 저장되는 곳은 Thread 객체가 아니라 작업 객체입니다.
Thread를 상속한 클래스에서는 작업과 스레드가 한 객체이므로, run()의 this가 곧 Thread 객체입니다. 그래서 start()로 실행했다면 run()을 실행하는 스레드가 곧 this이고, this.getName()과 Thread.currentThread().getName()도 같은 값을 냅니다. 하지만 이 일치는 start()로 실행했을 때만 성립합니다. 아래 프로그램은 같은 객체의 run()을 먼저 직접 호출하고, 이어서 start()로 한 번 더 실행합니다.
public final class ThisVersusCurrent {
private static final class NamedWorker extends Thread {
NamedWorker() {
super("named-worker");
}
@Override
public void run() {
System.out.println(
"this=" + this.getName() + ", current=" + Thread.currentThread().getName());
}
}
public static void main(String[] args) throws InterruptedException {
NamedWorker worker = new NamedWorker();
worker.run();
worker.start();
worker.join();
}
}this=named-worker, current=main
this=named-worker, current=named-worker두 줄 모두 this는 named-worker라는 이름의 Thread 객체입니다. 그러나 첫 줄은 run()을 일반 메서드처럼 직접 호출했으므로 새 스레드가 생기지 않았고, 그 코드를 실제로 실행한 스레드는 main입니다. start()로 실행한 둘째 줄에서만 this와 현재 스레드가 일치합니다. 직접 호출한 run()이 끝난 뒤에야 start()가 호출되므로 두 줄의 순서는 실행할 때마다 같습니다.
Runnable로 작업을 만들면 this는 스레드와 더 멀어집니다. Runnable 구현 클래스의 this는 작업 객체일 뿐이라서 getName() 같은 스레드 메서드가 없고, 익명 클래스로 만든 Runnable도 마찬가지입니다. 인스턴스 메서드 안에서 만든 람다의 this는 바깥 인스턴스를 가리키고, static 메서드 안의 람다에는 this가 아예 없습니다. 이 차이는 Runnable을 만드는 세 가지 방법과 람다 캡처에서 자세히 다뤘습니다. 정리하면, 지금 코드를 실행하는 스레드가 궁금할 때는 this가 아니라 언제나 Thread.currentThread()로 확인해야 합니다. 인스턴스 메서드에 붙인 synchronized는 this를 잠금 대상으로 쓰므로, '이 this는 어떤 객체인가'라는 질문은 동시성 코드를 읽을 때 계속 중요합니다.
join()도 호출 대상으로 적은 Thread 객체의 종료를 기다립니다. worker.join()은 worker를 기다리고, 그 코드가 어느 클래스 안에 있는지와는 상관이 없습니다. 주의할 곳은 Thread를 상속한 클래스 안에서 대상 없이 join()이라고만 쓴 경우입니다. 이것은 this.join()이므로, 그 스레드 자신의 run() 안에서 호출하면 자기 자신이 끝나기를 기다리게 됩니다. 스레드는 join()에서 돌아와야 끝날 수 있으므로, 이 대기는 중단 요청을 받기 전까지 끝나지 않습니다.
sleep으로 기다리면 안 되는 이유
join()을 모르면 흔히 이렇게 기다립니다. 작업자를 시작한 뒤 main을 100밀리초 동안 재우고, 그동안 작업자가 결과를 기록했으리라 믿고 값을 읽는 코드입니다.
worker.start();
Thread.sleep(100); // 100ms면 끝났겠지
System.out.println("result=" + task.result);이 코드는 '작업자가 100ms 안에 끝난다'는 가정에 기대고 있습니다. 개발 PC에서는 이 가정이 맞아서 테스트가 통과하지만, CI 서버처럼 바쁜 환경에서는 작업자가 늦게 실행될 수 있습니다. 가비지 컬렉션이 프로그램을 잠시 멈추거나 작업 안의 네트워크 호출이 늦어져도 가정은 깨집니다. 반대로 작업이 1ms 만에 끝나도 main은 남은 99ms를 그냥 기다립니다. Thread.sleep()은 현재 스레드를 정해진 시간 동안 쉬게 할 뿐, 다른 스레드가 끝났는지 확인하는 기능이 아닙니다.
문제가 하나 더 있습니다. 운 좋게 작업자가 100ms 안에 끝났더라도, 자바 언어는 main이 그 기록을 본다고 약속하지 않습니다. JLS 17.3은 Thread.sleep과 Thread.yield에 동기화 효과가 전혀 없다고 명시합니다. 실제로는 값이 보이는 경우가 많지만, 그것은 그렇게 동작했을 뿐 언어가 약속한 동작이 아닙니다. 반면 join(), 래치의 await(), 그리고 나중에 배울 Future.get()은 시간이 아니라 실제로 일어난 사건을 기다립니다. 각각 스레드의 종료, 래치의 카운트가 0이 되는 것, 작업의 완료입니다. 이 도구들은 앞에서 본 것과 같은 가시성 보장도 함께 줍니다.
같은 일을 join()으로 하면 다음과 같습니다. 세 문서를 각각 다른 스레드에서 빌드하고, 결과를 배열의 자기 칸에 모은 뒤, 모두 끝나면 출력합니다. build는 실제로 문서를 읽지 않고 경로 문자열의 길이로 가상의 제목 수를 계산합니다.
import java.util.List;
public final class DocumentBuildJoin {
private record Result(String path, int headings) {}
public static void main(String[] args) throws InterruptedException {
List<String> paths = List.of("java/thread", "java/memory", "java/lock");
Result[] results = new Result[paths.size()];
Thread[] builders = new Thread[paths.size()];
for (int i = 0; i < paths.size(); i++) {
int slot = i;
builders[i] = new Thread(() -> results[slot] = build(paths.get(slot)), "build-" + slot);
builders[i].start();
}
for (Thread builder : builders) {
builder.join();
}
int headings = 0;
for (Result result : results) {
System.out.println(result);
headings += result.headings();
}
System.out.println("heading-total=" + headings);
}
private static Result build(String path) {
return new Result(path, path.length() % 5 + 2);
}
}Result[path=java/thread, headings=3]
Result[path=java/memory, headings=3]
Result[path=java/lock, headings=6]
heading-total=12java/thread와 java/memory는 11글자이므로 11 % 5 + 2 = 3이고, 9글자인 java/lock은 9 % 5 + 2 = 6이어서 합계는 12입니다. 세 빌더가 끝나는 순서는 실행마다 다를 수 있지만 출력 순서는 늘 같습니다. main이 모든 빌더를 기다린 뒤 배열을 0번 칸부터 차례로 출력하기 때문입니다. 작업 시간이 길든 짧든 이 코드는 꼭 필요한 만큼만 기다립니다.
이 코드에도 빈틈이 하나 있습니다. build가 예외를 던지면 그 빌더 스레드는 처리되지 않은 예외로 끝나고, 해당 칸은 null로 남습니다. 그래도 join()은 정상적으로 돌아옵니다. join()이 알려 주는 것은 스레드가 끝났다는 사실이지 작업이 성공했다는 사실이 아니기 때문입니다. 실제로 java/memory에서 예외가 나도록 build를 바꿔 실행하면, build-1 스레드의 예외와 스택 트레이스가 표준 오류에 출력된 뒤에도 세 join()은 모두 돌아옵니다. 그다음 main은 첫 결과와 null을 출력하고, null 칸에서 headings()를 호출하는 순간 NullPointerException으로 끝납니다. 실제 빌드 시스템이라면 성공한 결과뿐 아니라 실패 원인도 함께 모아야 하는데, 그 방법은 다음 글에서 봅니다.
작업자가 영영 끝나지 않을 때도 문제입니다. 그러면 인자 없는 join()도 영영 돌아오지 않습니다. 기다리는 시간에 상한을 두는 시간 제한 join 역시 다음 글에서 다룹니다.
기다리는 도중 중단 요청을 받으면
join()은 검사 예외인 InterruptedException을 던지므로, 호출하는 쪽은 예외가 났을 때 무엇을 할지 정해야 합니다. 중단 요청을 무시하고 계속 기다릴 수도 있고, 기다리던 작업자에게도 중단을 요청할 수도 있고, 요청이 왔다는 사실을 상위 코드에 알릴 수도 있습니다. 가장 나쁜 선택은 예외를 잡고 아무것도 하지 않는 것입니다. 프로그램을 끄려고 보낸 중단 요청이 중간에서 사라져서, 종료가 늦어지거나 아예 일어나지 않을 수 있기 때문입니다.
아래 joinOrCancel은 기다리는 도중 중단 요청을 받으면 작업자에게도 중단을 요청하고, 현재 스레드의 interrupt 상태를 다시 표시한 뒤 false를 돌려줍니다. true는 작업자가 끝날 때까지 기다렸다는 뜻이고, false는 작업자가 끝났는지 확인하지 못했다는 뜻입니다.
public final class JoinCancellationPolicy {
public static void main(String[] args) {
Thread worker =
new Thread(
() -> {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
},
"short-work");
worker.start();
boolean completed = joinOrCancel(worker);
System.out.println("completed=" + completed + ", alive=" + worker.isAlive());
}
private static boolean joinOrCancel(Thread worker) {
try {
worker.join();
return true;
} catch (InterruptedException e) {
worker.interrupt();
Thread.currentThread().interrupt();
return false;
}
}
}completed=true, alive=false이 실행에서는 아무도 main에 중단을 요청하지 않으므로, join()은 작업자가 20ms를 자고 끝날 때까지 기다렸다가 정상적으로 돌아옵니다. 그래서 completed는 true입니다. join()이 정상적으로 돌아왔다는 것은 작업자가 이미 끝났다는 뜻이므로 alive는 false이고, 이 출력은 실행할 때마다 같습니다.
catch 블록의 두 호출은 하는 일이 다릅니다. join()이 InterruptedException을 던질 때는 기다리던 현재 스레드의 interrupt 상태가 지워집니다. Thread.currentThread().interrupt()는 지워진 상태를 다시 표시해서, joinOrCancel을 호출한 상위 코드도 중단 요청이 있었다는 사실을 알 수 있게 합니다. worker.interrupt()는 그와 별개로 작업자에게 중단을 요청합니다. 두 호출 모두 작업자가 끝나기를 기다리지는 않는다는 점에 주의하세요. interrupt 상태가 언제 표시되고 지워지는지는 interrupt 플래그와 InterruptedException 처리에서 자세히 다룹니다.
정상 경로만 실행해서는 catch 블록이 제대로 동작하는지 알 수 없습니다. 아래 프로그램은 main이 joinOrCancel을 호출하기 전에 자기 자신에게 중단을 요청해서, 다른 곳에서 main에 중단 요청이 들어온 상황을 흉내 냅니다. 작업자는 아무도 열지 않는 래치를 기다리므로, 중단 요청을 받기 전에는 끝나지 않습니다.
import java.util.concurrent.CountDownLatch;
public final class JoinInterruptPath {
public static void main(String[] args) throws InterruptedException {
CountDownLatch neverOpened = new CountDownLatch(1);
String[] exit = new String[1];
Thread worker =
new Thread(
() -> {
try {
neverOpened.await();
exit[0] = "gate-opened";
} catch (InterruptedException e) {
exit[0] = "interrupted";
}
},
"gated-work");
worker.start();
Thread.currentThread().interrupt(); // main에 중단 요청이 온 상황을 흉내 낸다
boolean completed = joinOrCancel(worker);
System.out.println(
"completed=" + completed + ", main-flag=" + Thread.currentThread().isInterrupted());
Thread.interrupted(); // 요청을 여기서 처리하기로 하고 상태를 지운다
worker.join();
System.out.println("worker-exit=" + exit[0] + ", alive=" + worker.isAlive());
}
private static boolean joinOrCancel(Thread worker) {
try {
worker.join();
return true;
} catch (InterruptedException e) {
worker.interrupt();
Thread.currentThread().interrupt();
return false;
}
}
}completed=false, main-flag=true
worker-exit=interrupted, alive=falsemain의 interrupt 상태가 이미 표시된 채로 살아 있는 작업자를 join()하면, 기다리기 시작하자마자 InterruptedException이 발생합니다. 그래서 joinOrCancel은 catch로 넘어가 작업자에게 중단을 요청하고, main의 상태를 다시 표시한 뒤 false를 돌려줍니다. 첫 줄의 main-flag=true가 상태를 다시 표시한 결과입니다.
false를 받은 main은 결과를 쓰면 안 됩니다. 작업자에게 중단을 요청했을 뿐, 작업자가 끝났는지는 아직 모르기 때문입니다. 끝났는지 알려면 다시 기다려야 하는데, interrupt 상태가 표시된 채로 살아 있는 작업자를 join()하면 그 join()도 곧바로 예외를 던집니다. 그래서 이 예제의 main은 요청을 여기서 처리하기로 하고 Thread.interrupted()로 상태를 지운 다음 다시 join()합니다. 둘째 줄은 작업자가 래치에서 InterruptedException을 받고 끝났음을 보여 줍니다. 작업자는 중단 요청을 받기 전에는 래치를 빠져나갈 수 없고, main은 마지막 join()이 돌아온 뒤에 읽으므로 출력은 실행할 때마다 같습니다.
정리
start()는 작업이 끝나기를 기다리지 않고 돌아옵니다. 작업자의 결과를 쓰려면 먼저 그 작업자가 끝났는지 확인해야 합니다.worker.join()은 호출한 스레드를 worker가 끝날 때까지 기다리게 합니다. worker를 멈추지 않으며, 시작하지 않은 스레드나 이미 끝난 스레드에 호출하면 기다리지 않고 바로 돌아옵니다.join()이 정상적으로 돌아온 뒤에는 그 스레드가 끝나기 전에 기록한 값이 반드시 보입니다. 이 예제들이 안전한 것은 칸마다 기록하는 스레드가 하나이고, main이join()전에는 읽지 않기 때문입니다.- 여러 작업자는 먼저 모두 시작하고 나중에 모두 기다립니다. 시작할 때마다 바로 기다리면 한 번에 하나씩 실행됩니다.
Thread.sleep()은 완료를 확인하지 못하고 가시성도 보장하지 않습니다. 기다릴 때는join()처럼 실제 사건을 기다리는 도구를 씁니다.join()이 정상적으로 돌아왔다는 것은 스레드가 끝났다는 뜻일 뿐 작업이 성공했다는 뜻은 아닙니다.InterruptedException으로 돌아왔다면 결과를 쓰지 말고, 작업자 처리와 interrupt 상태 복원을 정해 둔 방식대로 처리합니다.
연습 문제
int배열 세 개[3, 8, 2],[11, 4, 6],[7, 15, 1]의 최댓값을 작업자 셋이 하나씩 구해 각자의 칸에 기록하게 하세요. main은 모든 작업자를 먼저 시작하고, 모두join()한 뒤 세 부분 최댓값 가운데 가장 큰 값을 출력해야 합니다. 코드를 쓰기 전에 출력을 먼저 예측해 보세요.- 1번의 입력을
[-3, -8],[-11, -4],[-7, -15]로 바꿉니다. 부분 최댓값과 전체 최댓값은 무엇인가요? 작업자와 main이 최댓값을 구할 때 처음 값을Integer.MIN_VALUE대신 0으로 두면 어떤 답이 나오나요? result가 처음에 0이고, 실행되면 42를 기록하는 작업자가 있습니다. main이start()전에worker.join()을 호출하고result를 읽은 다음,worker.start(); worker.join();을 실행하고 다시 읽습니다. 두 번의 읽기는 각각 무엇인가요? 첫join()이 기다리지 않은 이유도 설명하세요.- 이미 시작한 작업자가 다른 래치가 열려야 끝나는 상황에서, main의
join()이 중단 요청 때문에 예외를 던졌습니다. 본문의joinOrCancel은 작업자에게 중단을 요청하고 main의 interrupt 상태를 복원한 뒤false를 돌려줬습니다. 작업자가 그 요청에 곧바로 반응한다는 보장은 없습니다. main은 결과 칸을 읽어도 되나요? 상태 복원과 별도로 무엇을 정해야 하나요?
해설 보기
1번. 세 부분 최댓값은 8, 11, 15이므로 출력은 maximum=15입니다. 각 작업자는 자기 칸 partial[slot]에만 쓰고, main은 모든 join()이 돌아온 뒤에 읽습니다. start() 직후에 읽거나 sleep()으로 기다리는 코드는 없습니다.
public final class JoinedMaximumSolution {
public static void main(String[] args) throws InterruptedException {
int[][] inputs = {{3, 8, 2}, {11, 4, 6}, {7, 15, 1}};
int[] partial = new int[inputs.length];
Thread[] workers = new Thread[inputs.length];
for (int i = 0; i < inputs.length; i++) {
int slot = i;
workers[i] =
new Thread(
() -> {
int max = Integer.MIN_VALUE;
for (int value : inputs[slot]) {
max = Math.max(max, value);
}
partial[slot] = max;
},
"max-" + slot);
}
for (Thread worker : workers) {
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
int maximum = Integer.MIN_VALUE;
for (int value : partial) {
maximum = Math.max(maximum, value);
}
System.out.println("maximum=" + maximum);
}
}maximum=15작업자가 끝나는 순서는 실행마다 달라도 출력은 늘 같습니다. 각 작업자의 partial[slot] = max 기록은 그 작업자가 끝나기 전에 일어나므로, 본문의 happens-before 규칙에 따라 그 작업자에 대한 join()이 돌아온 뒤 main에게 보입니다.
2번. 부분 최댓값은 -3, -4, -7이고 전체 최댓값은 -3입니다. 처음 값을 0으로 두면 모든 원소가 0보다 작아서 Math.max가 계속 0을 고르므로, 세 칸이 모두 0이 되고 출력도 maximum=0이 됩니다. 입력에 없는 값이 답으로 나오는 것입니다. 처음 값은 Integer.MIN_VALUE나, 비어 있지 않은 배열이라면 그 첫 원소로 둬야 합니다.
3번. 첫 읽기는 0, 두 번째 읽기는 42입니다. 시작하지 않은 스레드에 대한 join()은 기다리지 않고 바로 돌아오므로, 첫 join() 시점에는 작업이 실행된 적이 없고 result는 기본값 0 그대로입니다. join()은 start()를 대신하지 않고, 나중에 시작할 실행을 미리 기다려 주지도 않습니다. 두 번째 join()은 이미 시작한 작업자의 종료를 기다리므로, 그 뒤에는 42가 보입니다.
4번. 읽으면 안 됩니다. false는 join()이 작업자의 종료를 확인하지 못했다는 뜻이고, 작업자는 아직 결과를 쓰는 중일 수도 있습니다. 복원한 interrupt 상태는 'main에게 중단 요청이 왔다'는 사실을 상위 코드에 전할 뿐, 작업자에 대해서는 아무것도 알려 주지 않습니다. 따라서 작업자가 기다리는 래치나 자원을 어떻게 정리할지, 작업자의 종료를 언제 어떻게 다시 확인할지, 호출자에게 실패를 어떤 결과로 전할지를 따로 정해야 합니다. 중단을 요청했다는 사실만으로 작업자가 끝났다고 판단할 수는 없습니다.
다음 글
시간 제한 join과 interrupt에서는 join(millis)로 기다리는 시간에 상한을 둡니다. 시간이 다 되어 돌아온 join은 작업자가 끝났다는 뜻이 아니므로, 돌아온 뒤 작업자가 살아 있는지 확인하고 늦은 작업을 어떻게 정리할지 정하는 방법을 살펴봅니다.