개발/Java|Spring

Lock-Free 알고리즘 살펴보기(CAS, Volatile, Java Atomic Variables)

달리초이 2024. 12. 8. 22:37

 

들어가기 앞서

JVM이 새로운 객체를 생성할 때 발생하는 메모리 할당 방식을 보면 CAS 라는 개념이 나온다. 이 글에서는 자바를 기반으로 CAS 연산에 대해 좀 더 구체적으로 알아보고자 한다. 먼저 연관되는 배경지식을 알아보자.(미리 말하지만 알아야 될 내용이 많아서 멀리 돌아간다..)

 

멀티쓰레드가 동작하는 환경에서 데이터의 안정성을 보장하는 방법이 뭐가 있을까?

 

synchronized(동기화)

대표적인 방법이 MutexSemaphore를 이용한 Lock 기반 알고리즘이 있다. 이 둘은 바이너리 세마포어(동기화 대상이 오직 하나뿐)이냐 카운팅 세마포어(동기화 대상이 하나 이상)이냐에 따라 차이점이 존재하지만 결국 동시에 공유 자원에 접근하는 것을 막기 위해 Critical Section(임계 영역)에 진입하는 프로세스는 Lock 을 획득하고 Critical Section을 빠져나올 때, Lock 을 방출함으로써 동시에 접근이 되지 않도록 한다는 개념은 동일하다.

 

철도 세마포어 신호

참고로 Mutex는 상호배제(Mutual Exclusion)의 머릿글자를 따서 만들어졌고, Semaphore는 수기신호, 깃발이라는 뜻으로 근대에 와서는 철도 용어로 쓰였다. 여러 대의 기차가 하나의 철로를 공용하여 쓸 때, 오직 하나만 지나갈 수 있도록 양쪽 끝 선에 깃발 표시를 하여 사고를 방지한 데 유래한다.

 

 

 

 

 

 

 

 

Java Monitor

자바에도 Monitor라는 개념의 Lock이 있는데 이게 바로 Mutex와 유사한 역할을 한다. 참고로 모든 자바 객체는 모니터를 가지고 있고, 자바의 모니터는 Mutual Exclusion(상호 배제) 및 Cooperation(협력)이라는 두 가지 동기화 기능을 제공하고 있으며 이를 위해 Mutex와 Condition Variable(조건변수)를 사용한다. 그리고 우리가 메서드나 코드 블록에서 가끔 볼 수 있었던 synchronized 동기화 키워드를 사용하면 JVM이 내부적으로 Mutex 동기화를 암묵적으로 처리해준다.

 

/**
 * 동기화 기법 사용 안한 케이스
 * 결과: 20000 실패
 */
private static int count;

void addCount() {
    count++;
}

@Test
void add_count() throws InterruptedException {
    Thread thread1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            addCount();
        }
    });

    Thread thread2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            addCount();
        }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("count : " + count);
}
/**
 * synchronized 키워드 사용
 * 결과: 20000 성공
 */
private static int count;

@Test
void synchronized_add_count() throws InterruptedException {
    Thread thread1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (SynchronizedTest.class) { // class 명
                count++;
            }
        }
    });

    Thread thread2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (SynchronizedTest.class) {
                count++;
            }
        }
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("count : " + count);
}

 

 

장점

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

단점

  1. 락 획득 대기 시간: 하지만 Lock을 획득하지 못한 스레드는 Lock을 획득할 때까지 대기해야 한다. 획득 했더라도 다른 스레드가 CPU 할당량을 모두 사용하고 CPU 스케줄을 넘겨줄 때까지 대기해야 되기 때문에 고성능을 요구하는 작업에는 아주 치명적인 단점이 될 수도 있다.
  2. 컨텍스트 스위칭 오버헤드: Lock을 획득하는 시점과 대기하는 시점에 상태가 변경된다. 이때 컨텍스트 스위칭이 발생할 수 있으며, 이로 인해 오버헤드가 증가할 수 있다.
    1. 컨텍스트 스위칭(Context Switch)이란, CPU에서 실행할 프로세스를 교체하는 기술이다. 하나의 프로세스가 CPU를 사용 중인 상태에서 다른 프로세스가 CPU를 사용하도록 하기 위해, 이전의 프로세스의 상태를 보관하고 새로운 프로세스의 상태를 적재하는 작업을 말한다. 한 프로세스의 문맥은 그 프로세스의 PCB(프로세스 제어 블록)에 기록되어 있다.
    2. 오버헤드란, 어떤 처리를 하기 위해 들어가는 간접적인 처리 시간 · 메모리 등을 말한다.

