10. CAS - 동기화와 원자적 연산

임대일·2025년 5월 13일

Thread

목록 보기
10/13
post-thumbnail

1. 원자적 연산 - 소개

원자적 연산(Atomic Operation)

  • 원자적(Atomic)이란 "더 이상 나눌 수 없는, 중단 없이 완전하게 실행되는 연산"이라는 뜻입니다.
  • 전체 연산이 하나의 단위로 실행되며, 중간에 다른 스레드가 개입할 수 없습니다.
  • 멀티스레드 환경에서 다른 스레드의 간섭 없이 안전하게 실행되는 연산에 주로 사용합니다.
  • 물리학에서 ‘원자(Atom)’가 더 이상 나눌 수 없는 최소 단위였던 것에서 유래되었습니다.

예제 분석

volatile int i = 0;
i = 1; // ✅ 원자적 연산
  • 단 하나의 연산으로 실행됩니다.
  • 중간에 다른 스레드가 끼어들 수 없습니다. → 안전
i = i + 1; // ❌ 원자적 연산이 아님
  • 내부적으로 다음 세 단계로 쪼개짐:
    1. i 값을 읽습니다.
    2. 1을 더합니다.
    3. 다시 i에 대입합니다.
  • 문제점: 두 개 이상의 스레드가 이 연산을 동시에 실행할 경우, i의 값이 덮어써질 수 있습니다.

예제 시나리오: 멀티스레드 문제

순차 실행 (문제 없음)

i = 0
Thread-1: i = i + 1 → i = 1
Thread-2: i = i + 1 → i = 2
  • 스레드가 차례대로 실행되어 i = 2

동시 실행 (문제 발생)

i = 0
Thread-1: 읽음(i=0), +1, 대입(i=1)
Thread-2: 읽음(i=0), +1, 대입(i=1)
  • 결과: i = 1
  • 두 스레드가 동시에 i=0을 읽고, 동시에 i=1로 덮어써서 증가가 무시됩니다.

i++도 원자적이지 않음

  • i++는 실제로 i = i + 1과 동일합니다.
  • 즉, 내부적으로 나눌 수 있는 연산이며, 멀티스레드 환경에서는 안전하지 않습니다.

핵심 요약

항목원자적?멀티스레드 안전성
i = 1안전함
i = i + 1 / i++충돌 발생 가능

2. 원자적 연산 - 시작

목표: 멀티스레드 환경에서 원자적이지 않은 연산이 어떻게 문제가 되는지 실습을 통해 확인합니다.

사용된 인터페이스

package thread.cas.increment;

public interface IncrementInteger {
    void increment(); // 값을 1 증가
    int get(); // 현재 값을 조회
}
  • 여러 구현체를 테스트하기 위해 인터페이스로 정의합니다.

구현 1: BasicInteger

package thread.cas.increment;

public class BasicInteger implements IncrementInteger {
    private int value;

    @Override
    public void increment() {
        value++; // ❌ 원자적이지 않음
    }

    @Override
    public int get() {
        return value;
    }
}
  • value++는 여러 스레드가 동시에 접근하면 문제 발생합니다.

테스트 코드

package thread.cas.increment;

import static util.ThreadUtils.sleep;

import java.util.ArrayList;
import java.util.List;

public class IncrementThreadMain {

  public static final int THREAD_COUNT = 1000;

    public static void main(String[] args) throws InterruptedException {
        test(new BasicInteger());
    }

    private static void test(IncrementInteger incrementInteger) throws InterruptedException {
        Runnable runnable = () -> {
            ThreadUtils.sleep(10); // 스레드 동시 실행 유도
            incrementInteger.increment();
        };

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new Thread(runnable);
            threads.add(thread);
            thread.start();
        }

        for (Thread thread : threads) {
            thread.join(); // 모든 스레드 종료 대기
        }

        int result = incrementInteger.get();
        System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
    }
}

테스트 포인트

  • THREAD_COUNT = 1000
  • value++는 1000번 실행되어야 하므로 결과는 1000이어야 정상

참고로 스레드가 너무 빨리 실행되기 때문에, 여러 스레드가 동시에 실행되는 상황을 확인하기 어렵습니다. 그래서 run() 메서드에 sleep(10) 을 두어서, 최대한 많은 스레드가 동시에 increment() 를 호출하도록 합니다.

