Study/Java

Java. 원자적 연산, CAS 연산

염몽이 2024. 8. 30. 16:30

원자적 연산

아래의 코드는 단순히 값을 하나 증가 시키는 코드이다.
멀티스레드 환경이라면 synchronizedLock 같은 안전한 임계 영역을 설정해주어야 한다.

value++; 와 같은 연산은 원자적 연산이 아니라, 3단계에 걸친 연산이 진행되기 때문이다.

package thread.cas.increment;

public class BasicInteger implements IncrementInteger{

    private int value;

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

    @Override
    public int get() {
        return value;
    }
}
package thread.cas.increment;

public class VolatileInteger implements IncrementInteger{

    volatile private int value;

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

    @Override
    public int get() {
        return value;
    }
}
package thread.cas.increment;

public class SyncInteger implements IncrementInteger{

    private int value;

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

    @Override
    public synchronized int get() {
        return value;
    }
}
package thread.cas.increment;

import java.util.concurrent.atomic.AtomicInteger;

public class MyAtomicInteger implements IncrementInteger {

    AtomicInteger atomicInteger = new AtomicInteger(0);

    @Override
    public void increment() {
        atomicInteger.incrementAndGet();
    }

    @Override
    public int get() {
        return atomicInteger.get();
    }
}

AtomicInteger는 멀티스레드 상황에 안전하고, 다양한 값 증가, 감소 연산을 제공한다.
특정 값을 증가하거나 감소해야하는데 여러 스레드가 해당 값을 공유한다면, AtomicInteger를 사용하면 된다.

AtomicBoolean, AtomicLong과 같은 다양한 AtomicXxx 클래스가 존재한다.

원자적 연산 - 성능 테스트

BasicInteger: ms=77
VolatileInteger: ms=802
SyncInteger: ms=1099
MyAtomicInteger: ms=337
  1. BasicInteger: 가장 빠르지만 멀티스레드 상황에서는 사용할 수 없음.
  2. VolatileInteger: volatile를 사용해서 CPU 캐시를 사용하지 않고, 메인 메모리에서 작업한다. 안전한 임계영역이 없기 때문에 멀티스레드 환경에서는 안전하지 않음.
  3. SyncInteger: synchronized를 사용해 멀티스레드 상황에서도 안전하지만 MyAtomicInteger 보다 성능이 느림.
  4. MyAtomicInteger: AtomicInteger를 사용해 멀티스레드 상황에서도 안전하고, 성능이 synchronized보다 1.5~2배 빠름.

추가 설명:

AtomicInteger가 더 빠른 이유는 락을 사용하지 않고 원자적 연산을 제공하기 때문이다. value++와 같은 연산은 원자적이지 않은데 어떻게 된 것일까? CAS에 대해 알아보자.

참고: 우리가 직접 CAS 연산을 사용하는 경우는 거의 없다. 대부분 복잡한 동시성 라이브러리들이 CAS 연산을 사용한다.
우리는 AtomicInteger와 같은 CAS 연산을 사용하는 라이브러리들을 잘 사용하는 정도면 충분하다.

CAS연산은 락을 완전히 대체하는 것은 아니고, 기본은 락을 사용하고, 작은 단위의 일부 영역에 적용할 수 있다.

자바는 AtomicXxxcompareAndSet() 메서드를 통해 CAS 연산을 지원한다.

compareAndSet(0, 1) :

  • 만약 값이 현재 0 이라면 값은 1로 변경되고 true를 반환하고, 변경이 안되면 false로 반환된다.
  • 이 메서드는 원자적으로 실행된다는 점이다.

원자적 연산

  • AtomicInteger는 내부의 value 값을 원자적으로 변경할 수 있다.
  • CAS 연산(Compare-And-Set)을 사용하여, 현재 값이 기대하는 값일 경우 원하는 값으로 변경할 수 있다.
  • CAS 연산은 실제로 두 가지 단계(값 확인, 값 변경)로 이루어지지만, 이것이 원자적으로 처리된다.

CPU 하드웨어의 지원

  • 현대 CPU들은 CAS 연산을 위한 특별한 명령어를 제공하여, 두 개의 연산을 하나의 원자적 연산으로 묶어 처리한다.
  • 이를 통해 중간에 다른 스레드가 개입하지 못하게 한다.
  • CAS 연산은 하드웨어 수준에서 지원되므로 매우 빠르고 안전하게 동작한다.

결론적으로, AtomicInteger와 같은 클래스들은 이러한 하드웨어 지원을 활용하여 원자적 연산을 구현하며, 이는 멀티스레드 환경에서 매우 효과적이다.

value++와 같은 연산은 원자적 연산이 아니다. 3단계의 연산으로 나누어져 있기 때문이다.
AtomicIntger가 제공하는 incrementAndGet()메서드가 어떻게 CAS 연산을 활용해서 락 없이 구현했는지 이해를 하기 위해 코드로 구현해보자.

