본문 바로가기
[ JAVA ]/JAVA

[ Java ] 동시성 제어를 위한 세 가지 키워드 / CAS 알고리즘 개념 알아가기

by 환이s 2024. 8. 21.


Intro 

 

안녕하세요. 환이s입니다👋

오늘은 프로젝트에서 동시성 제어를 동시성 제어를 위한 세 가지 키워드에 대해 포스팅을 진행해보려고 하는데요

 

자바에서 동시성 제어를 하는 방법이나 Atomic 변수, CAS 알고리즘에 대한 글은 찾아보면 정말 많은 블로그를 참고할 수 있는데 대체적으로 알아보시는 분들이 신입분들이 아닌, 어느 정도 개발을 하셨던 분들이 찾아보고 계실 거라고 생각이 드네요

 

그래서 오늘은 Atomic 변수에 대해 알아보기 전에 동시성 제어를 위해 제공하는 세 가지 방법을 먼저 소개하고 왜 사용하는지?  CAS 알고리즘에 대한 개념 등 포스팅을 작성해 보겠습니다. 🙂


동시성 제어를 위한 세 가지 키워드

 

자바로 코드를 작성하다 보면 동시성 문제에 대해 한 번쯤은 생각을 해보게 되는데요

하지만 경험해 보면 간단하게 해결할 수 있는 문제가 아니라는 걸 알 수 있습니다..😅

 

제가 생각하는 스레드와 동시성 관리가 어렵다고 느껴지는 이유는 세 가지 정도로 말씀드릴 수 있습니다.

 

  1. 일반적으로 동시성으로 인해 생기는 예외는 재현하기 어렵습니다.
  2. 코드만 보고 동시성 문제의 발생 가능성을 파악하기 정말 어렵습니다.
  3. 최악의 경우에는 동시성 문제가 발생해도 진짜 결함으로 간주되지 않고 일회성 문제로 여겨 무시될 수 있습니다.

 

코드를 작성할 때 대부분 스레드를 크게 신경 쓰지는 않지만, 동시성 문제로 인한 결함이 치명적인 결과로 이어질 수 있기 때문에 동시성에 대해 자세히 아는 것이 중요합니다.

 

따라서 관련 키워드를 정리해 보겠습니다.

 

먼저 간단한 동시성 문제 테스트를 진행해 보겠습니다.

 

 private static long count = 0;

    @Test
    public void threadNotSafe() throws Exception {
        int maxCnt = 10;

        for (int i = 0; i < maxCnt; i++) {
            new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                count++;
                System.out.println(count);
            }).start();
        }

        Thread.sleep(100); // 모든 스레드가 종료될 때까지 잠깐 대기
        System.out.println("Final count: " + count);
        Assertions.assertThat(count).isEqualTo(maxCnt);
    }

 

위 코드를 확인해 보면 정말 간단한 코드로 테스트를 해보았는데, 

다수의 스레드들은 공유자원 static long count을 참조하여 값을 증가시킵니다.

 

위 테스트 케이스는 대부분의 경우 아무런 문제 없이 통과합니다.

하지만 값을 증가하는 count++ 연산이 원자성을 보장하지 않기 때문에 아래처럼 성공/실패 케이스가 발생합니다.

 

 

테스트에 실패한 이유는 2가 세 번 출력되었고, 기대했던 count 값이 10이 아닌, 9가 출력되면서 실패하는 것을 확인할 수 있습니다.

 

위 코드처럼 간단한 코드를 가지고도 동시성 테스트를 했을 때,

결과에 따라 성공/실패 값을 출력하는데, 이 처럼 코드만 보고 진짜 결함으로 간주하기 힘든 상황이 있습니다.

 

그렇다면 자바에서 동시성 문제 해결을 위한 세 가지 키워드를 바로 알아보겠습니다.

 


[ synchronized ]

 

synchronized  키워드를 통해 해등 블록의 액세스를 동기화할 수 있습니다.

간단히 말해서 synchronized 가 선언된 블록에는 동시에 하나의 스레드만 접근할 수 있습니다.

 

간단한 사용 예제를 보자면 다음과 같습니다.

 

public class SomeClass {
    // 메서드 전체에 동기화 적용
    public synchronized void foo() { 
        /* critical section */
    }

    // 내부에 동기화 블럭 생성
    public void bar() {
        synchronized (this) {
            /* critical section */
        }
    }
}

// 클래스 내부의 전역 메서드에서 동기화 블럭을 생성하는 방법
public class SomeClass {
    public static void syncMethod() {
        synchronized (SomeClass.class) {
            /* critical section */
        }
    }
}

 

그렇다면 synchronized  키워드를 앞서 실패한 테스트 케이스에 사용해 보겠습니다.

 

private static long count = 0;