결론적으로 Lock을 여기저기 잘못 사용한다면, 단일 쓰레드 동작이나 마찬가지의 성능을 보여줄 것이다. 그래서 프로그래밍 상황에 따라 연산이 길지 않고 단순한 작업을 처리하는 경우에 상대적으로 효율적으로 동작할 수 있는 낙관적인 방법이 있다.

 

 

Lock-Free 알고리즘

멀티쓰레드에서 동시에 호출 하더라도 정해진 단위 시간마다 적어도 한 개의 호출은 완료되게 만들겠다는 개념으로, 이게 바로 Lock-Free 알고리즘이다. CPU의 CAS 연산으로 대표되는 이 알고리즘은 Non-blocking이 보장되어야 Lock-free가 될 수 있다. 즉, 멀티쓰레드 환경에서 다른 쓰레드가 플래그를 세팅해 주고, Lock을 풀어 주는 등 다른 쓰레드가 끝나고 자기 순서가 오기를 기다리지 않아야 Non-blocking이다.

Lock-Free 알고리즘은 자료구조를 변경하면서 다른 쓰레드와의 충돌을 확인하는 과정이 추가된 것이라고 설명할 수 있다. 다른 쓰레드와 충돌이 있다면 정상적으로 작동했을지 예기치 못한 동작을 했을지 알 수 없다. 따라서 변경 시도 전으로 돌아가는 것이 기본 동작이다. 하지만 되돌린다는 것은 사실 불가능한 작업이므로, 현재 자료구조를 기준으로 성공적으로 변경한 후의 모습을 설계하고, Atomic하게 자료구조를 변경한 뒤 결과를 비교하는 방식으로 같은 효과를 낼 수 있다. 따라서 CAS가 사용되는 것이다.

장점

  1. Lock을 사용하지 않기 때문에 Lock을 획득하기 위해 대기하는 시간이 없다.
  2. 블로킹되지 않아 상대적으로 병렬처리에 더 효율적이고 성능이 좋다.
  3. 높은 부하에도 안정적이다.

단점

  1. 생산성 : 알고리즘이 복잡해진다.
  2. 신뢰성 : 알고리즘의 정확성을 증명하는 것이 어렵다.
  3. 확장성 : 새로운 메서드를 추가하는 것이 어렵다.
  4. 메모리 : 메모리 재사용이 어렵다. ABA 문제가 발생한다.
    1. ABA 문제란 CAS연산에서 공유 객체에 대한 변화를 감지하지 못할 때 발생하는 현상을 말한다. 이것은 CAS 연산에 메모리 주소 혹은 레퍼런스를 사용하는 가운데, 메모리가 재사용되는 경우에 발생한다.

❓ Blcok I/O와 Non-block I/O 에 대한 내용은 영상 참고

block I/O vs non-block I/O 개념을 설명합니다! 소켓 I/O를 예제로 주로 설명해요! I/O multiplexing(다중 입출력) 설명도 빠질 수 없겠죠? ;)

 

 

CAS (Compare And Swap) 연산

이제부터 살펴 볼 CAS 연산 역시 Lock-Free 알고리즘을 하드웨어적으로 지원하는 병렬 연산 방식이다. 운영체제와 JVM은 이 연산을 사용해 Lock과 여러 병렬 자료 구조를 작성해서 사용하고 있다.

비교와 교환

CAS 연산은 말그대로 비교하고 교환한다는 의미를 가지고 있다. 그렇다면 뭘 비교하고 뭘 교환한다는 말일까. 결론부터 정리하자면,

  1. CPU가 현재 메모리 위치에 저장된 값과 기대 값이 일치하는지 비교한다.
  2. 그리고 일치하는 경우에만 해당 메모리 위치에 새로운 값을 원자적으로 교환한다.
  3. 실패한다면 다시 처음으로 돌아가 성공할 때까지 루프를 돌며 재시도한다.

CAS 연산은 CPU 연산이 원자적인 특징을 이용한 것이며, 데이터의 조회-비교-교환의 과정을 하나의 원자적 연산으로 처리한 것이다. 결국 CPU 레벨에서 동작하는 이 과정을 통해 멀티쓰레드 환경에서 동시성 문제를 효율적으로 해결할 수 있다.

