[자바] 원자적 연산, 동기화
자바는 멀티스레드 상황에서 안전하게 연산을 할 수 있도록 AtomicXxx 클래스들을 지원한다.
먼저 Atomic이 아닌 int 값을 여러 스레드에서 ++하는 코드를 보도록 하자.
public class Test {
public static void main(String[] args) throws InterruptedException {
IntTest intTest = new IntTest(0);
System.out.println("연산 전 = " + intTest.getValue());
test(intTest);
System.out.println("연산 후 = " + intTest.getValue());
}
private static void test(IntTest intTest) throws InterruptedException {
Runnable runnable = () -> {
try {
Thread.sleep(10);
intTest.increment();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 1; i <= 1000; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
static class IntTest {
private int value;
public IntTest(int value) {
this.value = value;
}
public void increment() {
value++;
}
public int getValue() {
return value;
}
}
}
출력 결과
연산 전 = 0
연산 후 = 980 ← 계속 다르게 나옴
결과가 이렇게 나오는 이유는 ++연산이 원자적이지 않기 때문이다.
value++
- value 값을 읽어온다.
- 읽어온 value에 1을 더한다.
- 더한 값을 value 변수에 저장한다.
이렇게 원자적이지 않은 연산을 여러 스레드에서 수행할 시 결과가 예상과 다른 문제가 발생한다.
그렇다면 원자적 연산인지 어떻게 판단해야하는가?
예시로 int min = average가 원자적 연산인가에 대한 답은 average이 어떤 변수인가에 따라 다르다.
average가 상수이거나, 지역 변수면 다른 스레드가 간섭할 수 없으므로 원자적 연산이지만
average가 인스턴스 변수이거나 클래스 변수면 다른 스레드가 간섭할 수 있으므로 원자적 연산 X
즉 다른 스레드가 해당 연산에 영향을 미칠 수 있는 지로 판단.
→ 원자적 연산은 중간에 다른 스레드가 끼어들 수 없는 하나의 작업 단위로 수행되는 연산을 뜻함.
자바는 멀티스레드에서 안전하게 값 증가, 감소 연산을 할 수 있는 AtomicInteger 클래스를 제공한다.
위 코드를 AtomicInteger로 바꾸어보자.
public class Test {
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("연산 전 = " + atomicInteger.get());
test(atomicInteger);
System.out.println("연산 후 = " + atomicInteger.get());
}
private static void test(AtomicInteger atomicInteger) throws InterruptedException {
Runnable runnable = () -> {
try {
Thread.sleep(10);
atomicInteger.incrementAndGet(); // 값을 하나 증가 후 결과를 반환
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 1; i <= 1000; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
}
출력 결과
연산 전 = 0
연산 후 = 1000
마치 synchronized로 동기화한 것 처럼 안전하게 연산이 수행된 것을 확인할 수 있다.
AtomicInteger 작동 원리 (CAS)
AtomicInteger는 synchronized, Lock을 사용하지 않고도 동기화가 가능하고 이를 사용하는 경우보다 성능이 빠른데 어떻게 동작하는 것일까?
일단 락을 사용하는 경우를 살펴보면 일반적으로 다음과 같이 동작한다.
- 락이 있는지 확인
- 락이 있다면 얻고 임계 영역에 들어간 후 작업 수행
- 락 반납
→ 락 획득과 반납이 반복되는 복잡한 과정을 거침
CAS란?
락을 걸지 않고 원자적인 연산을 수행할 수 있는 방법으로 락 프리 기법이라고도 함.
위에서 작성한 AtomicInteger.incrementAndGet() 메서드는 다음과 같이 동작한다.
public class Test {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("연산 전 = " + atomicInteger.get());
int resultValue1 = incrementAndGet(atomicInteger);
System.out.println("연산 후 = " + resultValue1);
}
private static int incrementAndGet(AtomicInteger atomicInteger) {
int value;
boolean result;
do {
value = atomicInteger.get();
result = atomicInteger.compareAndSet(value, value + 1);
} while (!result);
return value + 1;
}
}
출력 결과
연산 전 = 0
연산 후 = 1
- 먼저 atomicInteger.get()으로 int 값을 가져온다.
- CAS(compareAndSet()) 연산으로 읽은 value 값이 메모리의 value 값과 같다면 (읽고난 뒤 연산이 수행이 안되었다면) value의 값을 하나 증가
- 성공했다면 true 반환 후 do~while 문을 빠져나감. 실패했다면 false 반환 후 다시 위 과정을 진행
CAS (CompareAndSet) → 연산이 원자적으로 수행된다.
CompareAndSet(0, 1) 의 경우를 보면 왼쪽이 기대하는 값, 오른쪽은 변경하고 싶은 값이다.
이 연산은 다음과 같이 수행이 된다.
- 먼저 메인 메모리에 있는 값을 확인.
- 메인 메모리에서 읽어온 값이 기대하는 값(0)이라면 원하는 값(1)으로 변경.
이렇게 보면 원자적인 연산이 아닌 것 처럼 보이는데 CAS 연산은 하드웨어 차원에서 이 두 연산을 하나의 원자적인 연산으로 묶어서 제공한다.
즉 1, 2번 과정이 진행되는 동안 다른 스레드가 해당 메인 메모리의 있는 값을 변경하지 못하도록 하드웨어 차원에서 막아 원자적인 연산으로 제공한다.
CAS를 이용한 동기화
이러한 원리를 이용해서 락을 구현해보도록 하겠다.
AtomicBoolean을 이용해서 연산이 원자적으로 수행되도록 한다.
lock() 메서드는 while 문을 이용해 lock의 값이 false(아무도 락을 가져가지 않음)라면 해당 값을 true로 설정한다. → 원자적
unlock() 메서드는 가지고 있는 락의 값을 false로 바꾼다. → 원자적
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
System.out.println(Thread.currentThread().getName() + " 락 획득 시도");
while (!lock.compareAndSet(false, true)) {
System.out.println(Thread.currentThread().getName() + " 락 획득 실패 - 스핀 대기");
}
System.out.println(Thread.currentThread().getName() + " 락 획득 성공");
}
public void unlock() {
lock.set(false);
System.out.println(Thread.currentThread().getName() + " 락 반납 완료");
}
}
스핀락 → 락이 해제되는 것을 반복문을 통해 계속해서 확인하는 방식
이렇게 스레드가 락을 획득할 때 까지 CPU 자원을 계속 사용해서 바쁘게 대기하는 것을 busy-wait 라고 함.
public class SpinLockTest {
public static void main(String[] args) throws InterruptedException {
SpinLock spinLock = new SpinLock();
Runnable runnable = () -> {
spinLock.lock();
try {
try {
System.out.println("비즈니스 로직 실행");
Thread.sleep(0); // 오래 걸리는 로직에 스핀락하면 성능 저하 - while문을 계속 돌기 때문
} catch (InterruptedException e) {
System.out.println("인터럽트 발생!");
}
} finally {
spinLock.unlock();
}
};
Thread t1 = new Thread(runnable, "Thread-1");
Thread t2 = new Thread(runnable, "Thread-2");
t1.start();
t2.start();
}
}
출력 결과
Thread-1 락 획득 시도
Thread-2 락 획득 시도
Thread-1 락 획득 성공
비즈니스 로직 실행
Thread-2 락 획득 실패 - 스핀 대기
Thread-2 락 획득 실패 - 스핀 대기
Thread-2 락 획득 성공
비즈니스 로직 실행
Thread-1 락 반납 완료
Thread-2 락 반납 완료
- thread-1과 thread-2가 동시에 락 획득을 시도한다.
- thread-1이 락을 얻고 로직을 수행한다.
- thread-2는 로직이 수행되는 동안 while 문에서 락을 얻기 위해 계속 확인한다.
- thread-1이 로직을 모두 수행하고 unlock()으로 락을 반납한다.
- thread-2가 락을 얻고 로직을 수행하고 완료 후 unlock()으로 락을 반납한다.
이렇게 CAS 연산으로 스레드의 상태를 변화시키고 객체 내부의 락 획득, 반납이라는 복잡한 과정없이 동기화를 진행할 수 있다는 장점이 있다.
하지만 위 코드에서 로직이 오래 걸린다고 가정했을 때의 경우를 보자. → sleep(10)으로 바꾸고 실행
그러면 콘솔에 어마어마하게 많은 Thread-2 락 획득 실패 - 스핀 대기 라는 문구가 출력되는 것을 확인할 수 있다. 이러한 이유는 thread-1이 시간이 걸리는 로직을 수행하는 동안 thread-2가 락을 얻기 위해 while()문 연산을 계속해서 한다는 것이다.
즉 synchronized, Lock을 통한 동기화와는 다르게 락을 얻기 위해 대기하는 과정에서 CPU를 낭비한다는 문제가 있다.
CAS 활용한 락 프리 기법 | 동기화 락 | |
충돌 관리 | 충돌이 많으면 루프를 계속 돌면서 락을 확인하므로 오버헤드 발생 | 충돌이 발생하지 않아 안정적으로 동작하며 락을 얻으려고 대기(WAITING, BLOCKED) 시에 CPU를 거의 사용하지 않음 |
컨텍스트 스위칭으로 인한 오버헤드 | 스레드 상태가 바뀌지 않으므로 오버헤드가 발생하지 않음 | 락 대기, 획득 시점에 스레드의 상태가 변경되면서 컨텍스트 스위칭이 발생할 수 있으므로 오버헤드 증가 |
락 획득 대기 | 락을 사용하지 않기 때문에 스레드가 blocking 되지 않아 병렬 처리에 효율적 | 락 획득을 위해 대기하여 대기 시간이 길어질 수 있음 (blocking) |
대부분 공유 자원을 사용할 때 충돌하지 않을 가능성이 훨씬 높다.
1시간에 주문이 100만 건 들어온다고 가정하면 1초에 약 277건 주문이 들어온다.
CPU의 연산 속도를 생각하면 이런 경우 충돌이 거의 발생하지 않는다.
즉 주문 수 증가와 같은 단순 연산의 경우 CAS가 뛰어난 성능을 보인다. 하지만 DB 요청을 기다리는 것과 같이 밀리초 이상의 오래 걸리는 작업이라면 동기화 락을 사용하자.
→ CAS 연산은 동기화 락을 완전 대체할 수 없고, 연산이 빠르게 끝나는 작은 단위의 일부 영역에 적용하는 것
→ 기본적으로 동기화 락을 사용, 특별한 경우에 CAS를 적용