실행 결과 예시 (환경마다 다를 수 있음)

BasicInteger result: 950
  • 1000보다 작음 → 연산 누락 발생합니다.
  • 이유: 여러 스레드가 value++을 동시에 수행하는데, 중간에 덮어쓰기 발생합니다.

핵심 요약

항목설명
value++의 문제읽기 → 계산 → 쓰기 3단계로 분리됨. 중간에 다른 스레드가 개입할 수 있음
실험 목적1000개의 스레드가 동시에 increment() 호출 시 충돌 발생 확인
결론원자적 연산이 아니므로 멀티스레드 환경에서 데이터 유실 발생 가능

이 문제는 앞서 설명한 것 처럼 여러 스레드가 동시에 원자적이지 않은 value++ 을 호출했기 때문에 발생했습니다. 그럼 혹시 volatile 을 적용하면 될까요?

3. 원자적 연산 - volatile, synchronized

이 파트에서는 value++ 문제를 해결하려고

  1. volatile을 사용하면 괜찮을까?
  2. synchronized는 어떤 차이가 있을까?

를 실험적으로 확인합니다.

1. volatile 실험

클래스: VolatileInteger

package thread.cas.increment;

public class VolatileInteger implements IncrementInteger {

    volatile private int value; // volatile 적용

    @Override
    public void increment() {
        value++; // ❌ 여전히 원자적이지 않음
    }

    @Override
    public int get() {
        return value;
    }
}

volatile메인 메모리와 CPU 캐시 간 일관성은 보장하지만, ++ 자체를 원자적으로 만들어주지는 않습니다.

결과 비교

BasicInteger result: 950
VolatileInteger result: 961
  • VolatileInteger도 1000 미만
  • 멀티스레드 안전성은 보장되지 않습니다.

정리

키워드보장
volatile읽기/쓰기의 메모리 일관성 O, 원자성 X
value++여전히 쪼개질 수 있음

2. synchronized 실험

클래스: SyncInteger

public class SyncInteger implements IncrementInteger {
    private int value;

    @Override
    public synchronized void increment() {
        value++;
    }

    @Override
    public synchronized int get() {
        return value;
    }
}
  • increment() 메서드를 임계영역으로 보호합니다.
  • 한 번에 하나의 스레드만 접근 가능합니다. → 동기화 보장

실행 결과

SyncInteger result: 1000
  • 모든 스레드가 정확하게 연산을 수행합니다.
  • 멀티스레드 상황에서도 정확한 결과 보장합니다.

핵심 요약

클래스원자성 보장?설명
BasicInteger단순 증가, 동기화 없음
VolatileInteger메모리 일관성은 있으나 증가 연산은 쪼개짐
SyncIntegersynchronized로 임계 영역 보호

보충 설명: volatilesynchronized의 비교

항목volatilesynchronized
용도메모리 일관성 보장임계 영역 보호 (원자성 보장)
원자성
멀티스레드 안전
성능가볍지만 불완전무거우나 안전

4. 원자적 연산 - AtomicInteger

AtomicInteger 목적

  • synchronized보다 더 빠르고 안전한 방식으로 동시성 문제를 해결합니다.
  • 자바가 제공하는 원자적 클래스 (Atomic Class) 사용합니다.

클래스: MyAtomicInteger

public class MyAtomicInteger implements IncrementInteger {

    private final AtomicInteger atomicInteger = new AtomicInteger(0);

    @Override
    public void increment() {
        atomicInteger.incrementAndGet(); // 원자적 증가
    }

    @Override
    public int get() {
        return atomicInteger.get();
    }
}
  • AtomicInteger는 내부적으로 CAS (Compare-And-Swap)를 사용해 락 없이 원자성을 보장합니다.
  • incrementAndGet():
    • 값을 1 증가시키고, 증가된 결과 반환합니다.

테스트 추가

package thread.cas.increment;

import static util.ThreadUtils.sleep;

import java.util.ArrayList;
import java.util.List;

public class IncrementThreadMain {

  public static final int THREAD_COUNT = 1000;

  public static void main(String[] args) throws InterruptedException {
    test(new BasicInteger());
    test(new VolatileInteger());
    test(new SyncInteger());
    test(new MyAtomicInteger());
  }