그런데 말입니다,

데이터를 메모리에서 가져오는 건 어찌보면 당연해 보일 수 있는데, 이 메모리에서 데이터를 가져오기때문에 동시성 문제를 해결할 수 있다는 말 자체가 이해가 안되는 부분이 있을 수 있다. 눈치가 빠른 사람이라면, 애초에 CPU가 메모리에서 값을 가져오지 않는 경우도 있는지 의문을 가질 수 있다.

그럼 CPU는 어떻게 동작할까. 이걸 이해하려면 먼저 캐시 메모리에 대해 알아야 한다.

 

캐시 메모리(Cache memory)

하드웨어의 발전으로 프로세서 속도는 빠르게 증가해왔지만, 상대적으로 메모리의 속도는 이를 따라가지 못했다. 프로세서가 아무리 빨라도 메모리의 처리 속도가 느리면 결과적으로 전체 시스템이 느려지기 때문에 이를 개선하기 위해 나온 장치가 바로 캐시(Cache)이다.

 

 

캐시는 CPU 칩 안에 들어가는 작고 빠른 메모리로 프로세스가 매번 메인 메모리로부터 데이터를 받아오면 시간이 오래 걸리기 때문에 캐시에 자주 사용하는 데이터를 담아두고 해당 데이터가 필요할 때마다 메인 메모리 대신 캐시에서 가져오는 방식으로 처리 속도를 높인다.(메인 메모리는 흔히 아는 DRAM 이라고 이해하면 된다) 멀티쓰레드 환경, 멀티 코어 환경에서 각 CPU는 메모리에 접근하기 전, 먼저 캐시 메모리에 현재 원하는 데이터가 있는지 여부를 확인한다.

💡 CPU > 캐시 메모리 > 메모리 > 보조 기억 장치

 

참고로 어떤 데이터를 캐싱 할 건지 판단하는 근거는 지역성의 원리(Principle of Locality)를 따르며, 여기에는 시간 지역성(Temporal locality)과 공간 지역성(Spatial locality)으로 구분해서 볼 수 있다. 추가적인 정보는 주제와 너무 멀어지므로 궁금하다면 아래 간략하게 추린 내용을 참고하자.

1. 시간적 지역성과 공간적 지역성

  • 시간적 지역성(Temporal Locality)
    • 시간적 지역성은 최근에 참조된 주소의 내용은 곧 다음에 다시 참조되는 특성이다.
    • 메모리 상의 같은 주소에 여러 차례 읽기 쓰기를 수행할 경우, 상대적으로 작은 크기의 캐시를 사용해도 효율성을 꾀할 수 있다.
  • 공간적 지역성(Spatial Locality)
    • 공간적 지역성은 기억장치 내에 서로 인접하여 저장되어 있는 데이터들이 연속적으로 액세스 될 가능성이 높아지는 특성이다.
    • CPU 캐시나 디스크 캐시의 경우 한 메모리 주소에 접근할 때 그 주소뿐 아니라 해당 블록을 전부 캐시에 가져오게 된다.
    • 이때 메모리 주소를 오름차순이나 내림차순으로 접근한다면, 캐시에 이미 저장된 같은 블록의 데이터를 접근하게 되므로 캐시의 효율성이 크게 향상된다.

2. 캐시의 종류

일반적으로 CPU는 이러한 캐시 메모리를 2~3개 정도 사용하며 순서에 따라 L1, L2, L3 등과 같이 구별한다(L은 Level을 의미한다). 각 캐시 메모리는 속도와 크기 등에 따라 구별되며, 레벨이 낮은 순으로 접근하게 된다.

  • L1 cache memory - 일반적으로 CPU 칩 내에 내장되어 있으며 데이터의 사용 및 참조 시 가장 먼저 사용된다. 8~64KB 정도의 용량을 가지고 있으며, 여기서 원하는 데이터를 찾지 못할 경우 L2 캐시 메모리를 탐색하게 된다. L1 캐시 메모리의 경우 속도를 위해 text 영역을 다루는 IC(Instruction Cache)와 그 외의 영역을 다루는 DC(Data Cache)로 나누어진다.
  • L2 cache memory - 기본적인 용도와 역할은 L1과 비슷하지만 속도는 그보다 느리다. 일반적으로 64KB~4MB 정도의 용량을 가지고 있으며 CPU 회로판에 별도의 칩으로 내장된다.
  • L3 cache memory - 마찬가지로 작동 원리 자체는 동일하며, 멀티 코어 시스템에서 사용되는 메모리이다. 보통은 L1/L2 캐시 정도까지만 CPU 성능에 직접적인 영향을 미치기 때문에 L3 캐시는 크게 신경 쓰지 않는 경우가 많으며, CPU가 아닌 메인보드에 내장되는 것이 일반적이다.

