카테고리 없음

OS 메모리 가시성의 이야기

데일리코딩 2024. 9. 5. 22:58

* 해당 포스팅은 김영한님의 고급 자바편 멀티스레드 강의를 보고 정리한 내용입니다.

 

지난 시간 우리는 멀티스레드에 대해서 알아보고 멀티스레드 기능을 활용해서 성능최적화를 할 수 있다고 

말했습니다.

 

다만 멀티스레드 개념없이 코드를 가져다가 쓰게 된다면 디버깅 하기도 어려운 엄청난 버그를 만날 수 있다고도

언급했습니다.

 

오늘은 바로 그 이야기를 하고자합니다.


 

메모리 가시성 (Memory Visibility)

우선 메모리 가시성이라는 단어부터 굉장히 낯선용어네요 

사전에 따르면 "눈에 쉽게 보이는 정도"를 뜻하는데 컴퓨터 과학에서는 다음과 같이 말합니다.

* 각각의 스레드가 공유자원에 대해서 모두 같은 상태를 바라보고 있는 것

 

흠.. 아직 용어가 어렵군요 여기서 말하는 공유자원이 뭘까요?

멀티스레드 개념으로 바라본다면 바로 메모리 영역이죠

자바에서는 heap 영역이 바로 공유자원입니다.

 

 

heap 영역에는 뭐가 들어가죠? 바로 선언한 변수에 들어간 값

객체지향에는 보통 생성된 객체의 필드값 도 공유자원으로 포함 되겠군요

그림을 통해서 확인할 수 있지만

모든 스레드들은 코드, 메모리, 기타 영역을 공유합니다

그리고 여기서 바로 우리가 이야기할 메모리 가시성 문제가 발생합니다.


메모리 가시성 문제

간단한 프로그램 코드를 통해서 메모리 가시성에 대해 이해하도록 노력해봅시다

package thread.volatile1;

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;
        // volatile boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // runFlag가 false로 변하면 탈출
            }
            log("task 종료");
        }
    }
}

 

 

프로그램은 아주 간단한데 runFlag를 사용해서 스레드의 작업을 종류하는 프로그램입니다.

- work 스레드는 MyTask를 실행한다. 여기에는 runFlag를 체크하는 무한 루프가 있습니다.

- runFlag 값이 false가 되면 무한루프를 탈출하며 작업을 종료합니다.

- 이후에 main 스레드가 runFlag의 값을 false로 변경합니다.

- runFlag의 값이 false가 되었으므로 work스레드는 무한 루프를 탈출하며, 작업을 종료합니다.

 

메모리 그림은 다음과 같습니다.

앞서 말한것 처럼 모든 스레드는 heap 영역을 공유합니다 메모리를 공유하는 것입니다.

 

우리는 위 코드를 실행하면 기대하는 결과값은 main 스레드에서 MyTask 스레드를 실행 시키고 

main 스레드에서 MyTask의 runFlag 값을 접근하여  false로 수정을 한다면 while 문으로 계속 동작하는 

MyTask 스레드는 반복문을 탈출하고 로그를 프린트 하는 것을 기대합니다

하지만 대반전.. 정말 과연 그럴까요?

기대하는 실행 결과
15:39:59.830 [ main] runFlag = true
15:39:59.830 [ work] task 시작
15:40:00.837 [ main] runFlag를 false로 변경 시도
15:40:00.838 [ main] runFlag = false
15:40:00.838 [ work] task 종료
15:40:00.838 [ main] main 종료

 

하지만 실제 실행한 코드 결과값은 다음과 같습니다.

실제 실행 결과
15:40:55.367 [ main] runFlag = true
15:40:55.367 [ work] task 시작
15:40:56.374 [ main] runFlag를 false로 변경 시도
15:40:56.374 [ main] runFlag = false
//task가 종료되지 않고 main 스레드만 종료되버린 상황...
15:40:56.375 [ main] main 종료

 

실제 실행결과를 살펴보면 task 종료가 출력되지 않습니다. 그리고 자바 프로그램도 멈추지 않고 while문에서 계속 반복하고 있죠

