Memory Reordering and Memory Model
어제 동작하던 코드가 왜 오늘은 돌지 않을까? 아마 많은 개발자들이 한번쯤은 생각해봤을 문제이다. 나는 이 문제의 99.9%의 원인을 알고있다. 그냥 개발자의 실수이고 사실 똑같이 재현하지 않았을 뿐이다. 허나 0.1%의 원인의 낮은 확률로 메모리 모델을 잘 이해하지 못해 발생하는 문제일 수도 있다. 오늘 이야기 하고 싶은 것은 0.1%의 원인을 다루고자 한다.
2025년 현재는 멀티코어가 아닌 환경을 찾는게 더 어려운 시대이다. 물론 나는 싱글코어 세대에는 일을 하지도, 컴퓨터를 잘 활용하지도 않았지만 말이다. 멀티코어 환경에서는 프로그램의 의도적인 동작을 유지하는 것은 단순한 일이 아니다. 그 이유 중 가장 큰 것이 메모리 재배치(memory reordering)이다. 메모리 재배치는 CPU와 컴파일러가 성능을 최적화하기 위해서 메모리의 연산 순서를 조정하는 과정에서 발생하는데, 이는 단일 쓰레드에서는 문제가 없어보일 수 있으나 여러 쓰레드가 데이터를 공유하는 멀티 쓰레딩 환경에서는 문제가 일어날 가능성이 크다.
메모리 재배치란 무엇인가?
컴파일러와 CPU는 여러 가지 이유로 코드에서 지정한 메모리 조작 명령의 연산 순서를 변경한다. 보통은 성능을 최적화하고 리스스 활용을 더 효율적으로 하기 위해서 발생된다.
컴파일러 수준 재배치
컴파일러는 프로그램의 성능을 향상시키기 위해서 코드의 명령어 순서를 변경하는데, 보통 레지스터의 사용을 줄이거나 파이프라인 병목을 줄이기 위해서 메모리 접근 순서를 조정한다. 보통 이 경우에서는 데이터 레이스가 발생할 수 있는데, 하나의 쓰레드의 시각에서는 문제 없는 것 같지만, 다른 쓰레드가 공유 데이터를 참조할 경우 잘못된 결과를 초래할 수 있다.
CPU 수준 재배치
아마 아웃 오브 오더 실행을 들어봤을 것이다. 이는 CPU의 성능을 극대화 하기 위한 최신 CPU에서 너무나도 당연하고 주로 사용되는 기법이다. 이 아웃 오브 오더 실행은 CPU 명령어 간의 의존 관계가 없는 경우에 명령어의 실행 순서를 바꿔 성능을 최적화 한다. 허나 이 트릭을 사용할 때는 하나의 CPU 코어의 캐시에서 변경된 데이터가 다른 코어에 반영되기 전에 새로운 연산이 발생할 수 있는데 이 때 문제가 발생할 수 있다.
재배치가 문제를 일으키는 이유
이런 메모리 재배치 자체는 성능 향상에 매우 중요한 역할을 한다 .하지만 위에서 언급한 것 처럼 멀티 쓰레드 환경에서는 이 재배치로 인해서 다양한 문제가 발생할 수 있는데 이는 재배치로 인해 각 쓰레드 사이의 일관된 메모리 상태(coherent memory state)를 깨뜨릴 수 있기 때문이다.
case1: Load-Store 순서가 깨짐
한 쓰레드가 A
를 쓰고 나서 B
를 쓰더라도, 다른 쓰레드에서는 B
가 먼저 업데이트 된 것으로 확인할 수 있다. 아래 코드를 살펴보자.
x = 1;
y = 2;
의도는 x
를 먼저 업데이트 한 뒤에 y
를 업데이트 하는 것인데, 다른 쓰레드에서는 y
가 먼저 변경된 것으로 보일 수 있다.
case2: Data Race
데이터 레이스는 동기화가 없이 두 개 이상의 쓰레드가 같은 데이터를 동시에 읽거나 쓰는 상황에서 발생하는 의도하지 않은 동작을 말한다. 컴파일러와 CPU는 데이터 의존성이 없는 경우 연산 순서를 자유롭게 재배치하기 때문에, 쓰레드 간에 충돌이 발생할 가능성이 높아진다.
case3: 캐시 일관성 문제
한 쓰레드에서 변경한 값이 다른 쓰레드에서 즉시 반영되지 않을 수 있다. 예를 들어 A 쓰레드가 변수 x
를 업데이트 했지만 쓰레드 B는 여전히 오래된 값을 참조한느 상하황이 발생할 수 있는데, 이는 각 코어마다 독립적인 캐시와 메모리 계층 구조를 가지고 있기 때문에 발생하며 보통 캐시 일관성 문제(Cache Coherency Issue)라고 부른다.
재배치는 디버깅도 어렵다
이런 재배치는 여러 문제를 발생시키는데, 더 큰 문제는 디버깅 하는 것 자체가 매우 어려운 케이스라는 것이다. 여러 이유가 있다.
첫째로 문제를 재현하기 매우 어렵다. 재배치로 인해 발생하는 문제는 실행 환경이나 CPU 아키텍처, CPU 코어들이 서로 협업하여 동작하는 타이밍에 따라 문제가 달라질 수 있다. 즉 동일한 코드라도 테스트 환경에서는 문제가 없지만 다른 환경으로 넘어가면 문제가 발생될 수 있다.
둘째로 실행 순서의 복잡성에 있다. 멀티 쓰레드 프로그램의 실행 순서는 쓰레드 간의 동기화 상태, 재배치된 연산 명령어들의 배치 순서, 그리고 타이밍에 따라 달라질 수 있다. 정말 말도 안되게 많은 실행 경로가 존재할 수 있기에 이 경로를 파악하여 문제를 추적하는 것은 매우 어렵다.
셋째로 도구의 제한이다. 보통 전통적인 디버깅 도구는 싱글 쓰레드에 초점에 맞춰서 개발되었고, 멀티 쓰레딩 환경은 지원이 미흡한 경우가 많다. 사실 미흡하다기 보다는 멀티 쓰레딩 문제를 해결하기 위해 봐야하는 데이터는 싱글 쓰레딩 환경에서 봐야하는 데이터보다 관련 데이터를 더 확장해여 봐야한다는 점이 큰 것 같다. 그래서 보통 ThreadSanitizer 같은 실행 순서를 시각화하는 전문 도구가 필요하다.
하드웨어 메모리 모델: 약한 메모리 모델과 강한 메모리 모델
하드웨어 메모리 모델은 특정 CPU 아키텍처가 메모리에 접근하는 방식을 의미한다. 이는 여러 쓰레드가 공유 메모리에 접근할 때, 각 쓰레드가 메모리 연산의 결과를 어떤 순서대로 관찰하게 될지를 결정한다. 메모리 모델은 강한 메모리 모델(Strong Memory Model)과 약한 메모리 모델(Weak Memory Model)로 나눌 수 있으며, 이는 각각 메모리 재배치의 허용 범위를 포함한 여러 지점에서 큰 차이를 가진다.
약한 메모리 모델
약한 메모리 모델은 재배치를 최대한 활용하여 성능을 극대화 하기 위한 모델이다. 약한 모델을 따르는 프로세서는 메모리 읽기와 쓰기 연산의 순서를 자유롭게 지 마음대로 재배치하여 성능을 극대화한다. 이런 메모리 재배치를 통해 메모리 읽기와 쓰기 연산은 성능이 최적화되어 높은 성능을 가질 수 있다. 허나 프로그래머가 명시적으로 동기화를 추가하지 않는다면 이는 잠재적인 일관성 문제로 이어질 수 있다. 이런 약한 메모리 모델을 구현하는 대표적인 아키텍처는 ARM이며 PowerPC, Itanium 같은 추가적인 아키텍처들이 있다고 한다 (본인은 다뤄본적 없다). 조금 더 언급해보고 싶은 아키텍처는 DEC Alpha인데, 이는 극단적으로 약한 메모리 모델이라고 불릴 정도로 메모리 재배치가 아주 많이 일어난다고 한다.
ARM 아키텍처에서
우리가 Graviton, Apple Silicon등으로 잘 알고있는 ARM 아키텍처는 약한 메모리 모델을 따른다. 메모리 읽기와 쓰기 연산에 대한 재배치를 폭 넓게 허용하면서 성능을 극대화하고 전력 소비를 줄여서 높은 성능을 보일 수 있게 했다. 허나 이런 재배치는 동기화를 제대로 이해하지 못하면 예측하기 어려운 버그를 초래할 수 있다. 물론 이런 문제를 해결하기 위해 ARM에서는 명시적으로 사용할 수 있는 DSB, DMB, ISB 같은 다양한 메모리 배리어 명령어를 제공한다. 이 명령어들은 메모리 읽기 및 쓰기 순서를 제어하며, 데이터 일관성을 보장할 수 있도록 도와준다.
데이터 의존성 보장
사실 약한 모델이라고 해서 항상 메모리 순서를 자기 마음대로 변경하는게 아니다. 그렇게 된다면 당연히 우리가 원하는 프로그램을 만들 수 없을 것이다. 약한 메모리 모델에서도 데이터 의존성(data dependency)는 보장한다. 예를 들어 변수 A를 읽고 그 값을 기반으로 변수 B를 읽거나 쓰는 경우, 변수 A가 최신 값이라면 변수 B도 일관된 값을 보장할 수 있다. 위에서 극단적인 메모리 재배치 모델인 DEC Alpha를 소개 했었는데. 사실 DEC Alpha는 예외로 이러한 데이터 의존성도 보장하지 못한다.
강한 메모리 모델
강한 메모리 모델은 연산의 순서를 엄격하게 제한해서 더 강한 일관성을 보장할 수 있게 해준다. 이 모델에서는 한 코어에서 수행된 메모리 연산이 다른 코어에서도 동일한 순서로 관찰될 수 있도록 보장할 수 있다. 이로 인해서 메모리 연산이 순서대로 실행되지만 약한 메모리 모델에 비해서 성능이 다소 떨어질 수 있다. 대표적으로 x86/64가 강한 메모리 모델의 대표적인 케이스다.
x86/64 아키텍처에서
X86/64는 기본적으로 강한 메모리 모델을 따른다고 말했다. 허나 완벽하게 모든 순서를 보장하는 것은 아니다. 대부분의 메모리 연산은 순서대로 실행되면서 CPU는 코어 간의 메모리 일관성을 비교적 잘 유지하지만, Store-Load 재배치와 같은 일부 최적화는 허용된다. Store-Load 재배치는 쓰기(Store) 연산 뒤에 이어지는 읽기(Load)연산이 재배치될 수 있다는 것을 의미한다. 이렇게 강한 메모리 모델로 분류되는x86/64에서도 재배치는 일어날 수 있으나, 여기도 MFENCE 같은 메모리 베리어 명령어들이 존재한다.
순차적 일관성(Sequential Consistency)
사실 강한 메모리 모델보다 더 강한 모델이 있는데, 이는 순차적 일관성 모델이다. 강한 메모리 모델에서도 설명했듯 사실 내부적으로 몰래 몰래 메모리 재배치를 수행한다. 허나 더 옛날에, 지금은 사용되지 않지만 이러한 허용까지도 하지 않고 모든 쓰레드의 메모리 연산이 단일 쓰레드에서 순차적으로 실행되는 것 처럼 보일정도로 일관성을 강력하게 보장하던 모델이 바로 순차적 일관성 모델이다.
프로그래밍 언어의 메모리 모델
각 CPU는 인스트럭션을 보고 연산을 수행한다. 메모리 베리어 명령어들도 결국 어셈블리로 나오는 명령어다. 그런데 어셈블리를 직접 다루는건 컴파일러를 만드는 사람들이지 우리가 아니다. 고수준 프로그래밍 언어를 다루는 우리는 이 메모리 모델을 적용할 때 무엇을 해야할까? 즉, 프로그래밍 언어에서는 메모리 재배치를 어떻게 통제 하고있을까?
Rust의 메모리 모델
Rust는 언어 수준에서 메모리 모델을 제공해준다. 여기에는 성능과 안전성을 균형을 맞추기 위해서 다양한 동기화 도구와 메모리 순서 제어 도구가 포함된다. 보통 std::sync
모듈에 관련된 도구들이 많이 포진해 있다.
러스트에서는 메모리 순서를 제어하기 위해서 AtomicBool
, AtomicUsize
같은 원자적 타입을 제공한다. 또한 명시적으로 메모리 재배치를 제어하기 위해 Relaxed
, Acquire
, Release
, SeqCst
같은 옵션을 제공한다.
Relaxed
: 쓰레드간의 연산 순서를 보장하지 않는 옵션이다. 성능을 최적화할 수 있지만 메모리 재배치를 예측하기 어렵다.Acquire
/Release
: 읽기(Acquire)와 쓰기(Release) 동작의 순서를 보장하면서 특정 연산 전후의 순서를 보장할 수 있다.SeqCst
: 가장 강력한 옵션인데, 모든 원자적 연산의 순서를 보장할 수 있다.
이 옵션들은 std::sync::atomic::Ordering
에 variant로 구현되어 있다.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
static READY: AtomicBool = AtomicBool::new(false);
static DATA: AtomicUsize = AtomicUsize::new(0);
DATA.store(42, Ordering::Release);
READY.store(true, Ordering::Release);
if READY.load(Ordering::Acquire) {
let value = DATA.load(Ordering::Acquire);
println!("Data: {}", value);
}
Release
와 Acquire
는 메모리 작업 순서를 보장하는 옵션들이다. DATA.store(42, Ordering::Release))
는 뒤에 있는 연산들보다 먼저 일어난다는 것이 보장된다.
메모리 배리어 활용
각 메모리 아키텍처마다 재배치를 제어하기 위한 배리어 명령어를 제공한다고 했었다. 이를 x86/64와 ARM에서 이를 조금 더 구체적으로 살펴보자.
x86/64
x86은 상대적으로 강한 메모리 모델을 제공하나 재배치를 하지 않는 것은 아니다. 결국 재배치가 발생할 수 있으므로 이를 제어하기 위한 배리어 명령어들이 존재한다. 대표적으로 MFENCE
, SFENCE
, LFENCE
라는 명령어가 있다. 아, 참고로 FENCE라는 이름이 붙는 이유는 메모리 배리어의 다른 이름이 메모리 펜스다.
MFENCE
(Memory Fence): 모든 읽기와 쓰기 연산이 순서돼로 실행되는 것을 보장한다.SFENCE
(Store Fence): 이름 그대로 쓰기 연산이 순서대로 실행되는 것을 보장할 수 있다.LFENCE
(Load Fence): 모든 읽기 연산이 순서대로 보장되는 것을 보장한다.
ARM
ARM은 x86보다 더 약한 메모리 모델을 사용하므로, 배리어 명령어를 명시적으로 넣어야할 경우가 훨씬 더 많다. 대표적인 ARM의 명령어는 DMB, DSB, ISB가 있다.
DMB
(Data Memory Barrier): 이전 메모리 연산이 완료된 후에만 메모리 연산을 실행하는 것을 보장한다DSB
(Data Synchronization Barrier): 메모리 연산 뿐 아니라 이전 명령어의 실행도 모두 완료되어야 다음 명령어를 실행할 수 있도록 보장한다ISB
(Instruction Synchronization Barrier): 명령어 파이프라인을 플러시 시키고 이후에 나오는 명령어들이 새로운 컨텍스트에서 실행될 수 있도록 한다.
x86에서만 동작하던 코드는 위험하다
위 내용들을 바탕으로 현 시대 가장 높은 점유율을 가지고 있는 아키텍처인 x86에서 잘 돌아간다고 하여, 다른 아키텍처로 넘어갔을 때 잘 동작한다는 것을 보장하기 어렵다. x86/64는 강한 메모리 모델 덕분에 운 좋게 코드를 잘 동작하게 만든다. 하지만 현대는 ARM이 매우 많이 보급화 되고있고, 이런 약한 메모리 모델을 사용하는 플랫폼으로 포팅해야 하는 경우가 매우 많다. PC 뿐 아니라 서버도 최근에는 ARM이 상당한 점유율을 늘려가고 있다. 이런 경우 동일한 코드가 의도치 않은 동작을 하거나 심각한 버그를 초래할 수 있다. 결국 여러분들의 코드가 잘 동작하는 것은 단지 플랫폼에 의한 착각일 수도 있다.