(Java) 멀티 스레드 동기화와 원자적 연산 (CAS)

BaekGwa·2024년 8월 24일
0

✔️ Java

목록 보기
5/12

원자적 연산

원자적 연산?

원자적 연산은, 연산이 더 이상 나눌 수 없는 단위로 수행된 다는 걸 의미.

int i = 0; 이라는 코드가 있다.
해당 코드는 연산 측면에서는 더 이상 나눌 수 없는 원자적 연산 이라고 표현 할 수 있다.

i = i + 1; 해당 코드는 원자적 연산일까?
정답은, 아니다 이다.
해당 코드는 두가지 작업으로 나눌 수 있다.
i의 값을 읽고 (step1),
읽어온 i의 값에 1을 더한다로 표현 할 수 있다.

i++ 또한, i = i + 1;의 코드를 다르게 표현 한 것일 뿐, 같은 코드이다.


원자적 연산의 중요성

  • 원자적 연산은 멀티 스레드 환경에서 중요한 부분이다.
  • 먼저 정리를 하자면, 원자적 연산이 아닌 연산은 멀티 스레드 환경에서 문제가 발생한다.
  • 예제 코드를 보며 확인 해 보자.

문제 코드1)

package cas;

public interface IncrementInteger {
    void increment();

    int get();
}
package cas;

public class BasicInteger implements IncrementInteger{

    private int value;

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

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

import java.util.*;

public class IntegerTestMain {

    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 = () -> {
            try {
                Thread.sleep(10);
                incrementInteger.increment();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        };

        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);
    }
}
//결과값
value = 989
  • 해당 코드는 Integer 값을 스레드 하나가 실행될때마다, 1씩 증가시키는 코드다. 총 1000회 반복 한다.
  • 결과값은 1000이 나오기를 기대하고 있다.
  • 하지만, 결과값은 989 이 나왔다.

문제 코드1 분석)

  • 왜 1000이 나오지 않은지 분석을 해보자.
  • 해당 스레드의 main 임계 영역 코드는 incrementInteger.increment(); 로 해석 할 수 있다.
  • 해당 연산이 들어있는 코드는 한번에 하나씩 실행되어야 한다.
  • 즉, 현재는 모든 스레드가 한번에 공유 자원value에 접근하여 ++ 연산을 시도 하는데, 이는 원자적 연산은 아니다.
  • 즉, 값을 불러오고, 증가를 시킨다 두가지 Step 으로 진행하게 되는데 이때, 중간에 다른 스레드에서 증가, 저장을 시키면 데이터의 정합성 문제가 발생된다.
  • 이 문제를 해결하기 위해서는 synchronized 키워드를 사용해서 임계영역 코드를 안전하게 보호 하면 된다.

문제 코드1 해결)

  • 간단하게, synchronized 키워드를 사용해서 한번에 하나의 스레드만 접근하게 처리하면 앞서 다룬 분석의 문제점이 해결된다.
package cas;

public class BasicIntegerSynchronized implements IncrementInteger{

    private int value;

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

    @Override
    public synchronized int get() {
        return value;
    }
}
BasicIntegerSynchronizedresult: 1000

문제 코드2 해결)

  • Java 에서는 Synchronized를 통해 해결 하는 방법 말고도, 원자적 연산을 지원하는 클래스를 지원한다.
  • AtomicInteger, Atomic~~~ 등이 있다.
  • AtomicInteger를 사용하여 문제를 회피 해보겠다.
package cas;

import java.util.concurrent.atomic.AtomicInteger;

public class BasicIntegerAtomicInteger implements IncrementInteger {

    private AtomicInteger value = new AtomicInteger(0);

    @Override
    public void increment() {
        value.incrementAndGet();
    }

    @Override
    public int get() {
        return value.get();
    }
}
BasicIntegerresult: 991
BasicIntegerSynchronizedresult: 1000
BasicIntegerAtomicIntegerresult: 1000
  • AtomicInteger 클래스를 통해서 증감 연산을 실행하였고, 데이터 정합성 문제 없이 기대한 결과값인 1000이 나온 모습을 볼 수 있다.

Synchronized VS Atomic( )

  • 그럼 두가지 모두 같은 결과값을 보여주고 있고 (Thread safe), 어떤 차이점이 있을까?
  • 먼저 성능적인 차이부터 살펴 보겟다.
package cas;

public class PerformanceTest {
    public static final int LOOP_COUNT = 100_000_000;

    public static void main(String[] args) {
        test(new BasicInteger());
        test(new BasicIntegerSynchronized());
        test(new BasicIntegerAtomicInteger());
    }

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

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

        long endTime = System.currentTimeMillis();

        System.out.println(incrementInteger.getClass().getSimpleName() + " , Running Time = " + (endTime - startTime) + "ms");
    }
}
BasicInteger , Running Time = 8ms
BasicIntegerSynchronized , Running Time = 1684ms
BasicIntegerAtomicInteger , Running Time = 593ms
  • 성능적인 측면에서는 역시 멀티스레드로 Blocking 동작 없이 작동하는 BasicInteger가 가장 빠른 성능을 보여주었다.
  • 여기서 주목할 부분은, AtomicIntegerSynchronized 보다 빠르게 동작 한 것이다.
  • Synchronized는 임계 영역의 코드를 실행하기 위해 Lock을 사용하여 순차적인 접근으로 데이터의 정합성을 보장하였다.
  • AtomicInteger는 CAS을 통해서 이 문제를 해결하였다.

CAS

  • 멀티 스레드 환경의 동시성 문제 해결을 위해, Lock 전략을 사용하는 것은 해결 방법에 있어 좋은 방법이지만 몇가지 문제점이 있다.