//    @Test
//    public void threadNotSafe() throws Exception {
//        int maxCnt = 10;
//
//        for (int i = 0; i < maxCnt; i++) {
//            new Thread(() -> {
//                try {
//                    Thread.sleep(1);
//                } catch (InterruptedException e) {
//                    Thread.currentThread().interrupt();
//                }
//                count++;
//                System.out.println(count);
//            }).start();
//        }
//
//        Thread.sleep(100); // 모든 스레드가 종료될 때까지 잠깐 대기
//        System.out.println("Final count: " + count);
//        Assertions.assertThat(count).isEqualTo(maxCnt);
//    }

    @Test
    public void threadNotSafe() throws Exception {
        int maxCnt = 10;

        for (int i = 0; i < maxCnt; i++) {
            new Thread(this::plus).start();
        }

        Thread.sleep(100); // 모든 스레드가 종료될 때까지 잠깐 대기
        Assertions.assertThat(count).isEqualTo(maxCnt);
    }

    public synchronized void plus() { // synchronized 키워드 사용
        count++;
    }

 

비교를 위해 실패한 테스트 케이스를 주석처리 하고 synchronized  추가해서 테스트를 진행했습니다.

 

synchronized  키워드를 추가함으로써 테스트 통과를 할 수 있습니다.

 

특정 스레드는 synchronized  메서드에 접근 시 블록 전체에 lock을 걸어둡니다.

따라서 해당 스레드가 블록을 빠져나가기 전까지 다른 스레드들은 동기화 처리된 블록에 접근할 수 없습니다.

 

하지만 다른 스레드들은 아무런 작업을 하지 못하고 기다릴 수밖에 없어 자원의 낭비가 발생할 수 있습니다.

 

따라서 동시성 문제를 해결하기 위해 synchronized  키워드는 매우 간단한 해결 방법이 될 수 있지만, critical section의 크기 및 실행 시간에 따라 성능 하락 및 자원 낭비가 매우 심해지게 됩니다.


 

[ volatile ]

 

JVM에서 스레드는 실행되고 있는 CPU 메모리 영역에 데이터를 캐싱합니다.

따라서 멀티 코어 프로세서에서 다수의 스레드가 변수 a를 공유하더라도 캐싱된 시점에 따라 데이터가 다를 수 있으며, 서로 다른 코어의 스레드는 데이터 값이 불일치하는 문제가 생깁니다.

 

임의로 데이터를 갱신해 주지 않는 이상 캐싱된 데이터가 언제 갱신되는지 또한 정확히 알 수 없습니다.

 

이런 경우 volatile 키워드를 사용하여 CPU 메모리 영역에 캐싱된 값이 아니라 항상 최신의 값을 가지도록 메인 메모리 영역에서 값을 참조하도록 할 수 있습니다. 

 

즉, 동일 시점에 모든 스레드가 동일한 값을 가지도록 동기화합니다.

 

volatile을 사용할 때 변수 옆에 키워드를 붙여서 선언이 가능합니다.

public volatile long count = 0;

 

하지만 volatile 을 통해 모든 동기화 문제가 해결되는 건 아닙니다.

 

앞에서 예로 들었던 ++ 연산과 같이 원자성이 보장되지 않는 경우 동시성 문제는 동일하게 발생합니다.

(단지 멀티 코어에서의 모든 스레드가 캐시 없이 최신의 값을 보게 할 뿐입니다.)

 

@RunWith(SpringRunner.class)
@SpringBootTest
public class VolatileTest {

    private static volatile long count = 0; // volatile 키워드 추가

    @Test
    public void threadNotSafe() throws Exception {
        int maxCnt = 1000;

        for (int i = 0; i < maxCnt; i++) {
            new Thread(() -> count++).start();
        }

        Thread.sleep(100); // 모든 스레드가 종료될때 까지 잠깐 대기
        Assertions.assertThat(count).isEqualTo(maxCnt);
    }

 

 

동시성 문제로 인해 실패한 케이스 결과 화면입니다.

( volatile 키워드도 원자성을 보장하지 않는 연산에서는 동일한 동시성 문제가 발생합니다.)

 

따라서, volatile 키워드의 특징은 다음과 같이 정리할 수 있습니다.

 

