KoreanFoodie's Study

C++ 기초 개념 15-3 : atomic 객체와 memory order 본문

Tutorials/C++ : Beginner

C++ 기초 개념 15-3 : atomic 객체와 memory order

GoldGiver 2022. 4. 19. 10:23

모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

 

파이프라이닝

다음과 같은 코드가 있다고 하자.

int a = 0;
int b = a + 1;
a = 1;

직관적으로는 b = a + 1 이 a = 1 보다 먼저 실행될 것 같지만, 컴파일러가 어셈블리 언어로 번역한 것을 보면 a = 1 이 더 먼저 실행되기도 한다. 물론 b 와 a 에는 각각 1이라는 값이 제대로 들어가지만, 컴파일러는 작업의 효율성을 위해 코드의 실행순서를 변경한다. 이를 파이프라이닝이라고 부른다.

이는 최적화를 위해 어셈블리 명령어를 효율적으로 배치하는 과정을 의미한다.

 

 

수정 순서(Modification Order)

명령어를 뒤죽박죽으로 실행하는데도 어떻게 제대로 된 결과를 보장할 수 있을까? C++의 모든 객체들은 수정 순서(Modification Order) 라는 것을 정의할 수 있다. 수정 순서란, 만약 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 했을 때, 해당 객체의 값의 변화를 기록한 것이다.

 

위처럼, 모든 쓰레드에서 변수의 수정 순서에 동의만 한다면 문제될 것이 없다. 즉, 같은 시간에 변수 a 의 값을 관찰했다고 해서 굳이 모든 쓰레드들이 동일한 값을 관찰할 필요는 없다는 뜻이다.

쓰레드 간에서 같은 시간에 같은 변수의 값을 읽었을 때 다른 값을 리턴해도 되는 이유는 뭘까? 그 이유는 CPU 캐시가 각 코어별로 존재하기 때문이다.

 

위처럼, 각 코어가 각각 자신들의 L1, L2 캐시들을 갖고 있다. 따라서 만약 쓰레드 1이 a = 5 를 한 후, 다른 코어들에게 이것을 알리지 않는다면 쓰레드에서 a 의 값을 확인할 때, 5를 얻는다는 보장이 없다. 동기화는 시간을 많이 잡아먹는 작업이기 때문이다.

 

원자성 (atomicity)

C++ 에서 모든 쓰레드들이 수정 순서에 동의해야만 하는 경우는 바로 모든 연산들이 원자적일 때이다. 원자적인 연산이 아닌 경우에는 모든 쓰레드에서 같은 수정 순서를 관찰할 수 있음이 보장되어 있지 않기에 적절한 동기화를 해 주지 않으면 정의되지 않은 행동(undefined behavior)이 나올 수 있다.

원자적(atomic)이라는 것은, CPU 가 명령어 1개로 처리하는 명령으로, 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산을 의미한다. 따라서 연산은 '했다' 와 '안 했다' 만 존재할 수 있다.

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

void worker(std::atomic<int>& counter) {
	for (int i = 0; i < 10000; ++i)
		++counter;
}

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

	std::vector<std::thread> workers;
	for (int i = 0; i < 4; ++i)
		workers.push_back(std::thread(worker, ref(counter)));

	for (int i = 0; i < 4; ++i)
		workers[i].join();

	std::cout << "Counter Value : " << counter << std::endl;
}

atomic 의 템플릿 인자로 원자적으로 만들고 싶은 타입을 전달하면 된다. 위 경우 0으로 초기화된 원자적인 변수를 정의했다. atomic 객체에서 제공하는 함수들을 통해, 여러가지 원자적 연산을 수행할 수 있다.

 

atomic 으로 변수를 생성하면, 연산시 다음과 같은 어셈블리어가 나오게 된다.

lock add DWORD PTR [rdi], 1 부분이 바로 ++counter 에 해당한다. lock add 의 경우, rdi 에 위치한 메모리를 읽고, 1을 더하고, 다시 rdi 에 위치한 메모리에 쓰기를 모두 해버린다. CPU 에 따라 위와 같은 원자적인 코드를 실행할 수 있는지 없는지는 갈릴 수 있다.

이같은 원자성은 is_lock_free( ) 함수를 호출함으로써 확인해볼 수 있다.

 

// is lock free ? : true
std::cout << "is lock free ? : " << std::boolalpha << counter.is_lock_free() << std::endl;

여기서의 lock 은 해당 명령을 원자적으로 수행하라는 의미로 사용된다. lock free 에서의 lock 이 없다는 의미는 뮤텍스와 같은 객체들의 lock, unlock 없이도 해당 연산을 올바르게 수행할 수 있다는 뜻이다.

 

 

memory_order

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

 

memory_order_relaxed

