728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

원자적 연산이란?

컴퓨터 과학에서 사용하는 원자적 연산(atomic operation)의 의미는 해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미한다. 즉, 원자적 연산은 중단되지 않고 다른 연산과 간섭 없이 완전히 실행되거나 전혀 실행되지 않는 성질을 가지고 있다. 쉽게 이야기해서 멀티 스레드 환경에서 다른 스레드의 간섭 없이 안전하게 처리되는 연산이라는 뜻이다. 

 

예를 들어 다음과 같은 필드가 있을 때,

volatile int i = 0;

 

다음 연산은 둘로 쪼갤 수 없는 원자적 연산이다.

i = 1

왜냐하면 이 연산은 다음 단 하나의 순서로 실행되기 때문이다.

  • 오른쪽에 있는 1의 값을 왼쪽의 i 변수에 대입한다.

하지만 다음 연산은 원자적 연산이 아니다.

i = i + 1

왜냐하면 이 연산은 다음 순서로 나누어 실행되기 때문이다.

  • 오른쪽에 있는 i의 값을 읽는다. (i의 값을 10이라고 가정)
  • 읽은 값에 1을 더해서 11을 만든다.
  • 더한 11을 왼쪽 i 변수에 대입한다.

원자적 연산은 멀티 스레드 상황에서 아무런 문제가 발생하지 않는다. 하지만 원자적 연산이 아닌 경우에는 synchronized 블록이나 Lock등을 사용해서 안전한 임계 영역을 만들어야 한다.

 

 

원자적 연산 시작

원자적이지 않은 연산을 멀티스레드 환경에서 실행하면 어떤 문제가 발생하는지 코드로 알아보자.

IncrementInteger는 숫자 값을 하나씩 증가시키는 기능을 제공한다. 예를 들어서 지금까지 접속한 사용자의 수 등을 계산할 때 사용할 수 있다.

 

IncrementInteger

package thread.cas.increment;

public interface IncrementInteger {
    void increment();

    int get();
}
  • IncrementInteger는 값을 증가하는 기능을 가진 숫자 기능을 제공하는 인터페이스이다.
  • increment(): 값을 하나 증가
  • get(): 값을 조회

BasicInteger

package thread.cas.increment;

public class BasicInteger implements IncrementInteger {

    private int value;

    @Override
    public void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}
  • IncrementInteger 인터페이스의 가장 기본 구현이다.
  • increment()를 호출하면 value++를 통해서 값을 하나 증가한다.
    • value 값은 인스턴스의 필드이기 때문에, 여러 스레드가 공유할 수 있다. 이렇게 공유 가능한 자원에 ++와 같은 원자적이지 않은 연산을 사용하면 멀티스레드 상황에 문제가 발생할 수 있다.

IncrementThreadMain

package thread.cas.increment;

import java.util.ArrayList;
import java.util.List;

import static util.ThreadUtils.sleep;

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);
    }
}
  • THREAD_COUNT 수 만큼 스레드를 생성하고 incrementInteger.increment()를 호출한다.
  • 스레드를 1000개 생성했다면, increment() 메서드도 1000번 호출하기 때문에 결과는 1000이 되어야 한다.
  • 참고로 스레드가 너무 빨리 실행되기 때문에 여러 스레드가 동시에 실행되는 상황을 확인하기 어렵다. 그래서 run() 메서드에 sleep(10)을 두어, 최대한 많은 스레드가 동시에 increment()를 호출하도록 한다. 

실행결과

BasicInteger result: 950

 

실행결과를 보면 기대한 1000이 아니라 다른 숫자가 보인다. 이 문제는 앞서 설명한 것처럼 여러 스레드가 동시에 원자적이지 않은 value++을 호출했기 때문이다. 물론 멀티 스레드 환경에서는 공유 자원에 여러 스레드가 아무런 안전 장치 없이 자원에 쓰기 작업을 하면 문제가 발생하는것을 이제는 너무 잘 알지만 원자적 연산 관점으로 한번 생각을 해보자. 결국 공유 가능한 자원에 원자적이지 않은 연산을 하면 멀티스레드 환경에선 문제가 될 수 있다가 핵심이다!

참고로, value++value = value + 1; 이다.

 

 

그럼 volatile, synchronized를 적용해보면 어떻게 나올지 결과를 보자!

 

VolatileInteger

package thread.cas.increment;

public class VolatileInteger implements IncrementInteger {

    volatile private int value;

    @Override
    public void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}

 

SyncInteger

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;
    }
}

 

IncrementThreadMain

public static void main(String[] args) throws InterruptedException {
    test(new BasicInteger());
    test(new VolatileInteger());
    test(new SyncInteger());
    test(new MyAtomicInteger());
}

 

실행결과

BasicInteger result: 987
VolatileInteger result: 972
SyncInteger result: 1000

 

이 결과도 예측 가능한 결과였다. 이젠 volatile은 동시성 문제에 아무런 해결 방안이 되지 않는다는 것을 알고 있기 때문에. 그래서 synchronized 블록을 사용했을 때 드디어 원하는 결과가 나왔다. 근데 이럴때 그냥 원자적 연산을 가능하게 해주는 기능이 따로 있으면 편하지 않을까? 

 