3. 캐시의 전송 단위

구분 내용
워드(word) CPU의 기본 처리 단위로서, 블록/라인을 구성하는 기본 단위
블록(block) 메모리를 기준으로 잡은 캐시와의 전송 단위캐시 라인과 크기가 동일하며, 여러 개의 워드로 구성됨
캐시 라인(cache-line) 캐시 관점에서의 캐시-메모리 간 전송 단위메모리 블록과 동일한 크기여러 개의 캐시 라인으로 이어져 있으며 각 라인은 여러 개의 워드로 구성됨

 

 

캐싱된 데이터는 빠르지만 위험하다!

결국 CPU의 동작 방식을 보면 캐싱된 데이터로 빠른 작업을 수행한다는 장점이 있지만, 멀티쓰레드 환경에서 다른 쓰레드가 바꾼 데이터를 누락하고 이미 캐싱된 이전 데이터를 가지고 연산 작업을 수행할 위험성을 내포하고 있는 것이다. CAS 연산은 바로 이런 캐시 메모리를 참고하지 않고 메인 메모리에 위치한 데이터를 직접적으로 읽어와 성공할 때까지 비교하고 교환하는 방법으로 동시성 문제를 해결하고 있는 것이다.

메모리 가시성(Memory Visibility)
여기서 한가지 알고 지나갈 개념이 가시성이다. 아래에서 volatile 키워드와 함께 좀 더 구체적으로 살펴볼 예정이라 간략하게 언급하자면, 메모리 가시성은 한 스레드에서 변경한 데이터가 다른 스레드에서 볼 수 있게 하는 것을 의미한다. 멀티쓰레드 환경에서 여러 쓰레드가 동시에 변수에 액세스하고 수정할 수 있기 때문에 모든 쓰레드에게 변수의 값이 일관되게 보여지도록 가시성이 확보되어야 한다. 가시성 문제란 CPU 캐시에서 작업한 결과가 메인 메모리에 즉시 반영되지 않을 경우 쓰레드 간에 결과가 다르게 보여지는 현상을 말한다. 참고로 synchronized 키워드 역시 가시성을 지원한다.

 

 

CAS 장단점

장점

  1. 낙관적 동기화 : Lock을 걸지 않고도 값을 안전하게 업데이트 할 수 있지만 실패 시 루프를 돌며 재시도를 하기 때문에 충돌이 자주 발생하지 않을 것이라고 예상되는 작업에 효과적이다. 즉 충돌이 적은 환경에서 높은 성능을 발휘한다.
  2. 단순 연산 : 단순히 숫자 값의 증가, 자료 구조의 데이터 추가와 같이 CPU 싸이클이 금방 끝나는 연산에 사용하면 효과적이다.

단점

  1. 충돌이 잦은 경우 : 장점에서 언급했지만 멀티쓰레드 환경에서 동일한 변수에 접근하여 업데이트를 시도할 때 충돌이 발생할 수 있고, 루프를 돌며 CPU 자원을 계속 소모할 여지가 있다. 때문에 충돌 빈도가 높을수록 반복되는 시도로 스핀락과 유사한 성능 저하가 발생할 수 있다.
  2. 작업 수행시간이 긴 경우 : 데이터베이스를 기다린다거나, 다른 서버의 요청을 기다리는 것 처럼 수 밀리초 이상의 시간이 걸리는 작업이라 면 CAS를 사용하는 것 보다 동기화 락을 사용하거나 쓰레드가 대기하는 방식이 더 효과적이다.

 

자바는 그럼?

