본문 바로가기
Java/Java

스레드 동기화를 위한 volatile, Atomic

by oneny 2023. 7. 22.

volatile

하나의 변수를 여러 스레드에서 사용할 때 사용하는 키워드로 변수를 CPU 캐시가 아닌 Main Memory에서 변수의 값을 읽거나 저장하겠다라는 것을 명시하는 것이다.

즉, volatile은 CPU Cache에 저장된 값을 읽는 것 보다는 성능이 안좋지만 컴파일러에게 해당 데이터(변수, 메서드)에 대해 멀티스레드로 접근하고 있음을 알려주어 읽기, 쓰기에 대해서 동기화를 보장하여 예측불허하게 바뀌는 것을 막을 수 있다.

synchronized와의 차이는 다음과 같다.

  • synchronized: 행위(메서드 및 블럭)에 대한 동기화
  • volatile: 행위의 타겟(변수)에 대한 동기화

volatile을 사용하는 이유는 Main Memory의 값을 읽고 저장해 가시성을 보장하기 위한 것이라고 할 수 있다.

 

메모리 가시성(Memory Visibility)

출처 - Volatile 키워드와 메모리 가시성

멀티스레드 환경에서 각각의 스레드들은 CPU Cache라는 작업 복사본을 가지고 있다. 이 CPU Cache는 메인 메모리에서 원본값을 읽기 전에 먼저 확인하고 있으면 CPU Cache에 있는 값을 가져온다. 하지만 각자가 CPU Cache에 변수의 복사본을 읽고 쓰기 동작을 하기 때문에 캐시가 불일치(Cache Inconsistency)하는 문제가 발생할 수 있다. 결과적으로 각 스레드가 동일한 공유 변수를 다른 값으로 바라보는 문제라고 할 수 있다.

 

 

volatile 예제(volatile 적용 전)

public class VolatileTest extends Thread {

  // volatile
  boolean isAdd = true;

  @Override
  public void run() {
    long count = 0;
    // 기본적으로 스레드는 자신의 복제된 Cache에서 keepRunning 값을 읽는다.
    // 그래서 volatile이 아닌 경우 main에서 값을 바꾸어도 영원히 true인 것이다.
    while (isAdd) {
      count++;
    }
    System.out.println("Thread terminated." + count);
  }
  
  public static void main(String[] args) throws InterruptedException {
    VolatileTest t = new VolatileTest();
    t.start();
    Thread.sleep(1000);
    System.out.println("after sleeping in main");

    // isAdd가 false로 바뀌면 스레드는 끝나야 한다.
    // 하지만 volatile이 아닌경우 끝나지 않는다.
    t.isAdd = false;
    t.join();
    System.out.println("main Thread End..." + t.isAdd);
  }
}

 

먼저 여기서 가장 핵심이 되는 코드는 boolean isAdd = true; 이다. run() 메서드를 살펴보면 isAdd가 true이면 count++;를 계속 실행하고 false일 때 빠져나와 count를 출력한다. main() 메서드에서 VolatileTest 객체를 만들어 start()를 실행하고 1초 쉬도록 만든 후 메시지("after sleeping in main")을 출력하고, t.isAdd = flase;를 실행하면 t 스레드는 while 문 루프를 빠져나와 count를 출력하도록 코드를 작성했다. 그러나 실제로 실행하면 위 코드는 아래처럼 while문을 빠져나오지 못한다. 즉, t.join(); 스레드가 끝나길 기다려도 끝나지 않는다.

 

가시성 문제가 발생하는 원인과 해결 방법