AtomicInteger

이거 얘기하려고 이만큼 빌드업했다..! 자바는 앞서 만든 SyncInteger와 같이 멀티 스레드 환경에서 안전하게 증가 연산을 수행할 수 있는 AtomicInteger라는 클래스를 제공한다. 이름 그대로 원자적인 Integer라는 뜻이다. 다음과 같이 MyAtomicInteger 클래스를 만들고, 자바가 제공하는 AtomicInteger를 사용해보자.

 

MyAtomicInteger

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();
    }
}
  • new AtomicInteger(0): 초기값을 지정한다. 생략하면 0부터 시작한다.
  • incrementAndGet(): 값을 하나 증가시키고 증가된 결과를 반환한다.
  • get(): 현재 값을 반환한다.

IncrementThreadMain

public static void main(String[] args) throws InterruptedException {
    test(new BasicInteger());
    test(new VolatileInteger());
    test(new SyncInteger());
    test(new MyAtomicInteger());
}

실행결과

BasicInteger result: 998
VolatileInteger result: 989
SyncInteger result: 1000
MyAtomicInteger result: 1000

실행 결과를 보면 AtomicInteger를 사용하면 이 결과 역시 1000이 잘 찍힌것을 알 수 있다. 1000개의 스레드가 안전하게 증가 연산을 수행한 것이다. AtomicInteger는 멀티스레드 상황에 안전하고 또 다양한 값 증가, 감소 연산을 제공한다. 특정 값을 증가하거나 감소해야 하는데 여러 스레드가 해당 값을 공유해야 한다면, AtomicInteger를 사용하면 된다. 

 

참고로, AtomicInteger, AtomicLong, AtomicBoolean 등 다양한 AtomicXXX 클래스가 존재한다.

 

원자적 연산 성능 테스트

AtomicInteger의 비밀을 하나씩 파헤쳐보자. 우선 한번 지금까지 만든 클래스들의 성능을 비교해보자.

 

IncrementPerformanceMain

package thread.cas.increment;

public class IncrementPerformanceMain {

    public static final long COUNT = 100_000_000_0;

    public static void main(String[] args) {
        test(new BasicInteger());
        test(new VolatileInteger());
        test(new SyncInteger());
        test(new MyAtomicInteger());
    }

    private static void test(IncrementInteger incrementInteger) {
        long startMs = System.currentTimeMillis();

        for (int i = 0; i < COUNT; i++) {
            incrementInteger.increment();
        }

        long endMs = System.currentTimeMillis();
        System.out.println(incrementInteger.getClass().getSimpleName() + ": ms= " + (endMs - startMs));
    }
}
  • 단일 연산을 너무 빠르니까 성능 확인을 위해 10억번 수행해보자.

실행결과

BasicInteger: ms= 712
VolatileInteger: ms= 2409
SyncInteger: ms= 3320
MyAtomicInteger: ms= 2356
  • BasicInteger
    • 가장 빠르다.
    • CPU 캐시를 적극 활용한다. CPU 캐시 위력을 알 수 있다.
    • 안전한 임계 영역도 없고, volatile도 사용하지 않기 때문에 멀티 스레드 상황에는 사용할 수 없다.
    • 단일 스레드가 사용하는 경우엔 효율적이다.
  • VolatileInteger
    • volatile을 사용해서 CPU 캐시를 사용하지 않고 메인 메모리를 사용한다.
    • 안전한 임계 영역이 없기 때문에 멀티 스레드 상황에는 사용할 수 없다.
    • 단일 스레드가 사용하기엔 BasicInteger보다 느리다. 그리고 멀티 스레드 환경에서도 안전하지 않다.
  • SyncInteger
    • synchronized를 사용한 안전한 임계 영역이 있기 때문에 멀티 스레드 환경에서도 안전하게 사용할 수 있다.
    • MyAtomicInteger보다 성능이 느리다.
  • MyAtomicInteger
    • 자바가 제공하는 AtomicInteger를 사용한다. 멀티 스레드 상황에 안전하게 사용할 수 있다.
    • 성능도 synchronized, Lock(ReentrantLock)을 사용하는 경우보다 1.5 ~ 2배 정도 빠르다.

SyncInteger 처럼 락을 사용하는 경우보다, AtomicInteger가 더 빠른 이유는 무엇일까? i++ 연산은 원자적인 연산이 아니다. 따라서 분명히 synchronized, Lock(ReentrantLock)과 같은 락을 통해 안전히 임계 영역을 만들어야 할 것 같다. 놀랍게도 AtomicInteger가 제공하는 incrementAndGet() 메서드는 락을 사용하지 않고, 원자적 연산을 만들어 낸다.

 

CAS 연산

락 기반 방식의 문제점

SyncInteger와 같은 클래스는 데이터를 보호하기 위해 락을 사용한다. 여기서 말하는 락은 synchronized, Lock(ReentrantLock)등을 사용하는 것을 말한다. 락은 특정 자원을 보호하기 위해 스레드가 해당 자원에 대해 접근하는 것을 제한한다. 락이 걸려 있는 동안 다른 스레드들은 해당 자원에 접근할 수 없고, 락이 해제될 때까지 기다린다. 또한 락 기반 접근에서는 락을 획득하고 해제하는 데 시간이 소요된다. 

 