JAVA 5.0 이후부터 int, long 그리고 모든 객체의 참조 대상으로 CAS 연산이 가능하도록 기능이 추가되었으며, JVM은 CAS 연산을 호출받았을 때 해당하는 하드웨어에 적당한 가장 효과적인 방법으로 처리하도록 되어 있다. CAS 연산을 직접 지원하는 운영체제의 경우 JAVA 프로그램을 실행할 때 CAS 연산 호출 부분을 직접 해당하는 기계어 코드로 변환해 실행한다. 하드웨어에서 CAS 연산을 지원하지 않는 최악의 경우에는 JVM 자체적으로 스핀 락을 사용해 CAS연산을 구현한다. 이와 같은 저수준의 CAS연산은 단일 연산 변수 클래스, 즉 AtomicInteger와 같이 java.util.concurrent.atomic 패키지의 Atomic Variables 클래스를 통해 제공하고 있다. java.util.concurrent 패키지의 클래스 대부분을 구현할 때 이와 같은 Atomic Variables 클래스가 직간접적으로 사용됐다.

 

드디어 우리가 사용중인 Atomic 변수들을 살펴 볼 차례이다. 제법 멀리 돌아왔지만 알아야 할 내용들이라 어쩔 수 없었다. 사실 CAS 연산이 뭔지 알게 되었다면, 각각의 Atomic 변수들이 제공하는 API는 쉽기 때문에 미리 학습할 필요가 없을 정도이다. 그래도 아쉬우니까 하나만 살펴보자.

 

Java Atomic Variables

Atomic 클래스들

java.util.concurrent.atomic 패키지를 열어보면 우리가 사용중인 AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference 등이 보인다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Atomic 변수들의 종류가 다양하지만, 대표로 AtomicInteger 클래스를 살펴보자.

 

AtomicInteger

public class AtomicInteger extends Number implements java.io.Serializable {

    private volatile int value;

    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    public AtomicInteger() {
    }
    
    public final int get() {
        return value;
    }
    
    public final boolean compareAndSet(int expectedValue, int newValue) {
        return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
    }
    
    ... 생략 ...
}

 

- 예제 코드

public class AtomicTest {

    public static void main(String... args) {
        AtomicInteger atomic = new AtomicInteger(0);
        int value = 0;
        int update = 1;
        
        while(!atomic.compareAndSet(value, update)) {
            // 작업 수행
        }
        
        System.out.println(atomic.get());
    }
}

 

클래스 내부를 보면 가장 먼저 volatile 이라는 생소한 키워드가 보이고, 또 우리가 봐왔던 것과 유사한 compareAndSet 메서드가 보일 것이다. 저장된 값과 기대값을 비교하는 메서드 시그니처만 봐도 알 수 있겠지만, compareAndSet 메서드가 바로 CAS 연산을 구체화 시킨 메서드 중 하나이다.

 

- Atomic 변수를 사용하지 않은 실패 케이스

private static int value = 0;
private static final int THREAD_COUNT = 3;

@Test
void non_atomic_test() throws InterruptedException {
    Thread[] threads = new Thread[THREAD_COUNT];
    for (int i = 0; i < THREAD_COUNT; i++) {
        
        threads[i] = new Thread(() -> {
            for (int j = 0; j < 100000; j++) {
                int expectedValue = value;
                int newValue = expectedValue + 1;
                value = newValue;
                System.out.println(Thread.currentThread().getName() + ": " + expectedValue + " / " + newValue);
            }
        });

        threads[i].start();
    }

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

    System.out.println("최종 value = " + value);
}

 

- Atomic 변수를 사용하여 성공하는 케이스

private static final AtomicInteger count = new AtomicInteger(0);
private static final int THREAD_COUNT = 3;

@Test
void atomic_test() throws InterruptedException {
    Thread[] threads = new Thread[THREAD_COUNT];
    for (int i = 0; i < THREAD_COUNT; i++) {

        threads[i] = new Thread(() -> {
            for (int j = 0; j < 100000; j++) {
                int incremented = count.incrementAndGet();
                System.out.println(Thread.currentThread().getName() + ": " + incremented);
            }
        });

        threads[i].start();
    }

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

    System.out.println("최종 value = " + count.get());
}

 

count.incrementAndGet() 메서드 내부를 들어가보면 결국 compateAndSet을 하고 있는 것을 알 수 있다.

 

 

- AtomicInteger가 제공하는 여러 API 샘플 코드

