모두의 코드
씹어먹는 C ++ - <15 - 3. C++ memory order 와 atomic 객체>

작성일 : 2019-04-07

이번 강좌에서는

에 대해 다룹니다.

안녕하세요 여러분!

지난 두 강좌를 통해 C++ 에서 멀티 쓰레딩을 위해 제공하는 기본적인 요소들인 쓰레드(thread), 뮤텍스(mutex), 조건변수(condtion variable) 들의 사용법에 대해 배웠습니다. 이번 강좌에서는 이러한 기본 요소들을 조금 더 세밀하게 컨트롤 할 수 있는 몇 가지 요소들에 대해 살펴볼 것입니다.

이번 강좌는 C++ 의 매우 세세한 디테일을 다루기 때문에, C++ 을 처음 배우시는 분들은 넘어가셔도 좋습니다.

메모리는 엄청 느리다.

강좌를 진행하기 전에, 컴퓨터 메모리에 관련한 몇 가지 중요한 사실들을 짚고 넘어갈 것입니다.

caption=CPU 와 RAM
CPU 와 RAM

기본적으로 CPU 와 컴퓨터 메모리인 RAM 은 물리적으로 떨어져 있습니다. 따라서 CPU 가 메모리에서 데이터를 읽어 오기 위해서는 꽤 많은 시간이 걸립니다. 실제로, 인텔의 i7-6700 CPU 의 경우 최소 42 사이클 정도 걸린다고 보시면 됩니다. CPU 에서 덧셈 한 번을 1 사이클에 끝낼 수 있는데, 메모리에서 데이터 오는 것을 기다리느라, 42 번 덧셈을 연산할 시간을 놓치게 되는 것입니다.

이는 CPU 입장에 굉장한 손해가 아닐 수 없습니다. 메모리에서 데이터 한 번 읽을 때 마다 42 사이클 동안 아무것도 못한다니 말입니다.

캐시

따라서 CPU 개발자들은, 이를 보완하기 위해 캐시(Cache) 라는 것을 도입하였습니다. 캐시란, CPU 칩 안에 있는 조그마한 메모리라고 보시면 됩니다. 여기서 중요한 점은 램과는 다르게 CPU 에서 연산을 수행하는 부분이랑 거의 붙어 있다 싶이 해서, 읽기 / 쓰기 속도가 매우 빠르다는 점입니다.

caption=위 그림에서 L1, L2, L3 라 표시된 것이 모두 캐시 입니다. CPU 에서 연산 하는 부분 (Core) 보다 캐시가 더 큽니다.
위 그림에서 L1, L2, L3 라 표시된 것이 모두 캐시 입니다. CPU 에서 연산 하는 부분 (Core) 보다 캐시가 더 큽니다.

캐시의 크기는 그렇게 크지 않습니다. 요즈음 컴퓨터 램 크기가 적어도 8 GB 정도는 달고 나오는 데에 비해, 인텔의 하스웰 아키텍쳐인 i7-4770 CPU 의 경우, L1 캐시는 32KB, L2 캐시는 256 KB, L3 캐시는 8 MB 정도 됩니다. 여러분이 다른 CPU 를 쓰고 있다 하더라도 아마 큰 차이는 없을 것입니다.

하지만, L1 읽기 쓰기의 경우 단 4 사이클이면 충분 하고, L2 캐시는 12 사이클, L3 캐시는 36 사이클 정도로 메모리를 왔다 갔다 하는 것 보다 훨씬 빠른 속도로 접근할 수 있게 됩니다.

따라서, 실제로는 다음과 같이 작동합니다. CPU 에서 가장 많이 접근하는 메모리 영역은 L1 캐시에 가져다 놓게 되고, 그 다음으로, 자주 접근하는 부분은 L2, 마지막으로 L3 캐시 순으로 놓게 된다는 것이지요.

CPU 가 특정한 주소에 있는 데이터에 접근하려 한다면, 일단 캐시에 있는지 확인한 후, 캐시에 있다면 해당 값을 읽고, 없다면 메모리 까지 갔다 오는 방식으로 진행됩니다. 이렇게 캐시에 있는 데이터를 다시 요청해서 시간을 절약하는 것을 Cache hit 이라과 하며 반대로 캐시에 요청한 데이터가 없어서 메모리 까지 갔다 오는 것을 Cache miss 라고 부릅니다.