Lock 문제점

  • 락이 걸려 있는 동안은 다른 스레드 들은 해당 자원에 접근 할 수 없다.
  • 락을 획득하고, 해제하기에 복잡하고 무겁고, 오래 걸린다.
    1. 락이 있는지 확인한다.
    2. 락을 획득하고 임계 영역에 들어간다.
    3. 작업을 수행한다.
    4. 락을 반납한다.
  • 이 문제점은 이전에 1억번의 반복 실행 시, 꽤 많은 시간 차이를 유발 한다는 것을 알 수 있었다.
  • CAS 연산은 Lock을 사용하지 않고, 원자적 연산을 수행 할 수 있다.

CAS(Compare-And-Swap)란?

  • CAS는 Compare-And-Swap의 약자로, "비교하고 교환한다"는 의미를 가지고 있습니다.
  • CAS는 Lock을 사용하지 않고도 원자적(atomic) 연산을 수행할 수 있는 기법으로, 멀티스레드 환경에서 동시성 문제를 효율적으로 해결할 수 있습니다.

CAS의 동작 순서

CAS는 다음과 같은 순서로 동작합니다:

  1. 현재 값 읽기: 메모리 위치에 저장된 현재 값을 읽습니다.
  2. 값 비교: 읽어온 현재 값이 기대 값과 일치하는지 비교합니다.
  3. 값 교환: 만약 현재 값이 기대 값과 일치한다면, 새로운 값으로 메모리 위치의 값을 원자적으로 교환합니다. 만약 일치하지 않는다면, 교환은 이루어지지 않고, 메모리 위치의 값은 그대로 유지됩니다.
    3-1. 실패 CASE 상세 : 실패 한다면, 다른 스레드가 값을 중간에 변경한 것이므로, 다시 처음으로 돌아가 위 과정을 반복한다.

CAS의 단점

  • CAS 연산은 위의 실패 CASE가 증가 할 수록, 즉 충돌이 많이 날 수록 성능이 나빠진다.
  • 이경우에는 충돌 날 때 마다, 반복적으로 재시도를 시도 하기 때문에, CPU 자원의 소모가 증가하게 된다.
  • 따라서 해당 CAS를 사용하는 동시성 제어 전략은 충돌이 잘 나지 않을 것 같은 경우 사용하는게 성능적으로 이득을 볼 때가 많다.
  • 실제 Java 에서는 CAS 연산을 통해 동시성 제어를 시도한다.

Lock(Synchronized..) VS CAS(Atomic( ))

  • 그렇다면 우리는 동시성 제어를 어떤 방식으로 하면 좋을까?

각각의 특징은 다음과 같다.

  • 락(Lock) 방식

    • 비관적(pessimistic) 접근법
    • 데이터에 접근하기 전에 항상 락을 획득
    • 다른 스레드의 접근을 막음
    • "다른 스레드가 방해할 것이다"라고 가정
  • CAS(Compare-And-Swap) 방식

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

    1. 낙관적 동기화: 락을 걸지 않고도 값을 안전하게 업데이트할 수 있다. CAS는 충돌이 자주 발생하지 않을 것이라고
      가정한다. 이는 충돌이 적은 환경에서 높은 성능을 발휘한다.
    2. 락 프리(Lock-Free): CAS는 락을 사용하지 않기 때문에, 락을 획득하기 위해 대기하는 시간이 없다. 따라서 스
      레드가 블로킹되지 않으며, 병렬 처리가 더 효율적일 수 있다.
  • CAS의 단점

    1. 충돌이 빈번한 경우: 여러 스레드가 동시에 동일한 변수에 접근하여 업데이트를 시도할 때 충돌이 발생할 수 있다.
      충돌이 발생하면 CAS는 루프를 돌며 재시도해야 하며, 이에 따라 CPU 자원을 계속 소모할 수 있다. 반복적인 재
      시도로 인해 오버헤드가 발생할 수 있다.
    2. 스핀락과 유사한 오버헤드: CAS는 충돌 시 반복적인 재시도를 하므로, 이 과정이 계속 반복되면 스핀락과 유사한
      성능 저하가 발생할 수 있다. 특히 충돌 빈도가 높을수록 이런 현상이 두드러진다.
  • 동기화 락의 장점

    1. 충돌 관리: 락을 사용하면 하나의 스레드만 리소스에 접근할 수 있으므로 충돌이 발생하지 않는다. 여러 스레드가
      경쟁할 경우에도 안정적으로 동작한다.
    2. 안정성: 복잡한 상황에서도 락은 일관성 있는 동작을 보장한다.
    3. 스레드 대기: 락을 대기하는 스레드는 CPU를 거의 사용하지 않는다.
  • 동기화 락의 단점

    1. 락 획득 대기 시간: 스레드가 락을 획득하기 위해 대기해야 하므로, 대기 시간이 길어질 수 있다.
    2. 컨텍스트 스위칭 오버헤드: 락을 사용하면, 락 획득을 대기하는 시점과 또 락을 획득하는 시점에 스레드의 상태가
      변경된다. 이때 컨텍스트 스위칭이 발생할 수 있으며, 이로 인해 오버헤드가 증가할 수 있다.

결론

즉, 간단한 연산이 주로 이뤄, 충돌이 날 가능성이 적은 경우, (하지만 발생 가능성이 1% 라도 있다면) CAS 선택
다른 스레드들 간에 충돌이 자주 발생할 것으로 예상이 되면, Lock 전략을 선택하는 것을 추천한다.
사실 대부분의 실무 상황에서는 동기화 락이 효율적인 가능성이 높다.

profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글