[34일차] | 컴퓨터 밑바닥의 비밀 | 책너두

Heechan Kang·2024년 5월 23일
0
post-thumbnail

5.4 봉화희제후와 메모리 장벽

  • 봉화희제후: 주유왕이 제후들을 놀리기 위해 거짓으로 봉화를 올리다가, 제후들이 진짜 적들이 침공했을 때에도 봉화를 믿지 않게되어 멸망한 사건

    • 그냥 양치기 소년 예시를 쓰면 되지 굳이 왜 이런 딱 맞지도 않는 예시를 가져왔는지 모르겠다. 주유왕 스레드니 제후 스레드니 가독성만 떨어진다.
    • 코드도 쓸데없이 길어진다..
  • 고전적인 스레드의 동기화는 본질적으로 위 사례의 봉화와 같다.

    • 두 개의 스레드는 서로 동기화 신호(synchronization signal)을 주고받으며 통신을 한다.
  • 이 상황을 아래와 같은 코드로 표현 할 수 있다.

    bool is_enemy_coming = false;
    int enemy_num = 0;
    
    void king_thread()
    {
      enemy_num = 100000;
      is_enemy_coming = true;
    }
    
    void other_thread()
    {
      int n;
    
      if(is_enemy_coming)
      {
        n = enemy_num;
      }
    }
    • 여기서 다른 스레드들에서 n의 값이 얼마인지를 보면, 대부분 100000이라고 할 것이다.
    • 하지만 실제로는 스레드의 작동순서, 코어의 종류(x86..)에 따라 0일 수도 있다.
  • 사견

    • 왜 굳이 '고의적으로 거짓말하는 사례'를 예시로 동시성 문제를 설명하려는지 모르겠다. 오히려 혼란만 가중시킨다고 생각한다.

5.4.1 명령어의 비순차적 실행: 컴파일러와 OoOE

  • 최적화와 성능 향상을 위해, CPU(하드웨어)는 때로 명령어의 실행 순서를 변경한다.

    • 아래와 같이, 종종 프로그래머가 작성한 코드의 순서와 다르게 실행하는 경우가 있다.
      • 기계 명령어 생성시(컴파일) 명령어를 재정렬한다.
      • CPU가 명령을 실행하는 단계에서 명령어가 비순차적으로 실행된다.
  • 컴파일시 코드가 재정렬되는 예시

    • C++ 코드

      int a;
      int b;
      
      void main()
      {
        a = b + 100;
        b = 200;
      }
    • 컴파일러가 생성한 어셈블리 코드

      ov  0x200b54($rip),%eax  # %eax = b
      dd  $0x64,%eax           # %eax = %eax + 100
      ov  %eax,0x200b4f(%rip)  # a = %eax
      ovl $0xc8,0x200b41(%rip) # b = 200
    • 생성 과정에서 최적화를 실행한 어셈블리 코드

      ov  0x200b54($rip),%eax  # %eax = b
      ovl $0xc8,0x200b41(%rip) # b = 200   <--- 이 부분의 순서가 변경됨
      dd  $0x64,%eax           # %eax = %eax + 100
      ov  %eax,0x200b4f(%rip)  # a = %eax
  • CPU의 기계명령어 실행시 순서가 조정되는 예시

    • 기본적인 CPU의 명령어 실행 과정(유휴시간 존재)

      1. 기계명령어(opcode)를 가져온다.
      2. 명령어의 피연산자(operand)가 레지스터에 저장되는 등의 준비를 하고, 아직 피연산자가 레지스터로 로드되지 않았다면 대기한다. - 이 때 CPU와 메모리의 작동 시간 차이로 유휴시간이 발생한다. -
      3. 데이터가 준비되었다면 명령어를 실행한다.
      4. 실행 결과를 메모리에 기록한다.
    • CPU의 명령어 실행 순서가 조정되는 과정(유휴시간 최소화)

      1. 기계 명령어를 가져온다.
      2. 명령어를 대기열에 넣고 피연산자를 읽어들인다.
      3. 명령어들은 대기열에서 피연산자의 준비를 기다리고, 피연산자가 준비되는 연산부터 실행한다.
      4. 명령어 실행 결과를 대기열에 기록한다.
      5. (코드상)이전 명령어의 실행 결과가 메모리에 기록된다면, 현재 명령어의 실행 결과를 메모리에 기록한다.
      • 이는 명령어의 원래 실행 순서와 같은 결과를 보장하기 위함이다.
  • 위와 같은 과정을 통해 CPU는 명령어의 실행 순서를 조정할 수 있으며, 이를 OoOE(Out-of-Order Execution)이라고 한다.

    • 이를 통해 CPU는 유휴시간을 최소화하고, 성능을 향상시킬 수 있다.
    • 이 과정을 거치지 않으면 CPU의 파이프라인 실행에 슬롯(slot)이 발생하며, 그만큼 전체 처리속도가 떨어진다
    • 하지만 모든 CPU가 이 기능을 지원하는 것은 아니다. 이는 CPU의 종류에 따라 다르다.

5.4.2 캐시도 고려해야 한다

  • CPU가 작업 시 캐시에 직접 기록하지 않고, 저장 버퍼를 둔다.

    • 이를 통해 매 번 캐시의 갱신을 기다리지 않고 연속적인 작업을 수행할 수 있다.
  • CPU의 명령어 실행시 데이터의 기록은 비동기로 이루어진다.

    • 이로인해 아래와 같은 현상이 발생한다.

      a = 1; // 수정된 값이 버퍼에 우선 저장되고, 잠시 후 캐시에 반영된다. 이후 캐시가 동기화되기까지 시간이 걸린다.
      b = y;
      • a의 초기값은 0, y의 초기값은 100이라고 가정하자.
      • 스레드A에서 해당 작업을 진행할 때, 스레드 B가 보기에는 2번 라인이 먼저 실행된 것 처럼 보일 수 있다.
        • 이는 버퍼가 캐시에 저장되고, 캐시가 전파되는 시간차로 인해 발생 할 수 있는 현상이다.
      • 그러나 이런 현상은 스레드 외부에서 관측할 수 있는 현상으로, 하나의 스레드 내부에서는 순서가 보장된다.
        • 근본적으로 단일 스레드 환경에서는 이 문제를 고려할 필요가 없다.

5.4.3 네 가지 메모리 장벽 유형

  • 네 가지 메모리 장벽의 유형
    • LoadLoad
      • 하나의 변수를 읽기 전에 다른 변수를 읽는 것을 막는다.
      • 처음 예시의 other_thread에서 is_enemy_coming 전에 enemy_num을 읽는 것을 막는다.
    • StoreStore
      • 하나의 변수를 쓰기 전에 다른 변수를 쓰는 것을 막는다.
      • king_thread에서 enemy_num을 쓰기 전에 is_enemy_coming을 쓰는 것을 막는다.
    • LoadStore
      • 하나의 변수를 읽기 전에 다른 변수를 쓰는 것을 막는다.
      • other_thread에서 is_enemy_coming을 읽기 전에 다른 변수를 쓰는 것을 막는다.
    • StoreLoad
      • 하나의 변수를 쓰기 전에 다른 변수를 읽는 것을 막는다.
      • CPU의 작업을 동기적으로 처리하게 한다.
profile
안녕하세요

0개의 댓글