memory_order_relaxed 는 가장 느슨한 조건이다. 즉, memory_order_relaxed 방식으로 메모리에서 읽거나 쓸 경우, 주위의 다른 메모리 접근들과 순서가 바뀌어도 무방하다. 다음 코드를 보자.

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

위의 코드에서 핵심이 되는 부분은 t1, t2 함수일 것이다. 코드의 순서가 각각 쓰기->읽기 순으로 되어 있으므로, 실행시 코드 배치에 따라 a 와 b 의 값은 각각 (1, 1), (1, 0), (0, 1) 이 전부 나올 수 있다. 심지어, t1, t2 두 경우 모두 만약 CPU 가 읽기 코드를 쓰기 코드보다 먼저 배치한다면 (0, 0) 또한 나올 수 있다.

memory_order_relaxed 는 CPU 에서 메모리 연산 순서와 관련, 무한한 자유를 주므로 CPU 에서 매우 빠른 속도로 실행이 된다. 예상치 못한 결과가 나올 수 있지만, 종종 사용할 수 있는 경우도 있다.

 

void worker(std::atomic<int>* counter) {
  for (int i = 0; i < 10000; i++) {
    // 다른 연산들 수행

    counter->fetch_add(1, memory_order_relaxed);
  }
}

이전에 쓰레드 4개를 이용해서 counter 값의 합을 40000으로 만드는 코드 중, worker 함수를 위와 같이 수정해 보자. 결과는 40000 이 나온다.

counter->fetch_add 는 counter++ 와 하는 일은 같지만(원자적임), counter++ 와는 다르게 메모리 접근 방식을 설정할 수 있다. 즉, 다른 메모리 연산들 사이에서 굳이 counter 를 증가시키는 작업을 재배치 못하게 막을 필요가 없기 때문이다. 비록 다른 메모리 연산들보다 counter++ 이 늦게 된다고 하더라도 결과적으로 증가되기만 하면 문제 될게 없기 때문이다.

 

memory_order_acquire 과 memory_order_release

producer - consumer 코드를 통해 memory_order_relaxed 의 한계를 살펴보자.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using std::memory_order_relaxed;

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

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

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

위의 경우, Data 의 기댓값은 10 이겠지만, 실제로는 0이 나올 수도 있다. 왜일까?

위의 producer 함수에서 is_ready->store(true, memory_order_relaxed) 가 *data = 10 보다 먼저 실행된다면, consumer 는 *data = 10 이 채 실행되기도 전에 data 값을 0으로 읽을 수도 있기 때문이다.

또한, consumer 함수에서도 while 문보다 std::cout << "Data : " << *data << std::endl 이 먼저 실행된다면, is_ready 가 true 가 되기 이전의 data 값을 읽을 수도 있다.

 

위의 코드는 memory_orderelaxed 보다 조금 더 엄격한 memory_order_acquire 과 memory_order_release 를 이용해 의도에 맞게 수정할 수 있다.

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;
}

이 경우, data 에 0 이 들어가는 일은 불가능하다. 먼저, std::memory_order_release 는 해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치되는 것을 금지한다. 또한, 만약 같은 변수를 memory_order_acquire 로 읽는 쓰레드가 있다면, memory_order_release 이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰될 수 있어야 한다.

즉, is_ready->store 이전에 반드시 *data = 10; 명령이 실행되며, is_ready->load 에서는 이전의 메모리 명령에 해당하는 *data = 10; 이 관찰될 수 있으므로, data 는 반드시 10 이라는 값을 가질 것이다.

memory_order_acquire 의 경우, release 와는 반대로 해당 명령 뒤에 오는 모든 메모리 명령들이 해당 명령 위로 재배치되는 것을 금지한다. 따라서, data 값을 출력하는 cout 문이 is_ready->load 문보다 앞서 실행되는 일도 없을 것이다.

 

memory_order_acq_rel

memory_order_acq_rel 은 acquire 와 release 를 모두 수행한다. 

 

memory_order_seq_cst

memory_order_seq_cst 는 메모리 명령의 순차적 일관성(sequential consistency) 를 보장해준다.

순차적 일관성이란, 메모리 명령 재배치도 없고, 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는 방식이다. 예시 코드를 보자.

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

std::atomic<bool> x(false);
std::atomic<bool> y(false);
std::atomic<int> z(0);

void write_x() { x.store(true, std::memory_order_release); }

void write_y() { y.store(true, std::memory_order_release); }

void read_x_then_y() {
  while (!x.load(std::memory_order_acquire)) {
  }
  if (y.load(std::memory_order_acquire)) {
    ++z;
  }
}

void read_y_then_x() {
  while (!y.load(std::memory_order_acquire)) {
  }
  if (x.load(std::memory_order_acquire)) {
    ++z;
  }
}

int main() {
  thread a(write_x);
  thread b(write_y);
  thread c(read_x_then_y);
  thread d(read_y_then_x);
  a.join();
  b.join();
  c.join();
  d.join();
  std::cout << "z : " << z << std::endl;
}