하지만 여기서 문제가 있습니다. CPU 가 어떻게 어느 영역의 메모리에 자주 접근할 지 어떻게 아는 것일까요? 답은 알 수 없다 입니다. 따라서 보통 CPU 에서 캐시가 작동하는 방식은 다음과 같습니다.

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

예를 들어서 캐시 크기가 1 KB 밖에 안되고 LRU 방식을 사용하는 CPU 가 있다고 했을 때 첫 번째 코드가 더 빠르게 작동할까요? 아니면 두 번째 코드가 더 빨리 작동할까요? 두 코드는 동일한 연산을 수행합니다.

for (int i = 0; i < 1000; i++) {
  for (int j = 0; j < 10000; j++) {
    s += data[j];
  }
}

for (int j = 0; j < 10000; i++) {
  for (int i = 0; i < 10000; i++) {
    s += data[j];
  }
}

답은 두 번째 방식입니다. 왜냐하면 첫 번째 경우에서 data[0] 를 접근하는 것을 생각해봅시다. 일단 첫 번째 루프에서 data[0] 는 캐시에 들어가게 됩니다. 하지만, CPU 캐시가 매우 작기 때문에 j = 256 이 되었을 때 data[0] 는 캐시에서 사라지게 되지요 (1KB = 1024 byte = int 256 개).

따라서 i = 1 인 루프에서 data[0] 에 다시 접근했을 때 이미 data[0] 는 캐시에서 사라진 이후기에 Cache Miss 가 발생하게 됩니다. 따지고 보면 data 원소의 모든 접근이 Cache Miss 가 되서 느리겠지요.

반면에 후자의 경우 data[0]10000 번 연속으로 접근하므로, 처음에 접근할 때 빼고 나머지 9999 번 접근이 Cache hit 이 되어서 빠르게 덧셈을 수행할 수 있게 됩니다.

캐시에 대해서는 이 정도로 줄이겠습니다. 사실 캐시에 대해서만 이야기해도 한 보따리는 풀 수 있지만, 이는 나중에 컴퓨터 시스템에 관한 강좌를 하게 되면다면 더 깊게 다루도록 하겠습니다.

컴퓨터는 사실 여러분이 시키는 대로 하지 않는다.

여태까지 여러분이 코드를 작성하면, 컴파일러가 그 코드를 그대로 기계어로 번역한 다음, CPU 가 해당 번역된 기계어를 그대로 실행시킨다고 생각하셨을 것입니다.

그런데 이게 사실이 아니라 한다면 여러분은 믿을 수 있으신가요?

int a = 0;
int b = 0;

void foo() {
  a = b + 1;
  b = 1;
}

위 코드를 그대로 컴파일 하였을 때, 생성되는 어셈블리는 아래와 같습니다.

caption=같은 색깔로 나타낸 부분이, 해당 부분의 코드가 어셈블리로 컴파일 된 결과 입니다
같은 색깔로 나타낸 부분이, 해당 부분의 코드가 어셈블리로 컴파일 된 결과 입니다

놀랍게도 a = b + 1 부분의 실행이 채 끝나기 전에 b = 1 이 먼저 실행이 끝나게 됩니다.

caption=그런데 그것이 실제로 일어났습니다.
그런데 그것이 실제로 일어났습니다.

물론, 위 foo 함수의 입장에선 크게 문제는 없습니다. 왜냐하면, 최종적으로는 a 에는 1 이, b 도 1 이 들어 있을테니 말이지요.

하지만, 만약에 다른 쓰레드가 있어서 ab 의 값을 확인하였을 때, 코드가 순서대로 실행되었더라면 b 가 1 이면 a 도 1 이어야하지만, a 가 0 인데, b 가 1 일 수 있다는 말입니다!

그렇다면 컴파일러는 도대체 왜 위와 같이 명령어를 재배치 한 것일까요? 이는 현대의 CPU 한 번에 한 명령어씩 실행하는 것이 아니기 때문입니다.

CPU 파이프라이닝 (pipelining)

여러분이 빨래를 하는 과정을 생각해봅시다.

