Home 메모리 배리어와 가시성: while 루프는 왜 멈추었을까?
Post
Cancel

메모리 배리어와 가시성: while 루프는 왜 멈추었을까?


최근 메모리 베리어에 대해 학습하게 됐고, 이 과정에서 2년 전 취준생 때 학습했던 내용이 기억났습니다. 당시 지식 부족으로 틀린 결론을 냈었는데요. 이를 바로 잡고 싶어 글을 작성하게 되었습니다.




1. 왜 while문이 중단될까?


최근 CPU 캐시를 공부하면서 2년 전 지율님과 공부할 때 봤던 코드가 떠올랐습니다. 둘이서 아래 코드가 어떻게 while 문을 탈출할 수 있는지 에 대해 새벽까지 고민했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
object Flag {
    var value = true
}

fun main() {
    val t1 = Thread {
        var count = 0L
        while (Flag.value) {
            count++

            // 해당 print문 때문에 while문이 종료
            println("count=$count")        
        }
        println("thread1 end, count=$count")
    }

    val t2 = Thread {
        Thread.sleep(100)
        Flag.value = false
        println("flag=$Flag.value")
    }

    t1.start()
    t2.start()
}




결론을 아래와 같이 냈는데, 당시 운영체제의 시스템 콜을 학습하고 있어서 어떻게든 해당 개념을 이용해 설명하려고 했던 것 같네요. 틀린 답이지만 어떻게든 답을 내려고 밤을 새우고 토론하던 게 생각나서 짠했습니다.

“print문 내부 synchronized 로 인해 시스템 콜, 컨텍스트 스위칭이 발생하며 flag 값의 변화를 감지한다”




해당 while 루프가 종료되는 이유는 println 호출로 인해 JIT 컴파일러가 루프 최적화를 적용하지 못하게 되었고, 그 결과 Flag.value를 다시 읽게 되었기 때문 입니다. 이 과정에서 다른 스레드의 쓰기가 우연히 관측될 수 있으나, 이는 자바 메모리 모델이 보장하는 동작은 아니어서요. 추가적으로 이는 메모리 베리어(Memory Barrier) 와도 관련이 있는데요. 이를 살펴보면서 코드를 분석해보죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PrintStream extends FilterOutputStream implements Appendable, Closeable {

    ...... 

    public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

    ......

}





2. 메모리 베리어


메모리 배리어는 CPU나 컴파일러가 배리어 앞뒤의 메모리 접근 순서를 임의로 재배치하지 못하도록 강제해, 특정 시점까지의 메모리 변경이 이후 연산에서 올바르게 관측되도록 만드는 장치입니다. 자바에서 메모리 배리어는 JVM이 내부적으로 삽입하며, 컴파일러와 CPU가 메모리 접근을 재정렬하지 못하게 합니다. 이는 volatile, synchronized, final 등의 규칙에 따라 스레드 간 가시성과 순서를 보장하죠.

In computing, a memory barrier, also known as a membar, memory fence or fence instruction, is a type of barrier instruction that causes a central processing unit (CPU) or compiler to enforce an ordering constraint on memory operations issued before and after the barrier instruction.



synchronized 키워드를 예시로 메모리 베리어를 살펴보겠습니다. synchronized 블록에 진입한다는 것은 단순히 임계 구역에 들어간다는 의미만을 가지지 않습니다. 이 지점은 스레드 간 메모리 상태를 동기화하는 기준점이 됩니다. synchronized는 실행 흐름을 보호하는 역할과 함께, 메모리 가시성을 보장하는 경계로 동작합니다.

An unlock on a monitor happens-before every subsequent lock on that monitor.



메모리 가시성은 synchronized 키워드 자체로 자동 보장되는 것이 아니라, 같은 락(monitor)을 기준으로 동기화했을 때만 보장됩니다. 한 스레드가 어떤 락을 획득한 상태에서 공유 변수를 수정하고 그 락을 해제하면, 이후에 같은 락을 다시 획득하는 다른 스레드는 해당 변경을 반드시 관측하게 됩니다. 이때 락 해제(unlock)와 이후 락 획득(lock) 사이에는 happens-before 관계가 성립하며, 이 관계를 통해 메모리 가시성이 보장됩니다.