예를 들어 락을 사용하는 연산이 있다고 하면 이런 흐름으로 동작한다.

  1. 락이 있는지 확인한다.
  2. 락을 획득하고 임계 영역에 들어간다.
  3. 작업을 수행한다.
  4. 락을 반납한다.

여기서 락을 획득하고 반납하는 과정이 계속 반복된다. 10000번의 연산이 있다면 10000번의 연산 모두 같은 과정을 반복한다. 이렇듯 락을 사용하는 방식은 직관적이지만 상대적으로 무겁다는 단점이 있다.

 

CAS

이런 문제를 해결하기 위해 락을 걸지 않고 원자적인 연산을 수행할 수 있는 방법이 있는데 이것을 CAS(Compare-And-Swap, Compare-And-Set) 연산이라고 한다. 이 방법은 락을 사용하지 않기 때문에 락 프리(lock-free) 기법이라고 한다. 참고로 CAS 연산은 락을 완전히 대체하는 게 아니라 작은 단위의 일부 영역에 적용할 수 있다. 기본은 락을 사용하고 특별한 경우에 CAS를 적용할 수 있다고 생각하면 된다.

 

다음 코드를 보자.

CasMainV1

package thread.cas;

import java.util.concurrent.atomic.AtomicInteger;

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());

        
    }
}
  • new AtomicInteger(0); 내부에 있는 기본 숫자 값을 0으로 설정한다.
  • 자바는 AtomicXxxcompareAndSet() 메서드를 통해 CAS 연산을 지원한다.

실행 결과

start value = 0
result1 = true, value = 1
result2 = false, value = 1

 

compareAndSet(0, 1)

atomicInteger가 가지고 있는 값이 현재 0이면 이 값을 1로 변경하라는 매우 단순한 메서드이다.

  • 만약 atomicInteger의 값이 현재 0이라면 atomicInteger의 값은 1로 변경된다. 이 경우 true를 반환한다.
  • 만약 atomicInteger의 값이 현재 0이 아니라면 atomicInteger의 값은 변경되지 않는다. 이 경우 false를 반환한다.

여기서 가장 중요한 내용이 있는데, 이 메서드는 원자적으로 실행된다는 점이다. 그리고 이 메서드가 제공하는 기능이 바로 CAS(compareAndSet)연산이다.

 

  • 여기서는 AtomicInteger 내부에 있는 value의 값이 0이라면, 1로 변경하고 싶다.
  • compareAndSet(0, 1)을 호출한다. 매개변수의 왼쪽이 기대하는 값, 오른쪽이 변경하는 값이다.
  • CAS 연산은 메모리에 있는 값이 기대하는 값이라면 원하는 값으로 변경한다.
  • 메모리에 있는 value의 값이 0이므로 1로 변경할 수 있다.
  • 그런데 생각해보면 이 명령어는 2개로 나누어진 명령어이다. 따라서 원자적이지 않은 연산처럼 보인다.
    • 1. 먼저 메인 메모리에 있는 값을 확인
    • 2. 해당 값이 기대하는 값이라면 원하는 값으로 변경

 

CPU 하드웨어의 지원

원자적이지 않은 연산도 원자적 연산으로 할 수 있는 이유는 바로 CPU의 지원 때문이다!

 

CAS 연산은 이렇게 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 특별하게 하나의 원자적인 연산으로 묶어서 제공하는 기능이다. 이것은 소프트웨어가 제공하는 기능이 아니라 하드웨어가 제공하는 기능이다. 대부분의 현대 CPU들은 CAS 연산을 위한 명령어를 제공한다.

 

CPU는 다음 두 과정을 묶어서 하나의 원자적인 명령으로 만들어버린다. 따라서 중간에 다른 스레드가 개입할 수 없다.

  1. x001의 값을 확인한다.
  2. 읽은 값이 0이면 1로 변경한다.

CPU는 두 과정을 하나의 원자적인 명령으로 만들기 위해 1번과 2번 사이에 다른 스레드가 x001의 값을 변경하지 못하게 막는다. 참고로 1번과 2번 사이의 시간은 CPU 입장에서 보면 진짜 아주 잠깐 찰나의 순간이다. 생각을 해보자. 1초에 몇번의 연산을 CPU가 할 수 있는지? 수억번이다 수억번. 그러니까 저 1번과 2번 사이에 다른 스레드가 변경하지 못하게 막는 행위가 성능에 큰 영향을 끼치지도 않는다. 

 

  • value의 값이 0 → 1이 되었다.
  • CAS 연산으로 값을 성공적으로 변경하고 나면 true를 반환한다.

 

여기까지 듣고보면 CAS 연산도 별 게 아니다. 그냥 CPU 차원에서 하드웨어적으로 원자적 연산을 가능하게 해준다는 것이다. CPU 입장에서 그 찰나의 순간은 사람이 느끼지도 못할 정도의 시간이니까 성능의 차이도 딱히 없다. 그럼 결국 1. 값을 확인하고, 2. 값을 변경하는 두 연산을 하나로 묶어 원자적으로 제공한다는 것을 이해했을 것이다. 그런데 이 기능이 어떻게 락을 일부 대체할 수 있다는 걸까?

 

