본문 바로가기
프로그래밍/Optimizations

Memory Model (Memory Order and Barrier)

by 별준 2024. 1. 27.

References

  • Ch5, The Art of Writing Efficient Programs

Contents

  • Concurrency and Order
  • Memory Model (Memory Order and Barrier)
  • Relaxed Memory Order
  • Acquire-Release Memory Order
  • Release Memory Order
  • Sequentially Consistent Memory Order

 

Threads and Memory

References Ch5, The Art of Writing Efficient Programs Contents Threads and Memory (Data Sharing) Concurrency and Memory Syncrhonization Mutex, Atomic Variables False Sharing 요좀 고성능 컴퓨터는 여러 CPU를 가지거나 여러 CPU 코어를 갖

junstar92.tistory.com

 

이전 포스팅에 이어서 동시성(concurrency) 프로그램에서 알아야 할 세부사항에 대해서 살펴본다.

 

Concurrency and Order

액세스 동기화(뮤텍스 또는 아토믹)없이 공유 데이터에 액세스하는 모든 프로그램에는 일반적으로 데이터 레이스(data race)라고 불리는 정의되지 않는 동작이 발생한다. 이론적으로는 간단해 보이지만 이전 포스팅에서 살펴본 예제는 스레드 간에 공유되는 변수가 단 하나만 존재하여 너무 단순했다.

 

The Need for Order

이번에는 producer-consumer queue(생산자-소비자 큐)로 알려진 예제를 통해 살펴본다. 두 개의 스레드가 있고, 첫 번째 스레드(생산자)는 객체를 구성하여 어떤 데이터를 준비하는 역할을 한다. 두 번째 스레드인 소비자는 데이터를 처리한다. 단순화하기 위해서 처음에 초기화되지 않은 큰 버퍼를 가지고 있으며 생산자 스레드는 마치 배열처럼 이 버퍼에서 새로운 객체를 구성한다고 가정한다.

size_t N;   // Count of initialized objects
T* buffer;  // Only [0]...[N-1] are initialized

 

객체를 구성하기 위해서 생산자 스레드는 placement new 연산자를 통해 배열의 각 요소에 대해 생성자(constructor)를 호출한다.

new (buffer + N) T(... args ...);  // starting N == 0

 

배열 요소인 buffer[N]은 이제 초기화되었으며 소비자 스레드에서 이용할 수 있게 된다. 생산자는 카운터 N을 증가시킴으로써 신호를 보내고 다음 객체를 초기화하기 위해 이동한다.

++N;

소비자 스레드는 카운터 N이 증가하여 i보다 커질 때까지 배열 요소 buffer[i]에 액세스해서는 안된다.

for (size_t i = 0; keep_consuming(); ++i) {
    while (N <= i) {}; // Wait for the i-th element
    consume(buffer[i]);
}

문제를 간단히 하기 위해 out of memory 문제를 무시하며 버퍼는 충분히 크다고 가정한다. 또한, 여기서는 생산자-소비자 프로토콜에만 집중하기 위해 루프의 종료 조건 또한 생략하였다. 

 

여기서 기본적으로 필요한 것은 공유 데이터에 대한 모든 액세스는 보호되어야 한다는 것이며, 명백히 위 코드에서 카운터 N은 공유 변수이다. 따라서, 아래 코드와 같이 뮤텍스를 이용한 락이 필요할 수 있다.

size_t N;       // Counter of initialized objects
std::mutex mN;  // Mutex to guard N

... Producer ...
{
    std::lock_guard l(mN);
    ++N;
}

... Consumer ...
{
    size_t n;
    do {
        std::lock_guard l(mN);
        n = N;
    } while (n <= i);
}

