728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

volatile, 메모리 가시성 - 1 

volatile이 뭔지, 메모리 가시성은 뭔지 간단한 예제를 만들어보자.

VolatileFlagMain

package thread.volatilestudy;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class VolatileFlagMain {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread t = new Thread(task, "work");

        log("runFlag = " + task.runFlag);
        t.start();

        sleep(1000);
        log("runFlag false로 변경");
        task.runFlag = false;
        log("runFlag = " + task.runFlag);
        log("main 종료");
    }

    static class MyTask implements Runnable {

        boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // ?
            }
            log("task 종료");
        }
    }
}
  • 프로그램은 아주 간단하다. runFlag를 사용해서 스레드의 작업을 종료한다.
  • work 스레드는 MyTask를 실행한다. 여기에는 runFlag를 체크하는 무한 루프가 있다.
  • runFlag 값이 false가 되면 무한 루프를 탈출하여 작업을 종료한다.
  • 이후에 main 스레드가 runFlag의 값을 false로 변경한다.
  • runFlag의 값이 false가 되었으므로 work 스레드는 무한 루프를 탈출하며, 작업을 종료한다.
  • 참고로, 여기서 runFlagvolatile 키워드가 안 붙었다. 주의하자!

  • main 스레드, work 스레드 모두 MyTask 인스턴스(x001)에 있는 runFlag를 사용한다.
  • 이 값을 false로 변경하면 work 스레드의 작업을 종료할 수 있다.

기대하는 실행 결과

2024-07-19 18:26:30.057 [     main] runFlag = true
2024-07-19 18:26:30.061 [     work] task 시작
2024-07-19 18:26:31.063 [     main] runFlag false로 변경
2024-07-19 18:26:31.064 [     main] runFlag = false
2024-07-19 18:26:31.064 [     work] task 종료
2024-07-19 18:26:31.064 [     main] main 종료

 

실제 실행 결과

2024-07-19 18:26:30.057 [     main] runFlag = true
2024-07-19 18:26:30.061 [     work] task 시작
2024-07-19 18:26:31.063 [     main] runFlag false로 변경
2024-07-19 18:26:31.064 [     main] runFlag = false
2024-07-19 18:26:31.064 [     main] main 종료

실제 실행 결과를 보면 task 종료가 출력되지 않고, 자바 프로그램도 멈추지 않고 계속 실행된다. 정확히는 work 스레드가 while문에서 빠져나오지 못하고 있는 것이다. 분명히 runFlagfalse로 변경됐고, 그럼 while문을 빠져나와야 맞는데 그렇게 동작하지 않는다. 무슨 일일까?

 

메모리 가시성 문제

멀티 스레드는 메모리 가시성 문제라는 것이 있다. 이게 어떤 문제이고 왜 이런 문제가 발생하는지 그리고 어떻게 해결하는지 차근차근 알아보자. 

 

먼저 일반적으로 생각하는 메모리 접근 방식을 보자.

  • main 스레드와 work 스레드는 각각의 CPU 코어에 할당되어서 실행된다.
  • 물론 CPU 코어가 1개라면 빠르게 번갈아 가면서 실행할거다. 지금은 2개라고 생각해보자.

  • 실선 위쪽은 스레드의 실행 흐름을 나타내고 실선 아래쪽은 하드웨어를 나타낸다.
  • 자바 프로그램을 실행하고 main 스레드와 work 스레드는 모두 메인 메모리의 runFlag 값을 읽는다.
  • 프로그램의 시작 지점에는 runFlag의 값을 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽는다.
    • runFlag의 초기값이 true니까 그렇다.
  • work 스레드의 경우 while(runFlag)가 만족하기 때문에 while문을 계속 반복해서 수행한다.

  • 0. main 스레드는 runFlag의 값을 false로 설정한다.
  • 1. 이때 메인 메모리의 runFlag의 값이 false로 변경된다.
  • 2. work 스레드는 while(runFlag)를 실행할 때 runFlag의 데이터를 메인 메모리에서 확인한다.
  • 3. runFlag의 값이 false이므로 while문을 탈출하고, "task 종료"를 출력한다.

아마도 이런 시나리오를 생각했을 것이다. 그런데 실제로는 이렇게 동작하지 않는다.

 