@Test
void atomic_api_test() {
    AtomicInteger atomicInteger = new AtomicInteger(3);
    System.out.println("atomicInteger: " + atomicInteger.get());    // atomicInteger: 3

    atomicInteger.set(10);
    System.out.println("set(10): " + atomicInteger.get());  // set(10): 10

    int andSet = atomicInteger.getAndSet(20);
    System.out.println("getAndSet(20): " + andSet); // getAndSet(20): 10

    System.out.println("incrementAndGet: " + atomicInteger.incrementAndGet());  // incrementAndGet: 21

    boolean isSuccess = atomicInteger.compareAndSet(21, 30);
    System.out.println("compareAndSet successful? " + isSuccess + ", result: " + atomicInteger.get());  // compareAndSet successful? true, result: 30

    IntUnaryOperator addTen = value -> value + 10;
    int beforeAdd = atomicInteger.getAndUpdate(addTen);
    int afterAdd = atomicInteger.get();
    System.out.println("getAndUpdate before: " + beforeAdd + ", afterAdd: " + afterAdd);    // getAndUpdate before: 30, afterAdd: 40
}

 

자, 이제 마무리 단계이다. 마지막으로 AtomicInteger 코드에서 봤던 volatile 키워드에 대해 알아보자.

 

Volatile

C/C++ 뿐만 아니라 Java 프로그래밍 언어에서도 이 키워드는 최적화 등 컴파일러의 재량을 제한하는 역할을 한다. 따라서 아래처럼 몇 가지 특징을 가진다.

최적화 방지

주로 최적화와 관련하여 volatile 키워드가 선언된 변수는 최적화에서 제외된다. 예를 들면 루프 내에서 변수 값이 변하지 않는다고 판단되면, 그 값을 캐시하여 메모리 접근을 줄이기도 하지만, volatile 로 선언된 변수는 이런 최적화 대상에서 제외된다.

volatile int num;

while (1) {
    if (num > threshold) {
        // 작업 수행
    }
}

 

위 반복문에서 volatile 선언으로 인해 매 루프마다 메모리에서 새로운 값을 읽어오게 된다.

 

가시성 보장(캐시 메모리 대신 메인 메모리 사용)

volatile로 선언된 변수의 값을 바꿨을 때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. 변수의 값을 읽을 때 CPU Cache에 저장된 값이 아닌 Main 메모리에서 읽기 때문이다.

 

 

기존에는 CPU Cache에만 반영되고, 실제 Main Memory에는 반영되지 않는다.

 

 

하지만 Volatile은 Main Memory에 즉시 저장한다. 프로세서의 레지스터에 캐시되지도 않고, 프로세서 외부의 캐시에도 들어가지 않습니다. volatile 이라는 영단어 자체가 ‘휘발성의’라는 의미가 있다. 캐싱되지 않고 휘발되어 그때 그때 메인 메모리에 즉시 저장된다. 그렇기 때문에 항상 다른 쓰레드가 접근했을 때도 보관해둔 최신의 값을 읽을 수 있다.

나눠질 수 있는 8바이트 변수 접근을 원자적으로

우리가 고급 프로그래밍 언어로 작성한 아주 단순한 변수 할당 구문을 생각해 보자. 사람이 보기에 이 명령어 한 줄은 반드시 하나라 동기화 문제가 발생할 것 같지 않다. 그러나 그렇지 않다. CPU 레벨로 내려가면 고급 프로그래밍 언어로 작성된 명령어 한 줄은 두 개 이상의 기계어 코드로 나누어 질 수 있다. 예를 들면 4바이트 크기의 CPU 레지스터를 사용해 변수 i의 상위 메모리 영역에 값을 쓰고 그 다음에 하위 4바이트 영역에 값을 쓰도록 기계어 코드가 나누어 질 수 있다. 따라서 우리가 value에 어떤 값을 읽으려는 시점이 때마침 다른 쓰레드에서 상위 4바이트에만 값을 기록한 상태이고 마침 그때 쓰레드 스위칭이 발생했다면 우리는 이전의 하위 4바이트 값과 새로 작성한 상위 4바이트 값이 섞여 있는 유효하지 않은 값을 읽게 된다. volatile 키워드는 나누어 질 수 있는 이 연산을 원자적으로 수행되도록 보장해줌으로 이 문제를 해결한다.

순서 보장

때때로 개발자가 의도하지 않은 컴파일러의 최적화 동작으로 인해 프로그램이 오동작하는 일이 발생할 수 있다. 예를 들면 명령어의 순서를 임의로 바꾸는 것 등의 최적화 행위를 컴파일러가 수행할 수 있고 이로 인해 멀티쓰레드 환경에서 문제가 발생할 수 있다.

 

 

