본문 바로가기
Old Posts/Java

[Java] 메모리 가시성(Visibility)

by A6K 2022. 5. 15.

소스코드의 특정 블럭을 동기화시키고자 할 때, 항상 메모리 가시성(Memory Visibility) 문제가 발생한다. 특정 변수의 값을 사용하고 있을 때 다른 스레드가 그 변수의 값에 접근하지 못하도록 막아야 하는 ‘상호배제’도 중요하지만 값을 수정한 다음 동기화 블록을 빠져나가고 나면 다른 스레드가 변경된 값을 즉시 사용할 수 있어야 하는 ‘가시성(Visibility)’도 중요하다.

메모리 가시성(Memory Visibility)

싱글 스레드 환경에서는 프로그램의 코드가 특정 변수에 값을 수정한 다음 다시 그 변수의 값에 접근해보면 이전에 수정한 값을 다시 가져올 수 있다.

멀티 스레드 환경에서는 반드시 수정한 값을 읽는 것이 보장되지는 않는다. 공유 변수에 대해서 어떤 스레드가 값을 수정했을 때, 그 값을 다른 스레드가 읽어갈 수 있다는 보장이 없다. 수정하기 전 변수 값을 읽거나 심지어 값을 읽어 가지 못 할 수도 있다.

Java Concurrency in Practice 책에 나오는 예제를 보자. 두 스레드가 공유 변수의 값에 접근하는 경우를 설명하기 위한 코드다.

public class NoVisibility {

  private static boolean ready;
  private static int number;

  private static class ReaderThread extends Thread {
    public void run() {
      while (!ready)
        Thread.yield();
      System.out.println(number);
    }
  }

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

코드를 작성한 의도는 메인 스레드에서 number 변수의 값을 42로 설정하고, read 변수를 true로 설정해두면 ReaderThread는 주기적으로 ready 변수를 확인하다가 준비되면 number 변수의 값을 출력하는 코드다.

메인 스레드가 number 값을 설정하고 ready 값을 변경하기 때문에 ready 값을 읽고 number 값을 읽는 ReadThread가 적당한 시기에 while 루프에서 벗어날 것으로 예상하기 쉽다. 하지만 그렇지 않다. ReaderThread는 0을 출력할 수도 있고, 영원히 while 루프에서 벗어나지 못 할 수도 있다.

자바 컴파일러는 프로그램의 효율적인 수행을 위해 ‘재배치(Reordering)’라는 작업을 수행한다. 같은 결과를 내는 코드라면 컴파일러가 코드의 실행 순서를 바꿀 수 있고 생략할 수도 있다.

예를 들어 위 코드에서 number 변수에 42라는 값을 넣고, ready를 true로 변경하리라 예상했지만 컴파일러의 최적화에 따라서 ready를 true로 변경한 다음 number 변수에 42를 할당할 수도 있다. 컴파일러 입장에서는 이 코드가 싱글 스레드에서 실행되리라고 생각해서 두 할당 연산의 순서를 바꿀 수 있기 때문이다.

따라서 코드를 작성할 때, 여러 스레드 사이에 공유되는 변수에 대해서는 적당한 동기화 기법을 사용해 컴파일러가 코드를 재배치 할 때 잘못 동작할 수 있는 쪽으로 최적화하지 않게 알려줘야 한다.

Stale data

여러 스레드 사이에 공유되는 변수에 적당한 동기화 기법을 사용하지 않으면 최신 값이 아닌 이전 값, 즉 Stale data를 보는 현상이 발생할 수 있다.

예를 들어 JCP 책에 있는 다음 코드를 보자

@NotThreadSafe
public class MutableInteger {
    private int value;
 
    public int get() {
        return value;
    }
 