  private static void test(IncrementInteger incrementInteger) throws InterruptedException {

    Runnable runnable = new Runnable() {
      @Override
      public void run() {
        sleep(10); // 너무 빨리 실행되기 때문에, 다른 스레드와 동시 실행을 위해 잠깐 쉬었다가 실행
        incrementInteger.increment();
      }
    };
    List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < THREAD_COUNT; i++) {
      Thread thread = new Thread(runnable);
      threads.add(thread);
      thread.start();
    }

    for (Thread thread : threads) {
      thread.join();
    }

    int result = incrementInteger.get();
    System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
  }
}

실행 결과

BasicInteger result: 950
VolatileInteger result: 961
SyncInteger result: 1000
MyAtomicInteger result: 1000
  • AtomicIntegerSyncInteger처럼 정확한 결과를 보장합니다.
  • 하지만 내부적으로 락이 없으므로 성능이 더 우수합니다.

AtomicInteger의 장점

항목설명
락 사용 여부❌ (Lock-Free)
원자성 보장✅ CAS 연산 기반
성능synchronized보다 빠름
실무 활용도✅ 매우 높음 (ex: 카운터, 통계 등)

핵심 요약

  • AtomicInteger멀티스레드 환경에서 성능과 안전성 모두 확보
  • Java의 java.util.concurrent.atomic 패키지에는 다양한 클래스 존재:
    • AtomicInteger, AtomicLong, AtomicBoolean

5. 원자적 연산 - 성능 테스트

성능 테스트 목적

  • value++ 연산을 다양한 방식(Basic, volatile, synchronized, AtomicInteger)으로 반복했을 때,
  • 성능 차이를 비교하여 어떤 방식이 가장 효율적인지를 실험적으로 확인합니다.

테스트 코드: IncrementPerformanceMain

package thread.cas.increment;

public class IncrementPerformanceMain {

  public static final long COUNT = 100_000_000; // 1 억 번 연산

  public static void main(String[] args) {
    test(new BasicInteger());
    test(new VolatileInteger());
    test(new SyncInteger());
    test(new MyAtomicInteger());
  }

  private static void test(IncrementInteger incrementInteger) {
    long startMs = System.currentTimeMillis();

    for (long i = 0; i < COUNT; i++) {
      incrementInteger.increment();
    }

    long endMs = System.currentTimeMillis();
    System.out.println(incrementInteger.getClass().getSimpleName() + ": ms=" + (endMs - startMs));
  }
}
  • 단일 스레드 환경에서 테스트를 진행합니다. (병렬 아님)
  • 1억 번 value++을 수행하여 소요 시간을 측정합니다.

실행 결과

BasicInteger: ms=39
VolatileInteger: ms=455
SyncInteger: ms=625
MyAtomicInteger: ms=367

※ 실행 환경에 따라 숫자는 다를 수 있으나, 패턴은 거의 동일합니다.

성능 비교 해석

클래스시간(ms)특징 및 해석
BasicInteger✅ 39가장 빠름 캐시 메모리 적극 활용, 동기화 없음 → 멀티스레드에서는 안전하지 않음
VolatileInteger❌ 455메모리 일관성만 보장. 캐시 무시하고 항상 메인 메모리 사용 → 성능 저하, 원자성 미보장
SyncInteger❌❌ 625synchronized로 락을 획득/반납하면서 실행 → 성능 저하 발생
MyAtomicInteger✅✅ 367락 없이 원자성 확보 성능도 괜찮음. 실무에서 자주 사용됨

핵심 요약

방식멀티스레드 안전성능 (단일 스레드)특징
BasicInteger⭐ 매우 빠름캐시 최적화만 활용
VolatileInteger❌ 느림메모리 일관성만 보장
SyncInteger❌❌ 가장 느림락 기반
AtomicInteger✅ 빠름락 없는 원자성 (CAS 기반)

보충: AtomicInteger가 왜 빠른가?

  • value++는 원자적 연산이 아니므로 원래는 락이 필요합니다.
  • SyncIntegersynchronized를 통해 매번 락 획득과 반납합니다. → 무거움
  • AtomicIntegerCPU의 CAS 명령어를 활용하여 락 없이 원자성 확보합니다.
    → 락 획득 대기 없이 바로 연산합니다. → 성능 우수