어떤 값을 하나 증가하는 value++ 연산은 원자적 연산이 아니다. 이 연산은 다음과 같다.

value = value + 1;

이 연산은 다음 순서로 실행된다. value의 초기값은 0으로 가정하겠다.

  1. 오른쪽에 있는 value의 값을 읽는다. value의 값은 0이다.
  2. 읽은 01을 더해서 1을 만든다.
  3. 더한 1을 왼쪽에 value 변수에 대입한다.

1번과 3번 연산 사이에 다른 스레드가 value의 값을 변경할 수 있기 때문에, 문제가 될 수 있다. 따라서 value++ 연산을 여러 스레드에서 사용한다면 락을 건 다음에 값을 증가해야 한다. 

 

CAS 연산을 활용해서 락 없이 값을 증가하는 기능을 만들어보자. AtomicInteger가 제공하는 incrementAndGet() 메서드가 어떻게 CAS 연산을 활용해서 락 없이 만들어졌는지 직접 구현해보자.

 

CasMainV2

package thread.cas;

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());

        int resultValue1 = incrementAndGet(atomicInteger);
        System.out.println("resultValue1 = " + resultValue1);

        int resultValue2 = incrementAndGet(atomicInteger);
        System.out.println("resultValue1 = " + resultValue2);
    }

    private static int incrementAndGet(AtomicInteger atomicInteger) {
        int getValue;
        boolean result;

        do {
            getValue = atomicInteger.get();
            log("getValue : " + getValue);

            result = atomicInteger.compareAndSet(getValue, getValue + 1);
            log("result: " + result);
        } while (!result);
        return getValue + 1;
    }
}

여기서 만든 incrementAndGet()atomicInteger 내부의 value 값을 하나 증가하는 메서드이다. 사실 atomicInteger도 이 메서드를 제공하지만 여기서는 이해를 위해 직접 구현해보자.

 

CAS 연산을 사용하면 여러 스레드가 같은 값을 사용하는 상황에서도 락을 걸지 않고, 안전하게 값을 증가할 수 있다. 여기서는 락을 걸지 않고 CAS 연산을 사용해서 값을 증가했다.

  • getValue = atomicInteger.get()을 사용해서 value값을 읽는다.
  • compareAndSet(getValue, getValue + 1)을 사용해서 방금 읽은 value값이 메모리의 value값과 같다면 value 값을 하나 증가한다. 여기서 CAS연산을 사용한다.
  • 만약 CAS 연산이 성공한다면 true를 반환하고 do~while문을 빠져나온다.
  • 만약 CAS 연산이 실패한다면 false를 반환하고 do~while문을 다시 시작한다.

실행 결과

start value = 0
2024-07-26 13:12:54.358 [     main] getValue : 0
2024-07-26 13:12:54.361 [     main] result: true
resultValue1 = 1
2024-07-26 13:12:54.361 [     main] getValue : 1
2024-07-26 13:12:54.361 [     main] result: true
resultValue1 = 2

 

지금은 순서대로 실행되기 때문에, 결과는 다음과 같다.

 

incrementAndGet 첫 번째 실행

  • atomicInteger.get()을 사용해서 value값을 읽는다 → 0이다.
  • compareAndSet(0, 1)을 수행한다.
  • CAS 연산을 성공했으므로 value값은 0에서 1로 증가하고 true를 반환한다.
  • do ~ while문을 빠져나온다.

incrementAndGet 두 번째 실행

  • atomicInteger.get()을 사용해서 value값을 읽는다 → 1이다.
  • compareAndSet(1, 2)를 수행한다.
  • CAS 연산이 성공했으므로 value값은 1에서 2로 증가하고 true를 반환한다.
  • do ~ while문을 빠져나온다.

지금은 main 스레드 하나로 순서대로 실행되기 때문에 CAS 연산이 실패하는 상황을 볼 수 없다. 우리가 기대하는 실패하는 상황은 연산의 중간에 다른 스레드가 값을 변경해버리는 것이다. 멀티 스레드로 실행해서 CAS 연산이 실패해서 다시 시도하는 경우를 알아보자.

 

CAS 연산 결과가 false가 나는 경우

 

CasMainV2

package thread.cas;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class CasMainV2 {

    public static final int THREADS = 100;

    public static void main(String[] args) throws InterruptedException {

        AtomicInteger atomicInteger = new AtomicInteger(0);
        System.out.println("start value = " + atomicInteger.get());

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < THREADS; i++) {
            Thread thread = new Thread(new MyTask(atomicInteger));
            threads.add(thread);
            thread.start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("main result = " + atomicInteger.get());
    }

    static class MyTask implements Runnable {

        private final AtomicInteger atomicInteger;

        public MyTask(AtomicInteger atomicInteger) {
            this.atomicInteger = atomicInteger;
        }

        @Override
        public void run() {
            int getValue;
            boolean result;

            do {
                getValue = atomicInteger.get();
                sleep(100);
                log("getValue : " + getValue);

                result = atomicInteger.compareAndSet(getValue, getValue + 1);
                log("result: " + result);
            } while (!result);
        }
    }
}

 

