Java. 원자적 연산, CAS 연산
원자적 연산
아래의 코드는 단순히 값을 하나 증가 시키는 코드이다.
멀티스레드 환경이라면 synchronized
나 Lock
같은 안전한 임계 영역을 설정해주어야 한다.
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
- BasicInteger: 가장 빠르지만 멀티스레드 상황에서는 사용할 수 없음.
- VolatileInteger:
volatile
를 사용해서 CPU 캐시를 사용하지 않고, 메인 메모리에서 작업한다. 안전한 임계영역이 없기 때문에 멀티스레드 환경에서는 안전하지 않음. - SyncInteger:
synchronized
를 사용해 멀티스레드 상황에서도 안전하지만MyAtomicInteger
보다 성능이 느림. - MyAtomicInteger:
AtomicInteger
를 사용해 멀티스레드 상황에서도 안전하고, 성능이synchronized
보다 1.5~2배 빠름.
추가 설명:
AtomicInteger
가 더 빠른 이유는 락을 사용하지 않고 원자적 연산을 제공하기 때문이다. value++
와 같은 연산은 원자적이지 않은데 어떻게 된 것일까? CAS
에 대해 알아보자.
참고: 우리가 직접 CAS 연산을 사용하는 경우는 거의 없다. 대부분 복잡한 동시성 라이브러리들이 CAS 연산을 사용한다.
우리는 AtomicInteger
와 같은 CAS 연산을 사용하는 라이브러리들을 잘 사용하는 정도면 충분하다.
CAS연산은 락을 완전히 대체하는 것은 아니고, 기본은 락을 사용하고, 작은 단위의 일부 영역에 적용할 수 있다.
자바는 AtomicXxx
의 compareAndSet()
메서드를 통해 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. 성공적으로 값을 증가시킨 후, 증가된 값을 반환
}
}
- 현재 값 읽기 (
getValue = atomicInteger.get();
):AtomicInteger
의 현재 값을 읽어와getValue
라는 지역 변수에 저장한다.
- 값 변경 시도 (
result = atomicInteger.compareAndSet(getValue, getValue + 1);
):compareAndSet
메서드를 사용하여, 현재 값이getValue
와 동일하다면getValue + 1
로 값을 변경하려고 시도한다.- 이 시도는 원자적으로 수행되며,
compareAndSet
이 성공하면true
, 실패하면false
를 반환한다.
- 반복 (
do-while
루프):compareAndSet
이 실패하면(즉, 다른 스레드가 값을 변경했다면), 루프가 다시 시작되어 현재 값을 다시 읽고, 값을 다시 업데이트하려고 시도한다.- 이 루프는
compareAndSet
이 성공할 때까지 반복된다.
- 최종 값 반환 (
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("락 반납 완료");
}
}
위의 코드는 원자적이지 않다.
- 락 사용 여부 확인
- 락의 값 변경
이 문제를 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 연산 덕분에 하나의 원자적인 연산으로 바꿀 수 있었다.
- 락을 사용하지 않는다면 락의 값을 변경
이렇게 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 기술이 있다는 것을 이해하는 정도면 된다.