CPU Out-of-Order Execution, Barrier, Happens-Before
Out-of-Order Execution #
우리가 짠 코드는 1번 줄 다음에 2번줄이 실행될 것 같지만, 실제 cpu와 컴파일러는 성능을 위해서 실행 순서를 제 멋대로 바꾼다.
이를 비순차적 실행 out-of-order exectuion 이라고 한다.
그 이유는 cpu가 특정 데이터를 메모리에서 가져오길 기다리는 동안(매우 느림), 그 뒤에있는 상관없는 명령어를 먼저 처리해버리는 것이 효율적이기 때문이다.
하지만 문제점이 있는데 싱글 스레드에서는 결과값이 같으면 상관없겠지만, 멀티스레드 환경에서는 a스레드가 작업 순서를 바꾸는 순간 b스레드가 엉뚱한 즉 아직 준비되지 않은 데이터를 읽어가는 대참사가 발생할 수 있다.
예를들어 가장 고전적인 객체 초기화 지연 사례를 설명해보겠다.
class Work {
int data = 0;
boolean ready = false;
// 스레드 A가 실행: 데이터 준비
void writer() {
data = 42; // (1) Store (값 쓰기)
ready = true; // (2) Store (준비 완료 플래그 쓰기)
}
// 스레드 B가 실행: 데이터 사용
void reader() {
if (ready) { // (3) Load (플래그 읽기)
System.out.println(data); // (4) Load (값 읽기)
}
}
}
우리의 상식이라면 ready가 true라면 data는 당연히 42여야 한다. 하지만 실제로는 0이 출력될 수 있다.
CPU와 컴파일러의 속임수 #
cpu 입장에서는 1번작업 data = 42을 처리하기 위해 메모리 주소를 계산하고 캐시에 쓰는데 시간이 걸릴 수 있다. 그런데 2번 작업 ready = true는 바로 처리가 가능하다면 cpu는 성능을 위해 2번을 먼저 실행한다.
하드웨어별 차이 (재정렬 범위) #
- ARM(Weak Model): cpu가 대놓고 1 2 순서를 바꿀 수 있고 3 4 순서도 바꿀 수 있다. 즉 데이터를 읽기도 전에 플래그부터 체크하거나 그런식으로
- x86 (Strong Model): 하드웨어 수준에서 쓰기 쓰기 store store 순서는 보장하나 JIT 최적화 과정에서 코드를 재배치하면 결국 똑같은 문제가 발생한다.
그래서 이때 Barrier 라는 개념이 필요한 것이고 ready 변수에 volatile 키워드를 붙이면 JVM은 다음과 같은 베리어를 삽입하게 동작한다.
volatie boolean ready = false;
스레드 a의 data = 42라는 연산에 일반적인 쓰기 작업, store store 베리어를 통해 위의 data를 쓰기전에 ready를 절대 쓰지말라는 경고를 cpu에게 주고 그 이후에 ready를 쓴다.
그 아래에 if ready 체크하는 부분도 volatie 읽기 이후에 println하는 LoadLoad Barrier가 동작하는것이다.
4가지 장벽 Barrier의 정체 #
cpu가 순서를 바꾸지 못하게 막는 명령어를 메모리 베리어라고 한다.
LoadLoad, StoreLoad 등이 바로 이 배리어의 종류이고 여기서는 Load 읽기, Store 쓰기라고 생각하면 쉽다.
- LoadLoad: 읽기1 다음에 읽기2가 오도록 보장 이전 읽기가 끝나기 전에 다음 읽기를 하지 마라
- StoreStore: 쓰기1 다음에 쓰기2가 오도록 보장 내가 값을 쓰기전에 이전의 데이터가 쓰기가 완료되어야한다.
- LoadStore: 읽기 다음에 쓰기가 오도록 보장 데이터를 다 읽기도 전에 새로운 값을 덮어쓰지마라
- StoreLoad: 쓰기 다음에 읽기가 오도록 보장 가장 강력하고 무거운 베리어임 쓴 데이터가 남들에게 다 보인뒤에 읽어라.
x86 일반 pc 아키텍처에서는 매우 똑똑해서 StoreLoad를 제외한 나머지 3개는 하드웨어가 알아서 지켜준다. 반면 ARM(아이폰, 맥북m칩)은 성능을 위해 이를 무시하므로 베리어를 명시적으로 더 많이 세워야한다.
Happens-Before #
하드웨어가 이렇게 복잡하게 돌아가는데, 개발자가 매번 cpu 명령어를 고민할 수는 없다. 그래서 자바 메모리 모델 JMM은 Happens-Before라는 규칙을 만들었다.
- 개념: A 작업이 B 작업보다 Happens-Before 관계에 있다면, A에서 발생한 모든 메모리 변경 사항은 B에서 반드시 볼 수 있어야 한다는 법적 계약과 같다.
대표적은 규칙은 아래와 같다.
- Thread Start: 스레드를 시작하기 전의 작업은 시작된 스레드에서 모두 보인다.
- Volatie Write/Read: volatie 변수에 쓰면, 그 이후에 읽는 스레드는 최신값을 보장받는다.
- Locking: 모니터락을 해제하기 전에 작업은 락을 획득한 다른 스레드에게 모두 보인다.
로드 스토어 순서를 유지하면 무엇이 좋은가 #
이 순설르 엄격히 유지하는 것은 Strong Memory Model이라고 부른다.
장점은 예측 가능성이다. 코드가 쓴 순서대로 남들에게 보이기 때문에 멀티 스레드 프로그래밍시에 버그가 훨씬 적게 발생한다.
단점은 성능이다. cpu가 더 빠르게 처리할 수 있는 기회를 포기하고 기다리는 것이므로 성능 최적화의 한계가 명확해진다.
반대로 ARM 같은 Weak Memory Model은 순서를 마음대로 바꾸게 허용하는 대신에 전력 효율과 성능 극대화를 한다. 자바는 JVM이 중간에서 이 차이를 메워주며, 우리가 어떤 cpu에서든 동일한 결과를 얻게 해준다.