여러 스레드가 한번에 실행되는 경우, 스레드 충돌 발생 가능성이 있다. 그 경우에 이 결과는 어떻게 될지 알아보자.

실행 결과

...
2024-07-26 13:20:28.476 [Thread-995] result: true
2024-07-26 13:20:28.476 [Thread-996] getValue : 996
2024-07-26 13:20:28.476 [Thread-996] result: true
2024-07-26 13:20:28.476 [Thread-997] getValue : 997
2024-07-26 13:20:28.476 [Thread-997] result: true
2024-07-26 13:20:28.476 [Thread-998] getValue : 998
2024-07-26 13:20:28.476 [Thread-998] result: true
2024-07-26 13:20:28.476 [Thread-999] getValue : 999
2024-07-26 13:20:28.476 [Thread-999] result: true
main result = 1000

 

실행해 보았지만, 충돌나는 경우가 단 한번도 없었다. 이처럼 간단한 연산은 CPU 입장에서 너무 쉬운 작업이기 때문에 스레드끼리 충돌 날 시간도 주지않고 바로바로 처리해버린다. 이 말이 결국 힌트다. 이 내용 핵심에 대한. 

 

그럼 중간에 시간을 좀 줘보자. 

do {
    getValue = atomicInteger.get();
    sleep(100); // sleep 추가!
    log("getValue : " + getValue);

    result = atomicInteger.compareAndSet(getValue, getValue + 1);
    log("result: " + result);
} while (!result);

값을 읽은 다음 0.1초 정도 대기하는 시간을 추가했다. 그럼 스레드 충돌이 일어날 가능성이 높아질 것이다.

실행 결과

...
2024-07-26 14:58:26.249 [Thread-69] getValue : 96
2024-07-26 14:58:26.249 [Thread-69] result: false
2024-07-26 14:58:26.249 [Thread-96] result: true
2024-07-26 14:58:26.350 [Thread-19] getValue : 97
2024-07-26 14:58:26.350 [Thread-19] result: true
2024-07-26 14:58:26.350 [Thread-16] getValue : 97
2024-07-26 14:58:26.350 [Thread-69] getValue : 97
2024-07-26 14:58:26.350 [Thread-69] result: false
2024-07-26 14:58:26.350 [Thread-16] result: false
2024-07-26 14:58:26.451 [Thread-16] getValue : 98
2024-07-26 14:58:26.451 [Thread-16] result: true
2024-07-26 14:58:26.451 [Thread-69] getValue : 98
2024-07-26 14:58:26.452 [Thread-69] result: false
2024-07-26 14:58:26.553 [Thread-69] getValue : 99
2024-07-26 14:58:26.553 [Thread-69] result: true
main result = 100

보면 결과가 false가 여러번 나오는 것을 확인할 수 있다. 그렇지만 결국 결과는 완벽하게 100을 찍었다. (스레드 개수를 100으로 했을 때 결과) 

 

당연히 멀티 스레드 환경에서는 공유 자원에 대해 여러 스레드가 동시에 값을 읽고 쓰고 할 수 있기 때문에 위 실행 결과에서 Thread-69 입장에서 본인이 읽었을 때 시점과 0.1초 후에 다시 CAS 연산을 시도했을 때 예상한 원래 값이 달라질 수 있을 것이라는 추측이 가능하다. 그럼에도 원하는 결과를 정확히 찍을 수 있는 이유는 연산 시점에서는 적어도? 다른 스레드가 접근하지 못하도록 CPU 차원에서 막고 있기 때문이다. 이게 바로 CAS 연산이다. 

 

정리를 하자면,

AtomicInteger가 제공하는 incrementAndGet() 코드가 우리가 직접 작성한 incrementAndGet() 메서드와 똑같이 CAS를 활용하도록 작성되어 있다. 그리고 조건에 맞을때까지 루프를 돌면서 확인하는 코드도 똑같다. CAS를 활용하면 락을 사용하지 않지만, 대신에 다른 스레드가 값을 먼저 증가해서 문제가 발생하는 경우 루프를 다시 돌아 재시도를 하는 방식으로 사용한다. 

 

이 방식은 다음과 같이 동작한다.

  1. 현재 변수의 값을 읽어온다.
  2. 변수의 값을 1 증가시킬 때, 원래 값이 같은지 확인한다. (CAS 연산 활용)
  3. 동일하다면 증가된 값을 변수에 저장하고 종료한다.
  4. 동일하지 않다면 다른 스레드가 값을 중간에 변경한 것이므로, 다시 처음으로 돌아가 위 과정을 반복한다.

두 스레드가 동시에 실행되면서 문제가 발생하는 상황을 스레드가 충돌했다고 표현한다.

이 과정에서 충돌이 발생할 때 마다 반복해서 다시 시도하므로, 결과적으로 락 없이 데이터를 안전하게 변경할 수 있다. CAS 연산을 사용하는 방식은 충돌이 드물게 발생하는 환경에서는 락을 사용하지 않으므로 높은 성능을 발휘할 수 있다. 이는 락을 사용하는 방식과 비교했을 때, 스레드가 락을 획득하기 위해 대기하지 않기 때문에 대기 시간과 오버헤드가 줄어드는 장점이 있다. 

 

