CS

[컴퓨터 밑바닥의 비밀] 메모리 장벽과 잠금 프로그래밍

ri5 2025. 7. 13. 21:06

컴파일러와 POoE

  • CPU는 프로그래머가 코드를 작성한 순서대로 명령어를 실행하지 않음 (성능을 높이기 위함)
  • 비순차적 실행 단계
    • 기계 명령어를 생성하는 단계: 컴파일 중 명령어 정렬
    • CPU가 명령어를 실행하는 단계: 실행 중 명령어가 비순차적으로 실행

컴파일러가 명령어 순서를 변경하는 과정

코드 예시

int a = 1;
int b = 2;
int c = a + 5;  // a에 의존
int d = b + 3;  // b에 의존

 

최적화된 어셈블리어

; 최적화된 어셈블리 코드
mov eax, 1      ; a = 1
mov ebx, 2      ; b = 2
add ebx, 3      ; d = b + 3 (먼저 계산)
add eax, 5      ; c = a + 5 (나중에 계산)

 

  • 아래와 같은 명령어로 정렬을 못하게 지시 가능.
    • asm volatile("" ::: "memory");
  • 하지만 CPU 또한 명령어를 실행할 때 비순차적으로 실행 

순차적 CPU 명령어 실행 과정

  • 기계 명령어를 가져오기
  • 명령어의 피연산자가 레지스터에 저장
  • 피연산자가 아직 메모리에 저장되지 않았다면 저장될 때 까지 대기
  • 데이터가 준비 되었다면 실행단계 돌입 
  • 실행 결과를 기록

비순차적인 CPU 명령어 실행 과정

  • 기계 명령어 가져오기
  • 명령어를 대기열에 넣고 명령어에 필요한 피연산자 읽기
  • 명령어는 대기열에서 피연산자가 준비 완료될 때 까지 대기, 준비 완료된 명령어부터 실행
  • 기계 명령어를 실행하면 실행 결과를 이전과 다른 재배치 버퍼에 적재
  • 이전 명령어 실행 결과가 기록될 때까지 대기, 현재 명령어의 실행 결과를 기록.
    • 이 과정은 명령어의 원래 실행 순서에 맞춰 결과를 응답하기 위함

이와같은 비순차적인 명령어 실행을 Out of Order Excution(OoOE), 즉 비순차적 명령어 실행이라고 합니다. CPU와 메모리의 실행속도는 엄청난 차이가 나기 때문에 대기하는 동안 빈공간(Slot)이 생기게 되고 다른 명령어로 이 공간을 메울 수 있다면 CPU를 효율적으로 사용가능해짐. (하지만 모든 CPU가 해당 기능을 보유하고 있지 않음)

 

캐시 고려

  • 캐시 시스템에서는 캐시 갱신과 캐시 일관성 유지가 매우 중요
  • 캐시로 저장하는 과정을 최적화하기 위해 코어와 L1 캐시 사이에 버퍼를 통해 전달
  • 코어는 버퍼를 통해 기록을 전달한 뒤 다음 명령어 실행

코드 예시

a = 1;
b = y;

print(a);
  • 비동기로 메모리가 갱신된다면 a를 출력할 때 초기값이 출력되어야 함.
  • CPU의 내부 설계에서 결과를 원래 순서대로 커밋(commit)하는 ROB(Reorder Buffer)를 사용하여 프로그램 순서를 보장
  • 이런 동작은 여러 개의 코어를 활용하여 명령어를 실행할 때 발생하는 현상
  • 단일 스레드 환경에서는 이런 문제를 신경 쓸 필요가 없음

저수준의 CPU

  • 저장 버퍼는 CPU 유형에 따라 다르며 각자의 최적화 방법들이 존재 
  • 설명한 과정이 어떤 명령어에는 적용이 되지만 어떤 명령어에는 적용이 안될 수 있음, CPU에 따라서도 지원이 안될 수 있음
  • 대부분 프로그래밍 언어에 이런 문제를 내부적으로 해결해주지만 잠금 없는 프로그래밍(lock-free programming)에서는 고려해야함
  • CAS(Compare and Store) 알고리즘을 통해 원자성 작업에는 문제가 없지만 중간 상태가 필요하다면 제어가 필요

 

