자바에서의 volatile

박시시·2022년 10월 9일
0

JAVA

목록 보기
11/13

volatile은 여러 최적화 기법 중 캐싱과 리오더링으로 발생할 수 있는 이슈를 예방할 수 있다.
이러한 이슈들을 살펴보고 volatile을 통해 어떻게 대처할 수 있을지 알아보자.

캐싱

프로세서는 프로그램 instruction을 실행시키며 이를 위해 RAM으로부터 해당 instruction과 필요한 데이터를 끌어 오는 식으로 작동한다.


(출처: https://www.baeldung.com/java-volatile)

CPU 위에서 돌아가고 있는 스레드가 변수를 요청하면 RAM -> Cache -> CPU Registers의 경로를 따른다. 마찬가지로 변수의 값이 업데이트 되면 이러한 변경은 CPU Register -> Cache -> RAM의 경로를 따른다. 즉 변수를 공유하는 멀티스레드 환경에서는 하나의 스레드가 공유 변수의 값을 변경하면 이러한 업데이트는 레지스터에서 수행된 뒤 그 다음은 캐시, 마지막으로는 RAM에서 수행되어야 한다. 그리고 다른 스레드에서 이 공유 변수를 읽어야 할 때, RAM에 존재하는 값을 읽는데 이는 캐시와 레지스터를 거쳐서 오게 된다.

CPU 입장에서 성능 개선을 위해서는 Cache에 값을 저장해두고 읽고 쓰는 것이 더 유리할 것이다. 실제로 위의 그림에서처럼 각 코어들은 관련성 높은 데이터와 instruction을 자신들의 캐시에 채워두게 된다.

이러한 캐싱으로 인해 전반적인 성능은 향상되지만 캐시일관성 문제라는 비용을 감수해야만 한다.

캐시일관성문제란? 서로 다른 프로세서가 각자의 캐시를 통해 메모리 접근시 주의하지 않으면 두 개의 다른 값을 갖게 되는데 이를 캐시일관성문제라 한다.

예제를 통해 캐시일관성 문제를 좀 더 자세히 살펴보자.

(baeldung 예제)

public class TaskRunner {

    private static int number;
    private static boolean ready;

    private static class Reader extends Thread {

        @Override
        public void run() {
            while (!ready) {
                Thread.yield();
            }

            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new Reader().start();
        number = 42;
        ready = true;
    }
}

단순히 생각하기론 위의 TaskRunner 프로그램은 약간의 딜레이 후에 42라는 숫자를 출력할 거 같지만 우리의 예상대로 작동하진 않는다. 딜레이가 생각보다 길거나, 딜레이가 멈추지 않는다거나, 아니면 0을 출력하거나 할 것이다.

이러한 이상현상의 이유는 메모리 가시성 부족(과 리오더링) 때문이다. 좀 더 살펴보자.

메모리 가시성

위의 예제 코드를 실행하면 2개의 스레드가 돌아가게 된다. 바로 main 스레드와 reader 스레드이다. 아래의 시나리오를 가정해보자.

  • OS가 이 두 스레드를 두 개의 서로 다른 CPU 코어에 스케쥴링했다.
  • 메인 스레드는 ready와 number 변수의 복사본을 코어 캐시에 가지고 있다.
  • 마찬가지로 리더 스레드 역시 각 변수의 복사본을 캐시에 가지고 있다.
  • 메인 스레드가 캐시된 값을 업데이트 한다.

대부분의 현대의 프로세서들은 write 리퀘스트를 즉시 적용하지 않는다. 프로세서는 이러한 write 리퀘스트를 write buffer에 queue해두는 경향이 있다. 그리고 버퍼가 어느정도 찼을 때 write 리퀘스트들을 한 번에 메인 메모리에 적용할 것이다.
이러한 이유로, 위의 예제에서는, 메인 스레드가 ready와 number 변수를 업데이트 할 때 리더 스레드가 무엇을 볼 것인지에 대한 보장은 없다. 다시 말해, 리더 스레드는 업데이트된 값을 바로 볼 수도 있고, 혹은 약간의 딜레이가 있을 수 있다. 아니면 아예 볼 수 없을지도 모른다.

Instruction Reordering

컴파일이나 프로세싱 중에, 컴파일러나 CPU가 처리량과 성능을 향상시키기위해 instructions을 병렬로 실행하도록 순서를 재배치(reorder)할 수도 있다. 예를 들어보자.

FullName = FirstName + LastName        // Statement 1
UniqueId = FullName + TokenNo         // Statement 2
 
Age = CurrentYear - BirthYear        // Statement 3

컴파일러는 1과 2를 병렬로 수행할 수 없는데 이는 2가 1의 결과를 필요로 하기 때문이다. 하지만 1과 3은 병렬로 실행될 수 있다. 각자 서로에 대해 독립적이기 때문이다. 컴파일러나 CPU는 아래의 방법으로 instructions을 재배치 할 수 있다.

FullName = FirstName + LastName      // Statement 1
Age = CurrentYear - BirthYear       // Statement 3

UniqueId = FullName + TokenNo        // Statement 2

하지만 이러한 리오더링이 변수를 공유하는 멀티 스레드 애플리케이션에서 수행된다면 데이터 정합성에 문제를 일으킬 수도 있다.

volatile

private volatile count;

변수를 volatile로 선언하게 되면

  • 모든 쓰기 작업은 바로 메인메모리에 적용된다(즉 캐시를 바이패싱한다).
  • 모든 읽기 작업은 메인메모리로부터 직접 읽어온다.

즉 스레드에 의해 쓰여지거나(업데이트) 읽혀질 때 마다 공유 변수 count는 항상 최신에 쓴 값과 일치한다.
공유변수에 대한 업데이트는 항상 이를 읽고자 하는 모든 스레드들에 대해 visible 하며, 즉 메모리 가시성 문제를 해결할 수 있다.

volatile의 몇 가지 중요한 포인트는 아래와 같다.

  • volatile 변수에 쓰기 작업을 할 때, 해당 스레드에 visible한 모든 non-volatile 변수들 역시 메인 메모리에 쓰여지거나 flushed 된다. 즉 volatile변수와 함께 가장 최근의 값이 RAM에 저장된다.
  • volatile 변수에 읽기 작업을 할 때, 해당 스레드에 visible한 모든 non-volatile 변수들 역시 메인 메모리에서 리프레시 된다. 즉 가장 최근의 값이 할당된다.

이를 volatile 변수의 가시성 보장(visibility guarantee)이라 한다.

리오더링과 관련해서는 다른 예제를 봐보자.

(geeksforgeeks 예제)

// Sample class
class ClassRoom {

    // Declaring and initializing variables
    // of this class
    private int numOfAssgnSubmitted = 0; // 제출된 어사인 숫자
    private int numOfAssgnCollected = 0; // 수집된 어사인 숫자
    private Assignment assgn = null; // 어사인먼트 변수

    // Volatile shared variable
    private volatile boolean newAssignment = false;

    // Methods of this class

    // Method 1
    // Used by Thread 1
    public void submitAssignment(Assignment assgn)
    {

        // This keyword refers to current instance itself
        // 1
        this.assgn = assgn;
        // 2
        this.numOfAssgnSubmitted++;
        // 3
        this.newAssignment = true;
    }

    // Method 2
    // Used by Thread 2
    public Assignment collectAssignment()
    {
        while (!newAssignment) {

            // Wait until a new assignment is submitted
        }

        // assignment가 제출되어서 newAssignment = true 되었을 때

        Assignment collectedAssgn = this.assgn;

        this.numOfAssgnCollected++;
        this.newAssignment = false;

        return collectedAssgn;
    }
}

위 코드의 목표는, 매번 새롭게 준비된 assignment만 수집하는 것이다.

volatile 변수 newAssignment는 동시에 실행되고 있는 스레드1과 스레드2 사이에서 공유되는 변수이다. newAssignment 변수와 함께 다른 모든 변수들이 각 스레드들에 visible하므로 read-write 작업은 메인메모리에서 직접 이루어진다.

submitAssignment() 메서드에 집중해보자. statements 1,2,3은 서로 독립적이다. 어떠한 statement문도 다른 statement문을 사용하지 않기 때문에 CPU는 더 나은 성능을 제공하기 위해 리오더링을 고려할 수도 있다. CPU가 아래의 방식으로 리오더링을 했다고 가정해보자.

this.newAssignment = true; // 3
this.assgn = assgn;   // 1
this.numOfAssgnSubmitted++; // 2

저 위의 코드에서 우리의 목표는, 매번 새로운 assignment를 수집하는 것이었다. 하지만 새로운 assgn 값을 기존의 assgn에 저장하기도 전에 newAssignment를 true로 업데이트 하는 statements3로 인해, 스레드2의 while 루프가 종료되고, 스레드 2의 instructions이 스레드1의 나머지 명령보다 먼저 실행되어 Assignment의 이전 값 개체가 submitted될 가능성이 있다. 메인메모리에서 직접 불러오고 있다하더라도, 이러한 경우처럼 instructions이 잘못된 순서로 실행되면 무용지물이다.
위의 경우처럼 변수의 가시성이 보장되더라도 instructions의 리오더링이 적절하지 않은 실행으로 이어질 수 있다. 이러한 이유로 자바에서는 volatile의 가시성과 관련하여 happens-before를 보장하고 있다.

Happens-Before in Volatile

  • volatile 변수 write 작업(a)을 하는 코드가 있을 때, 이 코드 전의 코드들 역시 어떠한 변수에 대한 write 작업(b)이라면, 이 b write 작업은 리오더링이 일어나더라도 반드시 a 작업 전에 일어나도록 순서가 유지된다.
  • non-volatile이나 volatile 변수의 read 작업이 있는 코드 전에 위치해 있는, volatile 변수에 대한 read 작업을 리오더링할 때 반드시 후속 read 작업들 전에 발생하도록 보장된다.

저 위의 statements와 관련된 예제를 다시 꺼내보자.
statements1,2는 volatile에 대한 쓰기 작업인 statements3 전에 위치해 있다. 그리고 statements1,2 역시 쓰기 작업인 것을 알 수 있다. 리오더링이 발생될 때 이 statements1,2는 volatile에 대한 쓰기 작업인 statements3 전에 항상 유지된다. 즉 statements3 이후로 재배치 되지 않는다.
즉 새로운 Assignment 값이 assgn에 할당되고 난 뒤에야 newAssignment가 true로 세팅되는 것을 보장하는 것이다. 이를 volatile의 happens-before 가시성 보장이라 부른다.

volatile vs synchronized

자바의 synchronized 키워드는 Mutual Exclusion()와 Visibility, 이 두 가지를 보장한다.

  • Mutual Exclusion - 임계영역에서는 한 번에 하나의 스레드만 실행되도록 해야 한다.
  • Visibility - 데이터 일관성을 유지하기 위해 한 스레드에서 공유 데이터에 대해 변경한 내용을 다른 스레드에서도 볼 수 있어야 한다.

만약 공유 변수의 값을 변경하는 스레드 블록을 synchronized로 만든다면 오직 하나의 스레드가 블록에 접근할 수 있고 변경사항은 메인메모리에 반영되게 된다. 같은 타이밍에 해당 블록에 접근하려는 모든 스레드들은 blocked된다.

어떠한 경우에는 우리는 원자성이 아니라 오직 가시성만 원할 수도 있다. 이러한 상황에서 synchronized 키워드의 사용은 과도하게 보일 수도 있다. 이러한 경우에 volatile 키워드가 필요하게 된다. volatile 변수는 synchronized 키워드의 가시성 특징은 갖고 있으나 원자성 특징은 갖고 있지 않다. volatile 변수의 값은 캐시되지 않으며 모든 읽기와 쓰기 작업은 메인메모리에서 직접 일어나게 된다. 하지만 volatile의 사용은 대부분은 한계가 있는데 보통은 atomicity(원자성)한 특징을 대부분의 경우에 필요로 하기 때문이다. 예를 들어 x = x + 1 또는 x++ 같은 단순한 증가문은 단일 연산으로 보이지만 실제로는 아래와 같이 작동된다.

  • x를 메모리로부터 읽고
  • 읽은 값이 1을 더하고
  • 연산한 값을 메모리에 저장한다.

멀티스레드 환경에서 한 스레드가 위의 명령어를 수행하는 도중, 다른 스레드가 개입하여 공유 변수에 접근할 수도 있으며 이로 인해 값이 꼬이게 된다. volatile 키워드는 이러한 값의 원자성까지는 보장해주지 않는다.

참조

https://www.baeldung.com/java-volatile
https://www.geeksforgeeks.org/volatile-keyword-in-java/
https://www.geeksforgeeks.org/happens-before-relationship-in-java/
https://steady-coding.tistory.com/554

0개의 댓글