콘텐츠로 이동

동기화와 원자적 연산

원자적 연산

원자적 연산(Atomic Operation): 더 이상 나눌 수 없는 단위로 수행되는 연산. 중간에 끊기지 않아 멀티스레드 환경에서도 안전하다.

원자적 연산 예시

원자적

  • 아래에 코드를 보면 오른쪽에 1을 i에 대입하는 연산이 끝이다.
    i = 1;
    

원자적 연산 아님

  • 아래에 코드를 보면 3가지 연산으로 분리되어 있다.
  • 오른쪽에 i의 값을 읽는다.
  • 읽은 i에 1을 더한다.
  • 더한 i를 왼쪽에 i에게 대입한다.
    i = i + 1;
    

AtomicInteger

  • 락을 사용하지 않고도 정수 연산의 원자성과 메모리 가시성을 보장하는 클래스이다.

동작 원리

  1. 현재 값을 읽음
  2. 기대값과 실제 메모리 값을 비교
  3. 같으면 새 값으로 교체
  4. 다르면 다시 시도 (Spin)

장점

  1. synchronized 없이도 정확성 보장
  2. 경쟁이 적을수록 매우 빠름
  3. 코드가 간결함
  4. 데드락 위험 없음

한계

  1. 경쟁이 심할수록 성능 저하
  2. CAS 실패 → 반복 재시도
  3. 단일 변수 연산에만 적합
  4. 여러 변수를 함께 다뤄야 하면 부적합

예시 코드

public class Main {

    private static final AtomicInteger atomicInteger = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {
        for (long i = 0; i < 100; i++) {
            Thread thread = new Thread(new MyJob());
            thread.start();
        }
        Thread.sleep(1_000);
        System.out.println(atomicInteger.get());
    }

    private static class MyJob implements Runnable {
        @Override
        public void run() {
            atomicInteger.incrementAndGet();
        }
    }

}

CAS (Compare-And-Swap / Compare-And-Set)

개요

  • CAS는 동시성 제어를 위해 사용되는 락 없는(Lock-Free) 원자적 연산이다.
  • CPU 하드웨어 차원에서 직접 지원되며, 현대적인 CPU는 CAS 전용 명령어를 제공한다.
  • 자바에서는 Atomic* 계열 클래스가 내부적으로 CAS를 사용한다.

하드웨어 관점

  • CAS 연산은 CPU 명령어 하나로 수행되는 원자 연산이다.
  • 연산 중 중간 상태가 존재하지 않으며, 다른 스레드는 이를 관찰할 수 없다.
  • 따라서 락을 사용하지 않고도 [경쟁 상태]를 방지할 수 있다.

CAS 연산의 핵심 개념

  • CAS는 다음 3단계를 하나의 원자 연산으로 수행한다.
  • 기대 값(Expected Value)과 메인 메모리에 실제 저장된 값(Current Value)을 비교한다.
    • 두 값이 같으면 → 새로운 값(New Value)으로 교체
    • 같지 않으면 → 아무 작업도 하지 않고 실패

compareAndSet() 메서드

  • 자바의 CAS 구현 메서드
  • 내부적으로 CPU의 CAS 명령어를 사용
    boolean compareAndSet(int expectedValue, int newValue);
    
  • 반환값
  • true : 값 변경 성공
  • false : 다른 스레드가 먼저 값을 변경함

CAS 기반 증가 로직 예제

public int incrementAndGet(AtomicInteger atomicInteger) {
    int getValue;
    boolean result;
    do {
        // 1. 현재 값 조회
        getValue = atomicInteger.get();
        // 2-1. 기대 값(getValue)과 실제 메모리 값 비교
        // 2-2. 같다면 atomicInteger 값 증가 
        result = atomicInteger.compareAndSet(getValue, getValue + 1);
        // 3-1. 실패 시 다른 스레드가 값을 변경한 것이므로 재시도
    } while(!result);
    // 3-2. 성공 시 getValue <- 실제 연산을 한 후 리턴
    return getValue + 1; // 결과 반환
}

SpinLock

  • 락을 얻을 때까지 계속 확인하는 방식의 락
  • 락 획득에 실패해도 스레드가 BLOCKED 상태로 바뀌지 않고 CPU를 사용하면서 반복 대기(Busy Waiting) 한다.
  • 주로 아주 짧은 임계 구역에서만 사용된다.

동작 방식 (간단 설명)

  1. 락이 비어 있는지 확인
  2. 비어 있으면 즉시 락 획득
  3. 이미 사용 중이면
    → 잠들지 않고 계속 확인(Spin)

장점

  1. 컨텍스트 스위칭 비용이 없음
  2. 스레드를 재우거나 깨우지 않음
  3. 커널 스케줄러 개입이 없어 빠름
  4. 락 대기 시간이 매우 짧을 때 성능이 좋음
  5. 잠깐 기다릴 바에는 바로 확인하는 것이 효율적

단점

  1. CPU를 계속 사용함
  2. 락을 기다리는 동안에도 CPU 점유
  3. 경쟁이 심하면 CPU 낭비 발생
  4. 임계 구역이 길면 성능이 급격히 나빠짐
  5. 비즈니스 로직, I/O, sleep 포함 시 매우 부적합
  6. CPU 코어 수가 부족하면 문제 발생
  7. 다른 스레드가 실행 기회를 못 얻을 수 있음

예시 코드

public class Main {

  public static void main(String[] args) {
    SpinLock lock = new SpinLock();
    Runnable task = new Runnable() {
      @Override
      public void run() {
        lock.lock();
        try {
          log("비즈니스 로직 실행");
        } finally {
          lock.unlock();
        }
      }
    };
    for (int i = 1; i <= 2; i++) {
      Thread thread = new Thread(task, "Thread" + i);
      thread.start();
    }
  }

  private static void log(Object obj) {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
    String time = LocalTime.now().format(formatter);
    System.out.printf("%s [%9s] %s\n", time, Thread.currentThread().getName(), obj);
  }

  private static class SpinLock {

    AtomicBoolean lock = new AtomicBoolean(); // default = false

    public void lock() {
      while (!lock.compareAndSet(false, true)) {
        log("락 획득 실패");
      }
      log("락 획득 성공");
    }

    public void unlock() {
      lock.set(false);
      log("락 해제 성공");
    }

  }

}