결론

  • 멀티스레드 환경에서는 AtomicInteger가 가장 실용적입니다.
  • value++처럼 간단한 연산에서는 락 없이 CAS 기반의 연산이 유리합니다.
  • 이 개념의 핵심이 되는 CAS 연산을 다음 섹션에서 본격적으로 다룹니다.

6. CAS 연산1 — Compare-And-Swap의 시작

목표: AtomicInteger가 내부에서 사용하는 CAS(Compare-And-Swap) 연산이 정확히 어떤 방식으로 동작하는지를 실습을 통해 이해하는 것이 목표입니다.

핵심 개념 요약

CAS(Compare-And-Swap)"현재 메모리에 있는 값이 내가 기대한 값이면, 새 값으로 바꿔!" 라는 논리입니다.

boolean compareAndSet(int expected, int update)
  • expected: 내가 기대하는 현재 값
  • update: 바꾸고 싶은 새 값

내부적으로는 CPU가 제공하는 명령어로 동작합니다. (→ 원자성 보장)

코드 실습: CasMainV1

package thread.cas;

import java.util.concurrent.atomic.AtomicInteger;

public class CasMainV1 {

    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        System.out.println("start value = " + atomicInteger.get());

        boolean result1 = atomicInteger.compareAndSet(0, 1);
        System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get()); // 1

        boolean result2 = atomicInteger.compareAndSet(0, 1);
        System.out.println("result2 = " + result2 + ", value = " + atomicInteger.get());
    }
}
start value = 0
result1 = true, value = 1
result2 = false, value = 1

실행 흐름 분석

  1. compareAndSet(0, 1) → 현재 값이 0이므로 1로 변경. true 반환합니다.
  2. compareAndSet(0, 1) → 현재 값은 1이라 기대한 값과 다릅니다. → false 반환, 값은 그대로!

왜 이게 "원자적"인가?

  • 비교(compare)교체(swap) 두 연산이 CPU 단에서 하나의 명령어로 실행됩니다.
  • 다른 스레드가 끼어들 여지가 없습니다.
  • 대부분의 CPU는 CMPXCHG, LOCK CMPXCHG 등의 CAS 명령어를 지원합니다.

성능 측면의 장점

  • 락을 사용하지 않습니다. → 락 획득/해제 과정에서 발생하는 컨텍스트 스위칭 비용이 없습니다.
  • 낙관적 동시성 제어(Optimistic Locking) 방식: 먼저 값을 바꾸고, 실패하면 그때 재시도 → 성공이 많을수록 빠릅니다.

핵심 요약

항목설명
CAS기대값과 현재값이 같을 경우에만 새 값으로 교체
compareAndSet자바에서 제공하는 CAS 연산 메서드
원자성CPU 차원에서 보장
실무 사용AtomicInteger, AtomicLong 등에서 사용됨
장점락 없이 빠르고 안전한 동기화
한계충돌 시 반복 재시도 필요 (→ 다음 파트에서 다룸)

7. CAS 연산2 — incrementAndGet() 직접 구현해보기

목표: AtomicInteger.incrementAndGet() 메서드가 내부적으로 CAS를 활용해 어떻게 원자적 증가를 구현하는지 직접 따라 만들어 봅니다.


i++는 원자적이지 않은가?

i++은 내부적으로 다음처럼 나뉩니다/

i = i + 1; // 이건 3단계로 나뉨
1. i 읽기
2. i + 1 계산
3. 결과를 다시 i에 저장

중간에 다른 스레드가 i를 바꿔버릴 수 있습니다.

CAS 기반 incrementAndGet() 직접 구현

package thread.cas;

import java.util.concurrent.atomic.AtomicInteger;

import static util.MyLogger.log;

public class CasMainV2 {

    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        System.out.println("start value = " + atomicInteger.get());

        // incrementAndGet 구현
        int resultValue1 = incrementAndGet(atomicInteger);
        System.out.println("resultValue1 = " + resultValue1);