  1. mutual exclusion(상호 배제)를 제공하지 않고도 데이터 변경의 가시성을 보장합니다.
  2. 원자적 연산에서만 동기화를 보장합니다.

[ Atomic Class ]

 

지금까지 synchronized, volatile 키워드에 대해 알아봤는데 두 키워드 만으로는 동시성 문제를 깔끔하게 해결할 수 없습니다.

 

Atomic Class멀티 스레드 환경에서 안전하게 공유 변수에 대한 연산을 수행할 수 있도록 도와주는 클래스입니다. 그리고 동기화나 락을 사용하지 않고 CAS 알고리즘을 채택했습니다.

 

CAS 알고리즘에 대해서는 아래에서 다룰 거라 구체적인 설명은 아래 글 참고해 주시면 좋을 거 같습니다.

 

자바에서는 위와 같은 문제들을 해결하기 위해, 비-원자적 연산에서도 동기화를 빠르고 쉽게 이용하기 위한 클래스 모음을 제공합니다.

 

대표적으로 컬렉션, Wrapper 클래스 등이 있는데 대표적인 클래스를 소개해보겠습니다.

 

  • java.util.concurrent.atomic.AtomicLong
public class AtomicLong extends Number implements java.io.Serializable {
	
    private volatile long value; // volatile 키워드가 적용되어 있다.
	
    public final long incrementAndGet() { // value 값을 실제로 증가시키는 메서드
        return U.getAndAddLong(this, VALUE, 1L) + 1L;
    }
	
}

 

  • jdk.internal.misc.Unsafe
public final class Unsafe {
    // 메모리에 저장된 값과 CPU에 캐시된 값을 비교해 동일한 경우에만 update 수행
    public final long getAndAddLong(Object o, long offset, long delta) {
        long v;
        do {
            v = getLongVolatile(o, offset);
        } while (!weakCompareAndSetLong(o, offset, v, v + delta)); // CAS 알고리즘 (JNI 코드로 이루어져 있다.)
        return v;
    }
}

 

Non-Blocking 임에도 동시성을 보장하는 이유는 CAS 알고리즘을 이용하기 때문입니다.

 

volatile 키워드를 이용하면서 현재 스레드에 저장된 값과 메인 메모리에 저장된 값을 비교해서

일치하는 경우 새로운 값으로 교체(thread-safe 한 상태이므로 로직 수행)하고

일치하지 않는 경우 실패 후 재시도(thread-safe 하지 않은 상태였으므로 재시도 수행) 합니다.

 

그렇다면 확실한 이해를 위해 성능 비교 테스트를 진행해 보겠습니다.

 

먼저 Blocking 일 때 속도를 확인해 보겠습니다.

 

  • Blocking
import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class BlockingTest {

    private static long startTime = System.currentTimeMillis();
    private static int maxCnt = 1000;
    private static long count = 0;

    @Test
    public void threadNotSafe() throws Exception {
        for (int i = 0; i < maxCnt; i++) {
            new Thread(this::plus).start();
        }

        Thread.sleep(2000); // 모든 스레드가 종료될때 까지 잠깐 대기
        Assertions.assertThat(count).isEqualTo(maxCnt);
    }

    public synchronized void plus() {
        if (++count == maxCnt) {
            System.out.println(System.currentTimeMillis() - startTime);
        }
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
    }
}

 

 

세 번의 테스트 결과로 평균 5700ms 정도 소요되었습니다.

그 이유는 Blocking 연산에서 1000개의 스레드가 각각 1ms의 수가 딜레이를 가지기 때문입니다.

 

다음으로 Non-Blocking 테스트를 진행해 보겠습니다.

 

  • Non-Blocking(AtomicLong)
import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.concurrent.atomic.AtomicLong;

@RunWith(SpringRunner.class)
@SpringBootTest
public class NonBlockingTest {
    private static long startTime = System.currentTimeMillis();
    private static int maxCnt = 1000;
    private static AtomicLong count2 = new AtomicLong();

    @Test
    public void threadNotSafe2() throws Exception {
        for (int i = 0; i < maxCnt; i++) {
            new Thread(this::plus2).start();
        }

        Thread.sleep(2000); // 모든 스레드가 종료될때 까지 잠깐 대기
        Assertions.assertThat(count2.get()).isEqualTo(maxCnt);
    }

