원자적 연산
소개
컴퓨터 과학에서 사용하는 원자적 연산(atomic operation)
의 의미는 해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미한다. 즉, 원자적 연산은 중단되지 않고, 다른 연산과 간섭 없이 완전히 실행되거나 전혀 실행되지 않는 성질을 가지고 있다. 쉽게 이야기해서 멀티스레드 상황에서 다른 스레드의 간섭 없이 안전하게 처리되는 연산이라는 뜻이다.
다음과 같은 필드가 있을 떄,
volatile int i = 0;
i = 1
은 둘로 쪼갤 수 없는 원자적 연산이다. 단 하나의 순서로 실행되기 때문이다.
하지만, 다음의 연산은 원자적 연산이 아니다.
i = i + 1;
왜냐하면 이 연산은 다음 순서로 나누어 실행되기 때문이다.
- 오른쪽에있는
i
의 값을 읽는다.i
의값은 10이다. - 읽은 10에 1을 더해서 11을 만든다.
- 더한 11을 왼쪽의
i
변수에 대입한다.
원자적 연산은 멀티스레드 상황에서 아무런 문제가 발생하지 않는다. 하지만 원자적 연산이 아닌 경우에는 synchronized
블럭이나 Lock
등을 사용해서 안전한 임계 영역을 만들어야 한다
i++
연산 역시 원자적 연산처럼 보이지만 사실은 i = i + 1
과 똑같은 연산을 수행하므로 원자적 연산이 아니다
그럼 혹시 volatile
을 적용하면 될까?
⇒ X
volatile
은 여러 CPU 사이에 발생하는 캐시 메모리와 메인 메모리가 동기화 되지 않는 문제를 해결할 뿐이다. 이 문제는 연산 자체가 확인, 계산, 변경으로 나누어져 있다. 동시에 변경을 하는 경우에는 해결될 수 없다.
이렇게 연산 자체가 나누어진 경우에는 synchronized
블럭이나 Lock
등을 사용해서 안전한 임계 영역을 만들어야 한다.
→ synchronized
를 적용하면 문제는 해결된다!
원자적 연산 - AtomicInteger
자바는 멀티스레드 상황에서 안전하게 증가 연산을 수행할 수 있는 AtomicInteger
라는 클래스를 제공한다. 이름 그대로 원자적인 Integer
라는 뜻이다.
AtomicInteger
는 멀티스레드 상황에 안전하고 또 다양한 값 증가, 감소 연산을 제공한다. 특정 값을 증가하거나 감소해야 하는데 여러 스레드가 해당 값을 공유해야 한다면, AtomicInteger
를 사용하면 된다.
AtomicInteger , AtomicLong , AtomicBoolean 등 다양한 AtomicXxx 클래스가 존재한다.
성능 비교
synchronized
를 사용할 경우와 AtomicInteger
를 사용한 경우 성능을 비교해보면, 후자가 약 1.5 ~ 2배 정도 빠르다
⇒ 놀랍게도 AtomicInteger
가 제공하는 incrementAndGet()
메서드는 락을 사용하지 않고, 원자적 연산을 만들어내기 때문이다.
CAS 연산
우리가 직접 CAS 연산을 사용하는 경우는 거의 없다. 대부분 복잡한 동시성 라이브러리들이 CAS 연산을 사용한다.
락 기반 방식의 문제점
락은 특정 자원을 보호하기 위해 스레드가 해당 자원에 대한 접근하는 것을 제한한다. 락이 걸려 있는 동안 다른 스레드들은 해당 자원에 접근할 수 없고, 락이 해제될 때까지 대기해야 한다. 또한, 락 기반 접근에서는 락을 획득하고 해제하는 데 시간이 소요된다.
→ 락을 사용하는 방식은 직관적이지만 상대적으로 무거운 방식이다.
CAS
이런 문제를 해결하기 위해 락을 걸지 않고 원자적인 연산을 수행할 수 있는 방법이 있는데, 이것을 CAS(Compare-And-Swap, Compare-And-Set)
연산이라 한다. 이 방법은 락을 사용하지 않기 때문에 락 프리(lock-free) 기법이라 한다.
참고로, CAS 연산은 락을 완전히 대체하는 것은 아니고, 작은 단위의 일부 영역에 적용할 수 있다. 기본은 락을 사용하고, 특별한 경우에 CAS를 적용할 수 있다고 생각하면 된다.
예시 코드를 봐보자.
public class CasMainV1 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
boolean result1 = atomicInteger.compareAndSet(0, 1);
System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get());
boolean result2 = atomicInteger.compareAndSet(0, 1);
System.out.println("result2 = " + result2 + ", value = " + atomicInteger.get());
}
}
compareAndSet(0, 1)
atomicInteger
가 가지고 있는 값이 현재 0이면 이 값을 1로 변경하라는 매우 단순한 메서드이다.- 만약
atomicInteger
의 값이 현재 0이라면atomicInteger
의 값은 1로 변경 후true
반환 - 0이 아니라면, 값은 변경되지 않고
false
를 반환
- 만약
- 여기서 가장 중요한 것은, 이 메서드는 원자적으로 실행된다는 것이다. 이 메서드가 제공하는 기능이 바로 CAS(compareAndSet) 연산이다.
- 생각해보면 이 명령어는 값을 확인하고, 기대하는 값이라면 값을 변경하는 2개의 작업으로 구성된 명령어처럼 보인다. → 원자적이지 않은 것처럼 보인다
- CAS 연산은 이렇게 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 특별하게 하나의 원자적인 연산으로 묶어 제공하는 기능이다. → 소프트웨어가 아닌 하드웨어가 제공하는 기능
- 현대 CPU들은 CAS 연산을 위한 명령어를 제공한다.
- CPU는 두 과정을 하나의 원자적인 명령으로 만들기 위해 두 명령어 사이에 다른 스레드가 해당 메모리의 값을 변경하지 못하게 막는다. → 중간에 다른 스레드가 개입할 수 없다.
- 이는 CPU 입장에서 아주 찰나의 순간이라서 성능에 큰 영향을 끼치지도 않는다.
- CPU가 지원하는 락과 같은 느낌이다. 하지만, 그 연산 시간이 너무 짧아서 락이라고 부르지 않는다.
그렇다면 이 CAS 기능이 어떻게 락을 일부 대체할 수 있다는 것일까?
incrementAndGet()
메서드 구현
public class CasMainV2 {
private static final int THREAD_COUNT = 2;
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
Runnable runnable = new Runnable() {
@Override
public void run() {
incrementAndGet(atomicInteger);
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
int result = atomicInteger.get();
System.out.println(atomicInteger.getClass().getSimpleName() + " resultValue: " + result);
}
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get();
sleep(100); // 스레드 동시 실행을 위한 대기
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1);
log("result: " + result);
} while (!result);
return getValue + 1;
}
}
여기서 만든 incrementAndGet()
은 atomicInteger
내부의 value
값을 하나 증가하는 메서드이다. 사실 atomicInteger
도 이 메서드를 제공하지만, 여기서는 이해를 위해 직접 구현해보았다.
CAS 연산을 이용하면 여러 스레드가 같은 값을 사용하는 상황에서도 락을 걸지 않고, 안전하게 값을 증가할 수 있다.
getValue = atomicInteger.get()
을 사용해서value
값을 읽는다.compareAndSet(getValue, getValue + 1)
을 사용해서, 방금 읽은value
값이 메모리의value
값과 같다면value
값을 하나 증가한다. 여기서 CAS 연산을 사용한다.- 만약 CAS 연산이 성공한다면
true
를 반환하고do-while
문을 빠져나온다. - 실패한다면,
false
를 반환하고do-while
문을 다시 시작한다.
실행 결과
start value = 0
15:15:09.706 [ Thread-1] getValue: 0
15:15:09.706 [ Thread-0] getValue: 0
15:15:09.711 [ Thread-1] result: true
15:15:09.711 [ Thread-0] result: false
15:15:09.814 [ Thread-0] getValue: 1
15:15:09.814 [ Thread-0] result: true
AtomicInteger resultValue: 2
분석
두 스레드의 실행 결과를 분석해보자. 보기 쉽게 스레드 별로 로그를 나누었다.
Thread-1 실행
15:15:09.706 [ Thread-1] getValue: 0
15:15:09.711 [ Thread-1] result: true
atomicInteger.get()
을 사용해서value
값을 읽는다.getValue
는 0이다.compareAndSet(0,1)
을 수행한다.compareAndSet(getValue, getValue + 1)
- CAS 연산이 성공했으므로
value
값은 0에서 1로 증가하고true
를 반환한다. do~while
문을 빠져나간다.
Thread-0 실행
// [Thread-0] do~while 첫 번째 시도
15:15:09.706 [ Thread-0] getValue: 0
15:15:09.711 [ Thread-0] result: false
// [Thread-0] do~while 두 번째 시도
15:15:09.814 [ Thread-0] getValue: 1
15:15:09.814 [ Thread-0] result: true
- [Thread-0] do~while 첫 번째 시도
atomicInteger.get()
을 사용해서value
값을 읽는다.getValue
는 0이다.compareAndSet(0,1)
을 수행한다.compareAndSet(getValue, getValue + 1)
- 그런데
compareAndSet(0,1)
연산은 실패한다.- CAS 연산에서 현재
value
값으로 0을 기대했지만Thread-1
이 중간에 먼저 실행되면서value
의 값을 0 → 1로 변경해버렸다.
- CAS 연산에서 현재
- CAS 연산이 실패했으므로
value
값은 변경하지 않고,false
를 반환한다.
실패했으므로do~while
문을 빠져나가지 못한다.do~while
문을 다시 시작한다.while (!result)
→while(!false)
→while(true)
이므로 다시 반복
- [Thread-0] do~while 두 번째 시도
do~while
문이 다시 시작된다.atomicInteger.get()
을 사용해서value
값을 읽는다.getValue
는 1이다.compareAndSet(1,2)
을 수행한다.compareAndSet(getValue, getValue + 1)
- CAS 연산이 성공했으므로
value
값은 1에서 2로 증가하고true
를 반환한다. do~while
문을 빠져나간다.
정리
CAS를 사용하면 락을 사용하지 않지만, 대신에 다른 스레드가 값을 먼저 증가해서 문제가 발생하는 경우 루프를 돌며 재시도를 하는 방식을 사용한다.
이 과정에서 충돌이 발생할 때마다 반복해서 다시 시도하므로, 결과적으로 락 없이 데이터를 안전하게 변경할 수 있다. CAS를 사용하는 방식은 충돌이 드물게 발생하는 환경에서는 락을 사용하지 않으므로 높은 성능을 발휘할 수 있다. 이는 락을 사용하는 방식과 비교했을 때, 스레드가 락을 획득하기 위해 대기하지 않기 때문에 대기 시간과 오버헤드가 줄 어드는 장점이 있다.
하지만, 충돌이 빈번하게 발생하는 환경에서는 성능에 문제가 될 수 있다. 여러 스레드가 자주 동시에 동일한 변수의 값을 변경하려고 시도할 때, CAS는 자주 실패하고 재시도해야 하므로 성능 저하가 발생할 수 있다. 이런 상황에서는 반복문을 계속 돌기 때문에 CPU 자원을 많이 소모하게 된다.
CAS와 락 방식의 비교
- 락(Lock) 방식
- 비관적(pessimistic) 접근법
- 데이터에 접근하기 전에 항상 락을 획득
- 다른 스레드의 접근을 막음
- "다른 스레드가 방해할 것이다"라고 가정
- CAS(Compare-And-Swap) 방식
- 낙관적(optimistic) 접근법
- 락을 사용하지 않고 데이터에 바로 접근
- 충돌이 발생하면 그때 재시도
- "대부분의 경우 충돌이 없을 것이다"라고 가정
⇒ 충돌이 많이 없는 경우에 CAS 연산이 빠르다!
그렇다면 충돌이 많이 발생하지 않는 연산은 어떤 것이 있을까? 언제 CAS 연산을 사용하면 좋을까?
⇒ 간단한 CPU 연산에는 락보다는 CAS를 사용하는 것이 효과적이다. 간단한 CPU 연산은 너무 빨리 처리되기 때문에 충돌이 자주 발생하지 않기 때문이다.
CAS 단점
CAS 연산은 그 특징 자체가 장점이자 단점이다. 스레드가 대기 상태로 빠지지는 않지만, RUNNABLE
상태로 락을 획득할 때까지 while
문을 계속 돌며 CPU 자원을 소모하게 된다.
동기화 락을 사용하면 CPU 자원을 거의 사용하지 않을 수 있다. 그래서 동기화 락을 사용하는 방식보다 스레드를 RUNNABLE
로 살려둔 상태에서 계속 락 획득을 반복 체크하는 것이 더 효율적인 경우에만 이런 방식을 사용해야 한다.
그게 어떤 경운데?
→ 안전한 임계 영역이 필요하지만, 연산이 길지 않고 매우매우매우 짧게 끝나는 경우만!
→ 일반적으로 동기화 락을 사용하고, 아주 특별한 경우에 한정해서 CAS를 사용해서 최적화해야 한다!
실무 관점에서 synchronized와 CAS 비교
실무에서는 공유 자원을 사용할 때, 충돌할 가능성보다 충돌하지 않을 가능성이 훨씬 높다. CPU가 1초에 얼마나 많은 연산을 처리하는지 생각해보면, 백만 건 중에 충돌이 나는 경우는 아주 넉넉하게해도 몇 십 건 이하일 것이다. 따라서 실무에서는 주문 수 증가와 같은 단순한 연산의 경우, 락을 걸고 시작하는 것 보다는, CAS처럼 낙관적인 방식이 더 나은 성능을 보인다.
반면, 데이터베이스나 다른 서버의 응답을 기다리는 것처럼 수 밀리초 이상의 시간이 걸리는 작업이라면 CAS를 사용하는 것보다 동기화 락을 사용하거나 스레드가 대기하는 방식이 더 효과적이다.
더 나아가기
실제로 운영 서버는 스케일 아웃되어 분산 시스템이 구축되어 있을 가능성이 크다. 이러한 경우에는 synchronized
나 CAS 등과 같은 애플리케이션 단의 락 기법보다는 데이터베이스의 트랜잭션 락을 활용해야 한다.
하지만 간단한 연산 및 충돌이 크지 않은 경우에는 낙관적 방식(@Version
이용)을 사용하는 것이 더 효과적이고, 대기 시간이 긴 혹은 충돌이 잦은 경우에는 비관적 방식(@Lock
사용)을 사용하는 것이 더 효과적이다.
'Java' 카테고리의 다른 글
[Java] 스레드 풀 & ExecutorService (0) | 2025.01.07 |
---|---|
[Java] 동시성 컬렉션 (0) | 2025.01.06 |
[Java] 스레드 - 생산자 소비자 문제 (0) | 2025.01.06 |
[Java] 스레드 동시성 문제 (0) | 2025.01.06 |
[Java] 스레드의 생성 / 실행 / 제어 / 생명 주기 (0) | 2025.01.06 |