스레드와 프로세스는 무엇을 함께 쓰고 어디까지 같이 죽나

동딩
2026.10.08·4분 읽기·

주고받을지 격리할지로 고른다

값을 자주 주고받는 일은 스레드로, 장애가 번지면 안 되거나 밖에서 강제로 끊어야 하는 일은 프로세스로 나눕니다. 프로세스는 실행 중인 프로그램 하나이고, 스레드는 그 안에서 코드를 따라가는 실행 흐름입니다. java Main 한 번이 프로세스 하나이고, main과 new Thread로 띄운 흐름이 그 안의 스레드입니다. 작업을 나눌 때는 같은 객체를 자주 읽고 쓰는지부터 봅니다.

스레드로 나눴으면 함께 쓰는 값은 읽기부터 쓰기까지 한 동작으로 올리는 AtomicInteger로 둡니다. 이렇게 바꾸면 매번 200000이 나옵니다.

java
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()로 밖에서 끝낼 수 있습니다.

java
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는 이름만 같은 다른 값입니다. 값을 나누려면 파이프·소켓·공유 메모리를 거쳐야 합니다.

스레드는 힙 하나를 함께 쓰고, 프로세스 힙끼리는 닿지 않는다

프로세스 A프로세스 B읽기·쓰기못 닿음보낸다받는다힙 · countnew 객체 · static 필드스레드 1스택스레드 2스택힙 · countA와 다른 값스레드 1스택파이프 · 소켓공유 메모리
두 스레드가 함께 쓰는 힙곧장 닿지 못함값을 건네는 길
프로세스 A에 스레드 둘, B에 스레드 하나가 돌 때

스레드 둘이 같은 count를 10만 번씩 올립니다.

java
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 하나가 사라진다

스레드 Acount스레드 B5 읽기5 읽기5+16 쓰기5+16 쓰기, 덮어씀6count에 남은 값두 번 올렸는데 한 번만 올랐다7이어야 맞다
앞 스레드가 쓴 값을 덮는 쓰기
count가 5일 때 두 스레드가 count++(읽기·+1·쓰기)를 한 번씩

예외는 스레드만, 크래시는 JVM을 끝낸다

자바에서 처리하지 않은 예외는 그 스레드만 끝냅니다. AtomicInteger로 고친 카운터에서 두 번째 스레드가 5만 번째에 예외를 던지게 합니다.

java
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());
text
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에서 돌리면 자식만 끝나고, 부모는 종료 코드를 받아 계속 갑니다.

text
exit 134
main alive
일어난 일스레드에서 나면자식 프로세스에서 나면
자바·파이썬의 처리하지 않은 예외그 스레드만 끝남자식만 끝남
Go의 처리하지 않은 panic프로그램 전체가 끝남자식만 끝남
세그폴트프로세스 전체가 끝남자식만 끝남
크래시를 내 보려면

Crash 클래스는 새 스레드에서 sun.misc.Unsafe로 0번지를 읽습니다. 그대로 돌리면 JVM이 A fatal error has been detected를 찍고 종료 코드 134로 끝나며, 그 뒤 줄은 찍히지 않습니다.

파이썬이나 Go에서 스레드를 쓴다면

파이썬 threading도 처리하지 않은 예외는 그 스레드만 끝냅니다. Go에서는 고루틴 하나의 panic을 아무도 받지 않으면 프로그램 전체가 끝납니다.

Comments

댓글

powered by giscus ↗