    public void set(int value) {
        this.value = value;
    }
}

value 변수에 값을 할당하고 읽는 메소드가 있다. 1번 스레드가 이 클래스의 인스턴스에 접근해 값을 0에서 10으로 설정한 다음 다른 스레드가 그 값을 읽을 때, 10이 아닌 0이 읽혀질 가능성이 있다. 즉 수정되기 전 값인 stale data, 0이 읽히는 것이다.

일반적인 컴퓨터의 메모리 구조

메인 메모리에 존재하는 변수의 값은 수정되기 위해서 프로세서의 캐시로 로드된다. 캐시에 있는 데이터는 다시 레지스터로 로드된 다음 프로세서의 처리를 받고 수정되어 다시 캐시로 쓰여진다. 이 때, 변경된 값이 바로 메인 메모리로 반영되는 것은 아니다.

멀티 프로세서에서의 Stale Data 현상

재앙은 여기에서 발생한다. 요즘 CPU는 멀티 프로세서가 기본이다. 1번 프로세서에서 공유 변수를 수정한 상황을 생각해보자. 아직 메인 메모리에는 수정된 값이 반영되지 않은채 1번 프로세서의 캐시에만 새로운 데이터가 존재한다. 이 상황에서 2번 프로세서가 공유 변수의 값을 접근한다. 시간상으로는 이미 수정된 데이터이지만 2번 프로세서는 메인 메모리에 있는 예전 값을 로드해서 사용한다. 즉 Stale Data를 읽어가게 된다.

자바의 동기화 메커니즘을 사용하면 이런 문제를 해결 할 수 있다. 예를 들어 synchronized 블럭을 사용할 경우 블럭을 벗어 날 때, 프로세서에 있는 아직 flush 되지 않은 데이터들을 모두 메인 메모리에 반영한다. 혹은 volatile 변수를 사용하면 수정된 변수의 내용이 바로바로 메인 메모리에 적용된다.

앞에서 봤던 코드는 다음과 같이 고칠 수 있다.

@ThreadSafe
public class SynchronizedInteger {
    @GuardedBy("this") private int value;
 
    public synchronized int get() {
        return value;
    }
 
    public synchronized void set(int value) {
        this.value = value;
    }
}

synchronized 블럭은 critical section을 형성해 상호배제를 구현하는데 사용될 뿐 아니라 프로세서 캐시에 있는 Dirty Block들을 flush해 가시성을 확보하게 해준다는 점을 잊어서는 안된다.

volatile 변수와 가시성

앞서 언급한 것처럼 volatile 변수는 값이 수정될 때 바로 메인 메모리에 값이 반영된다. 따라서 stale data를 읽는 일이 발생하지 않는다. 자바를 공부하면 배우게 되는 기본적인 내용이다.

volatile 키워드는 변수 하나에 대한 가시성 확보를 넘어서 조금 더 확장된 의미를 갖는다. 즉, volatile 변수로 접근하는 코드 이전에 수행된 내용은 volatile 변수에 접근한 이후에서는 모두 가시성이 확보된다.

nonVolVar1 = 100;
nonVolVar2 = 200;

// Volatile 변수
volVar = 10;
newValue = volVar + 1;

// 다음 값은 volatile 변수는 아니지만 stale data를 읽지는 않는다.
newValue1 = nonVolVar1 * 2;
newValue2 = nonVolVar2 * 2;

예를 들어 위와 같은 코드에서 volVar = 10; 이라는 코드가 실행된 이후에는 그 이전에 수행했던 nonVolVar1, nonVolVar2 변수에 대한 수정 사항도 가시성이 확보된다. nonVolVar1과 nonVolVar2 변수는 volatile 변수가 아니라서 stale data를 읽는 일이 발생할 수 있다. 하지만 volatile 변수인 volVar에 접근하고 나면 모든 DirtyBlock들이 메모리로 flush되어 가시성이 확보된다. (메모리 장벽, Memory Barrier 라고 한다)

일종의 사이드 이펙트가 있는건데 volatile 변수를 접근하면서 비 volatile 변수에 대한 가시성까지 한꺼번에 확보하는 형태의 코드는 버그가 발생할 가능성이 매우 높다. 차라리 synchronized 블럭을 써서 명시적으로 동기화시키는게 좀 더 코드를 명확하게 이해할 수 있게 해준다.

또 한 volatile 변수는 연산의 단일성을 보장하지 않는다. volatile 변수 count의 값을 증가시키는 count++ 같은 연산은 코드에서는 한 라인이지만 실제로는 읽고 수정하고 쓰는 작업으로 구성되어 있다. 만약 두 스레드가 동시에 count++을 수행한다면 값이 1만 증가하는 동시성 문제가 발생할 수 있다. volatile 변수는 연산의 단일성은 보장하지 못하고 가시성만 보장한다.

volatile 변수는 다음과 같은 상황에서만 사용하는 것이 좋다.

  • 변수에 값을 저장하는 작업이 해당 변수의 현재 값과 관련이 없거나 해당 변수의 값을 변경하는 스레드가 하나만 존재
  • 해당 변수가 객체의 불변조건을 이루는 다른 변수와 달리 불변조건에 관련되어 있지 않다.
  • 해당 변수를 사용하는 동안에는 어떤 경우라도 락을 걸어 둘 필요가 없는 경우

댓글