이것으로 충분할까? 조금 더 자세히 살펴보자. 여기에는 더 많은 공유 데이터가 있다. 객체 T의 배열 전체 또한 두 스레드 간에 공유되며, 각 스레드는 모든 요소에 액세스한다. 그러나 전체 배열을 위해서 뮤텍스를 사용한다면, 단일 스레드 구현과 다를 바가 없다. 두 스레드 중 하나는 무조건 잠겨져 있는 상태이다. 경험적으로 우리는 이러한 경우에 전체 배열을 락 시킬 필요가 없고 카운터만 락 해주면 된다는 것을 쉽게 알 수 있다. 실제로, 배열의 특정 요소는 절대로 동시에 액세스되지 않는다. 카운터가 증가하기 전에는 생산자 스레드만 액세스할 수 있고, 카운터가 증가한 후에는 소비자 스레드만 액세스할 수 있다.

 

그렇다면 정말 카운터만 뮤텍스로 락하는 것으로 충분할까 ? 실제로 우리가 생각한 순서대로 액세스가 발생한다는 것이 보장될까 ?

이와 관련된 내용은 아래 부분에서 조금 더 자세히 살펴보자.

 

일단 다시 예제 코드로 돌아와서, 카운터에 대한 액세스를 보호하는 가장 간단한 방법은 다음과 같다.

std::lock_guard l(mN);
while (N <= i) {};

하지만 이 코드는 데드락(deadlock)을 유발한다. 소비자가 락을 획득하면, 락을 풀기 전에 i 번째 요소가 초기화될 때까지 기다린다. 생산자는 카운터를 증가시키기 전에 락을 획득해야 하므로 어떠한 진행도 이루어질 수 없게 되며, 두 스레드 모두 기다리게 된다. 이와 같은 문제는 아토믹 변수를 쓰면 훨씬 간단해진다는 것을 알 수 있다.

std::atomic<size_t> N;  // Count of initialized objects

... Producer ...
{
    ++N; // Atomic, no need for locks
}
... Consumer ...
{
    while (N <= i) {};
}

이렇게 하면 소비자 스레드가 카운터 N을 읽는 것은 원자적이지만, 더 이상 생산자가 블락되지 않고 작업을 계속 수행할 수 있다. 이러한 동시성 접근 방식을 lock-free라고 한다.

 

여기서 중요한 질문은 여전히 생산자와 소비자가 동일한 객체 buffer[i]에 동시에 액세스할 수 없다는 보장이 있냐는 것이다.

 

Memory Order and Memory Barriers

공유 변수에 안전하게 액세스하는 것만으로 동시 프로그램을 구현하는 데 충분하지 않다. 위의 생산자-소비자 예제처럼 사건이 발생하는 순서에 대해 추론할 수 있어야 한다. 생산자-소비자 예제에서는 프로그램이 N번째 배열 요소의 구성, N+1로 카운터 증가, 소비자 스레드에서 N번째 요소에 대한 액세스 순서로 발생한다는 것을 가정하고 있다.

 

하지만 실제 프로그램은 생산자-소비자 예제보다 훨씬 더 복잡하다. 여기서 기억해야 할 가장 중요한 핵심은 가시성(visibility)이다.

 

스레드는 하나의 CPU에서 실행되고 있고 CPU가 변수에 값을 할당할 때는 메모리를 변경하고 있다. 하지만 CPU는 실제로 캐시의 내용만 변경한다. 캐시와 메모리 하드웨어는 이러한 변경을 메인 메모리나 공유된 상위 수준의 캐시에 전파하게 되며, 이 시점에서 이러한 변경이 다른 CPU에 표시(visible)될 '수' 있다. 다른 CPU는 자신의 캐시에 같은 변수에 대한 다른 값을 가지고 있을 수 있고, 해당 변수에 대해 다른 CPU가 변경한 값이 언제 업데이트될 지 알 수 없기 때문에 '다른 CPU에 표시 될 수 있다'라고 표현한다.

 

우리는 CPU가 아토믹 변수에 대한 작업을 수행하면 이 작업이 완료될 때까지 다른 CPU는 동일한 변수에 액세스할 수 없으며, 작업이 완료되면 다른 모든 CPU가 이 변수에 대한 최신 값을 볼 수 있다는 것을 알 고 있다. 또한, 락으로 보호되는 변수에도 동일하게 적용된다는 것을 알고 있다. 하지만 이러한 보장은 생산자-소비자 예제에서 충분하지 않다.

 