먼저 빨래를 세탁기에 넣어야 하고, 세탁이 끝나면, 건조기에 넣어야 하고, 마지막으로 건조가 끝나면 빨래를 잘 개어야 겠지요. 위와 같이 빨래라는 과정은 여러 단계를 거쳐야 합니다.

caption=비효율적으로 빨래를 하는 방법
비효율적으로 빨래를 하는 방법

그렇다면 여러 바구니의 빨래를 한다고 해봅시다. 한 가지 방법은 위 그림처럼 한 단계씩 차례대로 하는 방법입니다. 한 바구니 빨래를 세탁 - 건조 - 빨래 개기 한 후에, 다른 바구니의 빨래를 하는 것이지요.

하지만 위와 같은 방식은 효율적이지 않습니다. 왜냐하면 빨래를 건조기에 넣게 된다면, 세탁기가 비어 있으므로, 그 사이에 다른 빨래를 또 세탁할 수 있기 때문이지요! 따라서 효율적으로 빨래를 하는 방식은 아래와 같을 것입니다.

caption=효율적으로 빨래를 하는 방법
효율적으로 빨래를 하는 방법

위와 같이 모든 단계의 작업들을 쉬지 않고 계속 돌릴 수 있습니다. 즉, 이전의 방식은 효율이 33% 였다면, 새로운 방식의 경우 모든 단계를 100% 사용할 수 있게 되지요. 이와 같이, 한 작업 (세탁 - 건조 - 개기) 이 끝나기 전에, 다음 작업을 시작하는 방식으로 동시에 여러 개의 작업을 동시에 실행하는 것을 파이프라이닝(pipelining) 이라고 합니다.

CPU 도 마찬가지입니다. 실제 CPU 에서 명령어를 실행할 때 여러 단계를 거치게 됩니다. 명령어를 읽어야 하고 (fetch), 읽은 명령어가 무엇 인지 해석해야 하고 (decode), 해석된 명령어를 실행하고 (execute), 마지막으로 결과를 써야 하지요 (write).

CPU 역시 정확히 동일한 방법으로 명령어를 처리합니다.

caption=CPU 의 파이프라이닝; 알고보면 빨래하는 것과 다를바가 없다.
CPU 의 파이프라이닝; 알고보면 빨래하는 것과 다를바가 없다.

위 그림에서는 각 단계의 실행 속도가 동일한 것 처럼 나타났지만, 실제로는 실행 부분의 실행 속도는 명령어마다 천차 만별입니다. 따라서, 만일 매우 실행 시간이 오래 걸리는 명령어가 있다면, 해당 작업 때문에 다른 명령어들이 쫙 밀리게 되겠지요.

예컨대, 세탁이나 빨래 개기는 30 분 밖에 안걸리는데 건조가 3시간이 걸린다면, 건조기 기다리느라 빨래를 계속 할 수 없는 것과 마찬가지 입니다 (세탁이 끝난 빨래를 쌓아 놓을 수 없다는 전제하에)

따라서, 컴파일러는 우리가 어떠한 최대한 CPU 의 파이프라인을 효율적으로 활용할 수 있도록 명령어를 재배치하게 됩니다. 물론 전제 조건은 명령어를 재배치 하더라도 최종 결과물은 당연히도 달라지면 안되겠지요. 문제는 컴파일러가 명령어를 재배치 할 때, 다른 쓰레드들을 고려하지 않는다는 점입니다. 따라서 우리의 foo 함수 처럼, 멀티 쓰레드 환경에서는 예상치 못한 결과가 나올 수 도 있습니다.

과연 컴파일러만 재배치를 할까?

한 가지 더 재미있는 점은, 꼭 컴파일러만이 명령어를 재배치하는게 아니라는 점입니다. 예를 들어서 다음과 같은 두 명령을 생각해봅시다.

// 현재 a = 0, b = 0;
a = 1;  // 캐시에 없음
b = 1;  // 캐시에 있음

a = 1 의 경우 현재 a 가 캐시에 없으므로, 매우 오래 걸립니다. 반면에 b = 1; 의 경우 현재 b 가 캐시에 있기 때문에 빠르게 처리할 수 있겠지요. 따라서 CPU 에서 위 코드가 실행될 때, b = 1;a = 1; 보다 먼저 실행될 수 있습니다.

