CAS 원자적 연산 - 스레드란 무엇인가 (9)

이전글

 

스레드란 무엇인가: 생산자 소비자 문제-2 (8)

이전글 스레드란 무엇인가: 생산자 소비자 문제 (7)이전글 스레드란 무엇인가: Spring Boot 모니터링을 하다 생긴 궁금증(6)단2025.05.18 - [Developer/JAVA] - 스레드란 무엇인가: Spring Boot 모니터링을 하다

doitwojae.tistory.com

 

공유 자원에 스레드가 동시 접근하여 값을 변경 시도하는 것을 막기 위한 동기화 외에 CAS라는 방식에 대해서 알아보겠습니다. 

 

섬네일

 

🐻 CAS - 원자적 연산

CAS는 Compare and Swap의 줄임말입니다.

이는 데이터 원자성을 보장하기 위한 하드웨어적 지원 메커니즘입니다. 하드웨어적 지원 메커니즘이란 CPU가 하나의 작업을 수행할 때, 원자적 연산을 보장한다는 뜻입니다.

예를 들어서 특정 변수의 값을 가지고 오고, 값에 연산을 처리한 후 저장하는 작업이 도중에 끊기지 않도록 하드웨어적으로 지원한다는 뜻입니다.

이번 포스트에서는 CAS에 대해서 살펴보고 Java에서 어떻게 Lock을 걸지 않고 데이터 원자성을 보장하는지 살펴보겠습니다.


🐻 문제 정의

스레드 시리즈에서 지금까지 Lock에 대해서 다루었습니다. Java 프로그래밍에서 스레드가 공유하는 자원의 값에 접근해, 동시에 여러 수정을 하게 되며 데이터 정합성을 오염시키는 문제를 Lock을 이용하여 해결했습니다. 

Lock은 공유 자원을 보호하기 위해 해당 자원에 대한 스레드의 접근을 제한하는 것입니다. Lock이 걸려 있는 동안에는 다른 스레드들은 해당 자원에 접근할 수 없고, 락이 해제되고 획득할 수 있는 순간까지 대기해야 합니다. 그렇기에 여러 스레드에 병목이 생기고 throughput이 낮아지는 문제가 있습니다.

병목 현상


🐻 CAS란

어떻게 적용되었는지에 앞서서 간단하게 CAS의 정의에 대해서 살펴보겠습니다.

@warning
💡컴퓨터 과학에서 Compare and Wap, CAS 은 멀티스레딩에서 동기화를 구현하는 데 사용되는 원자적 명령어입니다. 이는 단일 원자 연산으로 수행된다는 것을 의미합니다.

CAS는 원자적 명령어입니다. 원자성은 더 이상 쪼개질 수 없는 성질을 의미합니다. CAS는 원자적으로 새 값이 최신 정보를 기반으로 계산됨을 보장합니다. 방식은 Compare and Swap으로 메모리 위치의 값을 비교하고, 예상되는 값과 일치하는 경우에만 새로운 값을 업데이트하는 방식입니다.

출처 : CAS

CASLocksynchronized와 다르게 Blocking 방식이 아닌 non-Blocking 방식으로 진행됩니다. 두 방식의 차이점은 다른 스레드 작업을 중지시키고 하나의 스레드가 작업을 마칠 때까지 대기하느냐 동시에 수행하느냐의 차이입니다.

CAS는 만약 작업 중간에 다른 스레드가 값을 업데이트했다면 쓰기는 실패하고 다시 시도합니다. 즉 메모리에서 읽은 값을 새로운 값으로 교환하는 방식으로 Lock을 사용하지 않아 Lock-Free 방식이라고 합니다.

Blockingnon-Blocking 이란 프로그래밍에서 특정 작업이 다른 작업에 의해 차단되는지 여부를 나타내는 개념입니다.
블로킹은 작업의 흐름이 진행될 때 이전 작업이 완료될 때까지 대기하고, 논 블록킹은 작업이 안료되지 않아도 바로 다음 작업을 진행하는 방식입니다.

🐻 CAS 장점

CAS를 활용하는 락 프리 방식의 장점은

  1. 락을 사용하지 않기 때문에, 컨텍스트 스위치 오버헤드가 없고 데드락의 위험이 줄어듭니다.
  2. 원자적 연산을 통해 고성능의 동시성 제어가 가능합니다.

락을 사용하지 않으면서 컨텍스트 스위치가 없어졌습니다. 예를 들어서 1000개의 스레드가 Lock 방식이라면 1000번의 스레드의 상태를 변경하는 작업을 하고, 대기하는 스레드는 락이 나왔을 때 서로 차지하려고 경쟁하게 될 것입니다. 

CAS는 락을 걸지 않고 값을 안전하게 업데이트 합니다. 하지만 충돌이 자주 발생하는 경우에는 오히려 비효율이 발생할 수 있습니다. 그렇기에 CAS는 충돌이 자주 발생하지 않을 것이라고 예상되는 환경에서 적용하는 것이 좋습니다.

 

컨텍스트 스위치 오버헤드란, CPU가 프로세스나 스레드를 전환할 때 발생하는 비용을 말합니다. 프로세스나 스레드 전환 시간이 소모되어 실제 작업에 사용되는 CPU 시간이 줄어드는 문제입니다.

 

🐻 CAS 단점

CAS를 활용하는 방식의 단점은

  1. CAS 연산이 실패할 경우 반복적으로 시도해야 하므로, 바쁜 대기 상태가 발생할 수 있습니다.
  2. CAS는 값이 변경되었다가 다시 원래 값으로 돌아온 경우 (A->B->A) 를 감지할 수 없습니다.