공유 데이터에 액세스하는 데에는 메모리 순서(memory order)라는 고려해야 할 또 다른 측면이 있다. 액세스 자체의 원자성과 마찬가지로 이는 특정 machine instruction(often an attribute or a flag on the atomic instruction itself)을 사용하여 활성화되는 하드웨어의 기능이다.

 

메모리 순서에는 여러 가지 형태가 있다.

 

가장 제약이 적은 것은 relaxed memory order이다. 여기서 보장되는 유일한 것은 작업 자체가 원자적으로 실행된다는 것이다. 이것이 의미하는 바가 무엇인지 살펴보자.

 

아토믹 연산 및 아토믹이 아닌 작업을 포함한 스레드를 실행하는 CPU가 있다고 가정해보자. 이러한 작업 중 일부는 메모리를 수정하고 다른 CPU는 이러한 작업의 결과를 볼 수 있다. 또 다른 작업에서는 메모리를 읽고, 다른 CPU에 의해 실행된 작업의 결과를 관측할 수 있다. 이러한 작업을 포함하고 있는 스레드를 실행하는 CPU는 특정 순서로 이 작업들을 실행하지만, 이는 프로그램에 작성된 순서가 아닐 수도 있다. 컴파일러 및 하드웨어 모두 일반적으로 성능을 향상시키기 위해 (결과를 바꾸지 않는 한) 명령어를 재배치(재정렬)할 수 있다. 그럼 이제 다른 스레드를 실행하는 CPU 관점에서 위 작업들을 살펴보자. 두 번째 CPU는 첫 번째 CPU가 작업을 수행할 때, 변경되는 메모리 내용을 볼 수 있다. 하지만, 첫 번째 CPU의 아토믹 연산에 대해 동일한 순서로 볼 수는 없다. 그림으로 표현하면 아래와 같다.

이것이 바로 앞서 언급했던 가시성(visibility)이다. 하나의 CPU는 특정 순서로 작업을 실행하지만, 그 결과는 다른 순서로 다른 CPU에 표시될 수 있다.

 

생산자-소비자 예제에서 카운터 N에 대한 작업이 relaxed memory order로 실행되면 심각한 문제가 발생하게 된다. 이런 경우에 올바르게 동작하도록 하는 유일한 방법은 생산자 또는 소비자 스레드 중 단 하나의 스레드만 실행할 수 있도록 락을 하는 것이며, 따라서, 동시성으로 인한 성능 향상은 기대할 수 없다.

 

 

다행히 사용할 수 있는 다른 것들이 있는데, 가장 중요한 것은 acquire-release memory order이다. 이것으로 아토믹 연산이 실행되면 메모리에 액세스하고 아토믹 연산 이전에 실행된 모든 연산이 해당 스레드가 동일한 아토믹 변수에 대해 아토믹 연산을 실행하기 전에 다른 스레드에 표시된다는 것을 보장할 수 있다. 마찬가지로 아토믹 연산 이후에 실행되는 모든 연산은 동일한 아토믹 연산 이후에만 표시된다. 이는 아래 그림에서 잘 보여주고 있다.

왼쪽은 CPU0에 의해서 실행되는 작업의 시점을 나타내며, 오른쪽은 CPU1 관점에서 관측하는 CPU0의 작업의 결과가 나타나는 시점을 나타낸다. 왼쪽의 CPU0에서 실행하는 아토믹 연산은 atomic write이다. 하지만 CPU1는 CPU0에서 실행한 atomic write의 결과를 확인하기 위해 atomic read를 실행한 것이다. CPU0에 의해서 atomic write 이전에 수행된 모든 결과는 CPU1이 동일한 공유 변수에 대해 atomic read를 할 때 표시된다는 것을 보장한다. 그 순서는 그림과 같이 다를 수 있다.

 

acquire-release memory 보장에 대한 몇 가지 중요한 사항에 대해서 설명하면 다음과 같다.

 

