개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Java › 스레드와 동시성

경쟁 상태

count++는 왜 숫자를 잃어버리나

여러 스레드가 같은 변수를 동시에 올립니다. count++는 읽기, 더하기, 쓰기의 세 단계라서 단계가 섞이면 증가가 사라집니다. 이렇게 스레드들이 실행되는 순서에 따라 결과가 달라지는 상황을 경쟁 상태라고 합니다.

실행으로 대조 · JDK 21에서 같은 코드를 실행

이 실습에 나오는 개념

경쟁 상태(Race Condition)
둘 이상의 스레드가 같은 데이터를 동시에 읽고 쓸 때 실행 순서에 따라 결과가 달라지는 상황이다. count++는 값을 읽고, 1을 더하고, 다시 쓰는 세 단계로 이뤄지는데 두 스레드의 단계가 섞이면 한쪽의 증가가 사라진다. 테스트에서는 잘 드러나지 않다가 부하가 걸릴 때 나타나는 것이 특징이다.
임계 영역과 상호 배제
임계 영역은 여러 스레드가 동시에 실행하면 문제가 생기는 코드 구간이다. 상호 배제는 그 구간을 한 번에 하나의 스레드만 실행하도록 보장하는 것으로, 락(잠금)을 이용해 구현한다. 한 스레드가 락을 가진 동안 다른 스레드는 기다린다.
synchronized
자바에서 임계 영역을 지정하는 키워드다. synchronized가 붙은 메서드나 블록은 한 번에 한 스레드만 실행할 수 있으며, 실행하려는 스레드는 해당 객체의 모니터 락을 얻어야 한다. 간단하지만 락을 기다리는 스레드는 멈춰 있게 되므로 범위를 필요한 만큼만 잡아야 한다.
volatile
변수에 붙이면 한 스레드가 쓴 값을 다른 스레드가 항상 최신으로 읽도록 보장하는 키워드다(가시성 보장). 그러나 읽고-수정하고-쓰는 복합 연산을 하나로 묶어 주지는 않으므로 count++ 같은 연산의 안전성은 보장하지 않는다. 한 스레드가 쓰고 여러 스레드가 읽는 상태 플래그에 적합하다.
가시성(Visibility)
한 스레드가 변경한 값이 다른 스레드에 보이는지를 뜻한다. 동기화가 없으면 CPU 캐시나 컴파일러 최적화 때문에 다른 스레드가 변경 전의 값을 계속 볼 수 있다. synchronized, volatile, java.util.concurrent의 도구들이 가시성을 보장한다.
원자성(Atomicity)과 CAS
원자성은 여러 단계로 이뤄진 연산이 중간에 끼어들 틈 없이 하나처럼 실행되는 성질이다. CAS(Compare-And-Swap)는 "현재 값이 내가 읽었던 값과 같을 때만 새 값으로 바꾼다"를 하나의 원자적 연산으로 수행하는 방법으로, 락 없이 안전한 갱신을 가능하게 한다. AtomicInteger 등이 이를 이용한다.
스레드 안전(Thread-safe)
여러 스레드가 동시에 사용해도 올바르게 동작하는 성질이다. 공유하는 상태가 없거나, 상태가 불변이거나, 접근이 적절히 동기화되어 있으면 스레드 안전하다. 스프링의 싱글톤 빈은 여러 요청 스레드가 함께 사용하므로 스레드 안전하게(보통 상태를 갖지 않게) 만들어야 한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 스레드 A: count를 읽었습니다. 읽은 값은 0입니다.
  2. 스레드 B: count를 읽었습니다. 읽은 값은 0입니다.
  3. 스레드 A: 읽은 값 0에 1을 더해 1을 만들었습니다. 아직 count에 쓰지는 않았습니다.
  4. 스레드 B: 읽은 값 0에 1을 더해 1을 만들었습니다. 아직 count에 쓰지는 않았습니다.
  5. 스레드 A: count에 1을 썼습니다.
  6. 스레드 B: count에 1을 썼습니다. 그런데 이 스레드가 읽은 뒤로 다른 스레드가 count를 1까지 올려 놓았습니다. 그 위에 1을 덮어써서 증가 1번이 사라졌습니다.
  7. 스레드 A: count를 읽었습니다. 읽은 값은 1입니다.
  8. 스레드 B: count를 읽었습니다. 읽은 값은 1입니다.
  9. 스레드 A: 읽은 값 1에 1을 더해 2를 만들었습니다. 아직 count에 쓰지는 않았습니다.
  10. 스레드 B: 읽은 값 1에 1을 더해 2를 만들었습니다. 아직 count에 쓰지는 않았습니다.
  11. 스레드 A: count에 2를 썼습니다.
  12. 스레드 B: count에 2를 썼습니다. 그런데 이 스레드가 읽은 뒤로 다른 스레드가 count를 2까지 올려 놓았습니다. 그 위에 2를 덮어써서 증가 1번이 사라졌습니다.
  13. 스레드 A: count를 읽었습니다. 읽은 값은 2입니다.
  14. 스레드 B: count를 읽었습니다. 읽은 값은 2입니다.
  15. 스레드 A: 읽은 값 2에 1을 더해 3을 만들었습니다. 아직 count에 쓰지는 않았습니다.
  16. 스레드 B: 읽은 값 2에 1을 더해 3을 만들었습니다. 아직 count에 쓰지는 않았습니다.
  17. 스레드 A: count에 3을 썼습니다.
  18. 스레드 B: count에 3을 썼습니다. 그런데 이 스레드가 읽은 뒤로 다른 스레드가 count를 3까지 올려 놓았습니다. 그 위에 3을 덮어써서 증가 1번이 사라졌습니다.

실행 결과

코드

static int count = 0;

Runnable task = () -> {
    for (int i = 0; i < 3; i++) {
        count++;                    // 읽기 -> 더하기 -> 쓰기
    }
};

// 스레드 2개가 같은 task를 동시에 실행
for (int t = 0; t < 2; t++) {
    new Thread(task).start();
}

이 모형의 전제

한 번에 한 스레드의 한 단계만 실행하는 것으로 단순화했습니다. 실제 멀티코어에서는 여러 스레드가 진짜로 동시에 실행됩니다. 또 읽을 때는 항상 최신 값이 보인다고 가정했는데, 실제 일반 변수는 다른 스레드가 쓴 값이 보이지 않을 수도 있습니다(가시성 문제). 이 PC에서 JDK 21로 스레드 2개가 100만 번씩 올리는 실제 코드를 돌렸을 때 동기화 없는 count++는 1,017,250 ~ 1,892,129로 매번 달랐고, volatile을 붙여도 모자랐으며, synchronized와 AtomicInteger는 항상 2,000,000이었습니다. AtomicInteger는 읽기 → CAS → 실패하면 재시도의 순서로 그렸는데, 실제 JVM은 CPU에 따라 이 증가를 명령 하나로 끝내기도 해서 재시도가 눈에 띄지 않을 수 있습니다.

앞 실습: 스레드의 생성과 상태 · 다음 실습: 교착 상태

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요