충돌이 빈번하여 여러 스레드가 동시에 공유 자원에 접근하여 업데이트를 시도합니다. 충돌이 발생하면 CAS는 루프를 돌며 재시도를 해야합니다. 이 과정이 계속 반복되면 스핀락과 유사한 성능 저하가 발생할 수 있습니다. 이에 CPU 자원을 계속 소모하여 오버헤드가 발생할 수 있습니다.

스핀락은 바쁜 대기의 한 종류입니다. CAS 연산을 수행하기 위해서 성공할 때까지 해당 스레드가 반복문 안에서 빙빙 돌고 있다는 것을 의미합니다.

 


🐻 Atomic

Java는 멀티스레드 상황에서 안전하게 연산을 수행할 수 있는 Atomic 클래스를 제공합니다. java.util.concurrent.atomic  에 위치하고 있습니다. AtomicInteger, AtomicLong, AtomicBoolean, 등 다양한 AtomicXXX 클래스가 존재합니다.

 

🐻 AtomicInteger

자바에서 CAS 연산을 수행해주는 AtomicInteger에 대해서 살펴보겠습니다. 

  • new AtomicInteger() 
  • incrementAndGet()
  • decrementAndGet()
  • get()

AtomicInteger객체를 이용해서 여러 스레드에서 접근해서 값을 증가시켜 본 후 해당 메서드가 어떻게 구현되어있는지 살펴보겠습니다.

 

🐻 예제 코드

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를 인스턴스 변수로 가지고 있는 테스트 클래스를 만들었습니다. 이를 실행해줄 메인에서 생성하고 테스트 해보겠습니다.

 

public IncrementMain {
	public static void main(String[] args) {
    	MyAtomicInteger atomicInteger = new MyAtomicInteger();
    	Runnable runnable = () -> {
        	for (int i=0; i<100_000; i++) {
            	atomicInteger.increment();
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        startThread(thread1, thread2);
        System.out.println(atomicInteger.get());
    }
    
    private static void startThread(Thread thread1, Thread thread2) throws InterruptedException {
    	thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

 

위의 코드를 실행하게 되면 예상한 대로 두 스레드가 100,000번씩 두 번 상승시켜 200,000 의 결과가 나옵니다. AtomicInteger 가 스레드 세이프하게 값을 증가시킬 수 있었는지 incrementAndGet 메소드를 살펴보겠습니다.


🐻 IncrementAndGet

public class AtomicInteger extends Number implements java.io.Serializable {
	private static final Unsafe U = Unsafe.getUnsafe();
    private static fina long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
    
    private volatile int value;
    
    public AtomicInteger(int initialValue) {
    	value = initialValue;
    }
    
    public AtmoicInteger() {}
    
    public final int incrementAndGet() {
    	return U.getAndAddInt(this, VALUE, 1) +1;
    }
}

AtomicInteger 내부적으로 호출하는 Unsafe 객체의 getAndAddInt 메서드를 살펴보겠습니다.

public final class Unsafe {

    @IntrinsicCandidate
    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }
}

incrementAndGet( ) 메소드 내부에서 CAS 알고리즘의 로직을 구현하고 있습니다. 내부적으로 U.getAndAddInt( ) 를 호출하며 다음과 같은 native 메소드를 실행하게 됩니다.

getIntVolatile( ) 을 통해 가시성을 확보한 채로 비교 기준 값을 조회합니다. 값이 비교 기준 값을 비교하고, 일치한다면 weakCompareAndSetInt( ) 메소드를 통해 값을 교체한 뒤 그 결과를 반환합니다.

@IntrinsicCandidate
public final boolean weakCompareAndSetInt(Object o, long offset, int expected, int x) {
	return compareAndSetInt(o, offset, expected, x);
}

@IntrinsicCandidate
public final native boolean compareAndSetInt(Object o, long offset, int expected, int x);

🐻 정리

CAS 연산은 이렇게 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 하나의 원자적인 연산으로 묶어서 제공하고 있습니다. 하드웨어가 제공하는 기능으로 대부분의 현대 CPU들은 CAS 연산을 위한 기능을 제공하고 있습니다.

Atomic 시리즈가 제공하는 기능들은 CAS를 활용하도록 작성되어 있습니다. 락을 사용하지 않지만 문제가 발생하는 경우 루프를 돌며 재시도하는 방식을 사용했습니다. 이를 통해 락 없이 데이터를 안전하게 변경할 수 있었습니다.

CAS는 문제가 발생할 경우 반복을 통해 해결하기 때문에 충돌이 빈번하게 발생하는 상황에서는 반복문을 계속 돌기 때문에 CPU 자원을 많이 소모하게 되어 문제가 발생할 수 있습니다.

안전한 임계 영역이 필요하지만, 연산이 길지 않고 매우 짧게 끝날 때 사용해야 합니다. 예를 들어 숫자 값으 증가, 자료 구조의 데이터 추가와 같은 작업에 효과적입니다. 반면에 데이터베이스 결과를 대기하거나, 다른 서버의 요청을 기다리는 오래 기다리는 작업에 사용하면 CPU를 계속 사용하며 성능에 문제가 생길 수 있습니다.

 

일반적으로 동기화 락을 사용하고, 아주 특별한 경우에 한정해서 CAS를 사용해서 최적화합니다. CAS를 통한 최적화가 더 나은 경우는 스레드가 RUNNABLE, BLOCKED, WAITING 상태에서 다시 RUNNABLE 상태로 가는 것보다는 반복 체크하는 것이 더 효율적인 경우입니다.