그러나, 충돌이 빈번하게 발생하는 환경에서는 성능에 문제가 될 수 있다. 여러 스레드가 자주 동시에 동일한 변수의 값을 변경하려고 시도할 때, CAS는 자주 실패하고 재시도해야 하므로 성능 저하가 발생할 수 있다. 이런 상황에서는 반복문을 계속 돌기 때문에 CPU 자원을 많이 소모하게 된다.

 

CAS(Compare-And-Set)와 락(Lock)방식의 비교

 

락 방식

  • 비관적(pessimistic) 접근법 (기본적으로 "여기선 충돌이 날거야!" 라고 생각하고 아예 입구를 틀어막는 관점)
  • 데이터에 접근하기 전에 항상 락을 획득
  • 다른 스레드의 접근을 막음
  • "다른 스레드가 방해할 것이다"라고 가정

CAS 방식

  • 낙관적(optimistic) 접근법
  • 락을 사용하지 않고 데이터에 바로 접근
  • 충돌이 발생하면 그때 재시도
  • "대부분의 경우 충돌이 없을 것이다"라고 가정

정리하면, 충돌이 많이 없는 경우에 CAS 연산이 빠른 것을 확인할 수 있다.

그럼 충돌이 많이 발생하지 않는 연산은 어떤 것이 있을까? 언제 CAS 연산을 사용하면 좋을까?

사실 간단한 CPU 연산은 너무 빨리 처리되기 때문에 충돌이 자주 발생하지 않는다. 충돌이 발생하기도 전에 이미 연산을 완료하는 경우가 더 많다. 

 

즉, 간단한 CPU 연산에는 락 보단 CAS 연산을 사용하면 더 효율적이고 복잡하고 어려운 과정이 들어가있는, 시간이 많이 소모되는 연산에 대해서는 락 방식으로 완전히 안전한 임계 영역을 만들어 사용하면 될 것 같다.

 

CAS 락

CAS는 단순 연산 뿐만 아니라, 락을 구현하는데도 사용할 수 있다. synchronized, Lock(ReentrantLock)없이 CAS를 활용해서 락을 구현해보자. 먼저 CAS의 필요성을 이해하기 위해 CAS없이 직접 락을 구현해보자.

 

SpinLockBad

package thread.cas.spinlock;

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("락 반납 완료");
    }
}
  • 구현 원리는 매우 단순하다.
  • 스레드가 락을 획득하면 lock의 값이 true가 된다.
  • 스레드가 락을 반납하면 lock의 값이 false가 된다. 
  • 스레드가 락을 획득하면 while문을 빠져나온다.
  • 스레드가 락을 획득하지 못하면 락을 획득할 때까지 while문을 계속 반복 실행한다.
"어? 왜 스핀 락인가요?" 락이 해제되기를 기다리면서 계속해서 반복문을 통해 확인하는 모습이 마치 계속 빙글빙글 돌고 있는것 같다고 해서 스핀 락이라고 불린다.

 

SpinLockMain

package thread.cas.spinlock;

import static util.MyLogger.log;

public class SpinLockMain {

    public static void main(String[] args) {
        SpinLockBad spinLock = new SpinLockBad();

        Runnable task = new Runnable() {
            @Override
            public void run() {
                spinLock.lock();

                try {
                    log("비즈니스 로직 실행");
                } finally {
                    spinLock.unlock();
                }
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();


    }
}

실행 결과

2024-07-26 15:43:41.447 [ Thread-1] 락 획득 시도
2024-07-26 15:43:41.447 [ Thread-2] 락 획득 시도
2024-07-26 15:43:41.553 [ Thread-1] 락 획득 완료
2024-07-26 15:43:41.553 [ Thread-2] 락 획득 완료
2024-07-26 15:43:41.553 [ Thread-1] 비즈니스 로직 실행
2024-07-26 15:43:41.553 [ Thread-2] 비즈니스 로직 실행
2024-07-26 15:43:41.554 [ Thread-1] 락 반납 완료
2024-07-26 15:43:41.554 [ Thread-2] 락 반납 완료

 

실행 결과를 보면 기대와 다르게 Thread-0, Thread-1 모두 둘 다 동시에 락을 획득했다. 이제는 왜 그런지 안다. 동시성 문제를 해결하지 못한 코드이기 때문이다. volatile은 동시성 문제를 해결하는 방안이 아니다. 

 

그럼 여기서 어떤 부분이 문제일까? 바로 다음 두 부분이 원자적이지 않다는 것이다.

  1. 락 사용 여부 확인
  2. 락의 값 변경

이 둘은 한번에 하나의 스레드만 실행해야 한다. 따라서 synchronized 또는 Lock을 사용해서 두 코드를 동기화해서 안전한 임계 영역을 만들어야 한다. 여기서! 다른 해결 방안도 있다. 바로 두 코드를 하나로 묶어서 원자적 연산처리를 하는 것이다.

CAS 연산을 사용하면 두 연산을 하나로 묶어서 하나의 원자적인 연산으로 처리할 수 있다.

 

락의 사용 여부를 확인하고, 그 값이 기대하는 값과 같다면 변경하는 것이다. CAS 연산에 딱 들어 맞는다!

 

SpinLock

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)) {
            log("락 획득 실패 - 스핀 대기");
        }
        log("락 획득 완료");
    }

    public void unlock() {
        lock.set(false);
        log("락 반납 완료");
    }
}

