std::memory_order 이해하기

이창준, Changjoon Lee·2025년 8월 28일
0

Game Server Hyperion 🎮

목록 보기
9/14

문제

MPMC에서 적용되는 lock-free buffer queue만들기를 하던 도중, 참고한 코드에서 std::memory_order_relaxed를 쓰고 있었다.
모두의 코드의 C++ memory order 와 atomic 객체에선 C++의 멀티 스레딩 내에서 스레드 간 실행 순서 보장을 위한 기법으로 소개되고 있다.

해결

std::atomic

먼저 std::atomic이 어떻게 동작하는지 간략하게 요약해 보면...
스레드 1과 2가 있고, 이 둘이서 std::atomic<int>로 선언된 변수 a에 값을 1씩 더한다고 해보자.
만약 a가 일반적인 int로 선언되었으면, 한 CPU에서 연산은 다음과 같다.

  1. 메인 메모리에서 CPU L1 캐시로 복사. (물론 중간에 L2, L3를 거쳐서 오긴 한다.) -> fetch 연산
  2. 읽어온 값이 뭔지 알아낸다. -> decode 연산
  3. 더한다. -> execute 연산
  4. 저장한다. -> write 연산

참고로 여기서 L1, L2 캐시는 CPU 코어 마다 할당돼 있다.
즉, 위 연산을 원자적이지 않게 수행하면 CPU 코어의 캐시마다 저장돼 있는 '현재 변수 a의 값'이 서로 다를 수 있다.
이를 '캐시 일관성이 없다'라고 말한다.

원자적이게 1~4 연산을 하게 되면,
어셈블리에서 1~4 연산은 한 덩어리로 취급된다.
또한, 어느 한 스레드를 실행하는 CPU 코어가 변수 a에 대한 연산을 할 동안에는 다른 스레드를 실행하는 CPU 코어들은...
해당 메모리 위치에 대한 메모리 접근이 막히거나 (Bus Lock),
접근하여 캐시에 복사를 했어도 무효화된다 (Cache Lock).

그럼 std::memory_order는 뭔데?

다음과 같은 코드가 있다고 할 때,

#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();
  }
}

아토믹 연산에 대해 memory_order_relaxed를 인수로 넣으면
printf(...)를 호출하는 시점에 ab0도 나오고 1도 나온다.
둘다 0이 나오기도 한다.

즉, 아토믹 연산의 순서를 풀어주는 것.

그러면 엄격하게 코드가 쓰인 순서대로 실행하도록 만드는 방법도 있을 것인데,
그게 바로 memory_order_release, memory_order_acquire, memory_order_acq_rel이다.

memory_order_release를 인수로 넣으면 해당 아토믹 연산 앞의 코드 보다 먼저 실행될 수 없다.
반대로 memory_order_acquire를 인수로 넣음녀 해당 아토믹 연산 뒤의 코드보다 후에 실행될 수 없다.
전후 코드 모두에 대해 순서를 보장하고 싶은 경우 memory_order_acq_rel를 쓴다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

void producer(std::atomic<bool>* is_ready, int* data) {
  *data = 10;
  is_ready->store(true, std::memory_order_release);
}

void consumer(std::atomic<bool>* is_ready, int* data) {
  // data 가 준비될 때 까지 기다린다.
  while (!is_ready->load(std::memory_order_acquire)) {
  }

  std::cout << "Data : " << *data << std::endl;
}

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

  std::atomic<bool> is_ready(false);
  int data = 0;

  threads.push_back(std::thread(producer, &is_ready, &data));
  threads.push_back(std::thread(consumer, &is_ready, &data));

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

출처 : https://modoocode.com/271#google_vignette

profile
C++ Game Developer

0개의 댓글