먼저, 순서는 두 스레드가 동일한 아토믹 변수에 대해 실행하는 작업을 기준으로 정의된다. 두 스레드가 동일한 변수에 원자적으로 액세스할 때까지 두 스레드의 시계는 임의의 상태로 유지되며 전후에 무슨 일이 발생하는지 추론할 수 없다. 전후에 발생하는 결과에 대해 이야기할 수 있는 것은 한 스레드가 다른 스레드에 의해 실행된 아토믹 연산의 결과를 관측한 경우에만 가능하다. 생산자-소비자 예제에서 생산자 스레드는 카운터 N을 증가시킨다. 소비자 스레드는 동일한 카운터 N을 아토믹으로 읽는다. 카운터가 변경되지 않은 경우, 소비자는 생산자의 상태에 대해서는 아무것도 알 수 없다. 그러나, 소비자가 카운터가 N에서 N+1로 변경되고 두 스레드 모두 acquire-release order를 사용하는 것으로 확인하면 카운터를 증가시키기 전에 생산자가 실행한 모든 작업이 소비자에게 표시된다는 것을 알 수 있다. 카운터를 증가시키기 전에 수행하는 작업에는 현재 배열 요소 buffer[N]을 구성하는 것을 포함하므로 소비자는 해당 객체에 안전하게 액세스할 수 있다.

 

두 번째는 두 스레드가 모두 아토믹 변수에 액세스할 때 acquire-release memory order를 사용해야 한다는 것이다. 생산자는 acquire-release order를 사용하지만 소비자가 relaxed order로 읽는 경우에는 모든 작업의 가시성이 보장되지 않는다.

 

마지막은 아토믹 변수에 대한 연산을 기준으로 전후에 발생하는 모든 작업들은 그 순서를 보장한다는 것이다. 다시 말해, 생산자-소비자 예제에서 N번째 객체를 구성하기 위해서 생산자가 실행한 작업의 결과가 카운터가 변경될 때 소비자가 볼 수 있다는 것이다. 한 스레드에서 아토믹 연산 이전에 발생한 작업은 다른 스레드가 동일한 변수에 대해 아토믹 연산을 수행할 때 볼 수 있다는 것이다. 물론, 이전에 발생한 작업들 간의 순서는 보장하지 않는다. 위 그림을 보면 조금 더 자세히 이해할 수 있는데, 전체 프로그램에서 아토믹 연산 전후를 나누는 barrier를 상상해보면 된다. 즉, 카운터가 증가하기 전과 증가한 이후에 발생한 모든 것들은 그 순서를 유지한다는 것이며, 카운터가 증가하기 전에 발생한 작업은 다른 CPU에서 관측할 때 카운터가 증가한 이후에 나타나지 않는다는 것을 의미한다.

 

 

생산자-소비자 프로그램에서 카운터 N의 모든 아토믹 연산에 acquire-release barrier가 있다고 가정해보자. 그러면 프로그램이 정확하다는 것이 확실하게 보장된다. 하지만, 이 프로그램에서 acquire-release order는 프로그램 요구사항에 비해 너무 과도하다. 생산자가 N+1로 카운터를 증가시키기 전에 생성된 buffer[0]부터 buffer[N]까지의 모든 객체가 소비자 스레드에서 카운터가 N에서 N+1로 변경될 때 볼 수 있다는 보장을 제공한다. 이러한 보장이 필요하긴 하지만, 사실 나머지 객체인 buffer[N+1] 및 그 이후 인덱스의 객체는 아직 볼 수 없다는 것도 보장한다. 하지만 이에 대한 보장은 사실 신경쓰지 않아도 되는 부분이다. 소비자는 다음 카운터 값을 볼 때까지 N+1 및 그 이후 객체에 액세스하지 않을 것이다. 또한, 생산자 스레드 입장에서 소비자가 buffer[N-1]과 같은 객체를 처리하기 위해 수행한 작업이 소비자가 다음 객체로 이동하기 전에 모든 스레드에 표시된다는 보장도 있다. 이에 의존하는 연산은 없기 때문에 이러한 보장이 필요하지 않다.

 

