C++ memory order 와 atomic 객체

은수·2022년 7월 12일

cpp study

목록 보기
20/21

캐시(Cache)

CPU 와 컴퓨터 메모리인 RAM 은 물리적으로 떨어져 있음. 따라서 CPU 가 메모리에서 데이터를 읽어 오기 위해서는 꽤 많은 시간이 걸림.

> 이를 보완하기 위해 캐시(Cache) 등장!

캐시란?

  • CPU 칩 안에 있는 조그마한 메모리
  • CPU 에서 연산을 수행하는 부분이랑 거의 붙어 있다 싶이 해서, 읽기 / 쓰기 속도가 매우 빠르다는 장점

데이터 접근 방식

  1. CPU 가 특정한 주소에 있는 데이터에 접근하려 한다면,
  2. 일단 캐시에 있는지 확인한 후,
  3. 캐시에 있다면 해당 값을 읽고(=Cache hit),
  4. 없다면 메모리 까지 갔다 오는 방식(=Cache miss)으로 진행

캐시 동작 방식

  • 메모리를 읽으면 일단 캐시에 저장해놓는다.
  • 만일 캐시가 다 찼다면 특정한 방식에 따라 처리한다.

여기서 말하는 특정한 방식은 CPU마다 다른데, 대표적인 예로 LRU (Least Recently Used) 가 있음.
즉, 가장 이전에 쓴 캐시를 날려버리고 그 자리에 새로운 캐시를 기록하는 것.
-> 최근에 접근한 데이터를 자주 반복해서 접근한다면 매우 유리


CPU 파이프라이닝

CPU에서 명령어를 실행할 때 여러 task 단계를 거침.

  • fetch : 명령어를 읽어야 하고,
  • decode : 읽은 명령어가 무엇인지 해석해야 하고,
  • execute : 해석된 명령어를 실행하고,
  • write : 마지막으로 결과를 작성

파이프라이닝
한 작업이 끝나기 전에, 다음 작업을 시작하는 방식으로 동시에 여러 개의 작업을 동시에 실행하는 것

하지만 각 tast의 실행 속도가 달라 비효율적일 수 있음.
: ex) 세탁이나 빨래 개기는 30 분 밖에 안걸리는데 건조가 3시간이 걸린다면, 건조기 기다리느라 빨래를 계속 할 수 없음

따라서, 컴파일러는 우리가 어떠한 최대한 CPU 의 파이프라인을 효율적으로 활용할 수 있도록 명령어를 재배치하게 됨.


수정 순서(modification order)

수정 순서
만약에 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 하였을 때, 해당 객체의 값의 변화를 기록한 것

C++ 에서, 원자적 연산을 할 경우에, 모든 쓰레드에서 같은 객체에 대해서 동일한 수정 순서 를 관찰할 수 있음을 보장.

순서가 동일하다!


어떤 쓰레드가 a 의 값을 읽었을 때, 8 로 읽었다면,
그 다음에 읽어지는 a 의 값은 반드시 8, 6, 3 중에 하나여야 함.
수정 순서를 거꾸로 거슬러 올라가서 5 를 읽는 일은 없음.

=> 이처럼 모든 쓰레드에서 변수의 수정 순서에 동의만 한다면, 모든 쓰레드들이 동일한 값을 관찰할 필요는 없음


원자성 (atomicity)

C++ 에서 모든 쓰레드들이 수정 순서에 동의를 해야만 하는 경우는,
바로 모든 연산들이 원자적 일 때

원자적(atomic)
CPU 가 명령어 1 개로 처리하는 명령으로, 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산
(즉, 이 연산을 반 정도 했다 는 있을 수 없고 이 연산을 했다 혹은 안 했다 만 존재할 수 있음)

std::atomic<int> counter(0);

코드에서는, atomic 의 템플릿 인자로 원자적으로 만들고 싶은 타입을 전달하면됨.
위 경우 0 으로 초기화 된 원자적인 변수를 정의함.


memory_order

atomic 객체들의 경우 원자적 연산 시에 메모리에 접근할 때 어떠한 방식으로 접근하는지 지정할 수 있음.

memory_order_relaxed

  • 가장 느슨한 조건.
  • memory_order_relaxed 방식으로 메모리에서 읽거나 쓸 경우, 주위의 다른 메모리 접근들과 순서가 바뀌어도 무방함.
  • 즉 단일 쓰레드 관점에서 결과가 동일하다면, 메모리 연산은 CPU 마음대로 재배치 가능
  • 메모리 연산 순서에 자유를 주기 때문에 속도 빠름
#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
using std::memory_order_relaxed;

void t1(std::atomic<int>* a, std::atomic<int>* b) {
  b->store(1, memory_order_relaxed);      // b = 1 (쓰기)
  int x = a->load(memory_order_relaxed);  // x = a (읽기)

  printf("x : %d \n", x);
}

void t2(std::atomic<int>* a, std::atomic<int>* b) {
  a->store(1, memory_order_relaxed);      // a = 1 (쓰기)
  int y = b->load(memory_order_relaxed);  // y = b (읽기)

  printf("y : %d \n", y);
}

int main() {
  std::vector<std::thread> threads;

  std::atomic<int> a(0);
  std::atomic<int> b(0);

  threads.push_back(std::thread(t1, &a, &b));
  threads.push_back(std::thread(t2, &a, &b));

  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

실행 시 위와 같은 결과 나옴.
혹은 x:1, y:0 or y:1, x:1과 같은 결과 나올 수도 있음.

b->store(1, memory_order_relaxed);      // b = 1 (쓰기)
int x = a->load(memory_order_relaxed);  // x = a (읽기)

storeloadatomic 객체들에 대해서 원자적으로 쓰기와 읽기를 지원해주는 함수임.
이 때, 추가적인 인자로 어떤 형태로 memory_order을 지정할 것인지 전달할 수 있는데 위의 경우 memory_order_relaxed 전달.

따라서 메모리 연산은 CPU 마음대로 아래와 같이 재배치할 수 있음.

int x = a->load(memory_order_relaxed);  // x = a (읽기)
b->store(1, memory_order_relaxed);      // b = 1 (쓰기)

memory_order_acquire 과 memory_order_release

data[0].store(1, memory_order_relaxed);
data[1].store(2, memory_order_relaxed);
data[2].store(3, memory_order_relaxed);
is_ready.store(true, std::memory_order_release);

memory_order_release

  • 해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 금지
  • 따라서 위의 코드의 경우, data 의 원소들을 store 하는 명령들은 모두 relaxed 때문에 자기들 끼리는 CPU 에서 마음대로 재배치될 수 있겠지만!!!! 아래 release 명령을 넘어가서 재배치될 수 는 없음
while (!is_ready->load(std::memory_order_acquire)) {
}

memory_order_acquire

  • 해당 명령 뒤에 오는 모든 메모리 명령들이 해당 명령 위로 재배치되는 것을 금지

두 개의 다른 쓰레드들이 같은 변수의 releaseacquire 를 통해서 동기화 (synchronize) 를 수행

memory_order_acq_rel

  • acquirerelease 를 모두 수행
  • 읽기와 쓰기를 모두 수행하는 명령들, 예를 들어서 fetch_add 와 같은 함수에서 사용될 수 있음

memory_order_seq_cst

  • 메모리 명령의 순차적 일관성(sequential consistency) 을 보장
  • atomic 객체를 사용할 때, memory_order 를 지정해주지 않는다면 디폴트로 memory_order_seq_cst 가 지정

순차적 일관성
메모리 명령 재배치도 없고, 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는, 우리가 생각하는 그대로 CPU 가 작동하는 방식

0개의 댓글