CAS 연산을 지원하는 AtomicBoolean을 사용했다.

 

구현 원리는 단순하다.

  • 스레드가 락을 획득하면 lock의 값이 true가 된다.
  • 스레드가 락을 반납하면 lock의 값이 false가 된다.
  • 스레드가 락을 획득하면 while문을 빠져나온다.
  • 스레드가 락을 획득하지 못하면 락을 획득할 때까지 while문을 계속 반복 실행한다.

락을 획득할 때 매우 중요한 부분이 있다. 바로 다음 두 연산을 하나로 만들어야 한다는 것이다.

  1. 락 사용 여부 확인
  2. 락의 값 변경

여기에 딱 맞는 방법이 바로 다음 코드 한 줄이다.

lock.compareAndSet(false, true);

→ 현재 lock의 값이 false라면 true로 변경해라.

이것은 CAS 연산으로 수행된다. 

 

SpinLock을 사용해서 다시 실행해보자!

package thread.cas.spinlock;

import static util.MyLogger.log;

public class SpinLockMain {

    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();

        Runnable task = new Runnable() {
            @Override
            public void run() {
                spinLock.lock();

                try {
                    log("비즈니스 로직 실행");
                } finally {
                    spinLock.unlock();
                }
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");

        t1.start();
        t2.start();


    }
}

실행 결과

2024-07-26 15:50:56.510 [ Thread-1] 락 획득 시도
2024-07-26 15:50:56.510 [ Thread-2] 락 획득 시도
2024-07-26 15:50:56.516 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:50:56.516 [ Thread-1] 락 획득 완료
2024-07-26 15:50:56.516 [ Thread-1] 비즈니스 로직 실행
2024-07-26 15:50:56.516 [ Thread-1] 락 반납 완료
2024-07-26 15:50:56.516 [ Thread-2] 락 획득 완료
2024-07-26 15:50:56.517 [ Thread-2] 비즈니스 로직 실행
2024-07-26 15:50:56.517 [ Thread-2] 락 반납 완료

 

실행 결과를 보면 락이 잘 적용된 것을 알 수 있다.

 

이렇게 CAS는 연산뿐 아니라 락을 구현해낼 수도 있다. 그렇지만 단점도 있다. 만약, 다음 코드로 변경하면 어떻게 될까?

Runnable task = new Runnable() {
    @Override
    public void run() {
        spinLock.lock();

        try {
            log("비즈니스 로직 실행");
            sleep(1);
        } finally {
            spinLock.unlock();
        }
    }
};

 

실제로 실행되는 비즈니스 로직, 그러니까 스레드가 실행하는 로직이 1 MS만 늘어나도 이 CAS를 사용한 락은 CPU를 많이 갉아먹을 것이다. 

실행 결과

2024-07-26 15:55:23.806 [ Thread-1] 락 획득 시도
2024-07-26 15:55:23.806 [ Thread-2] 락 획득 시도
2024-07-26 15:55:23.812 [ Thread-1] 락 획득 완료
2024-07-26 15:55:23.812 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.813 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.813 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.813 [ Thread-1] 비즈니스 로직 실행
2024-07-26 15:55:23.813 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.814 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.814 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.814 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.815 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.815 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.815 [ Thread-2] 락 획득 실패 - 스핀 대기
2024-07-26 15:55:23.815 [ Thread-1] 락 반납 완료
2024-07-26 15:55:23.815 [ Thread-2] 락 획득 완료
2024-07-26 15:55:23.816 [ Thread-2] 비즈니스 로직 실행
2024-07-26 15:55:23.817 [ Thread-2] 락 반납 완료

 

무슨 말이냐면, 비즈니스 로직의 실행 처리 속도나 복잡도가 올라가면 올라갈수록 이 락을 획득하기 위해 계속해서 스핀락은 시도를 할거고 그 시도엔 CPU 자원을 사용한다는 점이다. 

 

그래서, 계속 말하지만 결론은, 연산이 길지 않고 매우매우매우 짧게 끝날 때는 CAS가 더 효율적일 수 있고, 연산이 복잡하고 길다면 무조건 Lock(ReentrantLock)을 사용하면 된다. 그러니까 더 짧은 결론은 기본적으론 Lock(ReentrantLock)을 사용하는데 아주 특별한 경우에 한정해서 CAS를 사용하면 최적화할 수 있다.

 

정리

동기화 락을 사용하는 방식과 CAS를 활용하는 락 프리 방식의 장단점을 비교해보자!

 

CAS

