스레드 정보와 생명 주기
스레드의 ID, 이름, 우선순위 같은 정보를 조회하고, 스레드가 NEW에서 TERMINATED까지 거치는 여섯 가지 상태를 직접 만들어 보면서 상태 조회를 어디까지 믿을 수 있는지 배웁니다.
여러 스레드를 쓰는 프로그램이 멈춘 것처럼 보이면, 먼저 각 스레드가 지금 무엇을 하고 있는지 알고 싶어집니다. Thread 객체는 실행할 작업 말고도 ID, 이름, 우선순위, 데몬 여부 같은 정보와 지금의 상태를 알려 줍니다. JVM 안의 모든 스레드를 한꺼번에 찍은 진단 정보를 스레드 덤프(thread dump)라고 하는데, 여기에도 이 값들이 그대로 나옵니다.
이 정보는 문제를 찾을 때 쓸모가 많지만, 프로그램의 실행 순서를 정하는 데 쓰면 안 됩니다. 상태는 읽은 바로 다음 순간에도 바뀔 수 있기 때문입니다. 이 글에서는 먼저 스레드 하나에서 꺼낼 수 있는 정보를 조회해 보고, 스레드가 거치는 여섯 가지 상태를 직접 만들어 확인합니다. 그런 다음 상태 조회를 어디까지 믿을 수 있는지 정리합니다.
스레드 하나에서 꺼낼 수 있는 정보
다음 프로그램의 report 메서드는 현재 스레드의 ID, 이름, 우선순위, 데몬 여부를 한 줄로 출력합니다. main이 한 번 호출하고, 작업자 스레드도 자기 작업 안에서 한 번 호출합니다. 작업자에게는 import-csv라는 이름을 붙이고 import-workers라는 스레드 그룹(thread group)에 넣었습니다. main은 작업자를 시작하기 전과 끝난 뒤에 작업자의 상태도 조회합니다.
public final class ThreadMetadataReport {
public static void main(String[] args) throws InterruptedException {
ThreadGroup group = new ThreadGroup("import-workers");
Thread worker = new Thread(group, () -> report("inside"), "import-csv");
report("main-before");
System.out.println(
"new-state=" + worker.getState() + ", group=" + worker.getThreadGroup().getName());
worker.start();
worker.join();
System.out.println("end-state=" + worker.getState() + ", alive=" + worker.isAlive());
}
private static void report(String point) {
Thread t = Thread.currentThread();
System.out.println(
point
+ " id="
+ t.threadId()
+ ", name="
+ t.getName()
+ ", priority="
+ t.getPriority()
+ ", daemon="
+ t.isDaemon());
}
}JDK 21에서 실행한 결과는 다음과 같습니다. 출력은 네 줄이며, 줄 앞의 main-before, new-state, inside, end-state를 보면 코드의 어느 출력문에서 나온 줄인지 알 수 있습니다.
main-before id=1, name=main, priority=5, daemon=false
new-state=NEW, group=import-workers
inside id=18, name=import-csv, priority=5, daemon=false
end-state=TERMINATED, alive=false출력을 읽을 때는 각 줄이 누구의 정보인지부터 가려야 합니다. report가 조회하는 것은 Thread.currentThread(), 즉 그 메서드를 지금 실행하고 있는 스레드입니다. 그래서 main이 호출한 첫 줄은 main 스레드의 정보이고, 작업자 안에서 호출한 셋째 줄은 import-csv의 정보입니다. 같은 메서드라도 어느 스레드가 실행하느냐에 따라 조회 대상이 달라집니다.
반면 둘째 줄과 넷째 줄은 main이 worker라는 Thread 객체에 직접 물어본 결과입니다. 둘째 줄은 start() 전이라 NEW이고, 넷째 줄은 join()으로 작업자가 끝나기를 기다린 뒤라 TERMINATED이며 isAlive()도 false입니다. 이 프로그램의 네 줄은 실행할 때마다 같은 순서로 나옵니다. 셋째 줄은 작업자가 출력하지만, main이 앞의 두 줄을 start() 전에 출력하고 넷째 줄은 join() 뒤에 출력하기 때문입니다.
같은 main 코드라도 조회 대상은 호출 지점마다 다르다
main-before/NEW/worker inside/join 뒤 TERMINATED의 실제 조회 시점입니다. 고정 조회 대상과 현재 실행 흐름을 단계마다 구분해 조회 대상을 확인할 수 있습니다.
ThreadMetadataReport는 report 안에서 현재 스레드를 조회하고, main에서는 작업자의 시작 전 상태와 그룹 및 join 뒤 종료 상태를 조회합니다.
스레드 정보 · 조회 대상
같은 main 코드라도 조회 대상은 호출 지점마다 다르다
report는 호출한 현재 스레드를 조회합니다. worker의 NEW·TERMINATED 상태 조회는 별도의 객체 접근입니다.
실선: 원 호출·조회 시점ID는 고정 숫자로 외우지 않음우선순위는 완료 순서 계약 아님
실선: 원 호출·조회 시점ID는 고정 숫자로 외우지 않음우선순위는 완료 순서 계약아님
report("main-before")- 조회 대상
- 현재 실행 중인
main - 출력에서 구분할 정보
id / name / priority / daemon
시작 전
worker.getState()·그룹 조회- 조회 대상
worker객체- 출력에서 구분할 정보
new-state=NEW, group=import-workers
report("inside")- 조회 대상
- 실행 중인
import-csv - 출력에서 구분할 정보
id / name / priority / daemon
worker.join()뒤 조회- 조회 대상
worker객체- 출력에서 구분할 정보
end-state=TERMINATED, alive=false
report는 Thread.currentThread()를 읽습니다. 원문은 NEW인 작업자의 id를 main에서 출력하지 않습니다. id와 우선순위는 업무 식별자나 완료 순서의 보장이 아닙니다.
이 예의 조건
- ID 수치는 고정하지 않고 우선순위를 완료 순서로 읽지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 순서의 main 현재 흐름 조회, worker.NEW 조회, worker 안의 현재 흐름 조회, join 뒤 worker 조회입니다. currentThread와 worker 객체 접근의 대상을 구별하고 실제 ID 숫자를 고정하지 않습니다. |
| 6 | NEW |
| 7 | import-workers 그룹 |
| 11 | TERMINATED |
| 12 | alive=false |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 순서의 main 현재 흐름 조회, worker.NEW 조회, worker 안의 현재 흐름 조회, join 뒤 worker 조회입니다. currentThread와 worker 객체 접근의 대상을 구별하고 실제 ID 숫자를 고정하지 않습니다. |
| 전체 읽기 정보 | 같은 main 코드라도 조회 대상은 호출 지점마다 다르다 같은 main 코드라도 조회 대상은 호출 지점마다 다르다 ThreadMetadataReport는 report 안에서 현재 스레드를 조회하고, main에서는 작업자의 시작 전 상태와 그룹 및 join 뒤 종료 상태를 조회합니다. 스레드 정보 · 조회 대상 같은 main 코드라도 조회 대상은 호출 지점마다 다르다 report는 호출한 현재 스레드를 조회합니다. worker의 NEW·TERMINATED 상태 조회는 별도의 객체 접근입니다. report("main-before") 조회 대상 현재 실행 중인 main 출력에서 구분할 정보 id / name / priority / daemon 시작 전 worker.getState() ·그룹 조회 조회 대상 worker 객체 출력에서 구분할 정보 new-state=NEW, group=import-workers report("inside") 조회 대상 실행 중인 import-csv 출력에서 구분할 정보 id / name / priority / daemon worker.join() 뒤 조회 조회 대상 worker 객체 출력에서 구분할 정보 end-state=TERMINATED, alive=false report 는 Thread.currentThread() 를 읽습니다. 원문은 NEW인 작업자의 id를 main에서 출력하지 않습니다. id와 우선순위는 업무 식별자나 완료 순서의 보장이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 시작 전 조회 main과 worker 객체를 서로 다른 대상으로 조회합니다. |
|
|
| worker 내부 작업자 안에서 현재 흐름을 보고합니다. |
|
|
| join 뒤 조회 종료된 worker를 main이 읽습니다. |
|
|
같은 main 코드라도 조회 대상은 호출 지점마다 다르다
main-before/NEW/worker inside/join 뒤 TERMINATED의 실제 조회 시점입니다. 고정 조회 대상과 현재 실행 흐름을 단계마다 구분해 조회 대상을 확인할 수 있습니다.
ThreadMetadataReport는 report 안에서 현재 스레드를 조회하고, main에서는 작업자의 시작 전 상태와 그룹 및 join 뒤 종료 상태를 조회합니다.
스레드 정보 · 조회 대상
같은 main 코드라도 조회 대상은 호출 지점마다 다르다
report는 호출한 현재 스레드를 조회합니다. worker의 NEW·TERMINATED 상태 조회는 별도의 객체 접근입니다.
실선: 원 호출·조회 시점ID는 고정 숫자로 외우지 않음우선순위는 완료 순서 계약 아님
실선: 원 호출·조회 시점ID는 고정 숫자로 외우지 않음우선순위는 완료 순서 계약아님
report("main-before")- 조회 대상
- 현재 실행 중인
main - 출력에서 구분할 정보
id / name / priority / daemon
시작 전
worker.getState()·그룹 조회- 조회 대상
worker객체- 출력에서 구분할 정보
new-state=NEW, group=import-workers
report("inside")- 조회 대상
- 실행 중인
import-csv - 출력에서 구분할 정보
id / name / priority / daemon
worker.join()뒤 조회- 조회 대상
worker객체- 출력에서 구분할 정보
end-state=TERMINATED, alive=false
report는 Thread.currentThread()를 읽습니다. 원문은 NEW인 작업자의 id를 main에서 출력하지 않습니다. id와 우선순위는 업무 식별자나 완료 순서의 보장이 아닙니다.
이 예의 조건
- ID 수치는 고정하지 않고 우선순위를 완료 순서로 읽지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 순서의 main 현재 흐름 조회, worker.NEW 조회, worker 안의 현재 흐름 조회, join 뒤 worker 조회입니다. currentThread와 worker 객체 접근의 대상을 구별하고 실제 ID 숫자를 고정하지 않습니다. |
| 6 | NEW |
| 7 | import-workers 그룹 |
| 11 | TERMINATED |
| 12 | alive=false |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 순서의 main 현재 흐름 조회, worker.NEW 조회, worker 안의 현재 흐름 조회, join 뒤 worker 조회입니다. currentThread와 worker 객체 접근의 대상을 구별하고 실제 ID 숫자를 고정하지 않습니다. |
| 전체 읽기 정보 | 같은 main 코드라도 조회 대상은 호출 지점마다 다르다 같은 main 코드라도 조회 대상은 호출 지점마다 다르다 ThreadMetadataReport는 report 안에서 현재 스레드를 조회하고, main에서는 작업자의 시작 전 상태와 그룹 및 join 뒤 종료 상태를 조회합니다. 스레드 정보 · 조회 대상 같은 main 코드라도 조회 대상은 호출 지점마다 다르다 report는 호출한 현재 스레드를 조회합니다. worker의 NEW·TERMINATED 상태 조회는 별도의 객체 접근입니다. report("main-before") 조회 대상 현재 실행 중인 main 출력에서 구분할 정보 id / name / priority / daemon 시작 전 worker.getState() ·그룹 조회 조회 대상 worker 객체 출력에서 구분할 정보 new-state=NEW, group=import-workers report("inside") 조회 대상 실행 중인 import-csv 출력에서 구분할 정보 id / name / priority / daemon worker.join() 뒤 조회 조회 대상 worker 객체 출력에서 구분할 정보 end-state=TERMINATED, alive=false report 는 Thread.currentThread() 를 읽습니다. 원문은 NEW인 작업자의 id를 main에서 출력하지 않습니다. id와 우선순위는 업무 식별자나 완료 순서의 보장이 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 시작 전 조회 main과 worker 객체를 서로 다른 대상으로 조회합니다. |
|
|
| worker 내부 작업자 안에서 현재 흐름을 보고합니다. |
|
|
| join 뒤 조회 종료된 worker를 main이 읽습니다. |
|
|
그림은 네 번의 조회를 시점별로 나눠 보여 줍니다. start() 전에는 main이 자기 자신과 작업자 객체를 따로 조회하고, 작업자 안에서는 실행하는 스레드가 import-csv로 바뀌며, join() 뒤에는 main이 끝난 작업자를 다시 조회합니다.
각 값이 알려 주는 것
ID는 threadId()로 얻습니다. 스레드를 만들 때 정해지는 양수이고, 그 스레드가 살아 있는 동안 다른 스레드와 겹치지 않으며 바뀌지도 않습니다. 이 메서드는 Java 19에 추가되었습니다. 예전부터 쓰던 getId()도 같은 값을 돌려주지만 Java 19부터 사용 중단(deprecated)으로 표시되어, 이를 호출하는 코드를 컴파일하면 사용 중단된 API를 쓴다는 안내가 나옵니다. 출력의 1과 18이라는 숫자에는 의미를 두지 마세요. JVM은 main 말고도 내부용 스레드를 여럿 먼저 만들기 때문에, JDK 버전이나 실행 환경이 바뀌면 작업자의 ID도 달라질 수 있습니다.
이름은 getName()으로 얻습니다. 이 글처럼 Thread 생성자로 만든 스레드에 이름을 주지 않으면 Thread-0, Thread-1처럼 번호가 붙은 기본 이름을 받습니다. 기본 이름으로는 로그에서 어느 스레드가 무슨 일을 했는지 알기 어려우므로, import-csv처럼 역할이 드러나는 이름을 붙이는 것이 좋습니다. 다만 주문 번호 같은 업무 데이터를 스레드 이름이나 ID에 기대어 추적하지는 마세요. ID와 이름은 프로그램을 다시 실행하면 달라지고, 22장에서 배울 실행기처럼 스레드 하나가 여러 작업을 차례로 처리하는 경우도 많습니다. 업무를 추적하는 값은 로그에 별도 항목으로 남겨야 합니다.
우선순위 5와 daemon=false는 작업자가 자신을 만든 main에게서 물려받은 값입니다. 데몬 여부를 물려받는다는 것은 앞 글에서 보았고, 우선순위는 바로 아래에서 살펴봅니다.
getThreadGroup()은 스레드가 속한 그룹을 돌려주는데, 스레드가 끝나면 null을 돌려줍니다. 이 예제가 그룹 이름을 start() 전에 조회한 것은 그래서입니다. join() 뒤에 worker.getThreadGroup().getName()을 호출하면 NullPointerException이 납니다. 반대로 ID와 이름은 아직 시작하지 않은 NEW 상태의 스레드에서도 조회할 수 있습니다.
우선순위와 스레드 그룹은 실행 순서를 정하지 않는다
우선순위는 setPriority로 1(MIN_PRIORITY)부터 10(MAX_PRIORITY)까지 정할 수 있고, 기본값은 5(NORM_PRIORITY)입니다. 새 스레드는 자신을 만든 스레드의 우선순위를 물려받지만, 속한 스레드 그룹의 최대 우선순위를 넘지는 못합니다. 실제로 최대 우선순위를 3으로 정한 그룹에 스레드를 만들면 처음 값이 3이었고, setPriority(8)을 호출해도 3에 머물렀습니다.
하지만 우선순위는 스케줄러에게 주는 힌트일 뿐입니다. 이 값을 실제 스케줄링에 어떻게 반영할지는 JVM과 운영체제에 따라 다르고, 환경에 따라서는 우선순위를 바꿔도 실행에 아무 차이가 없기도 합니다. 그러니 우선순위가 높은 스레드가 먼저 시작하거나 먼저 끝난다고 기대할 수 없습니다. 어떤 작업이 다른 작업보다 먼저 끝나야 한다면 join()이나 래치 같은 동기화로 순서를 직접 만들어야 합니다. 특정 작업이 계속 뒤로 밀리는 기아(starvation)를 막고 싶을 때도 우선순위 대신 공정한 큐나 세마포어처럼 의미가 분명한 도구를 씁니다.
스레드 그룹은 초기 자바부터 있던 API입니다. 스레드를 이름으로 묶고, 그룹 단위로 살아 있는 스레드 수를 세거나 interrupt()를 보내는 정도의 기능을 제공합니다. 작업 결과를 모으거나 모든 작업이 끝나기를 기다리는 기능은 없습니다. JDK 21 기준으로 destroy() 같은 일부 메서드는 제거 예정(deprecated for removal)으로 표시되어 있어서, 호출하는 코드를 컴파일하면 경고가 나옵니다. 새 코드에서 여러 스레드를 묶어 관리해야 한다면 22장에서 배울 실행기(executor)를 쓰고, 스레드의 이름, 데몬 여부, 예외 처리기는 스레드 팩터리(thread factory)에서 한곳에 정하는 편이 낫습니다.
더 깊이: 가상 스레드와 Thread.Builder
이 글에서 new Thread(...)로 만드는 스레드는 운영체제 스레드 하나에 대응하는 플랫폼 스레드(platform thread)입니다. Java 21에서 정식 기능이 된 가상 스레드(virtual thread)는 앞의 규칙 일부를 따르지 않습니다. Thread.ofVirtual()로 만든 가상 스레드는 항상 데몬이고 우선순위는 언제나 5입니다. JDK 21에서 확인해 보면 setPriority(10)을 호출해도 값이 바뀌지 않았고, setDaemon(false)를 호출하면 IllegalArgumentException이 났습니다. 이름을 따로 정하지 않으면 빈 문자열이고, getThreadGroup()은 가상 스레드용 특별한 그룹을 돌려주는데 JDK 21에서 그 이름은 VirtualThreads였습니다.
Java 21에는 플랫폼 스레드를 만드는 빌더 Thread.ofPlatform()도 있습니다. 다음 코드는 이름 접두사와 시작 번호, 데몬 여부, 예외 처리기를 한 번 정해 두고, 같은 설정의 스레드를 만들어 내는 ThreadFactory를 얻습니다.
import java.util.concurrent.ThreadFactory;
public final class ThreadFactorySetup {
public static void main(String[] args) throws InterruptedException {
ThreadFactory factory =
Thread.ofPlatform()
.name("import-", 0)
.daemon(false)
.uncaughtExceptionHandler(
(thread, error) ->
System.err.println(thread.getName() + " failed: " + error))
.factory();
Thread first = factory.newThread(() -> System.out.println(Thread.currentThread().getName()));
Thread second =
factory.newThread(
() -> {
throw new IllegalStateException("boom");
});
first.start();
first.join();
second.start();
second.join();
}
}import-0
import-1 failed: java.lang.IllegalStateException: boom첫 줄은 첫 작업이 표준 출력에 쓴 것이고, 둘째 줄은 예외로 끝난 둘째 스레드의 처리기가 표준 오류에 쓴 것입니다. main이 첫 스레드를 join()한 뒤에 둘째 스레드를 시작하므로 두 줄의 순서는 바뀌지 않습니다. 이름의 번호는 name("import-", 0)에서 정한 0부터 시작해, 팩터리가 스레드를 만들 때마다 하나씩 늘어납니다. 실행기를 만들 때 이런 팩터리를 넘기면 실행기가 만드는 스레드의 설정을 한곳에서 통일할 수 있습니다.
스레드가 거치는 여섯 가지 상태
getState()는 Thread.State 열거형 값 하나를 돌려줍니다. 상태는 다음 여섯 가지입니다.
| 상태 | 언제 이 상태인가 |
|---|---|
| NEW | 스레드 객체를 만들었지만 아직 start()를 호출하지 않았다 |
| RUNNABLE | 실행 중이거나, 실행할 수 있어서 CPU 차례를 기다리고 있다 |
| BLOCKED | 다른 스레드가 가진 모니터 락을 얻으려고 synchronized 블록이나 메서드 입구에서 기다린다. wait()에서 깨어난 뒤 락을 다시 얻으려고 기다릴 때도 이 상태다 |
| WAITING | 시간 제한 없이 다른 스레드의 신호를 기다린다. 시간 제한 없는 join(), Object.wait(), LockSupport.park()가 이 상태를 만든다 |
| TIMED_WAITING | 정해진 시간까지 기다린다. Thread.sleep(), 시간 제한을 준 join()과 wait()가 이 상태를 만든다 |
| TERMINATED | run()이 끝났다. 정상적으로 반환했든 예외로 끝났든 같다 |
RUNNABLE에는 CPU에서 명령을 실행하는 순간뿐 아니라, 실행할 준비는 되었지만 CPU 차례를 기다리는 시간도 포함됩니다. 자바는 이 둘을 구분하지 않으므로, RUNNABLE이라고 해서 그 스레드가 지금 실제로 일하고 있다는 뜻은 아닙니다.
BLOCKED, WAITING, TIMED_WAITING은 모두 기다리는 상태이지만 무엇을 기다리는지가 다릅니다. BLOCKED는 락이 풀리기를, WAITING은 다른 스레드가 보낼 신호를, TIMED_WAITING은 신호나 정해진 시간이 지나기를 기다립니다. 그래서 스레드 덤프에 BLOCKED 스레드가 잔뜩 있다면 락을 얻으려는 경쟁을, WAITING 스레드가 끝없이 남아 있다면 아무도 보내지 않는 신호를 의심해 볼 수 있습니다. 스레드 덤프를 읽는 방법은 BLOCKED·WAITING 상태로 스레드 덤프 읽기에서 자세히 다룹니다.
getState()가 돌려주는 값은 읽은 순간의 상태일 뿐, 다음 순간에도 그대로라는 약속이 아닙니다. 그래서 Thread.getState API 문서도 이 메서드가 시스템 상태를 모니터링하려고 만든 것이지 동기화를 제어하려고 만든 것이 아니라고 밝힙니다. '작업자가 WAITING이 되면 다음 일을 한다'처럼 상태 조회로 작업 순서를 정하지 말고, 순서는 래치, 락, join() 같은 동기화 도구로 정해야 합니다.
더 깊이: 상태 이름과 실제로 하는 일이 다를 때
Thread.State API 문서에 따르면 이 상태들은 JVM이 보는 상태이며 운영체제의 스레드 상태를 그대로 반영하지 않습니다. 이 차이 때문에 이름만 보고 판단하면 틀리는 경우가 있습니다. JDK 21에서 확인해 보면 System.in.read()로 표준 입력을 기다리는 스레드는 WAITING이 아니라 RUNNABLE로 나왔습니다. 실제로는 입력이 올 때까지 멈춰 있지만, JVM이 보기에는 운영체제의 입력 함수를 실행하고 있는 중이기 때문입니다.
반대로 ReentrantLock을 얻으려고 기다리는 스레드는 락을 기다리는데도 BLOCKED가 아니라 WAITING으로 나왔습니다. BLOCKED는 synchronized가 쓰는 모니터 락을 기다릴 때만 쓰이고, ReentrantLock은 내부에서 LockSupport.park()로 기다리기 때문입니다. park와 스레드 상태의 관계는 park와 interrupt, BLOCKED와 WAITING에서 다룹니다.
작업 안에서 InterruptedException을 처리하는 이유
다음 절의 예제에서는 작업자가 Thread.sleep()이나 CountDownLatch.await()로 기다립니다. 두 메서드는 기다리는 도중 다른 스레드가 interrupt()로 중단을 요청하면 검사 예외인 InterruptedException을 던집니다. 그런데 이 예외를 run() 밖으로 내보낼 수는 없습니다. 다음 코드는 컴파일되지 않습니다.
public final class CheckedExceptionRunFailure extends Thread {
@Override
public void run() throws InterruptedException {
Thread.sleep(10);
}
}lab/CheckedExceptionRunFailure.java:3: error: run() in CheckedExceptionRunFailure cannot implement run() in Runnable
public void run() throws InterruptedException {
^
overridden method does not throw InterruptedException
1 errorRunnable.run()과 Thread.run()에는 throws 절이 없습니다. 예외 계층과 검사 예외에서 본 것처럼 재정의하는 메서드는 원래 메서드가 허용하지 않는 검사 예외를 선언할 수 없으므로, run()에 throws InterruptedException을 붙일 수 없습니다. 메시지에 Thread가 아니라 Runnable이 나오는 것은, Thread가 Runnable을 구현하므로 이 run()이 Thread.run()을 재정의하는 동시에 Runnable.run()도 구현하기 때문입니다. 어느 쪽에도 throws 절이 없으니 결론은 같고, 따져 보면 자연스러운 규칙이기도 합니다. 새 스레드에서 run()을 호출하는 것은 JVM이지 여러분의 코드가 아니므로, run()이 던진 검사 예외를 받아 처리할 호출자가 없습니다.
그래서 InterruptedException은 작업 안에서 직접 잡아야 합니다. 이때 예외를 그냥 삼키거나 RuntimeException으로 감싸 던지기만 하면, 중단 요청이 있었다는 사실이 사라질 수 있습니다. sleep()과 await()는 InterruptedException을 던지면서 스레드의 interrupt 상태(interrupted status) 표시를 지우기 때문입니다. 이 글의 예제들이 catch 블록에서 Thread.currentThread().interrupt()를 호출해 그 표시를 다시 켜는 것은 그래서입니다. 중단 요청을 다루는 방법은 interrupt 플래그와 InterruptedException 처리에서 자세히 배웁니다.
세 가지 대기 상태를 직접 만들기
BLOCKED, WAITING, TIMED_WAITING을 실제로 만들기 전에 synchronized를 짧게 짚어 두겠습니다. synchronized (monitor) { ... } 블록에는 한 번에 한 스레드만 들어갈 수 있습니다. 먼저 들어간 스레드가 monitor 객체의 락을 쥐고, 블록을 빠져나갈 때 놓습니다. 이 락을 모니터 락(monitor lock)이라고 하며, 자세한 내용은 19장에서 배웁니다. 다음 프로그램은 작업자 세 개를 각각 다른 이유로 기다리게 만듭니다.
state-waiting은 앞 글에서 본CountDownLatch가 열리기를 기다립니다. 시간 제한이 없으므로 WAITING이 되어야 합니다.state-timed는Thread.sleep(500)으로 0.5초 동안 잠듭니다. TIMED_WAITING이 되어야 합니다.state-blocked는monitor로synchronized블록에 들어가려 합니다. main이 그 모니터 락을 먼저 쥔 채로 이 작업자를 시작하므로 BLOCKED가 되어야 합니다.
스레드를 시작하자마자 상태를 읽으면 작업자가 아직 await()나 sleep()에 도달하지 못했을 수 있습니다. 그래서 waitFor 메서드는 1밀리초씩 쉬면서 최대 200번 상태를 확인하고, 끝내 목표 상태가 되지 않으면 예외를 던집니다.
import java.util.concurrent.CountDownLatch;
public final class ControlledThreadStates {
public static void main(String[] args) throws InterruptedException {
CountDownLatch waitingGate = new CountDownLatch(1);
Object monitor = new Object();
Thread waiting = new Thread(() -> await(waitingGate), "state-waiting");
Thread timed =
new Thread(
() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
},
"state-timed");
Thread blocked =
new Thread(
() -> {
synchronized (monitor) {
System.out.println("blocked-entered");
}
},
"state-blocked");
waiting.start();
timed.start();
synchronized (monitor) {
blocked.start();
waitFor(waiting, Thread.State.WAITING);
waitFor(timed, Thread.State.TIMED_WAITING);
waitFor(blocked, Thread.State.BLOCKED);
System.out.println(
waiting.getState() + "," + timed.getState() + "," + blocked.getState());
}
waitingGate.countDown();
waiting.join();
timed.join();
blocked.join();
}
private static void await(CountDownLatch gate) {
try {
gate.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void waitFor(Thread t, Thread.State target) throws InterruptedException {
for (int i = 0; i < 200 && t.getState() != target; i++) {
Thread.sleep(1);
}
if (t.getState() != target)
throw new IllegalStateException(t.getName() + "=" + t.getState());
}
}스무 번 실행해 매번 같은 결과를 얻었습니다. 세 작업자는 각각 래치, sleep, 모니터 락에서 멈추도록 만들었고, main은 synchronized (monitor) 블록 안에서 세 상태를 한 줄로 출력합니다. 둘째 줄 blocked-entered가 첫 줄보다 먼저 나올 수 있을지 생각해 보세요.
WAITING,TIMED_WAITING,BLOCKED
blocked-entered첫 줄은 main이 synchronized (monitor) 블록 안에서 세 작업자의 상태를 차례로 읽은 결과입니다. 둘째 줄 blocked-entered는 언제나 첫 줄 뒤에 나옵니다. state-blocked는 main이 블록을 빠져나가 모니터 락을 놓은 뒤에야 블록에 들어가 이 줄을 출력할 수 있기 때문입니다. 그다음 main은 countDown()으로 래치를 열어 state-waiting을 풀어 주고, 세 작업자가 모두 끝나기를 기다립니다. state-timed는 0.5초가 지나면 스스로 깨어납니다.
ControlledThreadStates는 래치 대기, 500밀리초 sleep, main이 보유한 모니터 진입을 각각 관측하려 합니다. 시간 대기는 출력 전에 끝날 수 있고 관측 실패 시 래치가 해제되지 않습니다.
대기 원인 · 래치 / 시간 / 모니터
세 대기 원인과 상태 관측의 한계를 나눠 본다
같은 대기라도 해제 조건은 다릅니다. 상태 조회 세 번은 하나의 원자적 스냅샷이 아닙니다.
WAITINGstate-waiting
WAITINGstate-waiting- 대기를 만드는 원문
waitingGate.await()- 유지 조건과 관측 한계
- 래치가 0이 될 때까지 기다림;
countDown()전에는 대기 원인이 유지됨
TIMED_WAITINGstate-timed
TIMED_WAITINGstate-timed- 대기를 만드는 원문
Thread.sleep(500)- 유지 조건과 관측 한계
- 시간 경과나 interrupt로 대기에서 벗어남; 폴링 뒤 출력 시점까지 유지된다는 보장은 없음
BLOCKEDstate-blocked
BLOCKEDstate-blocked- 대기를 만드는 원문
synchronized (monitor)- 유지 조건과 관측 한계
main이 모니터를 가진 동안 진입 대기; 모니터 해제 뒤 진입 가능
waitFor는 목표 상태를 관측했는지 확인합니다. 세 상태 조회는 하나의 원자적 스냅샷이 아니며, 관측 실패로 예외가 나면 래치를 해제하지 않아 비데몬 대기자가 남을 수 있습니다.
그림은 세 대기의 원인과 풀리는 조건을 나란히 보여 줍니다. 래치 대기와 모니터 대기는 main이 풀어 줄 때까지 이어지지만, sleep은 시간이 지나면 저절로 끝납니다. 그래서 첫 줄이 스무 번 같았다고 해서 언제나 같다고 말할 수는 없습니다. 컴퓨터가 몹시 바빠서 main이 확인을 마치는 데 0.5초 넘게 걸리면, 마지막에 timed.getState()를 다시 읽을 때 state-timed는 이미 깨어나 RUNNABLE이나 TERMINATED일 수 있습니다. 같은 이유로 waitFor(timed, ...)가 TIMED_WAITING을 끝내 보지 못하고 예외를 던질 수도 있습니다.
첫 줄의 세 값이 같은 순간의 상태가 아니라는 점도 기억해 두세요. getState()를 세 번 호출하면 서로 다른 세 시점에 읽은 값이 나옵니다. 하나를 읽고 다음 것을 읽는 사이에도 스레드들은 계속 움직이므로, 이 줄은 '세 작업자가 동시에 이 상태였다'가 아니라 '각 작업자를 읽은 순간에 이 상태였다'로 읽어야 합니다.
main이 잠들어도 모니터 락은 그대로다
waitFor 안의 Thread.sleep(1)을 실행하는 것은 main인데, 그때 main은 synchronized (monitor) 블록 안에 있습니다. main이 잠든 사이에 state-blocked가 모니터 락을 얻을 수 있을까요? 그렇지 않습니다. Thread.sleep API 문서에 적혀 있듯 sleep()은 현재 스레드가 가진 모니터 락을 하나도 놓지 않으므로, main이 몇 번을 잠들든 state-blocked는 main이 블록을 빠져나갈 때까지 BLOCKED로 남습니다. 잠드는 것과 락을 놓는 것은 별개의 동작이며, 기다리는 동안 락을 놓는 방법은 wait와 notify로 조건 기다리기에서 배웁니다.
같은 이유로 main이 synchronized (monitor) 블록 안에서 blocked.join()을 호출하면 프로그램은 영원히 멈춥니다. main은 state-blocked가 끝나기를 기다리고, state-blocked는 main이 쥔 모니터 락이 풀리기를 기다리므로 어느 쪽도 앞으로 나아갈 수 없습니다. 이 예제가 join()을 모두 synchronized 블록 밖에서 호출하는 것은 그래서입니다.
확인에 실패하면 프로그램이 끝나지 않는다
이 프로그램에는 약점이 하나 있습니다. waitFor가 예외를 던지면 main은 그 자리에서 끝나 버려서, 래치를 여는 waitingGate.countDown()까지 가지 못합니다. 정말 그런지 보려고 30번째 줄의 목표 상태를 일부러 waitFor(waiting, Thread.State.TIMED_WAITING)으로 틀리게 바꿔 실행해 보았습니다.
blocked-entered
Exception in thread "main" java.lang.IllegalStateException: state-waiting=WAITING
at ControlledThreadStates.waitFor(ControlledThreadStates.java:55)
at ControlledThreadStates.main(ControlledThreadStates.java:30)메시지대로 state-waiting은 WAITING이었으므로 200번을 확인해도 TIMED_WAITING이 되지 않았고, main은 예외로 끝났습니다. 예외가 synchronized 블록을 빠져나가면서 모니터 락이 풀렸으므로 state-blocked는 블록에 들어가 blocked-entered를 출력했습니다. 이 줄은 표준 출력으로, 예외 메시지는 표준 오류로 나가기 때문에 콘솔에서 둘의 순서는 실행마다 달랐습니다. 한 실행에서는 Exception in thread "main" 바로 뒤에 blocked-entered가 끼어들어 한 줄에 섞이기도 했습니다.
진짜 문제는 그다음입니다. 래치가 열리지 않았으니 state-waiting은 끝없이 기다리고, 데몬이 아닌 스레드가 살아 있으니 JVM도 끝나지 않습니다. 실제로 이 프로그램은 프로세스를 강제로 종료할 때까지 멈춰 있었습니다.
이런 일을 막으려면, 확인에 성공하든 실패하든 대기를 풀고 작업자를 회수하는 코드를 finally에 두어야 합니다. 다음 예제는 작업자 하나로 이 구조만 보여 줍니다. 0.5초 기한 안에 WAITING을 확인하면 반복을 멈추고, 결과와 상관없이 finally에서 래치를 열고 join()합니다.
import java.util.concurrent.CountDownLatch;
public final class WaitingObservationCleanup {
public static void main(String[] args) throws InterruptedException {
CountDownLatch gate = new CountDownLatch(1);
Thread worker = new Thread(() -> {
try { gate.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}, "waiting-observation");
worker.start();
boolean observed = false;
try {
long deadline = System.nanoTime() + 500_000_000L;
while (System.nanoTime() < deadline) {
if (worker.getState() == Thread.State.WAITING) {
observed = true;
break;
}
Thread.yield();
}
System.out.println("waiting-observed=" + observed);
} finally {
gate.countDown();
worker.join();
}
System.out.println("alive-after-cleanup=" + worker.isAlive());
}
}waiting-observed=true
alive-after-cleanup=false열 번 넘게 실행해 매번 이 결과를 얻었지만, 첫 줄은 달라질 수 있습니다. 컴퓨터가 바빠서 작업자가 0.5초 안에 await()에 도달하지 못하면 false가 나옵니다. 반복문 안의 Thread.yield()는 다른 스레드에게 CPU를 양보할 뜻이 있다는 힌트일 뿐, 작업자를 WAITING으로 만들어 주지는 않습니다. 작업자를 기다리게 하는 것은 래치이고, 기한은 확인을 끝없이 반복하지 않게 막는 장치입니다.
반면 둘째 줄은 언제나 false입니다. finally에서 래치를 열었으니 작업자는 끝날 수 있고, join()은 작업자가 끝난 뒤에야 정상적으로 반환하기 때문입니다.
시작과 종료 사이에서 일어나는 일
start()는 NEW 상태의 스레드를 실행하도록 JVM에 요청하고, 그 스레드의 run()이 끝나기를 기다리지 않고 반환합니다. 그렇다면 start() 바로 다음에 상태를 읽으면 RUNNABLE이 나올까요? 대부분은 그렇지만 언제나 그렇지는 않습니다. 다음 프로그램은 래치를 기다리는 작업자를 1000번 만들고, 매번 start() 바로 다음 줄에서 상태를 읽어 상태별로 횟수를 셉니다.
import java.util.EnumMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
public final class StateRightAfterStart {
public static void main(String[] args) throws InterruptedException {
Map<Thread.State, Integer> counts = new EnumMap<>(Thread.State.class);
for (int i = 0; i < 1000; i++) {
CountDownLatch gate = new CountDownLatch(1);
Thread worker = new Thread(() -> {
try {
gate.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
worker.start();
counts.merge(worker.getState(), 1, Integer::sum);
gate.countDown();
worker.join();
}
System.out.println(counts);
}
}한 번 실행한 결과입니다. counts는 상태별로 몇 번 나왔는지를 센 맵이라 값을 모두 더하면 1000이고, 각 숫자는 실행할 때마다 조금씩 달라질 수 있습니다.
{RUNNABLE=988, WAITING=12}대부분은 RUNNABLE입니다. 작업자가 아직 CPU를 받지 못했더라도, 시작된 스레드는 실행할 수 있는 상태이므로 RUNNABLE로 나옵니다. 그런데 몇 번은 WAITING이 나왔습니다. main이 상태를 읽기 전에 작업자가 먼저 실행되어 await()까지 도달한 경우입니다.
이 프로그램을 여러 번 실행해 보니 WAITING은 1000번 가운데 적게는 3번, 많게는 36번으로 매번 달랐습니다. 여러분의 환경에서는 비율이 다를 수 있고 WAITING이 한 번도 나오지 않을 수도 있습니다. 작업을 바꾸면 또 다른 상태가 나오기도 합니다. 예를 들어 아무 일도 하지 않는 작업으로 바꿔 실행했더니, main이 읽기 전에 작업자가 이미 끝나 TERMINATED가 나온 경우도 있었습니다. 확실하게 말할 수 있는 것은 한 번 시작한 스레드가 다시 NEW로 돌아가지 않는다는 것뿐입니다.
스레드는 run()이 끝나면 TERMINATED가 됩니다. run()이 정상적으로 반환했을 때뿐 아니라 처리되지 않은 예외로 끝났을 때도 마찬가지인데, 이때는 예외 처리기(uncaught exception handler)가 실행을 마친 다음에 TERMINATED가 됩니다. TERMINATED는 마지막 상태입니다. 끝난 스레드 객체에 다시 start()를 호출하면 Thread 생성과 start에서 본 것처럼 IllegalThreadStateException이 나므로, 같은 작업을 다시 실행하려면 새 Thread 객체를 만들어야 합니다.
isAlive()는 start()를 호출한 뒤 스레드가 끝나기 전까지 true입니다. 하지만 살아 있다는 것이 일을 잘하고 있다는 뜻은 아닙니다. 락을 서로 기다리며 멈춘 교착 상태(deadlock)의 스레드도, 아무도 보내지 않는 신호를 기다리는 스레드도 살아 있기는 마찬가지입니다. 앞에서 래치가 열리지 않아 멈춘 state-waiting이 그런 예입니다. 그래서 운영 중인 서비스에서 작업이 제대로 진행되는지 판단할 때는 큐에 쌓인 작업 수나 마지막으로 작업을 끝낸 시각처럼, 애플리케이션이 직접 기록한 값을 함께 봅니다.
성공해도 실패해도 TERMINATED
상태만으로는 작업이 성공했는지 알 수 없습니다. 다음 프로그램은 성공하는 작업과 예외를 던지는 작업을 하나씩 실행합니다. 두 스레드 모두에 예외 처리기를 등록해, 예외로 끝나면 그 사실을 outcomes 맵에 기록합니다. 성공한 작업은 스스로 ok를 기록합니다.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class LifecycleOutcomeRegistry {
public static void main(String[] args) throws InterruptedException {
Map<String, String> outcomes = new ConcurrentHashMap<>();
Thread good = worker("validate", () -> outcomes.put("validate", "ok"), outcomes);
Thread bad =
worker(
"publish",
() -> {
throw new IllegalStateException("offline");
},
outcomes);
good.start();
bad.start();
good.join();
bad.join();
System.out.println("states=" + good.getState() + "," + bad.getState());
System.out.println("outcomes=" + outcomes);
}
private static Thread worker(String name, Runnable task, Map<String, String> outcomes) {
Thread t = new Thread(task, "job-" + name);
t.setUncaughtExceptionHandler(
(thread, error) -> outcomes.put(name, "failed:" + error.getMessage()));
return t;
}
}states=TERMINATED,TERMINATED
outcomes={publish=failed:offline, validate=ok}두 스레드의 상태는 똑같이 TERMINATED이지만 결과는 다릅니다. job-validate는 run()을 정상적으로 마쳤고, job-publish는 IllegalStateException으로 끝나면서 처리기가 실패를 기록했습니다. getState()가 알려 주는 것은 스레드가 끝났다는 사실뿐이므로, 성공과 실패는 이처럼 따로 기록해 두어야 합니다. 처리기를 등록했기 때문에 JVM의 기본 처리기는 호출되지 않고, 그래서 표준 오류에 스택 트레이스도 출력되지 않습니다. 22장에서 배울 Future를 쓰면 작업의 반환값과 예외를 함께 돌려받을 수 있어서 이 일이 더 간단해집니다.
처리기는 예외로 끝나는 바로 그 스레드에서 실행됩니다. 이 프로그램에서는 job-validate와 job-publish 두 스레드가 같은 맵에 거의 동시에 쓸 수 있으므로, 여러 스레드가 함께 써도 안전한 ConcurrentHashMap을 골랐습니다. main은 두 스레드를 모두 join()한 뒤에 맵을 읽으므로 두 기록이 모두 보입니다. 맵을 출력할 때 항목의 순서는 ConcurrentHashMap이 약속하지 않습니다. 이 환경에서는 매번 publish가 먼저 나왔지만, 다른 키나 다른 구현에서는 달라질 수 있으므로 출력 순서에 기대는 코드는 쓰지 않아야 합니다.
정리
Thread객체에서 ID(threadId()), 이름, 우선순위, 데몬 여부, 스레드 그룹을 조회할 수 있습니다.Thread.currentThread()는 그 코드를 실행하는 스레드를 돌려주므로, 같은 메서드라도 어느 스레드가 호출하느냐에 따라 조회 대상이 달라집니다.- ID와 이름은 진단용입니다. 실행할 때마다 달라질 수 있으니 업무 식별자로 쓰지 말고, 이름은 역할이 드러나게 붙입니다.
- 우선순위는 스케줄러에게 주는 힌트일 뿐이고, 스레드 그룹은 오래된 API입니다. 순서는 동기화로 정하고, 스레드 관리는 실행기와 스레드 팩터리에 맡깁니다.
- 스레드 상태는 NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED 여섯 가지입니다. RUNNABLE은 CPU 차례를 기다리는 시간도 포함합니다. BLOCKED는 모니터 락을, WAITING은 신호를, TIMED_WAITING은 신호나 정해진 시간을 기다립니다.
getState()는 읽은 순간의 값입니다. 여러 번 읽은 값은 같은 순간의 상태가 아니고 다음 순간에는 바뀔 수 있으므로, 작업 순서를 정하는 데 쓰지 않습니다.run()은 검사 예외를 던질 수 없으므로InterruptedException은 작업 안에서 잡고 interrupt 상태 표시를 다시 켭니다.- 정상 종료와 예외 종료는 모두 TERMINATED입니다. 성공과 실패는 처리기나 결과 객체로 따로 기록합니다.
연습 문제
- 래치를 기다리는 작업자를 만들고,
start()전, 래치를 기다리는 중, 래치를 열고join()한 뒤의 상태를 차례로 출력하세요. 기다리는 중의 상태는sleep()으로 적당히 기다린 뒤 읽지 말고, 상한이 있는 반복으로 확인하세요.
해설 보기
래치 대기는 시간 제한이 없으므로 기다리는 중의 상태는 WAITING이어야 합니다. 작업자를 시작한 뒤 System.nanoTime() 기준으로 1초 기한을 두고, 그 안에 WAITING이 되면 반복을 멈춥니다. Thread.onSpinWait()는 지금 짧게 반복하며 기다리는 중이라고 JVM에 알려 주는 힌트입니다.
import java.util.concurrent.CountDownLatch;
public final class ThreeLifecycleSnapshotsSolution {
public static void main(String[] args) throws InterruptedException {
CountDownLatch gate = new CountDownLatch(1);
Thread worker =
new Thread(
() -> {
try {
gate.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
},
"snapshot-worker");
System.out.println("before=" + worker.getState());
worker.start();
long deadline = System.nanoTime() + 1_000_000_000L;
while (worker.getState() != Thread.State.WAITING && System.nanoTime() < deadline)
Thread.onSpinWait();
System.out.println("during=" + worker.getState());
gate.countDown();
worker.join();
System.out.println("after=" + worker.getState());
}
}before=NEW
during=WAITING
after=TERMINATEDbefore는 start() 전이므로 언제나 NEW이고, after는 래치를 연 뒤 join()이 반환한 다음이므로 언제나 TERMINATED입니다. during은 열 번 넘게 실행해 모두 WAITING이었지만, 1초 안에 작업자가 await()에 도달하지 못하면 그 순간의 다른 상태가 출력될 수 있습니다. 반복이 끝난 뒤 다시 읽은 값을 그대로 출력할 뿐, WAITING인지 검사하지는 않기 때문입니다. 실제 프로그램이라면 이런 상태 확인에 기대지 말고, 작업 사이의 순서는 래치 자체로 정해야 합니다.
- 다음 프로그램의 출력을 예상해 보세요.
public final class CurrentThreadQuiz {
public static void main(String[] args) throws InterruptedException {
Thread t =
new Thread(
() -> System.out.println("current=" + Thread.currentThread().getName()),
"quiz-worker");
t.run();
t.start();
t.join();
}
}해설 보기
current=main
current=quiz-workert.run()은 새 스레드를 시작하지 않고, main이 run() 메서드를 직접 호출하는 것입니다. 그래서 람다 안의 Thread.currentThread()는 main을 돌려줍니다. 이름이 붙은 Thread 객체를 통해 호출했다고 해서 현재 스레드가 바뀌지는 않습니다. 이어지는 t.start()는 새 스레드를 시작하고, 그 스레드가 실행하는 람다에서는 quiz-worker가 현재 스레드입니다. 첫 줄은 start()를 호출하기 전에 main이 출력하므로 두 줄은 항상 이 순서로 나옵니다.
- main이
synchronized (monitor)블록 안에 있고, 작업자는 같은monitor로 블록에 들어가려고 기다립니다. main이 블록 안에서Thread.sleep(10)을 호출하면 잠드는 것은 누구이고, 그사이 모니터 락이 작업자에게 넘어갈까요? 또 main이 블록 안에서 그 작업자를join()하면 어떻게 될까요?
해설 보기
잠드는 것은 sleep()을 호출한 main입니다. sleep()은 가진 모니터 락을 놓지 않으므로 작업자는 계속 BLOCKED로 기다립니다. 블록 안에서 join()하면 main은 작업자가 끝나기를, 작업자는 main이 모니터 락을 놓기를 기다리게 되어 둘 다 영원히 멈춥니다. 작업자를 기다려야 한다면 먼저 synchronized 블록을 빠져나와 락을 놓은 뒤에 join()해야 합니다.
- 서로 다른 세 작업자의 상태를 차례로 읽었더니 WAITING, TIMED_WAITING, BLOCKED였습니다. 세 작업자가 같은 순간에 이 상태였다고 말할 수 있을까요? 또 나중에 두 작업자가 모두 TERMINATED가 되었는데 하나는 정상 완료, 하나는 예외 종료였습니다. 상태만으로 둘을 구분할 수 있을까요?
해설 보기
둘 다 아닙니다. 세 번의 getState()는 서로 다른 시점에 읽은 값이므로 같은 순간의 상태라고 할 수 없습니다. 특히 TIMED_WAITING은 시간이 지나면 저절로 끝나므로, 다음 작업자를 읽는 사이에 이미 바뀌었을 수도 있습니다. TERMINATED도 스레드가 끝났다는 사실만 알려 줄 뿐 성공했는지는 담고 있지 않습니다. 작업 사이의 순서는 래치, 락, join() 같은 동기화로 정하고, 성공과 실패는 이 글의 outcomes처럼 따로 기록해 확인해야 합니다.
다음 글
join으로 스레드가 끝날 때까지 기다리기에서는 작업자의 결과를 너무 일찍 읽으면 어떤 일이 생기는지 확인하고, join()으로 작업자가 끝나기를 기다린 뒤 결과를 읽어야 하는 이유를 배웁니다.