        int resultValue2 = incrementAndGet(atomicInteger);
        System.out.println("resultValue2 = " + resultValue2);
    }

    private static int incrementAndGet(AtomicInteger atomicInteger) {
        int getValue; 
        boolean result; 
        do {
            getValue = atomicInteger.get(); // 1. 현재 값 읽기
            log("getValue: " + getValue);
            result = atomicInteger.compareAndSet(getValue, getValue + 1); // 2. 기대값 → 새로운 값 CAS 시도
            log("result: " + result);
        } while (!result); // 3. 실패하면 반복 재시도

        return getValue + 1;
    }
}
  • 이 메서드는 CAS 연산을 통해 락 없이 안전하게 값을 증가합니다.
  • compareAndSet() 실패 시 do-while 반복해서 재시도합니다.
  • 바로 AtomicInteger.incrementAndGet()이 내부에서 이렇게 작동합니다.

실행 예

AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());

// incrementAndGet 구현
int resultValue1 = incrementAndGet(atomicInteger);
System.out.println("resultValue1 = " + resultValue1);

int resultValue2 = incrementAndGet(atomicInteger);
System.out.println("resultValue2 = " + resultValue2);
start value = 0
15:41:00.228 [     main] getValue: 0
15:41:00.229 [     main] result: true
resultValue1 = 1
15:41:00.229 [     main] getValue: 1
15:41:00.229 [     main] result: true
resultValue2 = 2

핵심 흐름 정리

단계설명
① 읽기현재 값 읽기 (get())
② 비교 후 교체compareAndSet(기대한값, 새로운값)
③ 실패 시 재시도다른 스레드가 먼저 바꿨다면 다시 처음부터

주의할 점

  • 낙관적 동시성 제어 방식이기 때문에 충돌이 발생하면 루프가 계속 돌 수 있습니다.
  • 이 부분은 다음 파트에서 직접 실습으로 확인하게 됩니다. (멀티스레드 환경에서 충돌 발생 시)

정리 요약

항목내용
i++ 문제점3단계 연산, 원자성 없음
CAS 기반 증가 방식compareAndSet()으로 현재 값이 바뀌지 않았을 때만 교체
실패 시 처리루프 재시도
실제 AtomicInteger내부적으로 이렇게 구현되어 있음

8. CAS 연산3 — 충돌이 발생할 때 CAS는 어떻게 작동하는가?

목표: 멀티스레드 환경에서 compareAndSet()이 실패할 수 있는 상황을 만들고, 재시도 루프가 어떻게 충돌을 해결하는지 확인하는 실습입니다.

실습 코드: CasMainV3

package thread.cas;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class CasMainV3 {

  private static final int THREAD_COUNT = 2;

  public static void main(String[] args) throws InterruptedException {
    AtomicInteger atomicInteger = new AtomicInteger(0);
    System.out.println("start value = " + atomicInteger.get());

    Runnable runnable = new Runnable() {
      @Override
      public void run() {
        incrementAndGet(atomicInteger);
      }
    };

    List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < THREAD_COUNT; i++) {
      Thread thread = new Thread(runnable);
      threads.add(thread);
      thread.start(); // 스레드 실행
    }

    for (Thread thread : threads) {
      thread.join(); // 스레드 종료까지 대기
    }

    int result = atomicInteger.get();
    System.out.println(atomicInteger.getClass().getSimpleName() + " resultValue: " + result);
  }

  private static int incrementAndGet(AtomicInteger atomicInteger) {
    int getValue;
    boolean result;
    do {
      getValue = atomicInteger.get(); // ① 현재 값 읽기
      sleep(100); // 스레드 동시 실행을 위한 대기
      log("getValue: " + getValue);
      result = atomicInteger.compareAndSet(getValue, getValue + 1); // ③ CAS 시도
      log("result: " + result);
    } while (!result); // ④ 실패하면 재시도

    return getValue + 1;
  }
}

실행 시나리오: 2개의 스레드가 동시에 값 증가 시도

start value = 0
15:44:34.469 [ Thread-0] getValue: 0
15:44:34.469 [ Thread-1] getValue: 0
15:44:34.470 [ Thread-0] result: true
15:44:34.470 [ Thread-1] result: false
15:44:34.581 [ Thread-1] getValue: 1
15:44:34.581 [ Thread-1] result: true
AtomicInteger resultValue: 2

흐름 분석

Thread-1

  1. get() → 값 0 읽음
  2. compareAndSet(0, 1) 성공 → 값 1로 변경
  3. 결과: true, 루프 탈출