4 가지 메모리 장벽 유형

첫번째 장벽, Load 장벽

  • CPU의 성능을 위해 기다리지 않고 바로 다음 명령어를 실행
  • LoadLoad 유형의 메모리 장벽은 다음에 오는 Load 명령어가 먼저 실행되는 것을 방지
  • 장벽 이전의 모든 load 연산이 장벽 이후의 load 연산보다 먼저 완료되도록 보장
  • 읽기 연산들의 순서를 유지

두번째 장벽, Store 장벽

  • Store 장벽은 CPU가 Store 명령어를 실행될 때 다음 쓰기 명령어를 방지
  • 장벽 이전의 모든 store 연산이 장벽 이후의 store 연산보다 먼저 완료되도록 보장
  • 쓰기 연산들의 순서를 유지
  • 비동기로 처리되더라도 장벽으로 인해 변수 갱신 순서와 코드 순서를 보장

세번째 장벽, LoadStore 장벽

  • is_enemy_comming Load: 메모리에서 값을 읽어옴
  • is_enemy_comming Load 대기: 캐시에서 값이 완전히 로드될 때까지 대기
  • important=10 실행: is_enenmy_comming이 완료된 후에만 important 수행

4번째 장벽, StoreLoad:

  • Store A 실행: 값을 메모리에 쓰기 시작
  • Store A 완료 대기: 모든 캐시 레벨까지 쓰기가 완료될 때까지 대기
  • Load B 실행: Store A가 완전히 완료된 후에만 Load B 수행
  • StoreStore은 코드의 순서만 보장할 뿐 최신 값을 보장하지 않음

StoreLoad가 가장 비용이 큰 이유:

  • Store 연산은 여러 캐시 레벨을 거쳐야 하므로 완료 시간이 오래 걸림
  • CPU는 Store 완료를 기다리며 파이프라인이 정체됨
  • 따라서 StoreLoad 순서 보장이 성능에 가장 큰 영향을 미침

획득 해제 의미론

다중 스레드 프로그래밍에 고려해야되는 점

  • 공유 데이터에 대한 상호 베타적인 접근
  • 스레드 간 데이터 동기화

획득 의미론과 해제 의미론이란?

  • 획득 의미론: Load 뒤에 있는 모든 메모리 작업은 Load 작업 이전에 실행 불가
  • 해제 의미론: Store 앞에 있는 모든 메모리 작업은 Store 작업 이전에 실행 불가

C++에서 제공하는 명령어 순서 제어 인터페이스

1. std::memory_order (C++11)

메모리 순서를 제어하는 열거형으로, atomic 연산과 함께 사용됩니다.

#include <atomic>

std::atomic<int> data{0};
std::atomic<bool> ready{false};

// Acquire 의미론
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        // ready가 true가 될 때까지 대기
    }
    // 이 시점에서 data 읽기는 안전함
    int value = data.load(std::memory_order_relaxed);
}

// Release 의미론
void producer() {
    data.store(42, std::memory_order_relaxed);
    ready.store(true, std::memory_order_release);
}

2. std::atomic_thread_fence

명시적인 메모리 펜스를 제공합니다.

#include <atomic>

std::atomic<int> x{0}, y{0};

void thread1() {
    x.store(1, std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_release);
    y.store(1, std::memory_order_relaxed);
}

void thread2() {
    while (y.load(std::memory_order_relaxed) != 1) {}
    std::atomic_thread_fence(std::memory_order_acquire);
    assert(x.load(std::memory_order_relaxed) == 1); // 항상 성공
}

3. std::atomic_signal_fence

단일 스레드 내에서 컴파일러 최적화만을 제어합니다.

volatile int signal_var = 0;

void signal_handler() {
    std::atomic_signal_fence(std::memory_order_acquire);
    // 시그널 핸들러에서 안전한 메모리 접근
    signal_var = 1;
    std::atomic_signal_fence(std::memory_order_release);
}