실제 메모리 접근 방식

CPU는 처리 성능을 개선하기 위해 중간에 캐시 메모리라는 것을 사용한다.

  • 메인 메모리는 CPU 입장에서 보면 거리도 멀고, 속도도 상대적으로 느리다. 대신에 상대적으로 가격이 저렴해서 큰 용량을 쉽게 구성할 수 있다. 
  • CPU 연산은 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라 가려면 CPU 가까이에 매우 빠른 메모리가 필요한데, 이것이 바로 캐시 메모리이다. 캐시 메모리는 CPU와 가까이 붙어있고, 속도도 매우 빠른 메모리이다. 하지만 상대적으로 가격이 비싸기 때문에 큰 용량을 구성하기는 어렵다.
  • 현대의 CPU 대부분은 코어 단위로 캐시 메모리를 각각 보유하고 있다.
    • 참고로 여러 코어가 공유하는 캐시 메모리도 있다.

  • 각 스레드가 runFlag의 값을 사용하면, CPU는 이 값을 효율적으로 처리하기 위해 먼저 runFlag를 캐시 메모리에 불러온다.
  • 그리고 이후에는 캐시 메모리에 있는 runFlag를 사용하게 된다.

  • 실선 위쪽은 스레드의 실행 흐름을 나타내고, 점선 아래쪽은 하드웨어를 나타낸다.
  • 자바 프로그램을 실행하고 main 스레드와 work 스레드 모두 runFlag 값을 읽는다.
  • CPU는 이 값을 효율적으로 처리하기 위해 먼저 캐시 메모리에 불러온다.
  • main 스레드와 work 스레드가 사용하는 runFlag가 각각의 캐시 메모리에 보관된다.
  • 프로그램의 시작 지점에는 runFlag의 값을 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽는다.
    • runFlag의 초기값이 true이다.
  • work 스레드의 경우 while(runFlag)가 만족하기 때문에 while문을 계속 반복해서 수행한다.

  • 0. main 스레드는 runFlag의 값을 false로 변경한다.
  • 1. 이때 캐시 메모리의 runFlagfalse로 설정된다.

여기서 핵심은 캐시 메모리의 runFlag 값만 변한다는 것이다! 메인 메모리에 이 값이 즉시 반영되지 않는다.

  • main 스레드가 runFlag의 값을 변경해도 CPU 코어1이 사용하는 캐시 메모리의 runFlag 값만 false로 변경된다.
  • work 스레드가 사용하는 CPU 코어2의 캐시 메모리의 runFlag 값은 여전히 true이다.
  • work 스레드의 경우 while(runFlag)가 만족하기 때문에 while문을 계속 반복해서 수행한다.

  • 캐시 메모리에 있는 runFlag의 값이 언제 메인 메모리에 반영될까?
  • 이 부분에 대한 정답은 "알 수 없다"이다. CPU 설계 방식과 종류에 따라 다르다. 극단적으로 보면 평생 반영되지 않을수도 있다!
  • 메인 메모리에 반영을 한다고 해도 문제는 여기서 끝나지 않는다.
  • 메인 메모리에 반영이 된 runFlag의 값을 work 스레드가 사용하는 캐시 메모리에 다시 불러와야 한다.

  • 메인 메모리에 반영된 runFlag 값이 언제 CPU 코어2의 캐시 메모리에 반영될까?
  • 이 부분에 대한 정답도 "알 수 없다"이다. CPU 설계 방식과 종류에 따라 다르다. 극단적으로 보면 평생 반영되지 않을수도 있다!
  • 언젠가 CPU 코어2의 캐시 메모리에 runFlag 값을 불러오게 되면 work 스레드가 확인하는 runFlag의 값이 false가 되므로 while문을 탈출하고 "task 종료"를 출력한다.

캐시 메모리를 메인 메모리에 반영하거나, 메인 메모리의 변경 내역을 캐시 메모리에 다시 불러오는 것은 언제 발생할까?

이 부분은 CPU 설계 방식과 실행 환경에 따라 다를 수 있다. 즉시 반영될 수도 있고, 몇 밀리초 후에 될 수도 있고, 몇 초 후에 될 수도 있고, 평생 반영되지 않을 수도 있다. 주로 컨텍스트 스위칭이 될 때, 캐시 메모리도 함께 갱신되는데, 이 부분도 환경에 따라 달라질 수 있다.