Thread-0

  1. get() → 값 0 읽음
  2. 그러나 Thread-1이 먼저 값 1로 바꿈
  3. compareAndSet(0, 1) → 실패 (expected와 다름)
  4. 루프 다시 시작 → get() → 1 읽고 compareAndSet(1, 2) 시도 → 성공

핵심 동작 요약

단계설명
동시에 값을 읽음두 스레드가 같은 값을 읽음 (0)
한 쪽이 먼저 갱신Thread-1이 먼저 1로 바꿈
다른 스레드는 실패 후 재시도Thread-0은 실패하고 루프 다시 실행
최종적으로 둘 다 증가 성공결과 값: 2

핵심 요약

항목설명
충돌 처리 방식실패 시 루프 재시도 (낙관적 락)
데이터 손실 여부 없음CAS 실패 → 루프에서 다시 시도
멀티스레드 환경에서도 안전AtomicInteger는 충돌에도 올바르게 처리함

결론

  • 이 구조가 바로 AtomicInteger가 락 없이도 안전하게 작동하는 이유합니다.
  • 충돌을 허용하지만 재시도로 극복 → 락 프리(lock-free) 구조입니다.
  • 단순한 연산에서 성능과 안전성 모두 확보 가능합니다.

질문: for (Thread thread : threads) thread.join(); 는 왜 필요한가?

  • thread.join()해당 스레드가 끝날 때까지 현재 스레드를 멈추는 것입니다.
  • 즉, main() 스레드가 각 작업 스레드가 종료될 때까지 기다리는 것이죠.

코드 흐름을 다시 보면:

for (int i = 0; i < THREAD_COUNT; i++) {
  Thread thread = new Thread(runnable);
  threads.add(thread);
  thread.start(); // 스레드 실행
}

for (Thread thread : threads) {
  thread.join(); // 스레드 종료까지 대기
}

thread.start()로 실행된 스레드들은 비동기적으로 동작합니다. 즉, main() 메서드는 다른 스레드들이 끝나기도 전에 다음 줄로 넘어갈 수 있습니다.

그리고 다음 줄은 바로 이겁니다:

int result = atomicInteger.get();
System.out.println("resultValue: " + result);

만약 join()을 생략하면?

main() 스레드가 result = atomicInteger.get()을 호출할 때, 두 스레드 중 하나 혹은 둘 다 아직 증가 작업을 안 했을 수 있습니다.
→ 출력값이 2가 아니라 0이나 1일 수 있습니다. (논리적 오류 발생)

결론

목적설명
join() 사용 이유모든 스레드가 작업을 완료할 때까지 대기
안 쓰면 발생하는 문제결과를 아직 다 더하지도 않았는데 main()이 먼저 값을 읽어버릴 수 있음
특히 중요할 때테스트, 동시성 실험, 정확한 결과 측정 시 매우 중요

9. CAS 락 구현1 — 잘못된 락 구현부터 보기

목표: synchronized 없이 직접 락을 만들어보되, CAS 없이 잘못 구현한 락의 문제점을 먼저 실습을 통해 경험합니다.

잘못된 락 구현: SpinLockBad

package thread.cas.spinlock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class SpinLockBad {

  private volatile boolean lock = false;

  public void lock() {
    log("락 획득 시도");
    while (true) {
      if (!lock) { // 1. 락 사용 여부 확인
        sleep(100); // 문제 상황 확인용, 스레드 대기
        lock = true; // 2. 락의 값 변경
        break;
      } else {
        // 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
        log("락 획득 실패 - 스핀 대기");
      }
    }
    log("락 획득 완료");
  }

  public void unlock() {
    lock = false;
    log("락 반납 완료");
  }
}

문제점 핵심

if (!lock) {
    lock = true;
}
  • 두 줄은 원자적이지 않습니다.
  • 즉, 다른 스레드가 동시에 같은 조건을 통과할 수 있습니다.
  • → 둘 다 lock = true를 실행하게 되면 두 스레드가 동시에 락을 획득해버립니다!

참고로 락을 반납하는 다음 연산은 연산이 하나인 원자적인 연산입니다. 따라서 이 부분은 여러 스레드가 함께 실행해도 문제가 발생하지 않습니다.

public void unlock() {
  lock = false; // 원자적인 연산
	log("락 반납 완료");
}

실행 시나리오