4. memory_order 종류

enum class memory_order {
    relaxed,    // 순서 보장 없음
    consume,    // 의존성 기반 순서 (deprecated)
    acquire,    // 획득 의미론
    release,    // 해제 의미론
    acq_rel,    // 획득-해제 의미론
    seq_cst     // 순차 일관성 (기본값)
};

 

 

잠금 프로그래밍과 잠금 없는 프로그래밍

잠금없는 프로그래밍 

  • 개별 스레드가 실패해도 시스템 전체는 진행된다
  • 경합이 심할 때도 최소 하나의 스레드는 성공한다
  • 데드락이나 라이브락 없이 시스템이 동작한다
  • 스레드가 중단되어도 다른 스레드들은 계속 작업할 수 있다
  • 스레드가 대기 없이 항상 일을 하도록 하는 것에 큰 가치를 둠

잠금 프로그래밍

  • 상호 배제: 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 보장. 잠긴 뒤 다른 스레드는 대기 상태로 진입. 이는 락을 해제할 때까지 계속 됨
  • 스핀락: 잠금을 획득할 때까지 CPU를 소모하며 계속 확인. 컨텍스트 스위칭 오버헤드가 없음
  • 잠금을 사용하고 있을 때 다른 스레드는 대기 상태로 잠금이 풀릴 때까지 기달려야함. 모든 스레드가 앞으로 나아갈 수 없음

명령어 재정렬은 하드웨어인가 소프트웨어인가?

  • 하드웨어 엔지니어 입장에서 명령어 재정렬은 CPU 성능 향상의 도움을 줌
  • 명령어 재정렬은 어려우며 리눅스 토발즈 또한 버그를 일으키는 주요 원인 중 하나라고 지적
  • 소프트웨어 입장에서는 하드웨어 내부에서 문제를 해결하는 것이 편하다
  • 성능을 위해, CPU는 반드시 프로그래머가 작성한 순서대로 명령어를 실행할 필요가 없다
  • 단일 스레드인 경우에는 프로그래머가 비순차적인 실행을 볼 수 없기 때문에 명령어 재정렬에 신경 쓸 필요가 없다
  • 메모리 장벽은 특정 코어에서 명령어를 실행하는 순서와 다른 코어에서 보이는 순서가 코드 순서와 일치하게 만들기 위함
  • 잠금 기반 프로그래밍을 사용한다면, 개발자가 직접 명령어 재정렬(instruction reordering)을 고려할 필요가 없다

 

요약

  • 폰노이만 구조에서는 캐시가 없지만 메모리와 CPU의 속도가 극명하게 차이나게 되면서 캐시라는 개념이 생김
  • CPU 대부분에 캐시를 내장하고 있고 다중 코어, 다중 스레드, 캐시까지 추가되며 소프트웨어 설계의 복잡함은 높아짐
  • 캐시 용량은 제한되어 있기 때문에 프로그램에 필요한 데이터에 집중해서 쓰는 것을 지향
  • 여러 스레드 사이에 캐시의 일관성이 중요하다면 다중 스레드 프로그래밍에 캐시 튕김 현상을 경계
  • 스레드 사이에 데이터를 최대한 공유하지 않도록 설계하는 것이 중요
  • 빈번하게 들어가는 데이터가 같은 캐시 라인에 들어가는지 유의, 거짓 공유는 캐시 튕김을 일으킴
  • 최적화는 병목이 발생되는 부분이 판단이 섰을 때 적용, 섣부른 최적화는 만악의 근원
  • 명령어 재정렬 문제는 메모리 장벽으로 해결할 수 있지만 다중 스레드 기반의 잠금 없는 프로그래밍이 필요하지 않다면 고려할 필요 없음.

거짓 공유: 서로 다른 변수들이 같은 캐시 라인에 위치하여, 실제로는 공유하지 않는 데이터임에도 불구하고 캐시 일관성 프로토콜에 의해 성능 저하가 발생하는 현상