따라서, 다른 쓰레드에서 a 는 0 인데, b 가 1 인 순간을 관찰할 수 있다는 것입니다.

무엇을 믿어야 하는가?

아니, 이렇게 명령어 순서도 뒤죽 박죽 바꾸고 심지어 CPU 에서도 명령어들을 제대로 된 순서로 실행하지 않는다면, 도대체 무엇을 믿을 수 있을까요?

C++ 의 모든 객체들은 수정 순서(modification order) 라는 것을 정의할 수 있습니다. 수정 순서라 하는 것은, 만약에 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 하였을 때, 해당 객체의 값의 변화를 기록한 것이라 보면 됩니다. (물론 실제로 존재하지 않고, 가상의 수정 순서가 있다고 생각해봅시다.)

caption=a 의 수정 순서
a 의 수정 순서

만약에 위 처럼, 어떤 변수 a 의 값이 위와 같은 형태로 변화해왔다고 해봅시다. C++ 에서 보장하는 사실은, 원자적 연산을 할 경우에 모든 쓰레드에서 같은 객체에 대해서 동일한 수정 순서 를 관찰할 수 있다는 사실입니다.

여기서 강조할 점은 순서 가 동일하다는 것이라는 점입니다. 쉽게 말해 어떤 쓰레드가 a 의 값을 읽었을 때, 8 로 읽었다면, 그 다음에 읽어지는 a 의 값은 반드시 8, 6, 3 중에 하나여야 할 것입니다. 수정 순서를 거꾸로 거슬러 올라가서 5 를 읽는 일은 없습니다.

모든 쓰레드에서 변수의 수정 순서에 동의만 한다면 문제될 것이 없습니다. 이 말이 무슨 말이냐면, 같은 시간에 변수 a 의 값을 관찰했다고 해서 굳이 모든 쓰레드들이 동일한 값을 관찰할 필요는 없다 라는 점입니다. 예를 들어서 정확히 같은 시간에 쓰레드 1 과 2 에서 a 의 값을 관찰하였을 때 쓰레드 1 에서는 5 를 관찰하고, 쓰레드 2 에서는 8 을 관찰해도 문제될 것이 없습니다. 심지어, 동일한 코드를 각기 다른 쓰레드에서 실행하였을 때, 실행하는 순서가 달라도 (결과만 같다면) 문제가 안됩니다.

쓰레드 간에서 같은 시간에 변수의 값을 읽었을 때 다른 값을 리턴해도 된다는 점은 조금 충격적입니다. 하지만, 이 조건을 강제할 수 없는 이유는 그 이유는 아래 그림 처럼 CPU 캐시가 각 코어별로 존재하기 때문입니다.

caption=코어 각각 L1, L2 캐시를 가지고 있다.
코어 각각 L1, L2 캐시를 가지고 있다.

보시다시피, 각 코어가 각각 자신들의 L1, L2 캐시들을 갖고 있는 것을 알 수 있습니다. 따라서, 만약에 쓰레드 1 에서 a = 5 을 한 후에 자신들의 캐시에만 기록해 놓고 다른 코어들에게 알리지 않는다면, 쓰레드 3 에서 a 의 값을 확인할 때, 5 를 얻는다는 보장이 없다는 이야기 입니다.

물론, 매번 값을 기록할 때 마다, 모든 캐시에 동기화를 시킬 수 있겠지만 동기화 작업은 시간을 꽤나 잡아먹는 일입니다. 다행이도, C++ 에서는 로우 레벨 언어 답게, 여러분들이 이를 세밀하게 조정할 수 있는 여러가지 도구들을 제공하고 있습니다.

원자성(atomicity)

앞서 이야기 했듯이, C++ 에서 모든 쓰레드들이 수정 순서에 동의해야만 하는 경우는 바로 모든 연산들이 원자적 일 때 라고 하였습니다.

원자적인 연산이 아닌 경우에는 모든 쓰레드에서 같은 수정 순서를 관찰할 수 있음이 보장되지 않기에 여러분이 직접 적절한 동기화 방법을 통해서 처리해야 합니다. 만일 이를 지키지 않는다면, 프로그램이 정의되지 않은 행동(undefined behavior)을 할 수 있습니다.

그렇다면 원자적 이라는 것이 무엇일까요?