예를 들어, Thread.sleep()이나 콘솔에 내용을 출력할 때 스레드가 잠시 쉬는데, 이럴때 컨텍스트 스위칭이 되면서 주로 갱신된다. 하지만 이것이 갱신을 보장하는 것이 아니다. 

 

메모리 가시성 (memory visibility)

이처럼, 멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성이라 한다. 이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이다. 

 

그렇다면 한 스레드에서 변경한 값이 다른 스레드에서 즉시 보이게 하려면 어떻게 해야 할까?

 

volatile 키워드 사용

캐시 메모리를 사용하면 CPU 처리 성능을 개선할 수 있다. 하지만 때로는 이런 성능 향상보다는, 여러 스레드에서 같은 시점에 정확히 같은 데이터를 보는 것이 더 중요할 수 있다. 해결방안은 아주 단순하다. 성능을 약간 포기하는 대신에, 값을 읽을 때, 값을 쓸 때 모두 메인 메모리에 직접 접근하면 된다. 자바에서는 volatile 이라는 키워드로 이런 기능을 제공한다.

 

VolatileFlagMain

package thread.volatilestudy;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class VolatileFlagMain {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread t = new Thread(task, "work");
        log("runFlag = " + task.runFlag);
        t.start();

        sleep(1000);
        log("runFlag false로 변경");
        task.runFlag = false;
        log("runFlag = " + task.runFlag);
        log("main 종료");
    }

    static class MyTask implements Runnable {

        volatile boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // ?
            }
            log("task 종료");
        }
    }
}
  • 기존 코드에서 boolean runFlag 앞에 volatile 키워드만 추가했다.
  • 이렇게 하면 runFlag에 대해서는 캐시 메모리를 사용하지 않고, 값을 읽거나 쓸 때 항상 메인 메모리에 직접 접근한다. 

실행결과

2024-07-19 19:11:10.542 [     main] runFlag = true
2024-07-19 19:11:10.545 [     work] task 시작
2024-07-19 19:11:11.551 [     main] runFlag false로 변경
2024-07-19 19:11:11.551 [     work] task 종료
2024-07-19 19:11:11.551 [     main] runFlag = false
2024-07-19 19:11:11.551 [     main] main 종료

 

여러 스레드에서 같은 값을 읽고 써야 한다면, volatile 키워드를 사용하면 된다. 단 캐시 메모리를 사용할 때 보다 성능이 느려지는 단점이 있기 때문에 꼭! 필요한 곳에만 사용하는 것이 좋다.

 

자바 메모리 모델 (Java Memory Model)

메모리 가시성(memory visibility)

멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에 언제 보이는지에 대한 것을 메모리 가시성이라 한다. 이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이다.

 

위에서 봤지만 캐시 메모리를 사용한다면 스레드 간 공유하는 값이 서로 다를 수 있고 그 경우 메모리 가시성 문제가 있다고 말한다. (예: main에서 runFlagfalse로 변경했지만 work 스레드는 계속해서 while문을 돌고 있는 경우)

 

그래서 이 메모리 가시성 문제를 해결하기 위해, volatile 키워드를 사용해서 캐시 메모리를 사용하지 않고 메인 메모리에 직접 접근하게 하도록 했다. 이런 메모리에 대한 접근과 수정이 어떻게 이루어지는가에 대한 것을 정의한게 있는데 이게 자바 메모리 모델이다. 

 

Java Memory Model

Java Memory Model(JMM)은 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정하며, 특히 멀티 스레드 환경에서 스레드 간 상호작용을 정의한다. JMM에 대한 여러가지 내용이 있지만, 핵심은 여러 스레드들의 작업 순서를 보장하는 happens-before 관계에 대한 정의다.

 

happens-before