  • 장점
    • 낙관적 동기화: 락을 걸지 않고도 값을 안전하게 업데이트 할 수 있다. CAS는 충돌이 자주 발생하지 않을 것이라고 가정한다. 이는 충돌이 적은 환경에서 높은 성능을 발휘한다.
    • 락 프리: CAS는 락을 사용하지 않기 때문에, 락을 획득하기 위해 대기하는 시간이 없다. 따라서 스레드가 블로킹되지 않으며, 병렬 처리가 더 효율적일 수 있다.
  • 단점
    • 충돌이 빈번한 경우: 여러 스레드가 동시에 동일한 변수에 접근하여 업데이트를 시도할 때 충돌이 발생할 수 있다. 충돌이 발생하면 CAS는 루프를 돌며 재시도해야 하며, 이에 따라 CPU 자원을 계속 소모할 수 있다. 반복적인 재시도로 인해 오버헤드가 발생할 수 있다.
    • 스핀락과 유사한 오버헤드: CAS는 충돌 시 반복적인 재시도를 하므로, 이 과정이 계속 반복되면 스핀락과 유사한 성능 저하가 발생할 수 있다. 특히 충돌 빈도가 높을수록 이런 현상이 두드러진다.

동기화 락

  • 장점
    • 충돌 관리: 락을 사용하면 하나의 스레드만 리소스에 접근할 수 있으므로 충돌이 발생하지 않는다. 여러 스레드가 경쟁할 경우에도 안정적으로 동작한다.
    • 안정성: 복잡한 상황에서도 락은 일관성 있는 동작을 보장한다.
    • 스레드 대기: 락을 대기하는 스레드는 CPU를 거의 사용하지 않는다.
  • 단점
    • 락 획득 대기 시간: 스레드가 락을 획득하기 위해 대기해야 하므로, 대기 시간이 길어질 수 있다.
    • 컨텍스트 스위칭 오버헤드: 락을 사용하면, 락 획득을 대기하는 시점과 또 락을 획득하는 시점에 스레드의 상태가 변경된다. 이에 컨텍스트 스위칭이 발생할 수 있으며 이로 인해 오버헤드가 증가할 수 있다.

 

결론

일반적으로 동기화 락을 사용하고, 아주 특별한 경우에 한정해서 CAS를 활용해서 최적화해야 한다. CAS를 통한 최적화가 더 나은 경우는 스레드가 RUNNABLEBLOCKED, WAITING 상태에서 다시 RUNNABLE 상태로 가는 것 보다는, 스레드를 RUNNABLE로 살려둔 상태에서 계속 락 획득을 반복 체크하는 것이 더 효율적인 경우에 사용해야 한다. 하지만 이 경우 대기하는 스레드가 CPU 자원을 계속 소모하기 때문에 대기 시간이 아주아주아주 짧아야 한다. 따라서 임계 영역이 필요는 하지만, 연산이 길지 않고 매우매우매우 짧게 끝날 때 사용해야 한다. 예를 들어 숫자 값의 증가, 자료 구조의 데이터 추가, 삭제와 같이 CPU 사이클이 금방 끝나지만 안전한 임계 영역, 또는 원자적인 연산이 필요한 경우에 사용해야 한다. 

 

반면에 데이터베이스를 기다린다거나, 다른 서버의 요청을 기다리는 것처럼 오래 기다리는 작업에 CAS를 사용하면 CPU를 계속 사용하며 기다리는 최악의 결과가 나올 수도 있다. 이런 경우에는 동기화 락을 사용해야 한다.

 

또한 CAS는 충돌 가능성이 낮은 환경에서 매우 효율적이지만, 충돌 가능성이 높은 환경에서는 성능 저하가 발생할 수 있다. 이런 경우에는 상황에 맞는 적절한 동기화 전략을 사용하는 것이 중요하다. 때로는 락이 더 나은 성능을 발휘할 수 있으며 CAS가 항상 더 빠르다고 단정할 수는 없다. 따라서 각 접근 방식의 특성을 이해하고 애플리케이션의 특정 요구사항과 환경에 맞는 방식을 선택하는 것이 중요하다.

 

실무 관점

실무 관점에서 보면 대부분의 애플리케이션들은 공유 자원을 사용할 때 충돌할 가능성보다 충돌하지 않을 가능성이 훨씬 높다. 예를 들어, 여러 스레드에서 발생하는 주문 수를 실시간으로 증가하면서 카운트 한다고 가정해보자. 그리고 특정 피크시간에 주문이 100만건 들어오는 서비스라고 가정해보자 (이 정도면 국내 업계 탑이다).

  • 1,000,000 / 60분 = 1분에 16,666건, 1초에 277건

CPU가 1초에 얼마나 많은 연산을 처리하는지 생각해보면, 백만 건 중에 충돌이 나는 경우는 아주 넉넉하게 잡아도 몇 십 건 이하일 것이다. 따라서 실무에서는 주문 수 증가와 같은 단순한 연산의 경우, 락을 걸고 시작하는 것 보다는, CAS처럼 낙관적인 방식이 더 나은 성능을 보인다. 

 

그런데 여기서 중요한 핵심은 주문 수 증가와 같은 단순한 연산이라는 점이다. 이런 경우에는 AtomicInteger와 같은 CAS 연산을 사용하는 방식이 더 효과적이다. 이런 연산은 나노 초 단위로 발생하는 연산이다. 반면에 데이터베이스를 기다린다거나, 다른 서버의 요청을 기다리는 것 처럼 수 밀리초 이상의 시간이 걸리는 작업이라면 CAS를 사용하는 것보단 동기화 락을 사용하거나 스레드가 대기하는 방식이 더 효과적이다. 

 

 

728x90
반응형
LIST

+ Recent posts