결과적으로 필요한 것보다 더 엄격하게 보장된다는 것이다. 정확성 측면에서 해로울 것은 없지만 속도 성능 측면에서는 떨어질 수 있다. 애초에 메모리 순서에 대한 보장이 필요한 것은 컴파일러와 프로세서가 프로그램 코드를 임의로 재정렬할 수 있기 때문이다. 이는 일반적으로 성능을 향상시키기 위해 사용된다. 따라서 순서를 재정렬하는 것에 제약을 가할수록 성능에 부정적인 영향이 커진다는 것은 당연해보인다. 따라서, 일반적으로 프로그램의 정확성을 위해서 충분히 제한적이지만 최소한으로 제한하는 메모리 순서를 사용하는 것이 좋다.

 

생산자-소비자 예제 코드에서 필요한 요구 사항을 정확히 만족시키는 메모리 순서는 다음과 같다. 생산자 측에서는 acquire-release memory barrier가 보장하는 것의 절반 정도가 필요하다. 다시 말하면, barrier를 사용한 아토믹 연산 전에 실행된 모든 작업은 다른 스레드가 대응하는 아토믹 연산을 실행하기 전에 볼 수 있어야 한다. 이러한 메모리 순서를 release memory order라고 한다. 그림으로 표현하면 아래와 같다.

CPU1이 CPU0이 release memory order로 실행한 atomic write의 결과를 볼 때, CPU1은 이 아토믹 연산 이전에 CPU0에 의해 실행된 모든 작업을 볼 수 있다는 것이 보장된다. 아토믹 연산 이후 CPU0이 실행한 연산에 대해서는 어떠한 제약도 없다. 위 그림에서 볼 수 있듯이 아토믹 연산 이후에 CPU0에서 실행한 연산들은 CPU1에서 어떠한 순서로도 나타날 수 있다. 즉, 아토믹 연산에 의해 생성된 memory barrier는 한 방향으로만 유효하다. 즉, barrier 이전에 실행되는 모든 작업들은 barrier 이후로 재정렬될 수 없다. 그러나 반대 방향으로는 재정렬될 수 있다. 이러한 이유로 release memory barrier와 이에 대응하는 acquire memory barrierhalf-barriers라고도 불린다.

 

Memory acquire order는 소비자 측에서 사용해야 하는 메모리 순서이다. 이는 아래 그림에서 잘 보여주고 있다.

위 그림에 볼 수 있듯이, CPU0에서 barrier 이후에 실행된 모든 작업들이 CPU1에서 대응되는 barrier 이후에 발생한다는 것이 보장된다. Acquire memory barrier와 release memory barrier는 항상 함께 사용된다. 한 스레드(예제의 경우 생산자)가 아토믹 연산을 memory release order로 사용하는 경우, 다른 스레드(예제의 경우 소비자)는 동일한 아토믹 변수에 대해 memory acquire order를 사용해야 한다.

 

Memory Order in C++

이제 C++에서 이러한 메모리 순서를 어떻게 적용할 수 있는지 살펴보자.

 

먼저 아토믹 카운터가 있는 생산자-소비자 예제의 lock-free 버전을 다시 살펴보자.

std::atomic<size_t> N;  // Count of initialized objects
T* buffer; // Only [0]...[N-1] are initialized

... Producer ...
{
    new (buffer + N) T(... args ...);
    ++N;  // Atomic, no need for locks
}
... Consumer ...
for (size_t i = 0; keep_consuming(); ++i) {
    while (N <= i) {}; // Atomic read
    consume(buffer[i]);
}

카운터 N은 아토믹 변수이며 타입 파라미터가 size_t인 std::atomic 템플릿으로 생성된 객체이다. 모든 아토믹 타입은 atomic read 및 write 연산을 지원하며 할당 연산자로 사용할 수 있다. 또한, 정수형의 아토믹은 내부적으로 정의된 정규 정수 연산을 가지고 있어서, ++N과 같은 atomic increment를 사용할 수 있다. 지금 본 연산에서는 어느 것도 메모리 순서를 명시적으로 지정하지 않고 있다. 이렇게 메모리 순서를 지정하지 않으면 가장 강력한 보장, 즉, bidirectional memory barrier을 사용한다 (실제 보장은 조금 더 엄격하다). 따라서, 지정하지 않더라도 생산자-소비자 예제 코드는 정상적으로 동작한다.

 

