CAS 연산

sungs·2025년 7월 17일

자바

목록 보기
45/95

CAS 연산

compare and set의 줄임말로, 원자적 연산이 아닌 연산을 원자적 연산으로 만들어 준다.
정확히는 cpu에서 보낸 cas 명령어를 통해 compareAndSet 메서드의 연산이 원자적 연산이 된다.

compareAndSet()

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;

public class CompareAndSetExample {

    private static AtomicInteger status = new AtomicInteger(0); // 0: 초기 상태, 1: 처리 중, 2: 완료

    public static void main(String[] args) throws InterruptedException {
        System.out.println("초기 상태: " + status.get());

        ExecutorService executor = Executors.newFixedThreadPool(5); // 5개의 스레드 생성

        for (int i = 0; i < 5; i++) {
            final int threadId = i;
            executor.submit(() -> {
                try {
                    // 스레드 시작 시점에 랜덤 대기
                    Thread.sleep(ThreadLocalRandom.current().nextInt(100, 500));

                    System.out.println("스레드 " + threadId + ": 현재 상태 " + status.get() + ", 0이면 1로 변경 시도.");

                    // 현재 상태가 0(초기 상태)일 때만 1(처리 중)로 변경 시도
                    boolean success = status.compareAndSet(0, 1);

                    if (success) {
                        System.out.println("스레드 " + threadId + ": ✅ 상태를 0에서 1로 성공적으로 변경했습니다!");
                        // 상태가 1로 변경되었으므로, 실제 처리 작업 수행 (예시에서는 sleep)
                        Thread.sleep(ThreadLocalRandom.current().nextInt(500, 1000));
                        // 처리 완료 후 2(완료) 상태로 변경 (이것도 CAS로 처리할 수 있지만, 예시 단순화를 위해 set 사용)
                        // 실제 애플리케이션에서는 다시 CAS를 사용하여 다른 스레드의 간섭을 막을 수 있음
                        status.set(2);
                        System.out.println("스레드 " + threadId + ": 상태를 2(완료)로 변경했습니다.");
                    } else {
                        System.out.println("스레드 " + threadId + ": ❌ 상태를 0에서 1로 변경 실패. 이미 다른 스레드가 변경했거나 다른 상태입니다. 현재 상태: " + status.get());
                    }

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.out.println("스레드 " + threadId + " 인터럽트됨.");
                }
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        System.out.println("\n최종 상태: " + status.get());
        System.out.println("예상 결과: 0이었던 상태가 1로 한 번 변경되고, 이후 2로 변경되어 최종적으로 2가 됨.");
    }

Atomic연산이름.compareAndSet(expectedValue, updateValue)으로 사용하며, 현재값이 expectedValue인지 확인한 뒤 맞으면 true을 반환하고 updateValue 값을 바꾼다. 만약에 맞지 않으면 false를 반환한다.

이를 통해 락을 사용하지 않고도 안전하게 멀티 스레드 환경에서 값을 바꿀 수 있다.

incrementAndGet()이라는 Atomic 연산 메서드가 이 원리로 만들어 졌다.

 public static int customIncrementAndGet() {
        int oldValue;
        int newValue;
        boolean success;

        do {
            oldValue = atomicValue.get(); // 1. 현재 값(oldValue)을 읽습니다.
            newValue = oldValue + 1;     // 2. 새로운 값(newValue)을 계산합니다.

            // 3. compareAndSet 시도:
            //    현재 atomicValue가 oldValue와 같다면, newValue로 업데이트합니다.
            //    성공하면 true, 실패하면 (다른 스레드가 이미 oldValue를 변경했다면) false를 반환합니다.
            success = atomicValue.compareAndSet(oldValue, newValue);

            // 4. 성공 여부 확인:
            //    성공하지 않았다면 (success == false), 다시 루프를 반복하여 최신 값을 읽고 재시도합니다.
        } while (!success); // success가 true가 될 때까지 (즉, CAS가 성공할 때까지) 반복합니다.

        return newValue; // CAS 성공 후의 새로운 값을 반환합니다.
    }

이처럼 do-while문과 compareAndSet을 통해 incrementAndGet을 만들어 낼 수 있다.
다른 원자적 연산들도 이러한 원리로 구성되어 있다.

어떻게 락 없이도 멀티 스레드 환경에서 값을 안전하게 변경시킬 수 있냐면
1. 현재 값을 확인한다.
2. 맞으면 값을 변경하고 ture를 반환해 do-while문을 빠져나온다.
3. 맞지 않을 경우 false를 반환하여 다시 do-while문을 돌린다.

계속 돌리면 결국에는 작업을 수행하게 되고 기대한 결과가 나오게 된다.

징점

  • 락 프리: 락을 사용하지 않기 때문에 락을 사용했을 때 발생하는 컨텍스트 스위칭이 생기지 않는다.
  • 성능 향상: 락을 가져오고 반납하느 과정을 거치지 않고 runnable 상태에서 계속해서 반복문만 돌리므로 성능이 훨씬 나아진다.

단점

  • 충돌이 많이 발생하는 환경, 즉 복잡한 로직에서는 cpu가 너무 낭비된다. 스레드가 while문을 runnalbe 상태로 돌면서 cpu을 계속 사용해서 자원이 낭비되는 경향이 있다. 이러면 오버헤드가 발생한다.

이러한 단점이 있으므로 간단한 cpu 사이클링에서만 사용해야 한다.

스핀 락

cas를 통해 lock을 구현할 수 있다.

import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;

public class CASSpinLockExample {

    // AtomicBoolean을 사용하여 락의 상태를 나타냅니다.
    // true: 락이 잠겨 있음 (locked)
    // false: 락이 해제됨 (unlocked)
    private final AtomicBoolean locked = new AtomicBoolean(false);

    // 공유 자원에 대한 접근을 스핀 락으로 보호할 정수 카운터
    private int counter = 0;

    /**
     * 락을 획득하는 메서드.
     * do-while 루프와 compareAndSet을 사용하여 락이 해제될 때까지 스핀합니다.
     */
    public void lock() {
        // locked.compareAndSet(false, true)
        // 1. 현재 locked 값이 false(락 해제 상태)인지 확인합니다.
        // 2. 만약 false이면 true(락 잠김 상태)로 변경합니다.
        // 3. 이 모든 작업이 원자적으로 수행됩니다.
        // 4. 성공하면 true 반환, 실패하면 false 반환.
        // !locked.compareAndSet(false, true)는 CAS 실패 시 true가 되어 루프를 계속 돌게 합니다.
        int spinCount = 0;
        while (!locked.compareAndSet(false, true)) {
            // 락을 획득하지 못했으므로 계속 스핀(바쁘게 대기)합니다.
            // 실제 시스템에서는 CPU 낭비를 줄이기 위해 짧은 yield()나 Thread.onSpinWait()를 사용할 수 있습니다.
            // Thread.onSpinWait()는 Java 9에 추가되었으며, CPU에게 현재 스레드가 스핀 중임을 알려줍니다.
             // Thread.onSpinWait(); // Java 9 이상에서 사용 가능
            spinCount++;
        }
        // System.out.println(Thread.currentThread().getName() + " acquired lock after " + spinCount + " spins.");
    }

    /**
     * 락을 해제하는 메서드.
     * 락 상태를 false로 설정하여 다른 스레드가 획득할 수 있도록 합니다.
     */
    public void unlock() {
        // 락을 해제할 때는 단순히 locked 상태를 false로 변경합니다.
        // 락을 획득한 스레드만 unlock을 호출해야 합니다 (이 구현에서는 강제하지 않음).
        locked.set(false);
    }

    /**
     * 스핀 락으로 보호되는 공유 자원 접근 메서드.
     */
    public void increment() {
        lock(); // 락 획득 시도 (스핀)
        try {
            // 락 획득 성공 후 임계 영역 (Critical Section)
            // 여기서는 공유 자원인 counter를 증가시킵니다.
            counter++;
            // System.out.println(Thread.currentThread().getName() + " incremented counter to " + counter);
            // 실제 작업 부하를 시뮬레이션하기 위한 짧은 대기
            // Thread.sleep(ThreadLocalRandom.current().nextInt(1, 5));
        } finally {
            unlock(); // 락 해제 (매우 중요!)
        }
    }

    public int getCounter() {
        return counter;
    }

    public static void main(String[] args) throws InterruptedException {
        CASSpinLockExample spinLock = new CASSpinLockExample();
        final int NUM_THREADS = 10; // 스레드 개수
        final int INCREMENTS_PER_THREAD = 10000; // 각 스레드가 증가시킬 횟수

        ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);

        long startTime = System.nanoTime();

        for (int i = 0; i < NUM_THREADS; i++) {
            executor.submit(() -> {
                for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
                    spinLock.increment();
                }
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        long endTime = System.nanoTime();
        double durationMs = (endTime - startTime) / 1_000_000.0;

        System.out.println("기대하는 최종 카운터 값: " + (NUM_THREADS * INCREMENTS_PER_THREAD));
        System.out.println("스핀 락을 사용한 최종 카운터 값: " + spinLock.getCounter());
        System.out.printf("총 실행 시간: %.2f ms\n", durationMs);
        System.out.println("=========================================");

        // 비교를 위한 AtomicInteger 사용 예제 (더 단순하고 일반적)
        AtomicInteger atomicCounter = new AtomicInteger(0);
        ExecutorService atomicExecutor = Executors.newFixedThreadPool(NUM_THREADS);

        long atomicStartTime = System.nanoTime();
        for (int i = 0; i < NUM_THREADS; i++) {
            atomicExecutor.submit(() -> {
                for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
                    atomicCounter.incrementAndGet();
                }
            });
        }

        atomicExecutor.shutdown();
        atomicExecutor.awaitTermination(1, TimeUnit.MINUTES);

        long atomicEndTime = System.nanoTime();
        double atomicDurationMs = (atomicEndTime - atomicStartTime) / 1_000_000.0;

        System.out.println("AtomicInteger를 사용한 최종 카운터 값: " + atomicCounter.get());
        System.out.printf("AtomicInteger 총 실행 시간: %.2f ms\n", atomicDurationMs);
    }
}

다만 이럴 경우에도 lock을 획득할 때까지 대기하지 않아 성능은 향상되겠지만, 얻을 때까지 cpu을 사용하기 때문에 오버헤드가 발생할 수 있다.
따라서 대부분 경우 락이나 동기화를 사용하고 간단한 연산을 할 때만 cas 연산을 사용하는 게 좋다.

profile
앱 개발 공부 중

0개의 댓글