z 의 결과값은 2 또는 1이 될 수 있다. 그런데 z 가 0 이 될수도 있을까?

먼저 write_x 와 read_x_then_y 사이의 release_acquire 동기화와, write_y 와 read_y_then_x 사이의 release_acquire 동기화가 이루어지고 있다.

하지만 read_x_then_y 와 read_y_then_x 두 쓰레드가 같은 순서로 x.store 와 y.store 를 관찰한다는 보장이 없다. 따라서 read_x_then_y 입장에서는 x.store 가 y.store 보다 먼저 발생해도 되고, read_y_then_x 입장에서는 y.store 가 x.store 보다 먼저 발생해도 된다.

이 경우 두 if 문 안의 load 가 false 가 되어서 z 가 0 이 된다.

하지만 memory_order_seq_cst 를 사용하면, 해당 명령을 사용하는 메모리 연산들 끼리는 모든 쓰레드에서 동일한 연산 순서를 관찰할 수 있도록 보장한다. 참고로 atomic 객체를 사용할 때, memory_order 를 지정해 주지 않는 다면 디폴트로 memory_order_seq_cst 가 지정이 된다. 따라서 이전의 counter++ 은 사실 counter.fetch_add(1, memory_order_seq_cst) 와 동일한 연산이다.

문제는 멀티 코어 시스템에서 memory_order_seq_cst 가 꽤나 비싼 연산이라는 것이다. 특히 ARM 계열 CPU 의 경우 순차적 일관성을 보장하기 위해 들어가는 동기화 비용이 매우 크다. 따라서 더 약한 수준의 memory_order 을 사용한다면 프로그램의 성능을 더 크게 향상시킬 수 있다.

 

위의 코드는 다음과 같이 수정할 수 있다.

using std::memory_order_seq_cst;

std::atomic<bool> x(false);
std::atomic<bool> y(false);
std::atomic<int> z(0);

void write_x() { x.store(true, memory_order_seq_cst); }

void write_y() { y.store(true, memory_order_seq_cst); }

void read_x_then_y() {
  while (!x.load(memory_order_seq_cst)) {
  }
  if (y.load(memory_order_seq_cst)) {
    ++z;
  }
}

void read_y_then_x() {
  while (!y.load(memory_order_seq_cst)) {
  }
  if (x.load(memory_order_seq_cst)) {
    ++z;
  }
}

위 코드에서는 z 가 1 또는 2 의 값을 가질 것이다. 왜냐하면 read_x_then_y 와 read_y_then_x 에서 관찰했을 때 x.store 와 y.store 가 같은 순서로 발생해야 하기 때문이다.

 

memory_order 를 정리하면 다음과 같다.

연산 허용된 memory_order
쓰기 (store) memory_order_relaxed,
memory_order_release,
memory_order_seq_cst
읽기 (load) memory_order_relaxed,
 memory_order_consume,
memory_order_acquire
,
memory_order_seq_cst
읽고 - 수정하고 - 쓰기 (read - modify - write) memory_order_relaxed,
 
memory_order_consume,
 
memory_order_acquire,
 
memory_order_release,
 
memory_order_acq_rel,
 
memory_order_seq_cst

C++ 17 기준 memory_order_consume 의 정의는 수정 중이므로 memory_order_consume 의 사용은 권장되지 않는다.

 

 

+ 생각해보기

문제 1 : std::atomic<bool> 을 이용해서 lock( ) 과 unlock( ) 을 만들어 보자.

std::atomic<bool> gate;
bool lock()
{
    bool t = false;
    return gate.compare_exchange_strong(t, true);
}

bool unlock()
{
    bool t = true;
    return gate.compare_exchange_strong(t, false);
}

위의 compare_exchange_strong 함수를 살펴보자.

bool compare_exchange_strong(
  T& expected, T desired, std::memory_order order = std::memory_order_seq_cst);

현재 atomic 객체의 값이 expected 와 같다면 desired 로 바꾸고 true 를 리턴한다. 만약 expected 와 다르다면 desired 로 바꾸지 않고 그냥 false 를 리턴한다. 위 명령은 atomic 하게 실행된다.

 

문제 2 : atomic_flag  test_and_set 함수를 이용해 lock( ), unlock( ) 을 만들어 보자.

위는 atomic_flag  test_and_set 함수를 이용해서도 동일하게 만들 수 있다. atomic_flag  std::atomic<bool> 과 비슷하게 true 혹은 false 만 가질 수 있지만, atomic_flag  is_lock_free 가 언제나 참임이 보장된다. 반면에 std::atomic<bool> 은 그렇지 않다. (정확히 말하자면 모든 atomic 객체들은 is_lock_free 가 참인 것이 보장되지 않는다.)

Comments