이와 같은 보장이 과도하다고 생각되면 필요한 것만 보장할 수 있도록 할 수 있지만, 명시적으로 지정해주어야 한다. std::atomic 타입의 멤버 함수를 사용하여 아토믹 연산을 실행할 수 있고, 이때 메모리 순서를 지정할 수 있다. 소비자 스레드의 경우에는 acquire barrier가 있는 load 연산이 필요하다.

while (N.load(std::memory_order_acquire) <= i);

생산자 스레드는 release barrier를 사용하는 증분 연산이 필요하다.

N.fetch_add(1, std::memory_order_release);

 

 

뮤텍스로 다시 넘어가면, 뮤텍스는 아래와 같은 메모리 순서가 보장된다는 것을 알 수 있다.

뮤텍스 락은 acquire memory order를 사용하는 read 연산과 동일하다. 이 작업은 half-barrier를 생성하고 이 장벽 이전에 실행된 작업은 장벽 이후에 볼 수 있지만, 장벽 이후에 실행된 작업을 더 일찍 관측할 수는 없다. 뮤텍스 락을 해제하면 release memory order가 보장된다. 이 장벽 이전에 실행된 모든 작업은 장벽 이전에 표시된다. 즉, 한 쌍의 장벽인 acquire과 release가 크리티컬 섹션 역할을 하는 것을 볼 수 있다. 크리티컬 섹션 내에서 실행되는 모든 작업, 즉, 스레드가 락을 유지하는 동인 실행되는 모든 작업은 크리티컬 섹션에 들어갈 때 다른 모든 스레드에 표시된다. 어떠한 작업도 크리티컬 섹션을 벗어날 수 없지만, 외부의 다른 작업은 크리티컬 섹션 내로 들어갈 수 있다. 또한, 결정적으로 어떠한 작업도 크리티컬 섹션을 가로질러서 재정렬될 수는 없다. 따라서, 크리티컬 섹션 이전에 CPU0에 의해 수행된 모든 작업은 크리티컬 섹션 이후 CPU1에 표시되는 것이 보장된다.

 

생산자-소비자 예제에서 뮤텍스 락을 사용하면 다음과 같이 구현할 수 있다.

… Producer …
new (buffer + N) T( … arguments … );
{ // Critical section start – acquire lock
    std::lock_guard l(mN);
    ++N;
} // Critical section end - Release lock
… Consumer … 
{ // Critical section – acquire lock
    std::lock_guard l(mN);
    n = N;
} // Critical section – release lock
consume(buffer[N]);

N번째 객체를 생성하기 위해 생산자가 실행하는 모든 작업은 생산자가 크리티컬 섹션에 들어가기 전에 완료된다. 그리고 이 작업은 소비자가 크리티컬 섹션을 지나서 N번째 객체에 액세스하기 전에 소비자에게 표시된다.

 

Memory Model

메모리를 통한 스레드의 상호작용, 공유 데이터의 사용 및 이것이 동시성 프로그램에 미치는 영향을 설명하기 위해서는 보다 체계적이고 엄격한 방법이 필요하다. 이러한 설명을 메모리 모델(memory model)이라고 한다. 메모리 모델은 스레드들이 동일한 메모리 위치에 액세스할 때 어떠한 보장과 제한이 있는지 설명한다.

 

C++11 표준 이전에는 언어에 메모리 모델이 존재하지 않았다. 생산자-소비자 예제를 떠올려보면, 이는 문제가 될 수 있음을 알 수 있다. 컴파일러는 관측 가능한 결과가 유지되는 한 코드의 순서를 임의로 재정렬할 수 있다. 여기서 관측 가능한 결과에는 메모리에 쓰는 작업이 포함되지 않으며, 입출력과 같은 것에만 해당된다. 따라서, C++11 이전의 경우, 아래 코드는

std::mutex m;
size_t n;
...
m.lock();
++n;
m.unlock();

컴파일러에 의해서 아래와 같이 코드가 재정렬될 수 있다.

m.lock();
m.unlock();
++n;

 

