JAVA

멀티스레드 환경에서의 동시성 이슈 (CAS 알고리즘)

soyeonisgood 2025. 7. 2. 20:37

동시성

  • 한 CPU에서 동시에 여러 작업을 하는 것처럼 보이는 것
  • 예시
    • 하나의 CPU에서 2개의 프로세스가 있다고 가정해보자.
    • 두 개의 프로세스는 아주 짧은 시간에 컨텍스트 스위칭으로 번갈아 실행된다. 
    • 이 과정이 우리가 볼 때 동시에 동작하는 것처럼 보이는 것!

 

경쟁 상태 (Race Condition) 

  • 두 개 이상의 스레드가 공유 자원에 동시에 접근할 때, 스레드 간의 실행 순서에 따라 결과가 달라지는 현상
  • 경쟁 상태가 발생했다면 원자성과 가시성 모두 보장하지 못했기 때문
  • 동시성 문제의 발생을 파악하기 어렵고, 발생하더라도 결함이 아닌 일시적 버그로 여겨질 수 있음 (재현이 안되기 때문)
  • Java에서는 synchronized, Atomic Type, Concurrent Collection 등으로 해결 가능 

 

원자성 (Atomicity)

  • 공유 자원에 대한 작업 단위가 더 이상 쪼갤 수 없는 성질
  • 원자성을 갖는 작업은 실행 중에 멈추는 경우는 있을 수 없다.

 

가시성 (Visibility)

  • 한 스레드에서 변경한 값이 다른 스레드에서 즉시 확인 가능한 성질 

 

 

동시성이 보장되지 못하는 예제를 보자.

    private static long count = 0;

    public void calculator() throws Exception {
        int maxCount = 10;

        for (int i = 0; i < maxCount; i++) {
            new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                this.count++; // 동시성 보장 X  
            }).start();
        }

        Thread.sleep(100);
        System.out.println("Total Count: " + count);
    }

예상되는 Total Count는 10이다.

그러나 실제 수행 결과는 5~9 사이의 값이 나온다. ( TotalCount != 10)

 

 

Sychronized 키워드

  • 여러 스레드가 동시에 count++; 작업을 수행하면서 중간 단계에서 서로 덮어쓰는 현상이 발생한다 = 원자성이 깨짐
  • 각 스레드가 읽은 데이터가 캐싱됨 = 가시성 보장 X
  • count++;  로직을 메서드로 분리하고, 해당 메서드에 sychronized 키워드를 붙인다.
    • 메서드 레벨 뿐만 아니라 메서드 내부 블럭으로도 활용할 수 있다.
    • 원자성 보장 → 하나의 스레드만 임계 구역에 들어가므로 증가 연산이 끊기지 않음
    • 가시성 보장 → 동일 객체의 잠금(monitor)을 얻고 반납하는 순간, count 변수의 변경사항이 즉시 메인 메모리에 flush-in/flush-out 되어 다른 스레드도 갱신된 값을 봄
  • 다른 스레드들은 작업을 하지 않고 기다리고 있어서, 자원 낭비이 낭비된다.
    • 상호 배제 (Mutual-Exclusion): 동시에 하나의 스레드에서만 코드를 실행하거나, 공유자원 값을 변경하는 작없 수행
  • 키워드 하나만 붙이면 해결되는 간단한 방법이지만 성능 저하 및 자원 낭비의 단점이 있다.
    public synchronized void plusCount() {
        this.count++;
    }


    private static long count = 0;

    public void calculator() throws Exception {
        int maxCount = 10;

        for (int i = 0; i < maxCount; i++) {
            new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                // this.count++; // 동시성 보장 X  
                plusCount();    // synchronized 키워드로 동시성 보장
            }).start();
        }

         Thread.sleep(100);
        System.out.println("Total Count: " + count);
    }

 

 

 

