스레드 이해하기 — 공유 자원과 실행 수명
목차
두 스레드가 각각 카운터를 한 번 올렸는데 최종 값이 2가 아니라 1이다. 마지막 값을 읽기 전에 join()으로 두 스레드의 완료를 기다려도 두 수정 작업을 하나로 묶어 주지는 않는다. 공유 상태를 다루는 코드를 이해하려면 완료 대기와 갱신의 원자성을 구분해야 한다.
이 글은 스레드의 공유 범위부터 시작해, 값이 보이는 조건과 갱신이 충돌하지 않는 조건을 구분한다. 예제의 메모리 규칙은 Java 21 기준이다. 스레드와 프로세스의 일반적인 자원 관계는 Linux pthreads 설명을, 정확한 가시성과 순서 보장은 Java 언어 명세 17장을 기준으로 한다.
실행 위치는 따로, 주소 공간은 함께
같은 프로세스의 스레드는 각자 실행 위치, 레지스터 상태, 스택을 가진다. 코드, 힙, 전역 데이터와 열린 파일 디스크립터 등은 공유한다. 그래서 한 스레드가 힙의 객체를 바꾸면 다른 스레드도 같은 객체를 참조할 수 있다. 스택은 스레드마다 따로 마련되지만 같은 주소 공간에 있으므로, 스택 객체를 가리키는 참조를 다른 스레드에 넘겼다면 접근과 수명을 관리해야 한다. Linux pthreads
이 구조 덕분에 큰 객체를 복사하지 않고 작업을 나눌 수 있다. 동시에 두 스레드가 같은 객체의 상태를 수정할 수 있다는 뜻이기도 하다. 스레드가 분리된 실행 흐름이라는 말은 데이터가 격리됐다는 말이 아니다. 한 스레드의 치명적인 메모리 오류가 프로세스 전체에 영향을 줄 수 있는 이유도 이 공유 주소 공간에 있다.
스레드를 늘리면 대기 중인 I/O와 다른 작업을 겹쳐 진행할 수 있다. CPU 계산을 독립적으로 나누고 실제 실행 자원이 있으면 병렬로 처리할 수도 있다. 하지만 공용 락을 오래 쥐거나 CPU가 이미 포화했다면 스레드 수 증가가 처리량 증가로 이어지지 않는다. 스레드 개수는 실행 기회이고, 처리량은 작업의 대기·경합·자원 한도까지 포함한 결과다.
무엇이 보이는가와 누가 먼저 쓰는가는 다르다
공유 변수 counter의 초기 값이 0이라고 하자. 스레드 A와 B가 각각 counter + 1을 수행할 때, 둘 다 0을 읽은 뒤 둘 다 1을 쓰면 최종 값은 1이다.
시간 A B
1 counter 읽기 → 0
2 counter 읽기 → 0
3 0 + 1 쓰기 → 1
4 0 + 1 쓰기 → 1
결과 두 번 갱신했지만 counter = 1이것은 스레드가 값을 전혀 못 본다는 문제가 아니다. 두 작업의 읽기 → 계산 → 쓰기 전체가 원자적인 한 단계가 아니어서 생긴 갱신 손실이다. Java의 volatile은 변수에 대한 가시성과 순서 규칙을 제공하지만 이 세 단계를 한 번의 갱신으로 바꾸지 않는다. 반대로 단순한 int 필드를 아무 동기화 없이 서로 읽고 쓰면, 어느 시점에 어떤 값을 볼지도 메모리 모델의 제약을 받는다. Java 메모리 모델의 volatile·happens-before
다음 Java 21 예제는 두 스레드가 모두 읽은 뒤에만 쓰도록 CyclicBarrier로 순서를 고정한다. 같은 실행 파일에서 원자적 갱신과 비교한다.
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadMemoryDemo {
static volatile int counter;
static void meet(CyclicBarrier barrier) {
try {
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws InterruptedException {
CyclicBarrier bothRead = new CyclicBarrier(2);
Runnable lostUpdate = () -> {
int snapshot = counter;
meet(bothRead);
counter = snapshot + 1;
};
Thread a = Thread.ofPlatform().start(lostUpdate);
Thread b = Thread.ofPlatform().start(lostUpdate);
a.join();
b.join();
System.out.println("volatile counter = " + counter);
AtomicInteger atomic = new AtomicInteger();
Thread c = Thread.ofPlatform().start(atomic::incrementAndGet);
Thread d = Thread.ofPlatform().start(atomic::incrementAndGet);
c.join();
d.join();
System.out.println("atomic counter = " + atomic.get());
}
}코드를 ThreadMemoryDemo.java에 저장하고 JDK 21에서 실행한다.
javac ThreadMemoryDemo.java
java ThreadMemoryDemovolatile counter = 1
atomic counter = 2첫 결과는 드물게 일어날 수 있는 스케줄 운이 아니다. 두 스레드가 각각 snapshot=0을 만든 다음 barrier를 통과하므로, 둘이 어떤 순서로 1을 써도 최종 값은 1이다. volatile을 썼는데도 2가 되지 않는 이유가 바로 가시성과 복합 갱신의 차이다. 두 번째 결과는 AtomicInteger.incrementAndGet()가 갱신을 원자적으로 수행하기 때문에 2다. AtomicInteger API
join()도 역할이 있다. 두 스레드의 작업이 끝난 뒤 출력하고, 각 스레드의 작업이 성공적으로 join()한 쪽보다 먼저 일어났다는 happens-before 관계를 만든다. Thread.start()는 시작한 스레드의 실행보다 앞선다. 그러나 이 순서 보장은 A와 B가 공유 카운터를 수정하는 서로 사이의 경쟁을 없애지 않는다. ‘작업 종료를 기다렸으니 중간 상태도 안전했다’는 결론은 성립하지 않는다. Java 언어 명세: happens-before
카운터 하나라면 원자적 연산이 간단하다. 하지만 잔액과 처리한 요청 ID처럼 두 값이 함께 바뀌어야 한다면 각 필드만 원자적으로 갱신해서는 관계가 보존되지 않을 수 있다. 동일한 락으로 읽기, 조건 확인, 두 필드 수정을 감싸거나, 저장소의 원자적 변경을 사용해야 한다. 락은 한 번에 하나의 스레드만 임계 구역에 들어오게 하고, 같은 monitor의 unlock과 다음 lock 사이에도 happens-before를 만든다. 어느 상태를 하나의 불변식으로 다룰지가 락의 범위를 정한다. Java 언어 명세: monitor와 순서
값이 갱신돼도 종료를 보지 못할 수 있다
다른 종류의 오류도 있다. 한 스레드가 done=true를 쓰고 다른 스레드가 while (!done)을 반복한다고 하자. done이 동기화되지 않은 일반 필드라면 읽는 쪽이 매번 새 값을 읽는다고 가정할 수 없다. Java 명세는 Thread.sleep()이나 yield()에 동기화 의미가 없으며, 루프가 예전 값을 계속 사용할 수 있는 예까지 든다. Java 언어 명세: sleep/yield와 가시성
이처럼 한쪽이 쓴 완료 신호를 다른 쪽이 볼 수 있는가는 가시성 문제다. volatile, 같은 락, 동시성 도구의 규칙처럼 명시적인 관계가 필요하다. 반면 완료 신호를 보이게 만들었다고 작업이 안전하게 끝난 것은 아니다. 스레드가 파일을 계속 쓰는 중이라면 신호를 보낸 호출자가 파일을 바로 삭제할 수 없다. 파서 취소와 자원 소유권은 기다림의 취소와 실행 종료가 갈라지는 예를 보여 준다.
락을 둘 이상 써야 한다면 획득 순서를 일관되게 정해야 한다. A를 쥔 스레드가 B를 기다리고, 다른 스레드가 B를 쥔 채 A를 기다리면 둘 다 진행하지 못한다. 짧은 임계 구역과 일정한 락 순서가 필요한 이유다. Java 명세는 교착 상태를 언어가 자동으로 방지하거나 탐지한다고 보장하지 않는다. Java 언어 명세: synchronization
프로세스로 분리하면 어디까지 달라지는가
프로세스를 나누면 기본적으로 주소 공간이 분리된다. 한쪽의 힙 객체를 다른 쪽이 같은 포인터로 읽는 대신 IPC, 소켓, 파일, 명시적인 공유 메모리로 데이터를 주고받는다. 격리의 대가로 직렬화와 통신, 자원 관리가 필요하다. 반대로 프로세스를 나눴더라도 같은 DB, 파일, CPU, 메모리를 사용한다면 서로 영향을 줄 수 있다. 분리해야 할 것은 ‘스레드냐 프로세스냐’라는 이름보다 실패를 어느 경계 안에 가둘지다.
다음에 공유 상태 버그를 보면 세 질문을 순서대로 던져 보자. 값을 볼 수 있는 관계가 있는가? 여러 단계를 하나의 갱신으로 묶었는가? 그 자원을 마지막으로 쓰는 실행이 끝났는가? 위 예제에서 join(), volatile, AtomicInteger가 서로 다른 질문에 답한다.