원자적 연산이란
연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미합니다. 다른 연산과 간섭없이 완전히 실행되거나 전혀 실행되지 않는 성질입니다.
예를 들면 i = 1
인 경우 단 하나의 순서로만 실행되기 때문에 원자적 연산입니다.
하지만 i = i + 1
의 경우 원자적 연산이 아닙니다.
오른쪽에 있는 i의 값을 읽고 읽은 i의 값에 1을 더해서 11을 만들고 11을 i변수에 대입까지 해야 하므로 총 3가지 입니다.
그렇기 때문에 원자적 연산이 아닌 경우라면 멀티스레드 환경에서 synchronized
나 lock
등을 사용해서 안전한 임계 영역을 만들어야 합니다.
만약 2개의 스레드가 있고 i = i + 1
을 수행할 경우 2개의 스레드가 동시에 실행한다면 두 스레드 모두 더한 1을 i 번수에 대입하여 i의 값은 0이됩니다. -> 하나의 연산이 사라짐. (i++
도 원자적 연산이 아닙니다.)
IncrementThreadMain
에서의 예제로 스레드를 1000개 만들어서 1을 증가하는 코드를 완성했다면 결과는 1000이 나와야 하는데 1000이 나오지 않습니다. 동시에 원자적이지 않은 value++를 호출했기 때문에 발생합니다.
volatile은 여러 CPU 사이에서 발생하는 캐시 메모리와 메인 메모리가 동기화되지 않은 문제를 해결할 뿐입니다. volatile을 사용하면 CPU의 캐시 메모리를 무시하고 메인 메모리를 직접 사용하도록 할 뿐입니다. 메모리 가시성이 영향을 줄 수 있지만 캐시 메모리를 사용하지 않고 volatile을 사용해서 메인 메모리를 직접 사용해도 여전히 문제가 발생합니다.
연산 자체가 나눠져 있는 경우에는 synchronized 나 lock을 사용해야 합니다.
synchronized
를 사용한 것처럼 자바는 멀티스레드 상황에서 안전하게 값을 증가하고 감소할 수 있는 AtomicInteger 클래스를 제공합니다.
incrementAndGet()
메서드를 값을 하나 증가하고 get()
으로 반환합니다.
AtomicInteger
, AtomicLong
, AtomicBoolean
등 다양하게 존재합니다.
BasicInteger: ms=31
VolatileInteger: ms=195
SyncInteger: ms=353
MyAtomicInteger: ms=196
BasicInteger
가 가장 빠릅니다. CPU 캐시를 적극 사용합니다. 단일 스레드의 경우 매우 효율적이지만 안전한 임계 영역이 없어 멀티스레드 환경에서는 사용 불가능합니다.volatile
사용한 경우 메인 메모리를 사용하지만 안전한 임계 영역이 없고 멀티스레드 환경에서 사용할 수 없습니다. BasicInteger보다 느리기도 합니다. (원자적 연산이라면 사용 가능)Non-atomic operation on volatile field 'value'
라는 경고가 발생 SyncInteger
는 안전한 임계 영역이 있기 때문에 멀티스레드 상황에서 안전하게 사용할 수 있지만 AtomicInteger
보다 성능이 느림
AtomicInteger
가 빠른 이유
SyncInteger
의 경우 lock을 사용합니다. 원자적 연산이 아니라면 락을 통해 안전한 임계 영역을 만들어야 하지만AtomicInteger
의 경우incrementAndGet()
메서드는 락을 사용하지 않고 원자적 연산을 만들어냅니다.
사실 CAS 연산을 사용하는 경우는 매우 드물고 복잡한 동시성 라이브러리들이 이 CAS 연산이라는 걸 사용해서 성능을 최적화합니다.
그래서 AtomicInteger와 같은 CAS 연산을 사용하는 라이브러리들을 잘 사용하면 됩니다.
현재 이 부분은 심화 과정이니 적당히 이해하고 넘어가는 방식으로 진행
락은 특정 자원을 보호하기 위해 스레드가 해당 자원에 대한 접근을 제한합니다. 락이 걸령 있는 동안 다른 스레드들은 해당 자원에 접근할 수 없고, 락이 해제될 때까지 기다려야 합니다.
우리는 락을 흭득하고 반납하는 과정으로 직관적이지만 무거운 방식을 사용했습니다. 이런 문제를 해결하려면 락을 걸지 않고 원자적인 연산을 수행할 수 있는 방식이 CAS 연산입니다. (락 프리라고도 합니다.)
또한 락을 완전하게 대체하는 것이 아니고 특별한 경우에 CAS를 적용할 수 있습니다.
// 두 값을 비교해서 값이 맞으면 값을 세팅 -> 기대하고 있는 값이 0이면 1로 세팅
boolean result1 = atomicInteger.compareAndSet(0, 1);
이 코드는 원자적으로 실행됩니다.
조건을 줘서 원자적 연산을 하도록 메서드가 제공하는 게 CAS 연산입니다.
if 문으로 실행해서 여러 단계로 구현하기 때문에 원자적 연산이라 할 수 없지만 CPU가 원자적 연산으로 만들어 줍니다.
CPU 하드웨어의 지원으로 CAS 연산은 원자적이지 않은 2개 이상의 연산을 CPU 하드웨어 차원에서 특별하게 하나의 원자적인 연산으로 묶어서 제공하는 기능입니다.
(소프트웨어가 아닌 하드웨어에서 지원)
그래서 CPU는 두 과정을 묶어서 하나의 원자적인 명령으로 만들어버립니다.
값을 변경하지 않았을 경우에만 값을 바꾸는 것.
락 방식
CAS 방식
위처럼 정말 간단한 연산에만 CAS를 사용하는 것이 효과적입니다.
단순한 연산이 아닌 락을 구현하여 사용 가능합니다.
synchronized
, lock
없이 CAS를 활용해서 락을 구현하는 것입니다.
스레드가 락을 흭득하면 lock의 값이 true, 반납하면 false가 되고, 락을 흭득하면 while문을 탈출,
흭득하지 못하면 락을 흭득할 때까지 while문을 반복
이런 코드를 작성했을 때 결과는 Thread-1, Thread-2 둘다 동시에 락을 흭득하고 비즈니스 로직을 동시에 수행해버립니다.
그 이유는 하나의 연산이 아니라 두 개의 연산으로 나눠져 원자적 연산이 아니기 때문에 발생합니다. 한 번에 하나의 스레드만 실행하여 synchronized
, lock
을 사용하여 두 코드를 동기화해야 합니다. (안전한 임계영역 필요)
하나의 원자적인 연산으로 처리하고 싶다면 하나의 락의 사용 여부를 확인하고 그 값이 기대하는 값과 같다면 변경하는 방식으로 CAS 연산을 해버리면 됩니다.
두 연산을 하나로 만들기
1. 락 사용 여부 확인
2. 락의 값 변경
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
log("락 흭득 시도");
// 락이 false로 사용하고 있지 않다면 true로 바꿀 거임
while (!lock.compareAndSet(false, true)) {
log("락 흭득 실패 - 스핀 대기");
}
log("락 흭득 완료");
}
lock이 false일 때만 lock의 값을 변경할 수 있어서 lock의 값이 true로 변경될 때까지 lock의 값은 false를 유지해야 합니다.
중간에 다른 스레드가 lock의 값을 true로 변경해서 여러 스레드가 임계 영역을 통과하는 동시성 문제가 발생했기 때문에 이전 코드에서는 동시에 락 흭득, 비즈니스 로직 수행해버리는 과정이 발생한 것.
그래서 락이 없기 때문에 단순히 while으로 반복만 하고 대기하는 스레드도 RUNNABLE을 유지하면서 가볍고 빠르게 작동할 수 있습니다.
비즈니스 로직을 처리하는데 대략 1ms 정도 걸린다고 가정했을 때 조금만 대기해도 로직이 수십만번 작동할 수가 있습니다.RUNNABLE 상태에서 락을 흭득할 때까지 while을 반복하기 때문에 장점이자 단점이 될 수 있습니다.
비즈니스 로직이 오래 걸리는데 락을 걸면 락을 기다리는 스레드가 CPU를 계속 태웁니다. BLOCKED나 WAITING 상태의 스레드는 CPU를 거의 사용하지 않지만 RUNNABLE 상태에서 계속 반복하면 CPU 자원을 계속 사용합니다.
그래서 안전한 임계 영역이 필요하지만 연산이 길지 않고 정말로 짧을 때 사용해야 합니다.
숫자값의 증가, 자료 구조의 데이터 추가같은 CPU 사이클이 금방 끝나는 연산에 사용해야 효과적입니다. (ms 단위 이하로 될 때만 사용. DB의 결과 대기, 다른 서버의 요청을 대기 등에 사용하면 최악)