volatile

  • JVM에서 스레드는 실행 중인 CPU 메모리 영역에 데이터를 캐싱한다.
  • 멀티 코어 프로세스에서는 여러 개의 스래드가 동일한 변수 a를 공유하더라도, 캐싱된 시점에 따라 데이터가 달라질 수 있다.
    • 서로 다른 코어의 스레드는 데이터가 불일치할 수도 있다.
    • 임의로 갱신하지 않는 이상, 데이터가 일치하는 정확한 시점을 알 수 없다.
  •  volatile 키워드는 캐싱된 값이 아닌, 메인 메모리 영역의 값을 참조하는 키워드
    • 동일한 시점에 모든 스레드가 동일한 데이터를 가지도록 동기화 
  • sleep 시간을 늘리면 원하는 결과 10이 나오지 않을 것이다.
    • Thread A가 count=3 으로 읽음 → Context Switch → Thread B가 3으로 읽고, count=4로 쓰기 → Thread A가 3+1=4로 쓰기 → Thread B의 count=4 update가 덮어써짐
    • Thread B가 4로 바꿨다는 것을 즉시 볼 순 있지만, 이미 읽어온 3을 바꿀 순 없다. (업데이트 손실)
    • 가시성과 순서 보장만 제공하기 때문에, 동시성 이슈를 완벽히 해결하진 못한다. 
    • 원자성 보장 X 
    private static volatile long count = 0;

    public void calculator() throws InterruptedException {
        int maxCount = 10;
        Thread[] workers = new Thread[maxCount];

        for (int i = 0; i < maxCount; i++) {
            workers[i] = new Thread(() -> {
                try { Thread.sleep(1); } catch (InterruptedException e) {}
                count++;                 // 여전히 레이스 컨디션
            });
            workers[i].start();
        }

        for (Thread t : workers) t.join();
        System.out.println("Total Count: " + count); // 10이 아닐 확률 높음
    }

 

 

Atomic Class

java.util.concurrent.atomic 패키지가 제공하는 클래스

  • ex) AtomicInteger, AtomicLong) 
  • 단일 변수에 대한 read-modify-write 연산을 원자적으로 수행
  • 원자성 확보
    • AtomicLong 핵심 연산은 하나의 논리단위로 완전히 실행됨. 중간에 끼어들 틈이 없음.
      • 핵심 연산 ex) incrementAndGet(), addAndGet(), compareAndSet()
  • 가시성 확보
    • 내부 필드가 volatile 로 선언되어 있음
  • 순서 보장
    • JMM(Java Memory Model)은 volatile 쓰기와 읽기 사이에서 순서 보장 관계를 부여. 따라서 연산 순서가 재배열되지 않음
    private static final AtomicLong count = new AtomicLong();

    void calculator() throws InterruptedException {
        int max = 10;
        Thread[] ts = new Thread[max];
        for (int i = 0; i < max; i++) {
            ts[i] = new Thread(() -> {
                try { Thread.sleep(1); } catch (InterruptedException ignored) {}
                count.incrementAndGet();   // 원자 + 가시성 OK
            });
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        System.out.println("Total = " + count.get()); // 항상 10
    }

 

 

AtomicLong.incrementAndGet CAS 알고리즘

  • CAS (Compare-And-Swap)
  • CPU가 제공하는 원자 명령어를 사용해 현재 메모리 값이 prev와 같으면 next로 교체하는 작업을 한 번에 수행한다.
  • 성공하면 true, 실패하는 false. 실패하면 while 루프로 재시도
  • 이 과정에서 락(Lock)이 필요 없으므로 DeadLock, 우선순위 역전, Context Switch 비용이 없다.
  • 메모리 장벽
    • JVM의 CAS 수행 시 하드웨어 메모리 장벽이 함께 발동되어 읽기/쓰기 reorder를 막고 가시성 확보

 

public final long incrementAndGet() {
    long prev, next;
    do {
        prev = getVolatileValue();              // ① 현재 값 읽기
        next = prev + 1;                        // ② 계산
    } while (!compareAndSwap(prev, next));      // ③ CAS 실패 시 재시도
    return next;
}