package thread.cas.spinlock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class SpinLockMain {

    public static void main(String[] args) {
        SpinLockBad spinLock = new SpinLockBad();
//        SpinLock spinLock = new SpinLock();

        Runnable task = new Runnable() {
            @Override
            public void run() {
                spinLock.lock();
                try {
                    // critical section
                    log("비즈니스 로직 실행");
                    sleep(1); // 오래 걸리는 로직에서 스핀 락 사용X
                } finally {
                    spinLock.unlock();
                }
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();

    }
}
15:52:14.807 [ Thread-1] 락 획득 시도
15:52:14.807 [ Thread-2] 락 획득 시도
15:52:14.918 [ Thread-1] 락 획득 완료
15:52:14.918 [ Thread-2] 락 획득 완료
15:52:14.918 [ Thread-1] 비즈니스 로직 실행
15:52:14.918 [ Thread-2] 비즈니스 로직 실행
15:52:14.921 [ Thread-1] 락 반납 완료
15:52:14.921 [ Thread-2] 락 반납 완료
  • 락이 무의미해지는 상황 발생합니다.
  • sleep(100)으로 타이밍을 일부러 벌려도 실행 타이밍은 겹칩니다.

실행 결과를 보면 기대와는 다르게 Thread-1, Thread-2 둘다 동시에 락을 획득하고 비즈니스 로직을 동시에 수행해버립니다.

핵심 요약

항목설명
문제 원인if (!lock) { lock = true; }는 원자적이지 않음
발생 가능한 문제여러 스레드가 동시에 임계 영역을 실행
volatile 사용 의미캐시 일관성은 확보되지만 원자성 보장 못 함
해결 방향 (다음 파트 예고)CAS를 통해 "확인 + 변경"을 원자적으로 수행해야 함

10. CAS 락 구현2 — compareAndSet()으로 안전한 락 구현

목표: synchronized 없이, CAS를 활용하여 안전하고 원자적인 락(Spin Lock)을 직접 구현합니다.

코드: SpinLock

package thread.cas.spinlock;

import static util.MyLogger.log;

import java.util.concurrent.atomic.AtomicBoolean;

public class SpinLock {

  private final AtomicBoolean lock = new AtomicBoolean(false);

  public void lock() {
    log("락 획득 시도");
    while (!lock.compareAndSet(false, true)) {
      // 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
      log("락 획득 실패 - 스핀 대기");
    }
    log("락 획득 완료");
  }

  public void unlock() {
    lock.set(false);
    log("락 반납 완료");
  }
}

핵심 원리: CAS를 통한 락 획득

while (!lock.compareAndSet(false, true))
  • false이면 → true로 바꾸고 락 획득
  • true이면 → 다른 스레드가 이미 락을 점유 중 → 스핀 대기
  • 이 두 연산이 CPU 차원에서 하나의 명령어로 처리됨완전한 원자성

문제 해결됨

기존 (SpinLockBad)개선 (SpinLock)
if (!lock) { lock = true; }는 원자성 없음compareAndSet(false, true)는 원자적
동시에 여러 스레드가 락 획득 가능한 번에 하나만 가능
임계 영역 침범 가능성 존재침범 불가, 충돌 시 재시도

실행 결과 예시

15:57:37.727 [ Thread-1] 락 획득 시도
15:57:37.727 [ Thread-2] 락 획득 시도
15:57:37.728 [ Thread-1] 락 획득 완료
15:57:37.728 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.728 [ Thread-1] 비즈니스 로직 실행
15:57:37.729 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.729 [ Thread-2] 락 획득 실패 - 스핀 대기
...
15:57:37.732 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.732 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.733 [ Thread-2] 락 획득 완료
15:57:37.733 [ Thread-1] 락 반납 완료
15:57:37.733 [ Thread-2] 비즈니스 로직 실행
15:57:37.735 [ Thread-2] 락 반납 완료
  • Thread-2는 Thread-1이 락을 반납할 때까지 대기하고 재시도합니다.
  • 임계 영역이 정확히 보호됩니다.

핵심 요약

항목설명
CAS 기반 락 핵심compareAndSet(false, true)로 원자적 확인+변경
스핀 락(Spin Lock) 정의락을 획득할 때까지 반복적으로 시도하는 구조
unlock 처리lock.set(false)는 단일 원자적 연산이므로 안전
효율성아주 짧은 임계 영역에 적합, 긴 작업에는 비효율적 (다음에서 설명)

실무에서 언제 쓸까?

CAS 기반 스핀 락은 다음과 같은 상황에서 적합합니다:

  • 락을 획득하려는 대기 시간이 매우 짧을 때
  • 숫자 증가, 컬렉션 삽입/삭제 같은 짧은 연산
  • 스레드를 block/wait 상태로 만들기보다는 빠르게 스핀하며 처리하는 게 유리한 상황

경고: CPU 자원 과다 사용 가능성

  • 락을 기다리는 동안 while 루프가 계속 실행되므로 → CPU를 바쁘게 사용합니다.
  • 긴 작업이나 외부 대기(예: DB, 네트워크)에는 오히려 비효율적입니다.

→ 이 단점은 곧바로 다음 섹션에서 실험으로 보여줍니다.

11. 정리 — CAS vs 동기화 락, 실무 선택 기준

실험: 스핀 락의 단점 노출

public void run() {
    spinLock.lock();
    try {
        log("비즈니스 로직 실행");
        sleep(1); // ❗ 오래 걸리는 로직 (예: DB 처리)
    } finally {
        spinLock.unlock();
    }
}

실행 결과

  • 락을 기다리는 Thread-2는 계속 락 획득 실패합니다. → 스핀 대기 메시지 출력
  • 반복문이 빠르게 돌며 CPU를 계속 소비합니다.

문제점: 바쁜 대기(busy-wait)

상황설명
Thread-1이 락 보유 중Thread-2while 반복 수행
락이 해제될 때까지CPU를 계속 사용하며 대기
문제점CPU 리소스 낭비, 다른 작업 방해 가능

전통적인 동기화 락(synchronized, ReentrantLock)의 경우

  • 락을 기다리는 스레드는 BLOCKED 또는 WAITING 상태로 진입합니다.

    CPU는 거의 소모하지 않습니다.

    → 대신 락을 다시 획득할 때 컨텍스트 스위칭 비용이 발생합니다.

비교 정리: CAS vs 동기화 락

항목CAS (스핀 락)동기화 락 (synchronized, Lock)
접근 방식낙관적 (충돌 없을 거라고 가정)비관적 (충돌 날 거라고 가정)
락 대기 방식반복 루프 → CPU 사용함대기 상태 → CPU 사용 안 함
스레드 상태RUNNABLE (계속 도는 중)BLOCKED 또는 WAITING
컨텍스트 스위칭 비용없음 (루프 유지)있음 (상태 변경)
충돌이 드물 경우⭐ 매우 빠름느림
충돌이 많을 경우❗ 루프 많아지고 CPU 소모 커짐안정적이나 느림
락의 정밀 제어X (간단한 값 변경에 적합)O (복잡한 상황 처리 가능)

언제 CAS가 적합할까?

상황적합도
짧은 연산 (숫자 증가 등)✅ 매우 적합
충돌 가능성이 거의 없음✅ 적합
CPU 자원이 여유로움✅ 적합
긴 로직, 외부 IO 대기 등❌ 부적합
고빈도 충돌 예상❌ 부적합

실무 핵심 요약

실무에서의 전략

  1. 기본은 락을 써라 → 안정적, 일관성 보장
  2. CAS는 특별한 최적화 상황에만 → 충돌 가능성이 낮고, 임계 영역이 아주 짧을 때

실무 예시: 주문 수 카운트 증가

// 1초에 수백 건 들어오더라도, 대부분 충돌 없이 끝나기 때문에...
AtomicInteger orderCount = new AtomicInteger(0);
orderCount.incrementAndGet(); // 락 없이 빠르고 안전

→ 이런 경우 CAS가 매우 효율적입니다.

최종 요약

정리 항목한 줄 요약
i++ 문제원자성이 없고 충돌 가능
volatile캐시 일관성 보장, 원자성은 X
synchronized안전하지만 느림
AtomicIntegerCAS 기반, 락 없이 원자성 보장
compareAndSet()CAS 핵심 연산: 기대값 같을 때만 바꿈
스핀 락락 획득할 때까지 루프 → CPU 소비 많음
CAS vs 락 선택 기준짧은 연산 + 낮은 충돌 시 CAS, 그 외에는 락
profile
🤔오늘의 복습은❓

0개의 댓글