1
2
3
4
5
6
7
8
9
// 같은 락
synchronized(lock) {
    shared = 1
}

// 다른 스레드
synchronized(lock) {
    print(shared) // 반드시 1
}




반대로, 서로 다른 락을 사용할 때는 락 해제와 락 획득 사이에 happens-before 관계가 성립하지 않습니다. 한 스레드가 lockA를 기준으로 공유 변수를 수정하더라도, 다른 스레드가 lockB를 획득하는 것만으로는 해당 변경을 관측할 것이라는 보장이 없습니다. 이 경우 컴파일러나 JVM은 이전값을 그대로 사용해도 합법적이며, 메모리 가시성은 보장되지 않습니다.

1
2
3
4
5
6
7
8
9
// 다른 락
synchronized(lockA) {
    shared = 1
}

// 다른 락
synchronized(lockB) {
    print(shared) // 보장 없음
}

즉, 락을 해제하고 다시 획득하는 시점이 메모리 가시성이 보장되는 기준이 됩니다.




참고로 happened-before 관계는 두 이벤트 사이에 인과적 순서를 정의하는 규칙 으로, 실제 실행 순서가 달라지더라도 앞선 이벤트의 결과가 뒤의 이벤트에 반드시 반영 되어야 합니다. 이 관계는 같은 스레드의 실행 순서, 메시지 전송과 수신, 그리고 언어의 메모리 모델을 통해 형성되며, 이렇게 연결된 순서는 추이적으로 전파됩니다.

In computer science, the happened-before relation is a relation between the result of two events, such that if one event should happen before another event, the result must reflect that, even if those events are in reality executed out of orde.



자바 메모리 모델에서는, 명세가 허용하는 결과만 나온다면 컴파일러가 코드를 어떻게 재배치하더라도 문제가 되지 않는다고 규정합니다. 컴파일러의 최적화는 실행 결과가 합법적인 범위를 벗어나지 않는 한 자유롭게 수행될 수 있죠.

If the reordering produces results consistent with a legal execution, it is not illegal.



따라서 컴파일러나 CPU가 명령 실행 순서를 바꾸는 것을 막지는 않지만 재배치로 인해 규칙을 위반하는 결과가 나타나서는 안 됩니다. 즉, 실제 실행 순서는 달라질 수 있지만, 외부에서 관측되는 결과는 자바 메모리 모델이 허용하는 범위 안에 있어야 합니다.

It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation.



반대로, 동기화가 없는 경우에는 다른 스레드의 변경을 관측할 수 있는 보장이 없습니다. volatile도 아니고 synchronized로 보호되지 않은 필드는, 다른 스레드가 언제 값을 변경했는지를 알 수 없습니다. 따라서 컴파일러는 해당 값이 변하지 않는다고 가정할 수 있으며, 한 번만 읽은 뒤 레지스터에 저장해 반복 사용해도 됩니다.

The compiler is free to read the field just once and reuse the cached value



이 때문에 동기화되지 않은 루프에서는 값이 변하지 않는 것처럼 보일 수 있습니다. 다른 스레드가 값을 변경하더라도, 다시 메모리에서 읽어야 할 의무가 없기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
object Main {

    var running = true

    @JvmStatic
    fun main(args: Array<String>) {
        val worker = Thread {
            while (running) {
                // 아무 작업 없음
            }
            println("Loop Exited")
        }

        worker.start()

        Thread.sleep(1_000)
        running = false
        println("running set to false")
    }
}




그러나 synchronized 블록에 진입하는 순간, JVM은 이전에 읽은 값이 여전히 유효하다고 가정할 수 없습니다. 같은 락을 사용하는 다른 스레드가 값을 변경했을 가능성이 있기 때문입니다. 따라서 JVM은 synchronized 경계를 넘을 때, 레지스터나 캐시에 저장된 값을 그대로 사용하지 않고 최신 메모리 상태를 다시 관측해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
object Main {

    private val lock = Any()
    var running = true

    @JvmStatic
    fun main(args: Array<String>) {
        val worker = Thread {
            while (true) {
                synchronized(lock) {
                    if (!running) {
                        println("Loop Exited")
                        break
                    }
                }
            }
        }

        worker.start()

        Thread.sleep(1_000)

        synchronized(lock) {
            running = false
            println("running set to false")
        }
    }
}