하지만, C++11 이전에도 동시성 프로그램을 작성해왔으며, 분명히 컴파일러는 위와 같은 최적화를 수행하지 않았다는 것을 추정할 수 있다. 이에 대한 대답은 메모리 모델에서 찾을 수 있다. 컴파일러는 C++ 표준을 넘어서는 특정 보장을 제공했으며, 표준에서 요구하지 않더라도 특정 메모리 모델을 제공했다. 윈도우 기반에서는 windows 메모리 모델을 따르고, 대부분의 유닉스 및 리눅스는 POSIX 메모리 모델을 따라서 관련 보장을 제공했다.

 

C++11 표준에서는 C++ 자체 메모리 모델을 정의하여 제공한다. 위에서 활용한 몇 가지 모델도 이에 해당한다. 아토믹 연산을 수반하는 메모리 순서는 보장되며 C++ 표준 메모리 모델의 일부이다. C++ 메모리 모델을 통해 서로 다른 보장을 제공했던 플랫폼 간의 이식성을 제공한다.

 

C++에서는 몇 가지 메모리 보장을 제공하는데, 이미 relaxed, acquire-release, release, acquire 형태의 메모리 순서를 살펴보았다. C++에는 이보다 조금 더 엄격한 sequentially consistent(std::memory_order_seq_cst) memory order라는 것이 있다. 메모리 순서를 따로 지정하지 않으면 기본적으로 이 메모리 순서가 사용된다. 이를 사용하게 되면 각 아토믹 연산과 관련하여 bidirectional barrier가 있을 뿐만 아니라 순차적 일관성도 보장한다. 순차적 일관성에 대한 보장은 전체 프로그램이 모든 프로세서에 의해 실행되는 모든 작업이 하나의 전역적 순서로 실행된다는 것을 보장한다. 순차적 일관성은 프로그램의 정확성을 쉽게 추론할 수 있지만, 종종 성능의 저하가 발생한다. 이러한 성능은 다양한 메모리 순서에 대한 벤치마크를 통해 직접 확인해볼 수 있다.

 

아래와 같이 각 메모리 모델에 대한 벤치마크를 구현하여 성능을 측정한다. 전체 코드는 link에서 확인할 수 있다.

void BM_relaxed(benchmark::State& state) {
    for (auto _ : state) {
        REPEAT(x.store(1, std::memory_order_relaxed);)
    }
    state.SetItemsProcessed(32*state.iterations());
}

void BM_release(benchmark::State& state) {
    for (auto _ : state) {
        REPEAT(x.store(1, std::memory_order_release);)
    }
    state.SetItemsProcessed(32*state.iterations());
}

void BM_acquire(benchmark::State& state) {
    for (auto _ : state) {
        REPEAT(x.store(1, std::memory_order_acquire);)
    }
    state.SetItemsProcessed(32*state.iterations());
}

void BM_acq_rel(benchmark::State& state) {
    for (auto _ : state) {
        REPEAT(x.store(1, std::memory_order_acq_rel);)
    }
    state.SetItemsProcessed(32*state.iterations());
}

void BM_seq_cst(benchmark::State& state) {
    for (auto _ : state) {
        REPEAT(x.store(1, std::memory_order_seq_cst);)
    }
    state.SetItemsProcessed(32*state.iterations());
}

다양한 메모리 순서를 사용하여 atomic write 연산을 테스트한 결과는 아래와 같다. 하드웨어에 따라 결과가 다를 수 있다.