import util.MyLogger;

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;       // compareAndSet의 결과를 저장할 변수

        do {
            getValue = atomicInteger.get();           // 1. atomicInteger의 현재 값을 읽어서 getValue에 저장
            log("getValue: " + getValue);             // 현재 값을 로그에 기록
            result = atomicInteger.compareAndSet(     // 2. 현재 값이 getValue와 같으면 getValue + 1로 변경 시도
                getValue, 
                getValue + 1
            );
            log("result: " + result);                 // compareAndSet의 결과를 로그에 기록
        } while (!result);                            // 3. compareAndSet이 실패하면 루프를 반복

        return getValue + 1;                          // 4. 성공적으로 값을 증가시킨 후, 증가된 값을 반환
    }
}
  1. 현재 값 읽기 (getValue = atomicInteger.get();):
    • AtomicInteger의 현재 값을 읽어와 getValue라는 지역 변수에 저장한다.
  2. 값 변경 시도 (result = atomicInteger.compareAndSet(getValue, getValue + 1);):
    • compareAndSet 메서드를 사용하여, 현재 값이 getValue와 동일하다면 getValue + 1로 값을 변경하려고 시도한다.
    • 이 시도는 원자적으로 수행되며, compareAndSet이 성공하면 true, 실패하면 false를 반환한다.
  3. 반복 (do-while 루프):
    • compareAndSet이 실패하면(즉, 다른 스레드가 값을 변경했다면), 루프가 다시 시작되어 현재 값을 다시 읽고, 값을 다시 업데이트하려고 시도한다.
    • 이 루프는 compareAndSet이 성공할 때까지 반복된다.
  4. 최종 값 반환 (return getValue + 1;):
    • compareAndSet이 성공하면, 증가된 값을 반환한다. (getValue + 1)
  • 이 메서드는 AtomicInteger의 값을 원자적으로 1씩 증가시키기 위한 메서드이다.
  • 현재 값을 읽고, 그 값이 기대한 값과 같을 때만 1을 더하는 방식으로 안전하게 값을 증가시킨다.
  • 이 과정은 다른 스레드가 동시에 값을 변경할 수 있는 멀티스레드 환경에서도 안전하게 동작한다.

CAS 락 구현

package thread.cas.spinlock;

import util.MyLogger;

import static util.MyLogger.*;

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("비즈니스 로직 실행");
                } finally {
                    spinLock.unlock();
                }
            }
        };

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

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

    }
}
package thread.cas.spinlock;

import util.MyLogger;

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("락 반납 완료");
    }

}

위의 코드는 원자적이지 않다.

  1. 락 사용 여부 확인
  2. 락의 값 변경

이 문제를 CAS 연산을 통해서 원자적으로 바꾸면 된다.

package thread.cas.spinlock;

import java.util.concurrent.atomic.AtomicBoolean;

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

public class SpinLock {

    private final AtomicBoolean lock = new AtomicBoolean(false);

    public void lock() {
        log("락 획득 시도");
        while (!lock.compareAndSet(false, true)) { //false 라면 true로 변경
            log("락 획득 실패 - 스핀 대기");
        }
        log("락 획득 완료");
    }

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

}

CAS 연산 덕분에 하나의 원자적인 연산으로 바꿀 수 있었다.

    1. 락을 사용하지 않는다면 락의 값을 변경

이렇게 BLOCKED, WAITING 상태로 변하고, 컨텍스트 스위칭, 대기 상태의 스레드를 깨워야하는 무겁고 복잡한 과정이 포함된 무거운 동기화 작업 없이
CAS 연산을 사용해서 아주 가벼운 락을 만들 수 있다.

단순히 while문을 반복할 뿐이다. 따라서 대기하는 스레드도 RUNNABLE 상태를 유지하면서 가볍고 빠르게 작동할 수 있다.

CAS 단점

오래걸리는 비즈니스 로직에서 CAS 락 방식을 사용하면 RUNNABLE 상태에서 락을 획득할 때 까지 while문을 반복하는 문제가 있다.
락을 기다리는 스레드가 CPU를 계속 사용하면서 대기하는 것이다.

어떤 경우에 CAS 방식이 효율적일까?

  • 안전한 임계 영역이 필요하지만, 연산이 길지 않고 매우 매우 매우 짧게 끝날 때 사용
    • 숫자 값의 증가
    • 자료 구조의 데이터 추가
    • CPU 사이클이 금방 끝나는 연산에 사용하면 효과적

스핀 락(Spin Lock)

  • 스핀 락: 스레드가 락을 획득할 때까지 계속해서 확인하며 대기하는 락 메커니즘이다. 이 과정에서 스레드는 CPU 자원을 소모하며 반복적으로 락을 확인한다.
  • 바쁜 대기(Busy-Wait): 스핀 락에서 스레드가 락을 획득할 때까지 계속해서 CPU를 사용하는 상태를 말한다.
  • 효율성: 스핀 락은 아주 짧은 시간 동안만 락을 사용할 때 효율적이다. 그러나 잘못 사용하면 CPU 자원을 낭비할 수 있다.
  • 구현: 스핀 락은 일반적으로 CAS (Compare-And-Set) 연산을 사용하여 구현된다.

CAS 기법들은 이미 대부분의 라이브러리들에 제공이 되므로, CAS 기술이 있다는 것을 이해하는 정도면 된다.