왜 이런 현상이 발생했을까? CPU 1에서 수행된 스레드를 tThread, CPU 2에서 실행된 스레드를 mainThread라고 하자. mainThread가 실행되면 mainThread를 실행할 CPU가 메인 메모리에 있는 isAdd와 그 값을 자신의 CPU Cache에 저장한다. 그리고 tThread도 tThread를 실행할 CPU가 마찬가지로 메인 메모리에 있는 isAdd와 그 값을 가져와서 자신의 CPU Cache에 저장한다. 이후에 mainThread에서 CPU Cache에 있는 값을 false로 바꾸고 메인 메모리에 반영하는 쓰기 작업을 완료했다. 하지만 tThread는 자기를 실행하고 있는 CPU의 CPU Cache에 담긴 true라는 값을 계속 보고 있기 때문에 계속해서 반복문을 수행하게 된다.

즉, mainThread가 수정한 값을 tThread가 언제 보게 될지 보증할 수 없는 가시성 문제가 발생한다. 따라서 가기성을 챙기면 경쟁 조건이 발생하는 문제를 방지할 수 있다.

 

volatile boolean isAdd = true;

volatile 키워드를 추가하여 선언하면 isAdd 변수는 항상 메인 메모리에 있는 원본값을 읽기 때문에 t.isAdd = false;를 실행하게 되면 메인 메모리에 있는 isAdd가 false로 변경되어 while문 루프를 빠져나올 수 있게 된다. 즉, volatile은 변수에 대한 동기화라고 할 수 있다. 그러면 다음 그림처럼 CPU Cache Memory를 거치지 않고, RAM으로 직접 읽고 쓰는 작업을 수행하게 된다.

 

코드를 실행하면 1초 후 프로그램이 종료되는 것을 확인할 수 있다.

 

Volatile은 동기화를 보장할까?

volatile은 읽기/쓰기에 대한 동기화를 보장하고 연산에 대해서는 동기화를 보장하지 않는다. 따라서, 변수가 Thread에 안전하려면 행위에 대한 락을 제어하기 위해서는 Java에서 synchronized를 사용하는 방법과 java.utl.concurrent.atomic.Atomic* 클래스들을 사용하는 방법이 있다. Java SE 5 이후로 java.concurrent.Atomic.* 클래스가 따로 제공되어 원자성을 제공한다.  

 

public class AtomicTest {

//     static AtomicInteger i = new AtomicInteger(0);
    volatile static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        AtomicTest test = new AtomicTest();
        Ojc t1 = new Ojc();
        Ojc t2 = new Ojc();
        Ojc t3 = new Ojc();
        Ojc t4 = new Ojc();
        Ojc t5 = new Ojc();

        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();

        Thread.sleep(2000);
        System.out.println(test.i);
    }
}

class Ojc extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
//             AtomicTest.i.getAndAdd(1);
            AtomicTest.i = AtomicTest.i + 1;
        }

        System.out.println(this + "::::: END");
    }
}

예제 코드를 보면 volatile은 변수의 값을 Main Memory에서 직접 핸들링 하지만 그것에 대한 행위의 제어(lock)는 제공하지 않기 때문에 여러 스레드에서 접근하려고 한다면 동시성 제어를 보장할 수 없다. 즉, volatile 키워드를 사용하여 가시성 문제를 해결했지만 원자성이 확보되지 못해 위와 같은 결과가 출력되었다.

 

그래서 현재 주석으로 처리된 AtomicInteger 객체와 getAndAdd(1) 메서드로 바꾸면 50000으로 동시성을 보장하는 것을 확인할 수 있다. 어떻게 Atomic 클래스는 동시성을 보장할 수 있는 것일까?

 

Atomic 클래스

java.util.concurrent.atomic 패키지에는 lock-free 하면서도 thread-safe한 기능을 지원하는 클래스들이 있다.

Atomic 클래스는 다른 스레드의 작업여부와 상관없이 자신의 작업을 수행하는 non-blocking 방식으로 동기화를 보장하기 때문에 대기 상태에 들어가지 않아 synchronized 키워드를 사용하는 것 보다는 속도도 빠르고 확장성도 좋다.

