CAS - 동기화와 원자적 연산
원자적 연산
- 원자적 연산: 해당 연산이 더 이상 나눌 수 없는 단위로 수행 되는 것
1
int i = 0;
위 로직은 둘로 쪼갤 수 없는 원자적 연산입니다.
1
i =i +1;
- i의 값을 읽는다.
- 읽은 i에 1을 더해서 연산 된 값을 만든다.
- 연산 된 값을 i에 할당한다.
위 로직은 3개의 단위로 쪼개어 실행되므로 원자적 연산이 아닙니다.
예시
1000개의 스레드에서 값을 1씩 더하고 조회하는 예시입니다.
1
2
3
4
5
6
7
public interface IncrementInteger {
void increment();
int get();
}
기본
우선 기본적인 방법의 더하기 연산입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BasicInteger implements IncrementInteger {
private int value;
@Override
public void increment() {
value++;
}
@Override
public int get() {
return value;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class IncrementThreadMain {
public static final int THREAD_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
test(new BasicInteger());
}
private static void test(IncrementInteger incrementInteger) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
sleep(10); // 너무 빨리 실행되기 때문에, 다른 스레드와 동시 실행을 위해 잠깐 쉬었다가 실행
incrementInteger.increment();
}
};
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 = incrementInteger.get();
System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
}
}
1
BasicInteger result: 988
1000을 기대했지만 기대와 다르게 988이라는 숫자가 나왔습니다. 이는 스레드 충돌이 일어났기 때문입니다.
volatile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class VolatileInteger implements IncrementInteger {
volatile private int value;
@Override
public void increment() {
value++;
}
@Override
public int get() {
return value;
}
}
1
VolatileInteger result: 986
여전히 스레드 충돌로 인해 1000이 안나옵니다. 이 문제는 메모리 가시성 문제가 아니라, 같은 변수에 동시에 접근하기 때문에 생기는 문제라서 그렇습니다.
synchronized
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SyncInteger implements IncrementInteger {
private int value;
@Override
public synchronized void increment() {
value++;
}
@Override
public synchronized int get() {
return value;
}
}
1
SyncInteger result: 1000
스레드 충돌을 해결하기 위해 synchronized를 붙였더니 드디어 생각한대로 1000이 나왔습니다.
AtomicInteger
자바는 멀티 스레드 환경에서 안전하게 증가 연산을 수행할 수 있는 AtomicInteger라는 클래스를 제공합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyAtomicInteger implements IncrementInteger {
AtomicInteger atomicInteger = new AtomicInteger(0);
@Override
public void increment() {
atomicInteger.incrementAndGet();
}
@Override
public int get() {
return atomicInteger.get();
}
}
1
MyAtomicInteger result: 1000
이 방식을 써도 1000이 출력됩니다.
성능
1
2
3
4
BasicInteger: ms = 50
VolatileInteger: ms = 627
SyncInteger: ms = 974
MyAtomicInteger: ms = 700
synchronized 를 썼을 때 보다 AtomicInteger 을 썼을 때가 조금 더 빠른 것을 확인할 수 있습니다.
synchronized: 락 기반 방식AtomicInteger: CAS 연산 방식
CAS 연산
락을 걸지 않고 원자적인 연산을 수행할 수 있는데, 이것을 CAS(Compare-And-Swap, Compare-And-Set)이라고 합니다. 락을 사용하지 않기 때문에 락 프리(lock-free) 기법이라고 합니다.
CAS 연산은 락을 완전히 대체하는 것은 아니고, 작은 단위의 일부 영역에 적용
compareAndSet()
자바는 AtomicXxx의 compareAndSet() 메서드를 통해 CAS 연산을 지원합니다.
사용 방법
compareAndSet(비교할 값, 변경할 값)
1
2
3
AtomicInteger atomicInteger = new AtomicInteger()
atomicInteger.compareAndSet(0, 1)
atomicInteger의 현재 값이 0로 변경하고true를 반환atomicInteger의 현재 값이 0이 아니라면 값을 변경하지 않고false를 반환
2가지 연산인데 어떻게 원자적 연산?
- 값을 확인
- 0이면 1로 변경
즉, 연산을 2번하게 됩니다. 하지만 놀랍게도 이는 원자적 연산으로 처리됩니다.
CPU 하드웨어 지원
CAS 연산은 이렇게 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 특별하게 원자적 연산으로 묶어서 제공하는 기능입니다.
즉, 이 두 연산이 이루어지기 전에 다른 스레드가 연산 못하도록 하드웨어가 막아줍니다.
예시
2개의 스레드가 동시에 값을 증가시키는 로직입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CasMainV3 {
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;
}
}
- 두 스레드가 동시에
getValue = atomicInteger.get()접근하여 0을 반환 받음 result = atomicInteger.compareAndSet(getValue, getValue + 1)동시에 실행- 둘 중 하나의 스레드가 먼저 접근하여 값을 1 올리고
result에true가 할당 되어while종료 - 두 번째로 접근한 스레드가 위에서 받은 값(0)과
atomicInteger의 값(1)을 비교해 보니 일치하지 않아false를 반환 - 다시
getValue = atomicInteger.get()를 통해 1을 반환받음 result = atomicInteger.compareAndSet(getValue, getValue + 1)를 실행- 이제 일치하니
result에true할당하고while종료
즉, 원자적 연산이기에 동시에 연산은 불가능 하니 충돌이 발생하면 다시 실행하는 것입니다.
CAS 락 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
log("락 획득 시도");
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SpinLockMain {
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
Runnable task = new Runnable() {
@Override
public void run() {
spinLock.lock();
// critical section
try {
log("비즈니스 로직 실행");
} finally {
spinLock.unlock();
}
}
};
new Thread(task, "Thread-1").start();
new Thread(task, "Thread-2").start();
}
}
스핀 락
스레드가 락이 해제되기를 기다리면서 반복문을 통해 계속 해서 확인하는 모습이 마치 제자리에서 회전하는 것처럼 보여 스핀 락이라고 합니다.
스레드가 락을 획득 할 때 까지 대기하는 것을 스핀 대기(spin-wait) 또는 CPU 자원을 계속 사용하면서 바쁘게 대기한다고 해서 바쁜 대기(busy-wait)라고 합니다.
CAS와 락 방식 비교
CAS
- 장점
- 낙관적 동기화: 충돌이 자주 발생하지 않을 것이라 가정
- 락 프리(Lock-Free): CAS는 락을 사용하지 않기 때문에, 락을 획득하기 위해 대기하는 시간이 없음. 따라서 스레드가 블로킹 되지 않으며, 병렬 처리가 더 효율적일 수 있음
- 단점
- 충돌이 빈번한 경우: 충돌이 발생하면 루프를 돌며 재시도 해야 하므로 CPU 자원을 계속 소모할 수 있음
동기화 락
- 장점
- 충돌 관리: 락을 사용하면 하나의 스레드만 리소스에 접근 가능
- 안정성: 복잡한 상황에서도 락은 일관성 있는 동작을 보장
- 스레드 대기: 락을 대기하는 스레드는 CPU를 거의 사용하지 않음
- 단점
- 락 획득 대기 시간: 스레드가 락을 획득하기 위해 대기해야 하므로, 대기 시간이 길어질 수 있음
- 컨텍스트 스위칭 오버헤드: 락 획득을 대기하는 시점과 락을 획득하는 시점에 스레드의 상태가 변경됨. 이때 컨텍스트 스위칭이 발생할 수 있으며, 이로 인해 오버헤드가 증가할 수 있음
충돌이 빈번하게 발생하지 않는 경우 CAS를 이용하는 것이 좋습니다.
데이터베이스를 기다리거나 다른 서버와의 요청 같은 경우는 시간이 오래걸리기 때문에 이런 경우 동기화 락을 사용하는 것이 좋습니다.
