동기화와 공유 상태
여러 스레드가 같은 가변 상태를 읽고 쓰면 한 줄 연산도 여러 단계로 나뉘어 경쟁할 수 있습니다.
count++는 읽기, 증가, 쓰기가 원자적으로 묶이지 않습니다.
synchronized와 Lock은 같은 불변식을 지키는 복합 동작을 한 임계 구역으로 보호합니다.
volatile은 가시성과 일부 순서만 보장하며 복합 연산을 원자적으로 만들지 않습니다.
이 절에서는 먼저 공유를 줄이고 남은 상태에 맞는 동기화 도구를 고릅니다.
동기화는 한 번에 한 스레드만 들어가게 하는 것뿐 아니라 메모리 가시성도 만듭니다. 같은 모니터를 해제한 스레드의 쓰기는 이후 그 모니터를 획득한 스레드에 보입니다. 읽기와 쓰기가 서로 다른 락을 사용하면 모양은 synchronized여도 이 관계가 성립하지 않습니다.
경쟁 조건은 실행 순서에 따라 결과가 달라진다
두 스레드가 같은 이전 값을 읽으면 증가 하나가 사라질 수 있습니다.
드물게 재현돼도 데이터 규칙은 이미 깨진 것입니다.
synchronized는 같은 모니터의 임계 구역을 직렬화한다
상태 확인과 변경을 같은 락 아래 두고 락 객체를 외부에 노출하지 않습니다.
SynchronizedCounter.java는 두 필드의 증가와 관찰을 같은 모니터로 묶어, 단일 숫자보다 넓은 불변식을 보호하는 모습을 보여 줍니다.
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class SynchronizedCounter {
static final class Pair {
private int left;
private int right;
synchronized void incrementBoth() { left++; right++; }
synchronized String snapshot() { return left + ":" + right; }
}
public static void main(String[] args) throws Exception {
Pair pair = new Pair();
AtomicInteger completed = new AtomicInteger();
try (var executor = Executors.newFixedThreadPool(4)) {
for (int task = 0; task < 1_000; task++) {
executor.submit(() -> { pair.incrementBoth(); completed.incrementAndGet(); });
}
}
System.out.println(pair.snapshot());
System.out.println(completed.get());
}
}executor 범위가 닫힐 때 제출한 작업이 끝나기를 기다린 뒤 스냅샷을 읽습니다. incrementBoth와 snapshot이 같은 Pair 모니터를 사용하므로 좌우 값은 중간 상태가 아니라 한 불변식으로 관찰됩니다.
1000:1000
1000Pair의 두 필드는 같은 모니터에서 함께 바뀌어 스냅샷의 좌우 값이 항상 일치합니다.
두 스레드가 같은 이전 값을 읽으면 증가 하나가 사라질 수 있습니다.
상태 확인과 변경을 같은 락 아래 두고 락 객체를 외부에 노출하지 않습니다.
lock 뒤에는 반드시 finally에서 unlock합니다.
Lock은 타임아웃과 조건 등 명시 제어를 제공한다
lock 뒤에는 반드시 finally에서 unlock합니다.
단순 상호 배제에는 synchronized가 더 읽기 쉬울 수 있습니다.
아래 세 형태는 원자성이 필요한 범위에 따라 선택 도구가 달라짐을 보여 줍니다.
class UnsafeCounter {
int value;
void increment() { value++; } // 읽기-증가-쓰기 복합 연산
}java.util.concurrent.locks.Lock lock = new java.util.concurrent.locks.ReentrantLock();
lock.lock();
try {
// 여러 필드 불변식을 한 임계 구역에서 변경
} finally {
lock.unlock();
}volatile int visible = 0;
// visible++는 volatile이어도 원자적이지 않음Atomic 클래스는 단일 변수의 원자 연산을 제공한다
AtomicInteger의 incrementAndGet처럼 한 변수의 갱신에는 적합하지만 여러 필드 불변식은 별도 락이나 불변 상태 교체가 필요합니다.
복합 동작은 하나의 원자 경계로 묶는다
확인 후 변경처럼 여러 단계가 하나의 불변식을 지키면 같은 락 안에서 수행해야 합니다.
thread-safe 메서드 두 개를 따로 호출한다고 복합 동작까지 안전한 것은 아닙니다.
Concurrent 컬렉션은 순회 계약도 다르다
동시 변경을 허용하는 대신 스냅샷이나 약한 일관성 등 구현별 순회 의미를 가집니다.
필요한 관찰 일관성을 먼저 정합니다.
incrementBoth에서 synchronized를 제거하고 작업 수를 크게 늘려 여러 번 실행해 보세요. 결과가 한 번 1000:1000으로 나왔다고 안전한 것은 아닙니다. 다음으로 left와 right를 각각 AtomicInteger로만 바꿔도 두 증가 사이 스냅샷이 가능하므로 “두 값이 항상 같음”이라는 복합 불변식은 보장되지 않습니다.
락 안에서는 느린 I/O나 외부 콜백을 가능한 한 실행하지 않습니다. 임계 구역이 길어지면 대기 시간이 늘고, 외부 코드가 다시 같은 객체를 호출하면 교착 위험도 생깁니다. 보호할 상태를 읽거나 갱신하는 최소 구간과 락 밖에서 수행할 작업을 분리합니다.
여러 락이 필요하면 모든 코드에서 획득 순서를 고정하세요. A를 잡고 B를 기다리는 스레드와 B를 잡고 A를 기다리는 스레드가 만나면 각 메서드가 개별적으로 올바르더라도 전체 시스템은 멈춥니다.
동시성 안전성은 키워드 하나가 아니라 어떤 상태를 누가 공유하고 어느 연산 묶음이 하나처럼 보여야 하는지 정의하는 일입니다.