명령어 순서 변경 문제를 해결하기 위해 Java의 volatile 키워드는 가시성 보장과 더불어 "Happens-Before" 보장을 제공한다. volatile 변수에 대한 읽기/쓰기 명령은 JVM 에 의해 재정리되지 않음을 보장한다는 의미이다.

volatile로 선언한 변수는 컴파일러와 런타임 모두 해당 변수는 전체에 공유되고, 따라서 실행 순서를 재배치 해서는 안되는 것으로 이해한다. 그러나 아무런 Lock이나 동기화 기능이 동작하지 않기 때문에 synchronized를 사용한 동기화보다는 강도가 약하다. 또 volatile 변수만 사용해서 메모리 가시성을 확보한 코드는 synchronized로 직접 동기화한 코드보다 훨씬 읽기가 어렵다는 단점이 있다.

 

volatile 변수 사용을 권장하는 경우

  1. 변수에 값을 저장하는 작업이 해당 변수의 현재 값과 관련이 없거나, 해당 변수의 값을 변경하는 스레드가 하나만 존재하는 경우
    1. 예를 들면, 멀티 스레드 환경에서 1개의 쓰레드만 read & write를 수행하고, 나머지 1개의 쓰레드에서 read하는 상황을 말한다.
  2. 해당 변수가 객체의 불변조건을 이루는 다른 변수와 달리 불변조건에 관련되어 있지 않은 경우
  3. 해당 변수를 사용하는 동안에는 어떤 경우라도 락을 걸어 둘 필요가 없는 경우

volatile 한계점

💡 volatile는 메모리 가시성은 해결하지만, Race Condition(여러개의 스레드가 동시에 경쟁)을 해결하지 못한다.

 

volatile 변수를 읽기 쓰레드와 쓰기 쓰레드가 N:1 의 상황에서는 동시성을 보장하지만 N:N 의 상황에서는 동시성을 보장해 주지 못 한다.

 

흔히 실수할 수 있는 예를 들면, x = x + 1 또는 x++ 과 같은 연산이 있다. 만약 x가 0으로 초기화 된 상태에서 2개의 쓰레드가 병렬로 해당 연산을 수행하면 2가 나올거라고 예상할 수 있다. 하지만 결과는 그럴수도 있고 아닐 수도 있다.

사실 x++ 연산은 단순 증가문으로 단일한 연산으로 보이지만 실제로는 단일한, 즉 원자적인 연산이 아니기 때문이다.

내부적으로 아래의 3개 연산을 수행한다.

 

// pseudo-code
int operator++(int *x) {
	int temp = *x;     // 1. 임시 객체에 현재 값을 담고
	*x = temp + 1;     // 2. 그 값에 1을 더하고
	return temp;       // 3. 새 값을 리턴한다
}

 

이러한 이유로 값을 변경하는 스레드가 하나일 경우에만 Volatile 변수 사용을 권장한다.

 

- 예제 코드

volatile boolean flag = true; // volatile 키워드가 없으면 무한 루프에 빠진다

@Test
void volatile_test() throws InterruptedException {
    Thread thread1 = new Thread(() -> {
        int count = 0;
        while (flag) {
            count++;
        }
        System.out.println("thread1 count = " + count);
    });

    Thread thread2 = new Thread(() -> {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("thread2 종료");
        flag = false;
    });

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("테스트 종료");
}

 

 

결론

모든 기술이 트레이드 오프가 있듯이 syncronized와 CAS 역시 각각의 장단점이 있으며, 상황에 따라 적절한 기술을 선택해야 한다. 왜냐하면 syncronized는 구현이 간단하고 이해하기 쉽지만, 잘못하면 성능 저하의 원인이 될 수 있고, CAS는 락을 사용하지 않아 성능이 우수하지만, ABA 문제와 같은 특정 상황에서 문제가 발생할 수 있기 때문이다. 따라서 우리는 멀티쓰레드 환경에서 데이터 일관성과 성능이라는 두 마리 토끼를 모두 잡으려면 각 기술의 특성을 이해하고, 애플리케이션의 요구 사항에 맞게 적절한 선택을 할 줄 알아야 한다.

 

728x90
반응형