이미 이름에서 짐작하셨겠지만, 원자적 연산이란, CPU 가 명령어 1 개로 처리하는 명령으로, 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산을 말합니다. 즉, 이 연산을 반 정도 했다 는 있을 수 없고 이 연산을 했다 혹은 안 했다 만 존재할 수 있습니다. 마치 원자처럼 쪼갤 수 없다 해서 원자적(atomic) 이라고 합니다.

C++ 에서는 몇몇 타입들에 원자적인 연산을 쉽게 할 수 있도록 여러가지 도구들을 지원하고 있습니다. 또한 이러한 원자적 연산들은 올바른 연산을 위해 굳이 뮤텍스가 필요하지 않습니다! 즉 속도가 더 빠릅니다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

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

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

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

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

  cout << "Counter 최종 값 : " << counter << endl;
}

성공적으로 컴파일 하였다면

실행 결과

Counter 최종 값 : 40000

와 같이 잘 나옴을 알 수 있습니다.

atomic<int> counter(0);

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

counter++;

놀랍게도 counter ++; 을 아무런 뮤텍스로 보호하지 않았음에도 불구하고, 정확히 Counter40000 으로 출력 되었습니다. 원래 counter ++ 을 하기 위해서는 CPU 가 메모리에서 counter 의 값을 읽고 - 1 더하고 - 쓰는 총 3 개의 단계를 거쳐야만 했습니다. 그런데, 여기서는 lock 없이도, 제대로 계산하였지요.

그렇다면 컴파일러는 이를 어떻게 원자적 연산으로 만들었을까요? 이를 알기 위해서는 다시 컴파일러가 어떤 어셈블리 코드를 생성했는지 살펴봐야 합니다.

caption=붉은색 테두리가 counter ++ 부분이다.
붉은색 테두리가 counter ++ 부분이다.

놀랍게도 counter ++ 부분이 실제로 어셈블리 명령 한 줄인

  lock add DWORD PTR [rdi], 1

로 나타남을 알 수 있습니다. 원래 CPU 는 한 명령어에서 메모리에 읽기 혹은 쓰기 둘 중 하나 밖에 하지 못합니다. 메모리에 읽기 쓰기를 동시에 하는 명령은 없습니다. 하지만, 이 lock add 의 경우 rdi 에 위치한 메모리를 읽고 - 1 더하고 - 다시 rdi 에 위치한 메모리에 쓰기를 모두 해버립니다.

참고로 이러한 명령어를 컴파일러가 사용할 수 있었던 이유는 우리가 어느 CPU 에서 실행할 지 (x86) 컴파일러가 알고 있기 때문에 이런 CPU 특이적인 명령어를 제공할 수 있던 것입니다. 물론, CPU 에 따라 위와 같은 명령이 없는 경우도 있습니다.

이 경우 CPU 는 위와 같은 원자적인 코드를 생성할 수 없습니다. 이는 해당 atomic 객체의 연산들이 과연 정말로 원자적으로 구현될 수 있는지 확인하는 is_lock_free() 함수를 호출해보면 됩니다. 예를 들어서

atomic<int> x;
cout << "is lock free ? : " << boolalpha << x.is_lock_free() << endl;

를 실행해보면

실행 결과

Is lock free ? : true

와 같이 나옵니다. 여기서 lock freelock 과 실제 어셈블리 명령에서의 lock 과는 다른 lock 을 의미합니다.

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

memory_order

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

memory_order_relexed

memory_order_relaxed 는 가장 느슨한 조건입니다. 다시 말해, memory_order_relaxed 방식으로 메모리에서 읽거나 쓸 경우, 주위의 다른 메모리 접근들과 순서가 바뀌어도 무방합니다. 예를 들어서 아래 예제를 살펴봅시다.

#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
using namespace std;