happens-before 관계는 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념이다. 만약 A 작업이 B 작업보다 happens-before 관계에 있다면 A 작업에서의 모든 메모리 변경 사항은 B 작업에서 볼 수 있다. 즉, A 작업에서 변경된 내용은 B 작업이 시작되기 전에 모두 메모리에 반영된다.

 

  • happens-before 관계는 이름 그대로, 한 동작이 다른 동작보다 먼저 발생함을 보장한다.
  • happens-before 관계는 스레드 간의 메모리 가시성을 보장하는 규칙이다.
  • happens-before 관계가 성립하면, 한 스레드의 작업을 다른 스레드에서 볼 수 있게 된다.
  • 즉, 한 스레드에서 수행한 작업을 다른 스레드가 참조할 때 최신 상태가 보장되는 것이다.

이 규칙을 따르면, 프로그래머가 멀티 스레드 프로그램을 작성할 때 예상치 못한 동작을 피할 수 있다.

happens-before 관계가 발생하는 경우

이건 외우는 게 아니다! 이런것이 있구나를 알고 넘어가는 것이다. 

 

프로그램 순서 규칙

단일 스레드 내에서 프로그램의 순서대로 작성된 모든 명령문은 happens-before 순서로 실행된다. 예를 들어,

int a = 1;
int b = 2;

이런 코드가 있을 때 a = 1b = 2 보다 먼저 실행된다는 게 보장된다.

 

volatile 변수 규칙

한 스레드에서 volatile 변수에 대한 쓰기 작업은 해당 변수를 읽는 모든 스레드에 보이도록 한다. 즉, volatile 변수에 대한 쓰기 작업은 그 변수를 읽는 작업보다 happens-before 관계를 형성한다.

그래서, 위에서 volatile로 선언한 runFlagfalse로 변경하고, 그 어떤 다른 동작없이 work 스레드가 while문을 빠져나가고 종료된 것이다.

 

스레드 시작 규칙

한 스레드에서 Thread.start()를 호출하면, start()를 호출 하기 전에 수행된 모든 작업이 새로운 스렏르가 시작된 후의 작업보다 happens-before 관계를 가진다.

 

스레드 종료 규칙

한 스레드에서 Thread.join()을 호출하면, join 대상 스레드의 모든 작업은 join()이 반환된 후의 작업보다 happens-before 관계를 가진다. 그러니까 Thread.join() 이후에 호출된 코드는 저

join() 대상 스레드

에서 한 모든 작업에 대해 알고 있는 상태라는 얘기다.

 

인터럽트 규칙

한 스레드에서 Thread.interrupt()를 호출하는 작업이 인터럽트된 스레드가 인터럽트를 감지하는 시점의 작업보다 happens-before 관계가 성립한다.

 

객체 생성 규칙

객체의 생성자는 객체가 완전히 생성된 이후에만 다른 스레드에 의해 참조될 수 있도록 보장한다. 즉, 객체의 생성자에서 초기화 된 필드는 생성자가 완료된 후 다른 스레드에서 참조될 때 happens-before 관계가 성립한다.

 

모니터 락 규칙

한 스레드에서 synchronized 블록을 종료한 후, 그 모니터 락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있다. 예를 들어, synchronized(lock) { ... } 블록 내에서의 모든 작업은 블록을 나가는 시점에 happens-before 관계가 형성된다. 뿐만 아니라, ReentrantLock과 같이 락을 사용하는 경우에도 happens-before 관계가 성립한다. 

 

전이 규칙

만약, A가 B보다 happens-before 관계에 있고, B가 C보다 happens-before 관계에 있다면 A는 C보다 happens-before 관계에 있다.

 

 

정리

메모리 가시성이란, 멀티 스레드 환경에서 한 스레드의 작업이 다른 스레드에 언제 보이는가에 대한 이야기이다. CPU 코어는 성능의 효율성을 위해 메인 메모리가 아닌 각각 캐시 메모리를 사용하기 때문에 각 스레드가 같은 참조 필드를 알고 있을지라도 서로 다른 값을 가지고 있을 수 있다. 이럴때 메모리 가시성 문제가 발생하고 이런 문제를 해결하기 위해 volatile 키워드를 사용해서, 이 키워드가 붙은 필드는 캐시 메모리를 사용하지 않고 메인 메모리에 직접 접근하게 했다. 

 

즉, volatile 또는 스레드 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성 문제가 발생하지 않는다.

 

이제 스레드 동기화 기법을 배워보자!!

728x90
반응형
LIST

+ Recent posts