분명히 우리는 runFlag 값이 false로 변경되는것도 확인했습니다.

 

바로 이 문제가 메모리 가시성 문제입니다. 

분명 메모리의 값을 변경해서 내가 예측한 결과가 나와야하는데 멀티스레드 환경에서는 예측한 결과가 나오지 않는 상황인것이지요


메모리 가시성 2

왜 공유하는 메모리인 runFlag의 값이 false로 변경되었음에도 불구하고 
myTask 스레드는 while문 탈출하지 못했을까 정말 미스테리 하지 않습니까?

 

이 현상을 이해하기 위해서 우리는 각 스레드가 공유하는 메모리를 어디서 접근해서 읽는지 

내부 동작방식을 이해할 필요가 있습니다.

 

우리는 흔히 각 스레드에서 공유하는 메모리인 heap에 접근할 때 아래 그림처럼 접근한다고 생각하실겁니다.

main 스레드와 work 스레드는 각각의 cpu 코어에 할당되어서 실행됩니디.

점선 위쪽은 스레드의 실행 흐름을 나타내고, 점선 아래쪽은 하드웨어를 나타냅니다.

자바 프로그램을 실행하고 main 스레드와 work 스레드는 모두 메인 메모리의 runFlag의 값을 읽습니다.

프로그램의 시작 시점에는 runFlag를 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽습니다.

work 스레드의 경우 while(runFlag[true])가 만족하기 때문에 while문을 계속 반복해서 수행합니다.

 

아마 우리 모두는 당연히 아래 그림처럼 프로그램이 실행될것이라고 예상합니다.

 

1. 메인 스레드에서 work 스레드의 runFlag 값에 접근해서 false로 변경한다

2. work 스레드는 runFlag의 값을 false로 확인한다.

3. while 문의 조건을 만족하지 못하므로 반복문을 탈출한다

4. 더 이상 실행할 작업이 없으니 work 스레드는 종료한다.

 

하지만 이것은 틀렸습니다.

 

실제 메모리의 접근 방식은 다음과 같습니다.

각 스레드가 runFlag의 값을 사용하면 cpu는 이값을 효율적으로 처리하기 위해 먼저 runFlag를 캐시 메모리에 불러옵니다.

그리고 이후에는 캐시 메모리에 있는 runFlag를 사용하게 됩니다.

 

점선 위쪽은 스레드의 실행 흐름을 나타내며, 점섬 아래쪽은 하드웨어를 표현한 이미지입니다.

자바 프로그램을 실행하고 Main 스레드와 work 스레드는 모두 runFlag를 읽습니다.

cpu는 이값을 효율적으로 처리하기 위해 먼저 캐시 메모리에 불러옵니다.

main 스레드와 work 스레드가 사용하는 runFlag가 각 캐시 메모리에 보관합니다

프로그램의 시작 시점에는 runFlag를 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽습니다.

 

메인 스레드는 runFlag를 false로 설정합니다.

이때 캐시 메모리의 runFlag가 false로 설정됩니다.

 

여기서 핵심은 캐시 메모리의 runFlag가 변경되었지 메인 메모리는 즉시 반영이 되지 않는다는점입니다.

main 스레드가 runFlag의 값을 변경해도 cpu 코어 1이 상요하는 캐시 메모리의 runFlag값만 false로 변경됩니다.

work 스레드가 사용하는 캐시 메모리의 값은 아직도 true의 값을 읽습니다.

따라서 while문을 만족하니 계속해서 반복합니다.


이처럼 멀티스레드 환경에서는 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성 이라고합니다.

이름 그대로 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이죠

우리는 메모리 가시성 현상을 재대로 파악해서 

이런 문제를 마주쳤을때 기억해서 해당 문제를 해결하는 개발자가 되어야겠습니다.

 

오늘은 메모리 가시성에 대해 알아보았구요 다음 포스팅은 그렇다면 자바에서는 어떻게 메모리 가시성 문제를 해결했는지 

그리고 어떻게 해결해야하는지에 대해 포스팅하겠습니다!