void t1(atomic<int>* a, 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(atomic<int>* a, 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() {
  vector<thread> threads;

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

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

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

성공적으로 컴파일 하였다면 아래와 같은 결과들을 확인할 수 있습니다.

실행 결과

x : 1 
y : 0 

혹은

실행 결과

x : 0 
y : 1 

혹은

실행 결과

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 를 전달하였습니다.

여기서 잠깐 궁금한게 있습니다. 과연 아래와 같은 결과를 볼 수 있을까요?

실행 결과

x : 0 
y : 0

상식적으로는 불가능 합니다. 왜냐하면 x, y 둘다 0 이 나오기 위해서는 x = ay = b 시점에서 ab 모두 0 이어야만 합니다. 하지만 위 명령어들이 순서대로 실행된다면 이는 불가능 하다는 사실을 알 수 있습니다.

예를 들어서 x 에 0 이 들어가려면 a 가 0 이어야 합니다. 이 말은 즉슨, x = a 가 실행된 시점에서 a = 1 이 실행되지 않았어야만 합니다. 따라서 t2 에서 y = b 를 할 때 이미 b 는 1 인 상태이므로, y 는 반드시 1 이 출력되어야 하지요.

하지만, 실제로는 그렇지 않습니다. memory_order_relaxed 는 앞서 말했듯이, 메모리 연산들 사이에서 어떠한 제약조건도 없다고 하였습니다. 다시 말해 서로 다른 변수의 relaxed 메모리 연산은 CPU 마음대로 재배치 할 수 있습니다 (단일 쓰레드 관점에서 결과가 동일하다면).

예를 들어서

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

순으로 CPU 가 순서를 재배치 하여 실행해도 무방하다는 뜻입니다.

그렇다면 이 경우 xy 에 모두 0 이 들어가겠네요. memory_order_relaxed 는 CPU 에서 메모리 연산 순서에 관련해서 무한한 자유를 주는 것과 같습니다. 덕분에 CPU 에서 매우 빠른 속도로 실행할 수 있게됩니다.

이렇게 relaxed 메모리 연산을 사용하면 예상치 못한 결과를 나을 수 있지만, 종종 사용할 수 있는 경우가 있습니다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

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

    counter->fetch_add(1, memory_order_relaxed);
  }
}
int main() {
  vector<thread> threads;

  atomic<int> counter(0);

  for (int i = 0; i < 4; i++) {
    threads.push_back(thread(worker, &counter));
  }

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

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

성공적으로 컴파일 하였다면

실행 결과

Counter : 40000

와 같이 나옵니다. 여기서 중요한 부분은

counter->fetch_add(1, memory_order_relaxed);

로 이는 counter ++ 와 정확히 하는 일이 동일하지만, counter++ 와는 다르게 메모리 접근 방식을 설정할 수 있습니다. 위 문장 역시 원자적으로 counter 의 값을 읽고 - 1 을 더하고 - 다시 그 결과를 씁니다.

다만 memory_order_relaxed 를 사용할 수 있었던 이유는, 다른 메모리 연산들 사이에서 굳이 counter 를 증가시키는 작업을 재배치 못하게 막을 필요가 없기 때문입니다. 비록 다른 메모리 연산들 보다 counter ++ 이 늦게 된다고 하더라도 결과적으로 증가 되기만 하면 문제 될게 없기 때문 입니다.

memory_order_acquire 과 memory_order_release

memory_order_relaxed 가 사용되는 경우가 있다고 하더라도 너무나 CPU 에 많은 자유를 부여하기에 그 사용 용도는 꽤나 제한적입니다. 이번에 살펴볼 것들은 memory_order_relaxed 보다 살짝 더 엄격한 친구들 입니다.

아래와 같은 producer - consumer 관계를 생각해봅시다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

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

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

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

int main() {
  vector<thread> threads;

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

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

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

성공적으로 컴파일 하였다면

실행 결과

Data : 10

일반적인 경우 위와 같이 나옵니다. 하지만, 아래와 같은 결과를 얻을 수 도 있을까요?

실행 결과

Data : 0

있습니다! 왜냐하면 producer 쓰레드를 살펴보자면;

*data = 10;
is_ready->store(true, memory_order_relaxed);

is_ready 에 쓰는 연산이 relaxed 이기 때문에 위의 *data = 10 과 순서가 바뀌어서 실행된다면 is_readytrue 임에도 *data = 10 이 채 실행이 끝나지 않을 수 있다는 것이지요.

따라서 consumer 쓰레드에서 is_readytrue 가 되었음에도 제대로된 data 를 읽을 수 없는 상황이 벌어집니다.

consumer 쓰레드에서도 마찬가지 입니다.

while (!is_ready->load(memory_order_relaxed)) {
}

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

아래에 data 를 읽는 부분과 위 is_ready 에서 읽는 부분이 순서가 바뀌어 버린다면, is_readytrue 가 되기 이전의 data 값을 읽어버릴 수 있다는 문제가 생깁니다. 따라서 위와 같은 생산자 - 소비자 관계에서는 memory_order_relaxed 를 사용할 수 없습니다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

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

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

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

int main() {
  vector<thread> threads;

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

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

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

성공적으로 컴파일 하였다면

실행 결과

Data : 10

와 같이 나옵니다. 이 경우 data 에 0 이 들어가는 일은 불가능 합니다. 이유는 아래와 같습니다.

*data = 10;
is_ready->store(true, memory_order_release);

memory_order_release해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 금지 합니다. 또한, 만약에 같은 변수를 memory_order_acquire 으로 읽는 쓰레드가 있다면, memory_order_release 이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰 될 수 있어야 합니다.

쉽게 말해 is_ready->store(true, memory_order_release); 밑으로 *data = 10 이 올 수 없게 됩니다. 또한 is_readytrue 가 된다면, memory_order_acquireis_ready 를 읽는 쓰레드에서 data 의 값을 확인했을 때 10 임을 관찰할 수 있어야하죠.

while (!is_ready->load(memory_order_acquire)) {
}

실제로 cosnumer 쓰레드에서 is_readymemory_order_acquireload 하고 있기에, is_readytrue 가 된다면, data 는 반드시 10 이어야만 합니다.

memory_order_acquire 의 경우, release 와는 반대로 해당 명령 뒤에 오는 모든 메모리 명령들이 해당 명령 위로 재배치 되는 것을 금지 합니다.

이와 같이 두 개의 다른 쓰레드들이 같은 변수의 releaseacquire 를 통해서 동기화 (synchronize) 를 수행하는 것을 볼 수 있습니다.

아래 예시를 보시면 좀더 이해가 잘 되실 것입니다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

atomic<bool> is_ready;
atomic<int> data[3];

void producer() {
  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, memory_order_release);
}

void consumer() {
  // data 가 준비될 때 까지 기다린다.
  while (!is_ready.load(memory_order_acquire)) {
  }

  cout << "data[0] : " << data[0].load(memory_order_relaxed) << endl;
  cout << "data[1] : " << data[1].load(memory_order_relaxed) << endl;
  cout << "data[2] : " << data[2].load(memory_order_relaxed) << endl;
}

int main() {
  vector<thread> threads;

  threads.push_back(thread(producer));
  threads.push_back(thread(consumer));

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

성공적으로 컴파일 하였다면

실행 결과

data[0] : 1
data[1] : 2
data[2] : 3

와 같이 나옵니다.

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, memory_order_release);

여기서 data 의 원소들을 store 하는 명령들은 모두 relaxed 때문에 자기들 끼리는 CPU 에서 마음대로 재배치될 수 있겠지만, 아래 release 명령을 넘어가서 재배치될 수 는 없습니다.

caption=release - acquire 동기화
release - acquire 동기화

따라서 consumer 에서 data 들의 값을 확인했을 때 언제나 정확히 1, 2, 3 이 들어있게 됩니다.

memory_order_acq_rel

memory_order_acq_rel 은 이름에서도 알 수 있듯이, acquirerelease 를 모두 수행하는 것입니다. 이는, 읽기와 쓰기를 모두 수행하는 명령들, 예를 들어서 fetch_add 와 같은 함수에서 사용될 수 있습니다.

memory_order_seq_cst

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

순차적 일관성이란, 메모리 명령 재배치도 없고, 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는, 여러분이 생각하는 그대로 CPU 가 작동하는 방식이라 생각하면 됩니다.

memory_order_seq_cst 를 사용하는 메모리 명령들 사이에선 이러한 순차적 일관성을 보장해줍니다.

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

using namespace std;

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

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

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

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

void read_y_then_x() {
  while (!y.load(memory_order_acquire)) {
  }
  if (x.load(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();
  cout << "z : " << z << endl;
}

성공적으로 컴파일 하였다면

실행 결과

z : 2

혹은

실행 결과

z : 1

과 같이 나옵니다. 그렇다면

실행 결과

z : 0

은 발생할 수 있을까요?

일단, write_xread_x_then_y 사이의 release - acquire 동기화와, write_yread_y_then_x 사이의 release - acquire 동기화가 이루어지고 있음을 알 수 있습니다.

그렇다고 해서, read_x_then_yread_y_then_x 두 쓰레드가 같은 순서로 x.storey.store 를 관찰한다는 보장이 없습니다. 다시 말해 read_x_then_y 의 입장에서는 x.storey.store 보다 먼저 발생해도 되고, read_y_then_x 입장에서는 y.storex.store 보다 먼저 발생해도 된다는 것입니다.

이 경우 두 if 문 안의 loadfalse 가 되어서 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 가 꽤나 비싼 연산이라는 것입니다. 인텔 혹은 AMD 의 x86(-64) CPU 의 경우에는 사실 거의 순차적 일관성이 보장되서 memory_order_seq_cst 를 강제하더라도 그 차이가 그렇게 크지 않습니다. 하지만 ARM 계열의 CPU 와 같은 경우 순차적 일관성을 보장하기 위해서는 CPU 의 동기화 비용이 매우 큽니다. 따라서 해당 명령은 정말 꼭 필요 할 때만 사용해야 합니다.

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

using namespace std;

atomic<bool> x(false);
atomic<bool> y(false);
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;
  }
}

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();
  cout << "z : " << z << endl;
}

성공적으로 컴파일 하였다면

실행 결과

z : 2

혹은

실행 결과

z : 1

과 같이 나옵니다. x.storey.store 가 모두 memory_order_seq_cst 이므로, read_x_then_yread_y_then_x 에서 관찰했을 때 x.storey.store 가 같은 순서로 발생해야 합니다. 따라서 z 의 값이 0 이 되는 경우는 발생하지 않습니다.

정리해보자면 다음과 같습니다.

연산

허용된 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

참고로 memory_order_consume 은 다루지 않았는데 C++ 17 현재, memory_order_consume 의 정의가 살짝 수정 중에 있기에 memory_order_consume 의 사용이 권장되지 않습니다.

이렇게 C++ 에서 atomic 연산들에 대해 memory_order 을 지정하는 방법에 대해 알아보았습니다. C++ atomic 객체들의 경우 따로 지정하지 않는다면 기본으로 memory_order_seq_cst 로 설정되는데, 이는 일부 CPU 에서 매우 값비싼 명령 입니다. 만약에 제약 조건을 좀 더 느슨하게 할 수 있을 때 더 약한 수준의 memory_order 을 사용한다면 프로그램의 성능을 더 크게 향상 시킬 수 있습니다.

생각 해보기

문제 1

atomic<bool> 을 사용해서 lock()unlock() 을 만들어보세요. 참고로 compare_exchange_strong 함수를 사용하는 것이 도움이 됩니다. 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_flagtest_and_set 함수를 이용해서도 동일하게 만들 수 있습니다. 한 번 다시 만들어보세요! atomic_flagatomic<bool> 과 비슷하게 true 혹은 false 만 가질 수 있지만, atomic_flagis_lock_free 가 언제나 참임이 보장됩니다. 반면에 atomic<bool> 은 그렇지 않습니다. (정확히 말하자면 모든 atomic 객체들은 is_lock_free 가 참인 것이 보장되지 않습니다.)

문제 3

C++ memory_order 에 관련해서 매우 훌륭한 자료들이 인터넷에 많이 있습니다.

뭘 배웠지?

여러분의 코드는 여러분이 생각하는 순서로 작동하지 않습니다. (단일 쓰레드 관점에서) 결과값이 동일하다면 컴파일러와 CPU 는 명령어의 순서를 재배치 할 수 있습니다.

문제는 이렇게 마음대로 메모리 접근 명령어의 순서를 재배치 한다면 멀티 쓰레드 환경에서 그 결과가 달라질 수 있다는 점입니다. C++ 에서는 이와 같은 상황을 막기 위해서 메모리 재배치 순서를 강제할 수 있는 memory_order 라는 것을 제공합니다.

C++ 에서 원자적 연산을 쉽게 할 수 있는 도구로 atomic 이란 것을 제공합니다.

atomic 의 메모리 관련 연산에 적절한 memory_order 를 지정해서 올바른 결과를 이끌어낼 수 있습니다.

댓글이 2 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요