스레드와 프로세스는 무엇을 함께 쓰고 어디까지 같이 죽나
주고받을지 격리할지로 고른다
값을 자주 주고받는 일은 스레드로, 장애가 번지면 안 되거나 밖에서 강제로 끊어야 하는 일은 프로세스로 나눕니다. 프로세스는 실행 중인 프로그램 하나이고, 스레드는 그 안에서 코드를 따라가는 실행 흐름입니다. java Main 한 번이 프로세스 하나이고, main과 new Thread로 띄운 흐름이 그 안의 스레드입니다. 작업을 나눌 때는 같은 객체를 자주 읽고 쓰는지부터 봅니다.
스레드로 나눴으면 함께 쓰는 값은 읽기부터 쓰기까지 한 동작으로 올리는 AtomicInteger로 둡니다. 이렇게 바꾸면 매번 200000이 나옵니다.
import java.util.concurrent.atomic.AtomicInteger;static final AtomicInteger count = new AtomicInteger();Runnable inc = () -> { for (int i = 0; i < 100_000; i++) count.incrementAndGet(); };프로세스로 뗄 일은 ProcessBuilder로 띄운 자식 JVM에서 돌리고, 성패는 종료 코드로 받습니다. 자식은 Process.destroy()로 밖에서 끝낼 수 있습니다.
Process p = new ProcessBuilder("java", "Crash").start();System.out.println("exit " + p.waitFor());System.out.println("main alive");톰캣은 요청마다 스레드 풀의 스레드를 붙입니다. 크롬은 탭마다 렌더러를 따로 프로세스로 돌려, 한 탭이 죽어도 그 탭만 오류 화면을 띄웁니다.
스레드를 밖에서 멈추려고 `Thread.stop()`을 불렀다면
Thread.stop()은 고치다 만 객체를 다른 스레드에 남길 수 있어, Java 21에서는 항상 UnsupportedOperationException을 던집니다. 끊어야 하는 일은 처음부터 ProcessBuilder로 띄워 Process.destroy()로 끝냅니다.
스레드는 힙을 함께 써서 덮어쓴다
스레드마다 따로 갖는 것은 메서드 호출이 쌓이는 스택과 실행 위치뿐입니다. new로 만든 객체와 static 필드가 사는 힙은 같은 프로세스의 스레드가 함께 씁니다. 프로세스끼리는 힙이 따로라서, 다른 프로세스의 count는 이름만 같은 다른 값입니다. 값을 나누려면 파이프·소켓·공유 메모리를 거쳐야 합니다.
스레드는 힙 하나를 함께 쓰고, 프로세스 힙끼리는 닿지 않는다
스레드 둘이 같은 count를 10만 번씩 올립니다.
static int count = 0;Runnable inc = () -> { for (int i = 0; i < 100_000; i++) count++; };Thread a = new Thread(inc), b = new Thread(inc);a.start(); b.start();a.join(); b.join();System.out.println(count);결과는 돌릴 때마다 102416, 129269처럼 200000보다 작습니다. 두 스레드가 서로 쓴 값을 덮어쓰기 때문입니다. 실행 순서에 따라 결과가 달라지는 이런 상태가 경쟁 상태입니다.
두 스레드가 같은 5를 읽어, +1 하나가 사라진다
예외는 스레드만, 크래시는 JVM을 끝낸다
자바에서 처리하지 않은 예외는 그 스레드만 끝냅니다. AtomicInteger로 고친 카운터에서 두 번째 스레드가 5만 번째에 예외를 던지게 합니다.
Runnable broken = () -> { for (int i = 0; i < 100_000; i++) { if (i == 50_000) throw new IllegalStateException("boom"); count.incrementAndGet(); }};Thread a = new Thread(inc), b = new Thread(broken);a.start(); b.start();a.join(); b.join();System.out.println("count = " + count.get());Exception in thread "Thread-1" java.lang.IllegalStateException: boom at Boom.lambda$main$1(Boom.java:8) at java.base/java.lang.Thread.run(Thread.java:840)count = 150000프로세스는 살아서 마지막 줄을 찍었고, 예외를 던진 스레드의 몫만 빠져 150000에서 멈췄습니다. 서버라면 죽지 않는 대신 그 스레드가 하던 일이 조용히 사라집니다.
네이티브 라이브러리가 허락되지 않은 메모리를 건드리면 운영체제가 프로세스에 SIGSEGV 신호를 보냅니다. 이런 크래시가 세그폴트입니다. 시그널 처리는 프로세스 단위라서, 스레드 하나의 세그폴트에 JVM 전체가 종료 코드 134로 끝납니다. 같은 Crash 클래스를 첫 섹션의 ProcessBuilder로 자식 JVM에서 돌리면 자식만 끝나고, 부모는 종료 코드를 받아 계속 갑니다.
exit 134main alive| 일어난 일 | 스레드에서 나면 | 자식 프로세스에서 나면 |
|---|---|---|
| 자바·파이썬의 처리하지 않은 예외 | 그 스레드만 끝남 | 자식만 끝남 |
| Go의 처리하지 않은 panic | 프로그램 전체가 끝남 | 자식만 끝남 |
| 세그폴트 | 프로세스 전체가 끝남 | 자식만 끝남 |
크래시를 내 보려면
Crash 클래스는 새 스레드에서 sun.misc.Unsafe로 0번지를 읽습니다. 그대로 돌리면 JVM이 A fatal error has been detected를 찍고 종료 코드 134로 끝나며, 그 뒤 줄은 찍히지 않습니다.
파이썬이나 Go에서 스레드를 쓴다면
파이썬 threading도 처리하지 않은 예외는 그 스레드만 끝냅니다. Go에서는 고루틴 하나의 panic을 아무도 받지 않으면 프로그램 전체가 끝납니다.