이로 인해 루프 불변 코드 이동(Loop Invariant Code Motion)과 같은 JIT 최적화는 synchronized 경계를 넘을 수 없게 됩니다. 이후 코드에서는 다시 메모리를 읽는 것이 허용되고, 필요해집니다. 즉, 동기화 지점에서 메모리 가시성을 보장해야 한다는 자바 메모리 모델의 명시적인 규칙이죠.

In computer programming, loop-invariant code consists of statements or expressions that can be moved outside the body of a loop without affecting the semantics of the program.





3. 주의할 점


메모리 배리어는 모든 메모리 변경을 자동으로 관측하게 만드는 장치가 아니라, 동일한 동기화 경계를 기준으로 한 쓰기와 읽기 사이에서만 가시성을 보장합니다.

An unlock on a monitor happens-before every subsequent lock on that monitor.



System.out.println은 내부에서 System.out(PrintStream) 객체를 기준으로 synchronized 블록을 사용하므로, 출력마다 항상 같은 락을 획득합니다. 그러나 같은 락을 획득한다는 사실만으로는 모든 공유 변수의 변경이 자동으로 보장되지는 않습니다. 즉, 자바 메모리 모델에서 메모리 가시성은 락의 존재 여부가 아니라, 자바 메모리 모델이 정의한 happens-before 관계가 성립할 때만 보장되며, synchronized는 같은 락을 기준으로 한 경우에만 그 관계를 형성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class PrintStream extends FilterOutputStream implements Appendable, Closeable {

    ...... 

    public void println(String x) {
        // PrintStream을 기준으로 락 획득.
        synchronized (this) {
            print(x);
            newLine();
        }
    }

    ......

}




아래 코드에서 출력 스레드는 매 반복마다 System.out 락을 획득하지만, Flag.value를 변경하는 스레드는 어떤 락도 사용하지 않습니다. 즉, 쓰기와 읽기가 같은 락을 공유하지 않으므로, println 내부의 동기화는 Flag.value 변경에 대한 happens-before 관계를 만들지 못합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object Flag {
    var value = true
}

fun main() {
    val t1 = Thread {
        while (Flag.value) {
            println("running") // synchronized(System.out)
        }
    }

    val t2 = Thread {
        Thread.sleep(100)
        Flag.value = false    // 락 없이 쓰기
    }

    t1.start()
    t2.start()
}




이 경우 println 호출로 인해 우연히 메모리 상태가 다시 관측되어 루프가 종료될 수도 있지만, 이는 자바 메모리 모델이 보장하는 동작이 아닙니다. JVM은 System.out 락과 무관한 변수인 Flag.value를 다시 읽을 의무가 없으므로, 실행 환경이나 JIT 최적화에 따라 루프가 즉시 종료되지 않을 수도 있습니다. 메모리 가시성을 확실히 보장하려면, 쓰기와 읽기가 같은 동기화 기준을 사용해야 합니다. 예를 들어, volatile을 사용하거나, 동일한 락으로 동기화해야 합니다. 이 경우에만 자바 메모리 모델이 요구하는 happens-before 관계가 성립하며, 루프 종료가 반드시 보장됩니다. 위에서 봤던 내용이 이제 이해가 되죠?

1
2
3
object Flag {
    @Volatile var value = true
}
1
2
3
4
5
6
7
8
9
10
11
12
// 방법 2: 같은 락 사용
val lock = Any()

// Thread 1
synchronized(lock) {
    while (Flag.value) { }
}

// Thread 2
synchronized(lock) {
    Flag.value = false
}





4. 정리


메모리 배리어는 모든 메모리 변경을 무조건 전파하는 장치가 아니라, 자바 메모리 모델이 정의한 happens-before 관계가 성립하는 경우에만 메모리 가시성을 보장합니다. 이 관계는 특정 동기화 경계를 기준으로 한 쓰기와 읽기 사이에서만 형성되며, 관련 없는 메모리 접근까지 자동으로 동기화하지는 않습니다. 따라서 동기화되지 않은 루프에서 값이 변하지 않는 것처럼 보이는 현상은, 메모리 배리어가 없어서가 아니라 가시성을 보장하는 happens-before 관계가 존재하지 않기 때문입니다.


This post is licensed under CC BY 4.0 by the author.