멀티스레드 환경에서 원자성과 가시성을 보장하기 위해 락을 사용하는 대신 저수준의 하드웨어에서 제공하는 비교 후 교환(Compare And Swap) 등의 명령을 사용하는 알고리즘을 사용한다. 이러한 non-blocking 알고리즘은 여러 스레드가 동일한 변수를 놓고 경쟁하는 과정에서 대기 상태에 들어가는 일이 없기 때문에 스케줄링 부하를 대폭 줄여주고, 데드락 등의 문제가 발생할 위험도 없다.

ㅎ Atomic 클래스는 CAS(Compare And Swap) + Volatile을 활용하여 원자성과 가시성을 보장한다.

 

락을 기반으로 하는 blocking 알고리즘은 특정 스레드가 락을 확보항 상태에서 다른 스레드들이 잠자기 상태에 들어가거나 반복문을 실행하면 그 시간 동안 각자의 작업 가운데 락이 필요한 부분을 전혀 실행할 수 없다.

 

CAS(Compare And Swap) 알고리즘

연산하고자 하는 값을 자원값(destination)이라고 한다면 연산을 위해 자원값을 가져올 때 자원값이랑 똑같은 값인 기댓값(Compared value)을 만든다. 이 기댓값을 기반으로 연산을 진행해서 새로운 값(연산결과, Exchanged value)을 만든다. 그러면 이 새로운 값을 자원값에다가 쓰기 작업을 해줘야 하는데 쓰기 작업을 하기 직전에 내가 가지고 있는 자원값과 현재 메모리가 가지고 있는 값이 같은지를 확인한다. 만약 같으면 기존 자원값을 새로운 값으로 수정하고 true, 다르면 수정하지 않고 false 반환한다.

예를 들어, 스레드 A가 공유 변수에 대해 계산을 하고 메모리에 반영하려는데 그 사이에 다른 스레드가 공유 변수를 변경한 경우 자신의 계산값을 메모리에 반영하면 안되기 때문에 false를 반환하여 동기화를 보장한다.

이렇게 자원값을 비교하는 과정에서 원자성을 보장할 수 있다. 그리고 false를 반환하고 난 이후의 로직은 개발자의 요구사항에 따라 달라질 수 있다. while 문을 돌면서 조건이 true가 나올 때까지 재시도하는 방법이나 몇 번 시도하다 Exception을 발생시키는 방법 등이 있다.

 

 

스레드 안전한 객체를 설계하는 방법

스레드 안전한 객체는 여러 스레드가 동시에 클래스를 사용하려 하는 상황에서 클래스 내부의 값을 안정적인 상태로 유지할 수 있다. 이러한 스레드 안전한 객체를 설계하기 위해서는 블로킹 큐, 스레드 한정, 자바 모니터 패턴, 락, 인스턴스 한정, 상태범위 제한, ThreadLocal, 조건 큐, Volatile, 위임기법, Producer-Consumer 패턴, 동기화 컬렉션 등이 있다. 하나 하나가 복잡하고 전략에 따라서 선택해야 하는 것이 다르고 구현에 따라서 장단점도 다 다르기 때문에 공부해야 할 것이 많다.

 

하지만 가장 확실하고, 안전하고 간단한 방법으로는 공유변수를 최소화하여 동기화 문제를 최소화시키고, 써야하는 공유 변수를 한 곳에 모아서 한 객체에서 캡슐화를 통해 그 객체만 관리할 수 있게 한다. 그리고 공유 변수를 사용함으로써 동기화 정책을 많이 적용하게 될텐데 적용하면 코드를 파악하기 어렵기 때문에 문서화를 잘하는 것이 스레드를 안전하게 객체를 설계하는 최선의 방법이라 할 수 있다.

 

 

 

 

 

 

 

출처

자바멀티스레드 변수값동기화(volatile, Atomic)

자바쓰레드(JAVA Thread) Volatile, transient, java.util.concurrent.atomic.Atomic.*

[Java] volatile 키워드란?

Volatile 키워드와 메모리 가시성

[10분 테코톡] 알렉스, 열음의 멀티스레와 동기화 In Java

[자바] 단일 연산 변수와 넌블로킹 동기화