C++ 메모리 모델에는 단지 아토믹 연산과 메모리 순서만 있는 것이 아니다. 예를 들어, 여러 스레드에서 동시에 배열의 인접한 요소에 액세스하는 것은 안전하다고 가정할 수 있다. 인접한 요소들은 서로 다른 변수들이기 때문이다. 하지만, 이는 언어나 심지어 컴파일러가 채택하는 추가 제한 사항에 의해서 보장되지 않는다. 대부분의 하드웨어 플랫폼에서 정수 배열의 인접한 요소에 액세스하는 것은 실제로 thread-safe하다. 그러나 bool 배열과 같은 크기가 더 작은 데이터 타입의 경우에는 그렇지 않다. 많은 프로세서들은 masked integer write를 사용하여 단일 바이트에 write하는데, 해당 바이트가 포함된 전체 4바이트 워드(word)를 로드하고 바이트를 새 값으로 변경한 다음에 다시 워드를 쓰게 된다. 따라서, 두 프로세서가 동일한 4바이트 워드를 공유하는 2바이트에 대해서 동시에 이 작업을 수행하면 두 번째 write가 첫 번째 write를 덮어쓰게 된다. C++11 메모리 모델에서는 두 스레드가 동일한 변수에 액세스하지 않는 경우, 배열 요소와 같은 고유한 변수에 대한 쓰기는 스레드로부터 안전해야 한다. C++11 이전에는 인접한 두 bool 또는 char 변수에 쓰는 것이 스레드로부터 안전하지 않다는 것을 쉽게 확인할 수 있다. 최근 컴파일러에서는 이와 같은 현상을 재현하기 어려운데, 사용할 C++ 버전을 C++03으로 지정하더라도 컴파일러가 C++11과 동일한 명령어를 사용하기 때문이다.

 

언어와 컴파일러가 메모리 모델을 전부 정의하지는 않는다. 하드웨어도 메모리 모델을 있고, 운영체제와 런타임 환경에도 메모리 모델이 있으며, 프로그램이 실행되는 하드웨어 및 소프트웨어 시스템의 각 구성 요소에도 메모리 모델이 있다. 프로그램에 사용할 수 있는 보장 및 제한은 이러한 모든 메모리 모델의 중첩으로 볼 수 있다. 이식 가능한 C++ 코드는 언어 자체의 메모리 모델에만 의존한다.

 

언어의 메모리 모델과 하드웨어의 메모리 모델의 차이로 인해 두 가지 문제가 발생할 수 있다.

첫째는 특정 하드웨어에서는 감지할 수 없는 버그가 프로그램에 있을 수 있다. 생산자-소비자 예제에서 사용한 acquire-release 프로토콜을 생각해보자. 실수로 생산자 측에서는 release memory order를 사용했지만 소비자 측에서는 relaxed memory order를 사용한 경우 프로그램이 간헐적으로 잘못된 결과를 생성할 것이라고 예상할 수 있다. 하지만 이를 x86 CPU에서 실행하면 올바른 것처럼 보인다. 이는 x86 아키텍처의 메모리 모델에는 모든 store에 release barrier를 수반하고 모든 load에 암시적인 acquire barrier가 있기 때문이다. 이를 ARM 기반 프로세서로 포팅하면 문제가 발생할 수 있다. x86 하드웨어 이와 같은 버그를 찾는 유일한 방법은 GCC 및 Clang에서 사용할 수 있는 TSAN(thread sanitizer)과 같은 도구를 사용하는 것이다.

 

두 번째 문제는 첫 번째 문제의 또 다른 측면에 해당한다. 즉, 메모리 순서에 대한 제한을 완화하는 것이 항상 더 나은 성능을 가져오는 것이 아니다. 방금 살펴본 내용에서 기대할 수 있듯이 write 작업 시 release에서 relaxed memory order로 전환하더라도 x86 프로세서에서는 아무런 이점도 없다. 전체 메모리 모델이 여전히 release order를 보장하기 때문이다.

 

메모리 모델은 메모리 시스템과 어떻게 상호 작용하는지 논의하기 위한 과학적 기초와 공통의 언어를 모두 제공한다. 메모리 장벽(memory barrier)은 프로그래머가 코드에서 메모리 모델의 기능을 제어하는 데 사용하는 실제 도구이다. 종종 이러한 장벽은 락을 사용하여 암시적으로 호출되지만 항상 존재하며, 이를 최적으로 사용하면 고성능 동시 프로그램의 높은 효율성을 달성할 수 있다.

'프로그래밍 > Optimizations' 카테고리의 다른 글

Concurrent Data Structures  (0) 2024.02.03
Lock-Based, Lock-Free, Wait-Free Program  (0) 2024.02.02
Threads and Memory  (0) 2024.01.25
Cache  (0) 2024.01.22
Pipelining & Branch Optimizations  (0) 2024.01.21

댓글