CAS 알고리즘과 Atomic 클래스 - 멀티스레드 환경에서의 락 없는 동기화
Atomic 클래스의 기술적 원리
여는 글
현대 소프트웨어 개발에서 동시성 프로그래밍은 필수적인 요소로 자리 잡고 있습니다. 특히, Java는 멀티스레드 환경을 효율적으로 지원하기 위한 다양한 동시성 도구를 제공하고 있습니다. 최근 함수형 프로그래밍의 부상과 함께 등장한 Lambda 표현식은 간결하고 직관적인 코드를 가능하게 하여 개발 생산성을 크게 향상시켰습니다. 그러나 Lambda 표현식이 멀티스레드 환경에서 활용될 경우, 자원의 비동기적 접근으로 인해 Race Condition
과 같은 동시성 문제가 발생할 가능성이 존재합니다.
Java의 기존 동시성 관리 방식은 ‘synchronized’ 키워드나 ReentrantLock
과 같은 락 기반 접근 방식에 의존해왔습니다. 하지만 이들은 성능 저하, 데드락 위험, 코드 복잡성 증가와 같은 한계를 가지고 있습니다. 이러한 문제를 해결하기 위해 Atomic
클래스가 등장하였으며, CAS(Compare-And-Swap)
기반의 락 없는(lock-free) 동기화를 통해 보다 빠르고 안정적인 동시성 처리를 가능하게 합니다.
오늘 포스팅에서는 두 기술의 원리 그리고 결합한 코드를 보여드리도록 하겠습니다.
동시성 (Concurrency)
동시성(Concurrenc)는 여러 작업이 동일한 자원에 접근하거나 병렬적으로 실행될 수 있는 환경을 의미합니다. 동시성 제어를 위한 시스템 구성은 자원활용과 처리량 관점에서 높은 효율성을 제공하지만, 이와 동시에 Race Condition
, Deadlock
, Memory Inconsistency
와 같은 심각한 문제가 발생할 수 있습니다.
Race Condition (경쟁 상태)
두 개 이상의 스레드가 동일한 자원에 접근하여 값을 수정하는 경우 발생하는데요. 작업 순서에 따라 결과가 달라질 수 있어서 문제가 되는 상황입니다.
int count = 0;
Runnable task = () -> count++;
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
위 코드처럼 두개의 스레드(Thread)가 동시에 count를 증가시키면 최종 값이 1또는 2가 될 수 있는데요. 이렇게 공유 자원에 대한 접근이 동기화되지 않으면, 경쟁 상태가 발생하고 그에 따라 예측 불가능한 값이 도출이 되버립니다.
Deadlock (교착 상태)
두 개 이상의 스레드(Thread)가 서로 자원을 점유하고, 상대방의 자원이 해제되기를 기다리는 상태를 의미합니다. 이로인해 프로그램이 멈추는 문제가 발생합니다.
Memory Inconsistency (메모리 불일치)
멀티스레드(Multi Thread)환경에서 각 스레드가 메모리의 동일한 변수에 대해 다른 값을 읽거나 쓰는 현상을 말합니다. 이는 Java Memory Model(JMM)의 가시성(visibility) 문제과 관련이 있습니다.
여기서 말하는 가시성(visibility)는 한 스레드에서 변경된 데이터가 다른 스레드에서 즉시 관찰될 수 있는 특성을 말합니다.
JMM(Java Memory Model)은 Java 프로그램에서 스레드 간 메모리 읽기/쓰기의 동작을 정의하는데, 기본적으로 스레드는 CPU Cache를 사용하여 데이터를 저장하고 처리합니다. 이 때 가시성(다른 스레드가 변경 사항을 즉시 볼 수 있도록)을 보장하지 않으면 메모리 불일치 (Memory Inconsistency) 문제가 발생하는 것이죠.
기존 해결 방법의 한계
Java에서는 동시성 문제를 해결하기 위해 다양한 도구와 기법을 제공하지만, 기존 방식에는 다음과 같은 한계가 있습니다.
synchronized
public synchronized void increment() {
count++;
}
평소 자바 코드에서 보기드문 방식이긴 하지만, 간단한 구현으로 동기화를 제공하는 키워드입니다. 키워드를 사용하면 코드 블록에 락이 설정되고 다른 스레드가 접근하지 못합니다. 하지만, 이러한 점 때문에, 다른 스레드가 접근하지 못해서 병렬 실행의 이점이 감소하는 문제도 있고, 락(lock)기반이기 때문에 성능이 저하되고 데드락 가능성이 존재합니다.
ReentrantLock
ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
ReentrantLock
을 사용하면 유연하게 락(lock)관리가 가능하고 타임아웃도 설정이 가능합니다. AQS(AbstractQueuedSynchronizer)
기반으로 동작하며, 락 대기 중인 스레드를 FIFO(First In First Out) 큐에 저장하는 방식으로 동작합니다. 스레드가 락을 요청하면, AQS는 락 소유 여부를 확인하고, 소유자가 없으면 락을 부여합니다. 타임아웃 설정을 통해 락 중첩 사용이 가능하지만 락 기반이라는 한계를 극복하지는 못합니다.
Volatile 변수
private volatile int count = 0;
public void increment() {
count++; // 비원자적 연산으로 Race Condition 발생 가능.
}
volatile 키워드를 추가하면, 모든 스레드가 메인 메모리에서 직접 데이터를 읽고 씁니다. 따라서 volatile
변수는 값을 즉시 읽고 쓸 수 있기 때문에 가시성(visibility) 문제를 해결할 수 있으나 원자적 연산을 보장하지 않습니다.
Atomic Class의 등장
Atomic Class는 멀티스레드(Multi Thread) 환경에서 동시성 문제를 해결하기 위한 Java의 락 없는 동기화 (lock-free synchronization) 도구입니다. java.util.concurrent.atomic
패키지에 존재하는 이 클래스는 CAS(Compare And Swap)
알고리즘을 사용하여, 락을 사용하지 않고도 스레드 간의 동기화를 보장합니다. 락을 사용하지 않기 때문에, 성능이 보장되고 데드락이 발생하지 않는다는 장점이 있습니다.
원자적 연산 (Atomic Operation)
원자적 연산은 한 번에 실행되며, 중간에 중단되거나 간섭받지 않는 연산을 의미합니다. CPU 또는 JVM은 원자적 연산을 보장하여 동시성 문제가 발생하지 않도록 하는데요.
AtomicInteger.incrementAndGet()
으로 예를 들면, 다음을 ‘단일 연산’으로 처리합니다.
- 현재 값 읽기
- 값 증가
- 새 값쓰기
Atomic Class 내부 동작
Atomic Class는 뒤에서 설명할 CAS 알고리즘을 사용하여 락 없는 동기화를 제공하는데요. 이 클래스는 Java에서 sun.misc.Unsafe
라는 내부 클래스를 활용하여 메모리 조작 및 CAS 연산을 수행합니다.
Unsafe Class
Unsafe
는 JVM 수준에서 메모리를 직접 조작할 수 있는 API를 제공하는데요. Atomic Class는 이 Unsafe를 사용해 메모리 주소에 직접 접근하고 CAS 연산을 수행합니다.
AtomicInteger의 내부를 구현해보자면
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
valueOffset
은 메모리 주소의 오프셋 값을 의미하고, getAndAddInt
는 Unsafe의 메서드로 메모리 값에 워자적으로 연산을 수행하는 역할을 합니다.
Memory Barrier
Atomic Class는 메모리 베리어(Memory Barrier)를 사용해 JMM을 준수하는데요. 이 메모리 베리어는 연산이 CPU Cache에만 머물지 않고, 메인 메모리에 반영되게끔 읽기/쓰기 순서를 보장합니다. 또한, 스레드의 가시성을 보장하여 한 스레드의 변경 사항이 다른 스레드에서도 즉시 반영됩니다.
원래는 각 스레드가 자신의 CPU Cache에 데이터를 저장하기 때문에, 가시성(visibility)문제가 발생하는 것인데,
Volatile 키워드는 메모리 읽기/쓰기 작업이 메인 메모리를 직접 사용하여 가시성을 보장하는 방식으로 가시성 문제를 해결하는 것이고,
CAS와 Atomic클래스는 메모리 베리어를 사용해 변경 사항을 강제로 메인 메모리에 반영하는 방식으로 가시성 문제를 해결합니다.
CAS (Compare And Swap)
CAS는 원자적 연산을 구현하기 위한 알고리즘입니다. 앞서 설명한 Atomic Class는 CAS 알고리즘으로 구현이 되어있는데요. CAS는 '특정 메모리 값의 상태를 확인한 후 조건에 따라 변경하는 과정'을 한 번의 연산으로 처리합니다. 이러한 원리로 락(lock)을 사용하지 않고도 데이터의 일관성과 안전성을 보장합니다.
기본동작
CAS는 세 가지 요소를 기반으로 작동합니다.
- 메모리 위치 (Address): 수정 대상 변수의 메모리 주소.
- 예상 값 (Expected Value): 현재 메모리 위치에 저장된 값.
- 새 값 (New Value): 조건이 충족될 경우 메모리에 저장할 값.
연산 과정
- 메모리의 특정 위치에서 현재 값을 읽고
- 이 값을 비교해 예상 값과 같다면 새 값으로 교체합니다.
- 예상 값과 다르면, 반영되지 않습니다.
한계
위에서 말한 CAS의 특징으로 인해 데드락(Deadlock)위험이 없고 성능이 좋지만 한계점이 존재합니다.
- ABA문제
메모리 값이 예상 값으로 돌아오면, CAS는 이 값이 변경되지 않았다고 판단하는데요. 이 판단에는 중간에 값이 변경되었을 가능성을 간과합니다.
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 1);
이러한 판단오류는 AtomicStampedReference
를 사용해서 값 뿐만 아니라 수정 횟수(Stamp)를 함께 비교해서 해결할 수 있습니다.
- 스핀락(Spinlock)으로 인한 성능 저하
CAS는 실패 시 다시 시도하는 루프동작이 있습니다. 경쟁이 심한 환경(대규모 트래픽)에서는 CPU 리소스를 과하게 소비할 수도 있습니다.
정리
- CAS 그리고 CAS로 구현된 Atomic Class를 사용하여 동시성 문제, 메모리 가시성 문제를 해결할 수 있다.
- Atomic Class는 CAS와 Unsafe Class를 활용하여 동기화를 보장한다.
- 락 없이도 동기화를 보장할 수 있는 이유는 CAS의 재시도 동작 때문이다.
- 메모리의 가시성을 확보할 수 있는 이유는 메모리 베리어를 사용하기 때문이다.
- 이처럼 락 기반이 아니기 때문에, 동시성을 제어하면서 성능 저하를 방지하고 데드락을 없앨 수 있다.
부록 : Lambda 함수와 Atomic Class 결합
Lambda 함수는 Java8 에서 도입된 함수형 프로그래밍 도구로 코드를 간결하게 작성하고 병렬 처리에서 유용성이 강조되는 도구입니다. Lambda 함수는 (매개변수) -> {실행 내용}
형태로 정의가 되는데요. 다음 코드를 살펴볼까요
// 올바르지 않은 코드 작성
List<Object> list = repository.findAll();
int count = 0;
list.foreach( obj -> {
count += 1;
})
int total = count;
대충 이런 코드가 있다고 했을때, 코드 작성자가 원하는 바는 다들 이해를 하셨을 겁니다. 하지만, 이 코드는 동작하지 않습니다. variable used in lambda expression should be final or effectively final
오류가 예상이 되는데요. 이는 Java의 Lambda 표현식에서 사용되는 변수가 final 또는 Effectively final이어야 하기 때문입니다. 즉, 변수가 선언된 이후 값이 변경되지 않는 경우여야 한다는 뜻인데요.
Java의 람다 표현식은 클로저(closure)로 동작합니다. 따라서 표현식 내에서 외부 변수를 사용하는 경우, 해당 변수는 불변이어야 합니다. 하지만 위 코드에서는 외부 변수를 람다 내부에서 수정하고 있기 때문에 오류가 발생합니다.
위 코드의 논리를 유지하고 코드를 올바르게 수정한다고 했을때 여러 방법이 있지만, 앞서 소개한 Atomic Class를 사용하여 해결할 수도 있습니다. Atomic Class는 가변 참조를 제공하여 람다 내부에서도 상태를 안전하게 변경할 수 있습니다.
// Atomic을 사용하여 코드 수정
List<Object> listA = repository.findAll();
AtomicInteger count = 0;
list.foreach( obj -> {
count.incrementAndGet();
})
int total = count;
일반적으로 병렬 처리의 장점을 사용하기 위해 Lambda 표현식을 사용하게 되는데, 병렬 처리 과정에서 동시성 문제가 발생할 가능성이 있기때문에 Atomic Class와 결합하여 사용하면 성능과 안정성을 동시에 보장할 수 있습니다.