    public void plus2() {
        if (count2.incrementAndGet() == maxCnt) {
            System.out.println(System.currentTimeMillis() - startTime);
        }
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
    }
}

 

 

세 번의 테스트 결과로 평균 3810ms 정도 소요되었습니다.

Non-Blocking 연산에서 1ms의 추가 딜레이는 큰 의미가 없습니다.

 

당연한 결과이지만, synchronized 키워드는 효과적인 성능과 함께 사용하기에는 큰 어려움이 있습니다.

 

테스트에서는 임의로 딜레이를 1ms로 주었기 때문에 결과가 차이 난다고 말할 수도 있습니다.

public void plus() {
    synchronized (this) {
        if (++count == maxCnt) {
            System.out.println(System.currentTimeMillis() - startTime);
        }
    }
    try {
        Thread.sleep(1);
    } catch (InterruptedException e) {
    }
}

 

위 코드와 동일하게 plus() 메서드에서 동기화가 필요한 부분만 synchronized 블록으로 감싸주면 성능 하락이 생기지 않습니다.

 

하지만 실무에서 위 테스트 코드와 같이 동시성 문제가 예상되는 곳들을 모두 파악해서 synchronized를 통해 동기화 설정을 할 수 있을까요?

 

복잡한 비즈니스 로직 사이사이에 들어가 있는 비-원자적 연산을 여러 개의 synchronized 블록으로 설정하는 건 가능하겠지만, 코드가 복잡해질 것이고, 개발자가 모든 코드에 동시성 문제를 하나하나 검토해야만 완벽하게 적용할 수 있을 거라고 예상이 드네요.

 

따라서 기본으로 제공하는 concurrent 패키지의 클래스들을 이용하는 것이 자바에서 동시성 문제를 해결하는 적절한 방법이라고 생각합니다.


CAS(Compare-And-Swap) 알고리즘

 

CAS(Compare-And-Swap) 알고리즘은 동시성 프로그래밍에서 사용되는 락-프리 알고리즘의 일종으로, 변수의 값을 비교한 후 예상하는 값이면 새로운 값으로 교체하는 원자적 연산을 수행합니다.

 

그 이유는 CAS기존 값과 예상 값이 일치할 때만 변수의 값을 업데이트하기 때문에, 데이터의 일관성을 보장할 수 있습니다.

 

자바에서는 java.util.concurrent.atomic 패키지 즉, 위에서 언급한 Atomic Class를 통해 CAS 연산을 지원하고, 락을 사용하지 않기 때문에, 데드락과 같은 문제를 방지하면서도 높은 성능을 유지할 수 있습니다.

 

따라서 CAS는 멀티 스레드 환경에서 성능 저하 없이 데이터의 일관성을 유지할 수 있는 효과적인 방법입니다.

 

 

위 그림을 토대로 CAS 알고리즘의 동작 원리를 알아보겠습니다.

 

  • 인자로 기존 값(Compared Value)과 변경할 값(Exchanged Value)을 전달합니다.
  • 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 같다면 변경할 값(Exchanged Value)을 반영하며 true를 반환합니다.
  • 반대로 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 다르다면 값을 반영하지 않고 false를 반환합니다.

 

동작 원리에 대해 알아보니 한 가지 의문이 생길 수도 있습니다.

"기존 값으로 요청을 보냈는데 현재 메모리가 가지고 있는 값과 다르다?"

 

해당 의문에 대해 파헤쳐 보자면

스레드 A가 공유 변수에 대해 계산을 하고, 메모리에 반영하기 직전에 다른 스레드 B가 공유 변수를 변경하여 메모리에 반영한 경우를 의미합니다.

 

이때 당연히 스레드 A의 변경할 값을 메모리에 반영하면 안 됩니다. 

 

따라서 false를 반환하는 경우에는 무한 루프를 구성하여 변경된 값(다른 스레드에 의해 변경된 메모리 값)을 읽고 같은 시도를 반복하거나, 다른 더 중요한 작업이 있으면 다른 작업을 해도 됩니다. 

 

이 부분은 해당 개발자가 결정하면 됩니다.

 

정리하자면, AtomicBlocking 방식을 사용하는 synchronized에 비해 훨씬 효율적인 방법이라고 할 수 있습니다. 무한 루프를 돌면서 값을 반영할 수 있는지 물어보는 경우에도 스레드의 상태를 변경하는 작업이 발생하지 않으므로 성능이 더 우수하다고 할 수 있습니다.


Outro

 

정리하자면

Atomic Class를 사용할 때는 크게 두 가지 상황일 때 사용하시는 게 좋다고 생각합니다.

 

  • 성능 저하를 막기 위해 사용합니다.
    • 가장 일반적으로 알고 있는 synchronized 같은 경우는 가장 안전하다고 알려져 있지만, 성능 저하가 발생한다는 단점이 있습니다.
  • 목적에 좀 더 부합하기 위해 사용합니다.
    • volatile은 메모리 가시성을 위해 Main 메모리를 이용하는 것이 주목적이라고 생각합니다. 이러한 목적에 해당하지 않다면 volatile은 제외해도 될 거 같습니다.

 


마치며

 

오늘은 동시성 제어를 위한 세 가지 키워드CAS 알고리즘 개념에 대해 알아봤습니다.

개발자로서 성능 이슈 부분에 대해 알아보는 건 좋은 기회라고 생각합니다.

 

중요한 만큼 시간 투자를 많이 해서 공부를 해야 하는 파트라고 생각이 들고, 실무에서 성능 이슈 부분을 다룰 줄 아는 개발자는 꼭 필요한 개발자가 아닌가 싶을 정도네요.

 

다음 포스팅에서 뵙겠습니다.

 

728x90