Skip to content

Chapter 05, The Cpp memory model and operations on atomic types

Dongmin Choi edited this page Nov 14, 2015 · 4 revisions

(초반 서문 생략) 이번 장에서는, 메모리 모델의 기초 지식들을 알아보고, atomic 타입들과 Operation들, 그리고 최종적으로는 이것들을 통해 가능한 여러가지 동기화에 대해서 알아보겠습니다. 이것은 매우 복잡합니다. 당신이 이 책의 챕터 7에 나오는 Lock-Free 컨테이너 설계 같은 걸 계획하고 있지 않은 이상, 이러한 세부 내용이 필요하지는 않습니다. 자 그럼 기초 지식부터 시작해 볼까요?

5.1 Memory model basics

메모리 모델에는 두 가지 측면이 있습니다. 첫번째는 기본적인 “구조적” 측면 (어떻게 무언가가 메모리에 적재되느냐에 연관이 있습니다.) 이고, 두번째는 “동시성(concurrency)” 적 측면 입니다. 구조적 측면은 병렬 프로그래밍에서 매우 중요합니다. 당신이 로우 레벨 atomic operation 을 살펴볼 때에는 특히나 중요하죠. 그러니까 구조적 측면부터 시작해보려 합니다. C++에서 구조적 측면이란, ‘객체’와 ‘메모리 공간(memroy location)’ 에 대한 이야기 입니다. (memory location은 기억 장소 대신 메모리 공간으로 번역하였습니다.)

5.1.1 Objects and memory locations

C++ 의 모든 데이터는 ‘객체(object)’ 로 형성되어 있습니다. 이것은 Ruby 나 SmallTalk 처럼 “모든 것은 객체이다” 라고 말하며 int 를 상속 받을 수 있다던지, int 가 멤버 함수를 가진다던지 하는 것 까지는 아닙니다. C++에서는, 객체란 단지 만들어진 데이터 블록의 상태를 의미합니다. C++ 표준은 “객체란 저장 공간의 한 영역이다.” 라고 정의하고 있습니다. 객체의 타입이나 수명(lifetime) 같은 프로퍼티 들이 객체에 할당되어 있더라도 말이죠.

객체는 그 타입이 무엇이든, 메모리 안에 특정 공간에 저장됩니다. 다르게 말하면, 메모리 속의 어떤 특정 위치는 ‘unsigned short’ 같은 스칼라 값이 될 수도 있고, ‘my_class*’ 일 수도 있고, ‘adjacent bit fields(인접한 곳에 위치한 비트 필드들)’ 일 수도 있습니다. 비트필드를 사용할 때 주의할 점은, 이것이 분명히 객체이긴 하지만, 메모리와 똑같이 취급된다는 것입니다. 그림 5.1 은 struct가 어떻게 객체와 메모리로 나뉘는지를 보여줍니다.

첫째로, struct 전체는 하나의 객체입니다. 몇몇 개의 서브 객체로 구성되어 있습니다. 비트필드 bf1 과 bf2는 메모리 공간을 공유하고 있고, std::string 타입의 객체 s 는 내부적으로 여러 개의 메모리 공간으로 구성되어 있지만, 다른 멤버들은 각자 자신의 메모리 공간을 가지고 있습니다. 길이가 0인 비트필드 bf3 이 별도의 메모리 공간으로 bf4와 구분된 것을 유심히 보세요. (Note how the zero-length bit field bf3 separates bf4 into its own memory location.)

이것으로 다음 네 가지를 알 수 있습니다. 모든 변수는 객체이다. 객체는 멤버와 다른 객체들을 포함한다. 모든 객체는 최소한 한 개의 메모리 공간을 차지한다. int 나 char 같은 기본 타입은 그것의 사이즈와는 상관 없이 정확히 한 개의 메모리 공간을 차지한다. 심지어 그것들이 인접해 있거나, 배열의 원소여도 마찬가지 이다. 인접한 곳에 위치한 비트 필드들은 같은 메모리 공간에 위치한다. 이게 Concurrency 랑 무슨 상관인지 궁금하시죠? 자! 한번 보시죠.

5.1.2 Objects, memory locations, and concurrency

자, C++ 멀티스레드 어플리케이션에서 중대한 부분이 여기 있습니다. 모든 것은 메모리 공간에 달려있지요. 만약 두 개의 스레드가 분리된 메모리 공간에 접근하면, 아무런 문제도 일어나지 않습니다. 모든 것이 잘 돌아가죠. 반면에, 만약 두 개의 스레드가 같은 메모리 공간에 접근한다면, 조금 조심할 필요가 있습니다. 만약 두 스레드 모두 메모리 공간을 수정(update)하지 않는다면, 안심하셔도 좋습니다. read-only 데이터는 동기화나 보호가 필요하지 않거든요. 반면에 두 스레드가 모두 데이터를 수정하려 한다면, 그것은 챕터3에서 말했던 경쟁 상태(race condition) 일 가능성이 있습니다.

race condition 을 피하려면, 두 스레드의 접근 순서를 정해야 합니다. 그 방법 중 하나는 이미 챕터 3에서 보셨던 뮤텍스(mutex)를 사용하는 것입니다. 만약 스레드들이 데이터에 접근하기 전에 같은 뮤텍스를 잠근다면, 어떤 시점에 단 하나의 스레드만이 메모리에 접근할 수 있습니다. 그러므로 두 개의 접근 중 하나가 반드시 먼저 일어납니다. 다른 방법 중 하나는 원자적 연산(atomic operation) 을 사용하는 것입니다. 원자적 연산을 이용하여 스레드 간의 순서를 정하는 방법은 섹션 5.3에서 알아보겠습니다. 이보다 많은 (두 개 초과) 스레드가 같은 메모리 공간에 접근한다면, 각각의 접근이 반드시 순서가 정해져 있어야 합니다.

정리하자면, 만약 두 스레드가 한 메모리 공간을 접근하려 할 때, 스레드 간에 순서가 정해져 있지 않거나, 둘 중 하나라도 접근이 원자적(atomic)이지 않고, 마지막으로 둘 중 하나라도 쓰기 연산일 경우, 이것은 data race 이며, 그것은 곧 미정의 동작(undefined behavior)으로 이어집니다.

이 상황은 매우 중요합니다. 미정의 동작은 C++의 가장 끔찍한 부분 중 하나이기 때문입니다. 언어 표준에 따르면, 어떤 어플리케이션이 한 번 미정의 동작을 하면, 망한 거나 다름없습니다. 완료된 어플리케이션의 동작이 당장 정의되지 않고, 어떤 일이라도 일어날 수 있습니다. 내가 알고 있는 미정의 동작의 한 예는 당신의 모니터에 갑자기 불이 붙는 것입니다. 이것이 당신에게 일어날 확률은 적지만, 데이터 레이스는 분명히 심각한 버그이며, 무슨 수를 써서라도 피해야 할 상황입니다.

여기서 또 중요한 포인트가 하나 더 있습니다. 당신은 또한 데이터 레이스와 관련된 메모리 공간에 접근하는 것으로부터 야기되는 미정의 동작을 원자적 연산을 이용하여 피해야 합니다. 원자적 연산을 사용하는 것만으로는 데이터 레이스를 근본적으로 막을 수는 없지만, 프로그램을 정의된 동작의 영역으로 되돌릴 수는 있습니다.

우리가 원자적 연산을 보기 전에, 한가지 더 알아야 할 개념이 있습니다. 이것은 객체와 메모리 공간을 이해하는데 중요한 개념입니다. 바로, ‘수정 순서(modification orders)’입니다.

5.1.3 Modification orders

C++의 모든 객체는 정의된 ‘수정 순서’를 가집니다. 수정 순서란, 한 프로그램 안의 모든 스레드가 그 객체를 Write 하려고 하는 모든 동작으로 구성되어 있습니다. 객체를 초기화하는 것을 포함해서 말이죠. 대개의 경우 이 명령은 프로그램이 실행되는 도중에라도 다를 수 있습니다. 하지만 어떤 프로그램이라도, 어떤 시스템의 모든 스레드는 반드시 그 수정 순서에 동의해야 합니다. 만약 그 객체가 섹션 5.2에 서술된 원자적 타입 중 하나가 아니라면, 당신은 그 스레드들이 각각의 변수에 대한 수정 순서에 동의하는 것을 보장하도록 충분한 동기화를 확실히 할 책임이 있습니다. 만약 서로 다른 스레드들이 한 변수 값의 분명한 시퀀스를 본다면, 당신은 섹션 5.1.2에서 보았던 데이터 레이스와 미정의 동작을 모두 갖고 있는 것입니다.만약 당신이 원자적 연산을 사용한다면, 컴파일러는 필요한 동기화를 그것이 있어야 할 곳에 있도록 할 책임이 있습니다.

이 요구사항은, 어떠한 약간의 추측에 근거한 실행도 허용되지 않는다는 것을 의미합니다. 왜냐하면, 한 스레드가 수정 순서 중에 특정한 엔트리를 보면, 그 차후의 읽기 연산은 반드시 그 이후의 값을 반환(return)해야 하고, 이후의 쓰기 연산은 반드시 그 수정 순서 다음에 일어나야 합니다. 또한, 하나의 객체를 한 스레드 내에서 쓰기 연산에 이어서 객체를 읽는 것은, 바로 이전에 쓰여진 (자기가 쓴) 값을 리턴하거나, 그 객체의 수정 순서 이후에 발생한 다른 값을 반환해야 합니다. 비록 모든 스레드들이 반드시 그 프로그램에서 각 객체의 수정 순서에 동의해야 할 지라도, 그 스레드들은 어쩔 수 없이 별개의 다른 객체의 상대적인 연산 명령에는 동의할 의무가 없습니다. 다른 스레드들 간의 연산 순서에 대해서는 섹션 5.3.3 을 보세요. (See section 5.3.3 for more on the ordering of oper- ations between threads.) 자 그럼, 무엇이 원자적 연산일까요? 그리고 어떻게 이것들로 순서를 확실히 정할 수 있을까요?

5.2 Atomic operation and types in C++

원자적 연산(atomic operation)이란 더 이상 나눌 수 없는 연산을 말합니다. 당신이 시스템 내의 어떤 쓰레드로부터 연산이 반만 된 상태를 관측할 수 없다는 뜻이죠. 그 상태는 “되거나, 안되거나” 둘 중 하나입니다. 만약 어떤 객체로부터 값을 읽는 load 연산이 원자적 이고, 그 객체에 대한 모든 수정 연산들도 원자적 이라면, 그 load 연산은 아무런 수정 연산도 적용되기 전의 초기값을 반환하거나, 수정 연산들 중 하나가 저장한 값을 리턴하게 됩니다.

반면, 비원자적(nonatomic) 연산은 다른 스레드로부터 연산이 반만 된 상태가 보여질 수 있습니다. store 연산의 경우, 값을 쓰는 도중 다른 쓰레드에서 그 값을 보면, “쓰기 전 상태”도 아니고 “쓴 후의 상태”도 아닌 전혀 다른 값일 수 있다는 것입니다. 만약 그 비원자적 연산이 load 라면, 이 연산은 객체의 값 일부만을 리턴할 수도 있습니다. “쓰기 전 상태”도 아니고 “쓴 후의 상태”도 아닌, 그 둘이 겹쳐진 어떤 값을 말이죠. 이 상태를 “데이터 레이스(data race)” 라고 하며, 이 상태는 미정의 동작을 유발하게 됩니다.

C++ 에서, 대부분의 경우 당신은 원자적 연산을 위해 원자적 타입을 사용하게 될 것입니다. 자 원자적 타입들을 한번 봐 볼까요?

###5.2.1 The Standard atomic types 표준 원자적 타입들(atomic types)은 헤더에 있습니다. 여기 있는 타입들의 모든 연산은 원자적입니다. 그리고 오로지 이 타입들에 대한 연산만 원자적이죠. 비록 뮤텍스를 이용해서 다른 연산들도 원자적으로 보이게 할 순 있지만요. 사실상, 표준 원자적 타입들 자체는 에뮬레이션 처럼 사용됩니다. 그들은 거의 모두가 is_lock_free() 멤버함수를 가지고 있습니다. 이 함수는 유저들이 주어진 타입에 대한 연산이 원자적인 명령인지 아닌지를 결정할 수 있게 해줍니다. is_lock_free() 가 true 를 리턴하면, 주어진 타입에 대한 연산이 atomic하게 즉시 완료될 수 있다는 뜻이고, false 를 리턴하면 컴파일러나 라이브러리에서 내부적으로 lock을 사용하는 연산이라는 뜻입니다.

is_lock_free() 멤버 함수를 제공하지 않는 유일한 타입은 std::atomic_flag 입니다. 이 타입은 아주아주 간단한 Boolean Flag 이고, 타입에 대한 연산은 lock-free 여야 합니다. 이 타입이 is_lock_free() 를 제공하지 않는 이유는, 있을 필요가 없어서 입니다. 제가 “아주아주 간단하다” 라고 말한 것은 다음과 같은 의미입니다. atomic_flag의 객체는 clear 로 초기화 된 이후, “물어보고 세팅하기(test_and_set() )” 랑 “초기화하기(clear() )” 밖에 안합니다. 얘는 배정(assignment)도 없고, 복사 생성자도 없고, “물어보고 초기화하기”도 없고, 그 밖의 다른 어떤 연산자도 가지지 않습니다.

남아있는 원자적 타입들은 모두 std::atomic<> 템플릿 클래스를 특수화 하여 사용합니다. 이것들은 좀 더 많은 기능들이 있지만, lock-free 하지 않을 수도 있습니다. 인터페이스에서 볼 수 있듯, 각각의 타입으로 특수화된 클래스들은 그 타입들의 특성을 반영합니다. 그런데 &=같은 비트 연산자들은 지원되지 않아요.

추가로 테이블 5.1을 보시면, std::atomic<> 템플릿 클래스를 미리 특수화 해놓은 타입들을 볼 수 있습니다. atomic_bool 은 std::atomic 과 동일합니다. 한 프로젝트에서 이 두 가지의 이름을 혼용하는 것은 이식성이 떨어뜨릴 수 있습니다. 앞서 보신 타입들만큼, C++ 표준 라이브러리는 size_t 같은 비원자적 타입을 위한 typedef들과 대응하는, 원자적 타입을 위한 typedef 들도 잘 정의해 두었는데요. 표 5.2에서 확인하실 수 있습니다.

타입이 정말 많네요! 보다 심플한 패턴은 이겁니다. 앞에 ‘atomic_‘ 을 붙이는 거죠. prefix 가 atomic_ 이니까, size_t 는 atomic_size_t가 됩니다. 쉽죠? signed 와 unsigned는 각각 s, u 로, long long 은 llong 으로 바뀝니다. std::atomic 는 atomic_uint 가 되죠. 근데 일반적으로 std::atomic 로 쓰는게 간편합니다.

표준 원자적 타입들은 관습적인 복사나 배정이 불가능합니다. 얘들은 복사 생성자나 복사 배정 연산자가 없죠. 하지만, load() 와 store() 멤버 함수들 통해 배정을 지원하고, exchange(), compare_exchange_weak(), compare_exchange_strong() 을 통해 암시적인 교환을 지원합니다. 얘들은 또 +=, -=, *=, |= 같은 축약형 배정 연산자들도 지원합니다. 숫자 타입이나 포인터를 특수화한 타입들에는 ++나 --도 지원하죠.

이러한 연산자들은 또한 1:1로 대응하는 멤버 함수도 가지고 있습니다. fetch_add(), fetch_or() 같은 친구들이죠. += 연산자와 fetch_add() 간에는 작은 차이점이 있는데, += 연산자는 연산 이후 저장된 값을 리턴하며, fetch_add() 는 연산 전의 값을 리턴합니다. (즉, += 연산자는 ++i 처럼 전위 증가, fetch_add() 는 i++ 처럼 후위 증가라고 볼 수 있습니다.) 이것으로 일반적인 습관으로부터 비롯될 수 있는 예방 가능한 잠재적인 문제를 피할 수 있습니다. 배정 연산자가 할당되는 객체의 레퍼런스를 리턴 해버리는 것 같은 문제를요. 레퍼런스로부터 저장된 값을 가져오기 위하여, 그 코드는 별개의 읽기 연산를 수행해야 할 것이고, 이것은 다른 스레드가 배정 연산과 읽기 연산 사이에 그 값을 수정해 버리는 것을 허용하게 됩니다. 경쟁 상태로 가는 문이 열리는 것이죠.

std::atomic<> 클래스 템플릿은 단지 특수화의 집합이 아닙니다. 기반이 되는 템플릿(주 템플릿, primary template)이 있습니다. 이 템플릿은 사용자 정의형(user-defined type)의 원자적 타입을 생성하는 데 사용될 수 있습니다. 이것이 제네릭 클래스 템플릿이기 때문에, 그것의 연산들은 다음 다섯가지로 한정되어 있습니다. 바로 load(), store() (그리고 사용자 정의형으로의 교환과 사용자 정의형으로부터의 배정 연산), exchange(), compare_exchange_weak(), 그리고 compare_exchange_strong() 입니다.

원자적 타입의 각각의 연산들은 선택 가능한 memory-ordering 매개변수를 가지고 있습니다. 이것들은 요구되는 memory-ordering 의미론(sementics)를 명시하는데 사용될 수 있습니다. 쉽게 말해 어떤 memory-ordering 을 사용할 건지 컴파일러에게 알려주는 겁니다. 좀 더 자세한 설명은 섹션 5.3 에서 다루겠습니다. 지금은, 이 연산들을 세 가지 카테고리로 나누어 보기만 하죠.

  • Store 연산은 memory_order_relaxed, memory_order_release, 혹은 memory_order_seq_cst 를 가질 수 있습니다.

  • Load 연산은 memory_order_relaxed, memory_order_comsume, 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 연산을 가질 수 있습니다.

디폴트 ordering 은 memory_order_seq_cst 입니다.

자 이제 당신이 사용할 수 있는 표준 원자적 타입을 한번 볼까요? std::atomic_flag 부터 시작하죠.

5.2.2 Operations on std::atomic_flag

std::atomic_flag 는 Boolean flag 에 해당하는 타입으로, 표준 원자적 타입 중 가장 단순한 타입입니다. 이 타입의 객체들은 set 혹은 clear 중 하나의 상태만 가질 수 있습니다. 이 타입은 기본이며, 구성 요소로 사용되기 위해 만들어졌습니다. 엄밀히 말하면, 저는 이 타입을 현업에서 사용할 거라곤 생각치 않습니다. 그렇기는 하지만, std::atomic_flag는 다른 원자적 타입들을 논하기 위한 시작점 입니다. 왜냐하면 이것은 다른 원자적 타입들에 적용될 범용적인 법칙을 나타내기 때문입니다. std::atomic_flag의 객체들은 반드시 ATOMIC_FLAG_INIT으로 초기화되어야 합니다. 이것은 플래그를 clear 상태로 초기화합니다. 다른 방법은 없습니다. 이 객체의 상태는 항상 clear로 시작되어야 합니다.

std::atomic_flag f = ATOMIC_FLAG_INIT;

이것은 객체가 어떻게 선언되었든, 어떤 스코프를 가졌든 상관없이 적용됩니다. 이것은 유일하게 초기화에 특별한 처리가 필요한 원자적 타입이며, lock-free 임이 보장되는 유일한 타입이기도 합니다. std::atomic_flag 가 static 으로 선언되면, 초기화 순서에 관한 문제가 발생하지 않습니다.

플래그를 초기화 한 이후, 당신이 이것으로 할 수 있는 행동은 세 가지 입니다. 파괴하거나, 다시 초기화(clear) 하거나, 혹은 다른 값을 세팅하면서 이전 값을 가져오는 것입니다. 이것은 각각 소멸자(destructor), clear() 멤버함수, 그리고 test_and_set() 멤버 함수와 대응됩니다.

clear() 와 test_and_set() 멤버 함수는 앞서 말씀드렸던 memory-ordering 을 매개변수로 취할 수 있습니다. clear() 는 store 연산이므로, memory_order_acquire 혹은 memory_order_acq_rel 의미론을 취할 수 없습니다. 그러나 test_and_set() 은 read-modify-write 연산이므로, 제공되는 모든 memory-ordering 태그를 취할 수 있습니다. 다른 모든 원자적 연산들 처럼, 두 함수의 기본 값(default)은 memory_order_seq_cst 입니다. 예를 들어볼까요?

f.clear(std::memory_order_release);
bool x = f.test_and_set();

여기서, clear() 호출은 명시적으로 “이 플래그를 초기화할 때 release 의미론을 사용하라” 라고 요구하고 있습니다. test_and_set() 호출은 기본 momory-ordering을 사용하고 있죠. std::atomic_flag는 복사생성 될 수 없고, 배정(assign)될 수도 없습니다. 복사생성과 배정은 항상 두 개의 객체와 연관되어 있습니다. 두 객체에 대해서, 한 쪽에서 값을 read 하고, 다른 쪽에 write 하는 두 개의 연산을 수행해야 하죠. 두 개의 객체에 대한 두 개의 연산을 조합하는 것이 원자적일 수 없기 때문에, 이 복사생성자와 배정연산자는 허용되지 않습니다.

std::atomic_flag은 스핀락 뮤텍스같은 곳에서 사용하기에 안성맞춤입니다. 최초로 플래그는 clear이며, 뮤텍스는 잠기지 않은 상태입니다. 뮤텍스를 잠그기 위해서는, test_and_set() 을 반복해야 합니다. 언제까지? test_and_set() 이 false를 리턴할 때까지. false를 리턴한다는 것은, 내가(이 스레드가) 플래그의 값을 true로 세팅하는 데 성공했다는 뜻입니다. 플래그가 true가 된 이후 시도되는 test_and_set()은 플래그의 값은 true로 유지한 채 true를 리턴합니다. 뮤텍스의 잠금을 해제하는 것은 간단합니다. 플래그를 clear 해주면 됩니다. 바로 아래처럼 구현하시면 됩니다.

Listing 5.1 Implementation of a spinlock mutex using std::atomic_flag
class spinlock_mutex
{
    std::atomic_flag flag;
public:
    spinlock_mutex():
        flag(ATOMIC_FLAG_INIT)
    {}
    void lock()
    {
        while(flag.test_and_set(std::memory_order_acquire));
    }

    void unlock()
    {
        flag.clear(std::memory_order_release);
    }
};

이런 뮤텍스는 매우 기초적이지만, std::lock_guard<> 와 사용하기에 충분합니다. 이 뮤텍스의 성질 상 lock() 함수 내에서 바쁜대기(busy-wait)를 하기 때문에 그리 좋은 선택이라곤 할 수 없으나, 상호배제를 보장하긴 합니다.

std::atomic_flag는 매우 제한적입니다. 심지어 일반적인 bool 보다도 활용도가 낮습니다. 왜냐하면 getter가 없어서, 값을 수정하지 않고서는 현재 값을 알아낼 수 없기 때문이죠. getter가 필요하다면 atomic을 쓰시는 게 낫습니다. 다음으로 이 걸 한번 다뤄보죠.

5.2.3 Operation on std::atomic

std::atomic은 비록 복사생성되거나 복사배정될 순 없지만, 당신은 이 객체를 비원자적 bool 로부터 초기화 될 수 있습니다. 이것은 초기값으로 false, true 모두 취할 수 있다는 걸 의미합니다. 그리고 복사 뿐 아니라 배정도 가능하죠.

std::atomic<bool> b(true);
b = false;

여기서 주목할 점은 std::atomic의 배정 연산자가 비원자적 bool의 그것과 조금 다르다는 것입니다. 일반적인 배정 연산자는 참조를 리턴하는데, std::atomic은 값을 리턴합니다. 이것은 원자적 타입의 또다른 일반적인 패턴입니다.

atomic 의 쓰기 연산은 std::atomic_flag의 clear()와는 달리 true 든 false 든 관계없이 store() 멤버 함수를 호출하는 것으로 이루어집니다. memory-order 의미론은 여전히 명시될 수 있지만요. 비슷하게, test_and_set() 은 더 일반적인 exchange() 멤버 함수로 대체됩니다. 이 함수를 통해, 객체의 값을 당신이 원하는 값으로 교체할 수 있으며, 원자적으로 원래의 값을 반환합니다.

std::atomic은 load() 멤버 함수 혹은 bool 로의 암시적 변환을 통해 “수정하지 않고 읽기”를 지원합니다. 당신이 예상한 대로, store() 는 저장 연산, load() 는 읽기 연산, exchange() 는 read-modify-write 연산입니다.

std::atomic<bool> b;
bool x=b.load(std::memory_order_acquire);
b.store(true);
x=b.exchange(false,std::memory_order_acq_rel);

exchange() 는 std::atomic에서 지원하는 유일한 read-modify-write 연산이 아닙니다. 이제 또 다른 연산을 소개합니다. 이 연산은 현재 값이 예상한 값과 같을 때에만 새로운 값을 저장하는 연산입니다. (Compare And Swap. CAS 라고도 합니다.)

STORING A NEW VALUE (OR NOT) DEPENDING ON THE CURRENT VALUE

이 새로운 연산은 비교/교체(compare/exchange) 라고 불리우며, compare_exchange_weak(), compare_exchange_strong() 멤버 함수로 구현되어 있습니다. 비교/교체 연산은 원자적 타입을 이용한 프로그래밍의 초석입니다. 이 연산을 위해서는 세 개의 값이 필요합니다. 하나는 원자적 타입의 변수이고, 또 하나는 “내가 예측한 값”, 나머지 하나는 “내가 저장하고자 하는 값” 입니다. 이 연산은 이런 식으로 이루어집니다. 원자적 변수의 현재 값을 “내가 예측하는 값”과 비교하여, 두 값이 같을 때에만 “내가 저장하고자 하는 값”을 저장하는 것입니다. 만약 두 값이 다르면, “내가 예측한 값”이 원자적 변수의 실제 값으로 갱신되고, 원자적 변수 값의 교체는 일어나지 않습니다. 비교/교체 함수들의 리턴 타입은 bool 이며, 교체가 성공했을 때에는 true, 실패하면 false를 리턴합니다.

compare_exchange_weak() 에서는, 원래 값과 예측 값이 같더라도 저장 연산이 성공하지 못할 수도 있습니다. 이 경우 변수 값이 변하지 않고, 함수의 리턴값은 false 가 됩니다. 이것은 대부분 compare-and-exchange 명령을 지원하지 않는 머신에서 일어납니다. 프로세서가 이 명령이 원자적으로 완료되는 것을 보장하지 않을 경우 말이죠. 프로세서의 개수보다 더 많은 스레드가 있는 상태에서, 운영체제에 의해 현재 스레드가 compare-and-exchange 도중 다른 스레드로 컨텍스트 스위칭 당할 수 있기 때문에 위와 같은 문제가 발생하게 됩니다. 이것을 “가짜 실패(spurious failure)” 라고 부릅니다.

compare_exchange_weak() 는 가짜로 실패할 수 있기 때문에, 이 함수는 특별히 루프 안에서 사용되어야 합니다.

bool expected=false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected,true) && !expected);

이 경우, expected가 계속 false인 동안에도 루프를 유지하게 됩니다. expected가 false라는 것은 가짜 실패가 일어났다는 뜻입니다. (진짜 실패라면 expected 가 true로 갱신되었겠죠?) 반면, compare_exchange_strong()은 원자적 변수의 실제 값이 예측 값과 다를 때에만 false를 리턴하는 것이 보장됩니다. 이것으로 인해 compare_exchange_weak() 처럼 값이 성공적으로 교체되었는지, 아니면 다른 스레드가 치고 들어왔는지 알기 위해 루프를 돌 필요가 없습니다.

만약 초기값이 무엇이든 상관없이 원자적 변수의 값을 바꾸고 싶다면, 예측 값(위 코드에서는 expected)이 업데이트 되는 것을 유용하게 사용할 수 있습니다. 매 루프마다 expected가 실제 값으로 업데이트 되므로, 다른 스레드가 그동안 값을 수정하지 않았다면 다음 루프에서 compare_exchange_weak() 나 compare_exchange_strong()은 성공적으로 호출될 것입니다. 만약 저장되어야 할 변수에 대한 계산이 간단하다면, compare_exchange_weak()가 가짜 실패를 할 수 있는 환경에서는 두 번 루프를 도는 것을 피하기 위해 compare_exchange_weak()를 사용하는 것이 이득입니다. (그래서 compare_exchange_strong() 은 루프를 포함하고 있습니다.) 반면 저장되어야 할 변수에 대한 계산이 시간을 많이 잡아먹는다면, expected 값이 변하지 않았을때 저장할 값을 재계산 하는 것을 피하기 위해 compare_exchange_strong() 을 사용하는 편이 낫습니다. std::atomic 에서는 이 선택이 별로 중요하지 않습니다. 가질 수 있는 상태가 false 아니면 true 밖에 없거든요. 하지만 더 큰 원자적 타입에 대해서는 이 선택이 차이를 만들 수 있습니다.

비교/교체 함수들은 또한 두 개의 memory-ordering 파라미터를 취할 수 있다는 점이 특징입니다. 이 함수들은 함수 호출이 성공했을 때와 실패했을 때의 memory-ordering 의미론을 다르게 적용할 수 있습니다. 성공 시에는 memory_order_acq_rel을 적용하고, 실패 시에는 memory_order_relaxed 의미론을 적용할 수 있으니, 바람직합니다. 하지만 실패 시에만 memory-ordering을 지정하는 것은 불가능합니다. 또한 실패 시에, 성공했을 때보다 더 강력한 memory-ordering을 적용할 수도 없습니다. 만약 당신이 memory_order_acquire 나 memory_order_seq_cst 를 실패 시에 적용하기 원한다면, 당신은 성공 시에도 반드시 이보다 같거나 높은 것을 명시해주어야 합니다.

만약 실패 시에만 ordering을 명시하지 않는다면, 성공 시의 ordering과 같은 것으로 추정됩니다. 다만, 실패 시에는 release 의미론을 적용할 수 없기 때문에, memory_order_release는 memory_order_relaxed가 되며, memory_order_acq_rel 은 memory_order_acquire 가 됩니다. 만약 성공 시에도 ordering을 명시하지 않는다면, 다른 원자적 타입들처럼 성공 시와 실패 시 모두 memory_order_seq_cst가 됩니다. 이 ordering은 두 경우 모두에게 “full sequential ordering(직역하자면 완전히 순차적인 ordering)” 을 제공합니다. 다음 두 개의 compare_exchange_weak() 호출들은 동등합니다.

std::atomic<bool> b;
bool expected;
b.compare_exchange_weak(expected,true,
    memory_order_acq_rel,memory_order_acquire);
b.compare_exchange_weak(expected,true,memory_order_acq_rel);

어떤 memory-ordering의 선택에 대한 논의는 5.3에서 다루겠습니다.

atomic_flag와 std::atomic의 또다른 차이점 하나는 std::atomic은 lock-free가 아닐 수도 있다는 점입니다. 연산들의 원자성을 보장하기 위해 std::atomic의 내부 구현은 뮤텍스를 필요로 할 수도 있기 때문입니다. std::atomic이 진짜로 내부적으로 뮤텍스를 사용하는지, 아니면 lock-free인지 확인하려면, is_lock_free() 멤버 함수를 사용하면 됩니다. 이 멤버 함수 역시 atomic_flag을 제외한 모든 원자적 타입들의 공통 특징입니다.

이 다음으로 제일 간단한 원자적 타입은 포인터를 특수화 한 std::atomic<T*>타입입니다. 다음으로 이 것을 보도록 하지요.

5.4.2 Operations on std::atomic< T* >: pointer arthmetic

어떤 타입 T의 포인터가 있습니다. 이 포인터의 원자적 형태는 std::atomic< T* > 입니다. 인터페이스는 근본적으로 앞서 보신 std::atomic과 동일합니다만, 추가로 포인터 타입이 할 수 있는 연산들을 제공합니다. std::atomic처럼 복사생성자와 복사 배정 연산자가 없으나, 일반 포인터 타입으로부터 생성되거나 대입될 수 있습니다. std::atomic< T* > 는 의무적으로 is_lock_free(), load(), store(), exchange(), compare_exchange_weak(), 그리고 compare_exchange_strong() 멤버 함수를 가지고 있습니다.

이 책에서 std::atomic< T* >를 통해 새롭게 알려드릴 연산들은 바로 포인터 산술 연산들입니다. 기본이 되는 연산들은 저장된 주소에 더하기와 빼기 연산을 원자적으로 수행하는 연산들 입니다. fetch_add() 와 fetch_sub() 멤버 함수, += -= 연산자, 그리고 전위, 후위 ++, -- 연산자를 통해 더하기 빼기 연산을 할 수 있습니다. 이 연산자들은 딱 당신이 예상한 것 처럼 동작합니다. 생 포인터 타입의 연산과 동일하죠. 생 포인터에서 볼 수 없는 함수인 fetch_add()와 fetch_sub() 멤버 함수는, 내부적으로 exchange() 나 compare_exchange_weak(), compare_exchange_strong() 같은 read-modify_write 연산이며, exchange-and-add 연산으로 불리웁니다. fetch_add()의 리턴 값은 더하기 전의 값과 같습니다. (a 의 초기값이 0일 때, a.fetch_add(3)을 하면 리턴값은 0, a의 값은 3). 후위 증가와 같죠. 그래서 다음 코드는 assert 없이 실행됩니다.

class Foo{};

Foo some_array[5];

std::atomic< Foo* > p(some_array);

Foo* x=p.fetch_add(2);

assert(x==some_array);

assert(p.load()==&some_array[2]);

x=(p-=1);

assert(x==&some_array[1]);

assert(p.load()==&some_array[1]);

또한 fetch_xxx() 멤버 함수는 매개 변수를 통해 memory-ordering 의미론을 허용합니다. p.fetch_add(3, std::memory_order_release); 이 멤버 함수들은 read-modify-write 연산이기 때문에, 모든 memory-ordering 태그를 취할 수 있습니다.

오버로딩된 연산자들은 memory-ordering을 지정하는 것이 불가능합니다. 왜냐하면 연산자에는 태그를 지정할 방법이 없기 때문이죠. 그래서 연산자들은 모두 memory_order_seq_cst 의미론을 따릅니다.

남아있는 기본 원자적 타입들은 근본적으로 모두 같습니다. 모두 숫자 타입이며, 모두 같은 인터페이스를 가지고 있습니다.

5.2.5 Operations on standard atomic integral types

원자적 타입의 공통 멤버함수 집합 (load(), store(), exchange(), compare_exchange_weak(), compare_exchange_strong()) 들 처럼, std::atomic or std::atomic 같은 원자적 숫자 타입들도 완전히 포괄적인 연산 집합을 가집니다. 바로 fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor() 멤버함수들과, 그것의 합성-대입 연산자(compound-assignment)들(+=, -=, &=, |=, ^=), 그리고 전위 후위 증가 감소 (++, --) 들 입니다. 나누기, 곱하기, 그리고 쉬프트 연산자를 제외한 모든 합성-대입 연산자들을 가지고 있습니다. 원자적 숫자 타입들은 보통 카운터나 비트 마스크로 사용되기 때문에, 나누기 같은 것 쯤 못한다고 해서 별 문제가 되지는 않습니다. 만약 이런 연산들이 필요하다면, compare_exchange_weak() 루프를 통해서 쉽게 할 수 있습니다.

이 의미론들은 std::atomic<T*>에서 보았던 fetch_add(), fetch_sub() 멤버 함수들과 거의 비슷합니다. 이름 있는 함수들은 연산을 원자적으로 수행한 뒤, 이전 값을 리턴합니다. 그런데 합성 연산자들은 은 새 값을 리턴합니다. 전위, 후위 증가 연산자 및 감소 연산자는 원래대로(int와 동일하게) 작동합니다. 전위 증가(++i) 는 새 값을 리턴하고, 후위 증가 (i++)는 이전 값을 리턴합니다.

이제 범용 std::atomic<> 기본 클래스 템플릿(generic std::atomic<> primary class template)만 남았습니다. 다음으로 살펴보지요.

5.2.6 The std::atomic<> primary class template

기본 템플릿을 이용하여 사용자 정의 자료형의 원자적 변수를 생성할 수 있습니다. 심지어 표준 원자적 타입으로 말이죠. 이 타입은 아직 확실한 표준이 완성되지 않아서, 어떠한 타입이든 상관없이 std::atomic<>으로 선언할 수는 없습니다. std::atomic (UDT : user-defined type) 를 사용하기 위해서는, UDT가 자명한 복사 배정 연산자를 가져야 합니다. 이것이 무엇을 의미하냐면, 그 타입이 절대로 가상 함수나 가상 베이스 클래스를 사용하면 안되고 반드시 컴파일러가 만든 복사 배정 연산자를 사용해야 한다는 뜻입니다. 그뿐 아니라, 모든 베이스 클래스 클래스들과 static으로 선언되지 않은 멤버 변수들도 역시 자명한 복사 배정 연산자를 가져야 합니다. 이것은 배정 연산을 위해, 컴파일러가 memcpy() 혹은 동등 비교연산 하는 것을 허용합니다. 왜냐하면 그곳에서는 유저가 작성한 코드를 실행할 수 없기 때문입니다.

마지막으로, 그 타입은 반드시 비트 동등 비교가 가능(bitwise equality comparable)해야 합니다. 이것은 내부적으로 memcpy() 뿐만이 아니라 memcmp() 도 사용하기 때문입니다. 이러한 보증은 비교/교체 연산 을 수행하기 위해 요구됩니다.

이러한 제약들의 이유는 챕터3의 가이드라인으로부터 비롯되었습니다. 바로 “lock으로 보호된 데이터 내부의 포인터와 레퍼런스를 사용자가 제공하는 함수의 매개변수로 전달하지 마라.” 라는 가이드라인 입니다. 일반적으로, 컴파일러는 std::atomic에 대한 lock-free 코드를 생성할 수 없습니다. 그러므로 모든 연산에 대해 내부적으로 락을 사용하게 됩니다. 만약 사용자가 제공하는 복사 배정 연산자나 비교 연산자를 허용하면, 이 가이드라인을 어기는 것이 됩니다. 또한, 라이브러리는 모든 원자적 연산에 대해 필요하다면 언제든지 하나의 락을 사용할 자유가 있는데, 이 때 유저가 제공하는 함수가 호출된다면 데드락이나 다른 스레드의 블록을 야기할 수 있습니다. 마지막으로, 이러한 제약들은 컴파일러가 std::atomic에 원자적인 명령들을 사용할 “기회”를 증가시킵니다. 이러면 특정한 인스턴스화는 lock-free가 됩니다. 이렇게 되는 이유는 UDT를 “생 바이트의 집합(set of raw byte)”으로 다룰 수 있기 때문입니다.

당신이 std::atomic 이나 std::atomic을 사용할 때 주의할 점이 있습니다. 기본 부동 소수점 타입들은 memcpy() 와 memcmp() 에 안전하기 때문에 std::atomic<>의 템플릿 파라미터로 사용될 수 있지만, 이 친구들의 compare_exchange_strong() 동작에는 깜짝 놀라실 수도 있습니다. 이 연산은 저장된 값과 비교 값이 같을 때에도 실패할 수 있습니다. 저장된 값이 다른 대표값(representation)을 가질 경우 말이죠. 부동 소수점 값을 위한 원자적 수식 연산은 없다는 것을 기억해 주세요. 비교 연산자를 사용자가 만든 UDT를 std::atomic<>와 함께 사용했을 때에도 비슷한 일이 일어납니다. 비트 수준 비교 결과와 사용자 정의 비교 결과가 다르기 때문에 발생하는 일입니다.

만약 UDT가 int나 void* 보다 작거나 같은 사이즈라면 대부분의 플랫폼에서는 std::atomic에서 원자적인 명령어 사용이 가능할 것입니다. 그런데 어떤 플랫폼에서는 int나 void* 사이즈의 두 배까지 원자적 명령어 사용이 가능합니다. 이것을 double-word-compare-and-swap(DWCAS) 명령이라고 부릅니다. 챕터 7에서 볼 수 있듯, 이러한 명령들은 lock-free코드를 만드는데 도움을 줄 수 있습니다.

이번 장에서 본 제약들은 당신이 할 수 없는 것들을 나타냅니다. 예를들면 std::atomic<std::vector> 를 만드는 것이 불가능하다는 것이죠. 하지만 당신은 이것을 객체 카운터나, 플래그나, 포인터나, 심지어 간단한 데이터로 이루어진 배열에도 사용할 수 있습니다. 이것은 특별히 문제가 되지 않습니다. 더 복잡한 자료구조, 배정과 비교 연산보다 복잡한 연산에는 뮤텍스를 사용하는 것이 낫습니다.

사용자 정의형 T로 std::atomic를 인스턴스화 하면, 그 인터페이스는 std::atomic과 같아집니다. load(), store(), exchange(), compare_exchange_weak(), compare_exchange_strong(), 그리고 T의 인스턴스로부터의 배정연산 뿐입니다. Table 5.3은 각각의 원자적 타입이 사용할 수 있는 연산들을 보여줍니다.

5.2.7 Free function for atomic operation

지금까지는 원자적 타입의 멤버 함수들만 알아보았습니다. 그러나 모든 연산에는 그와 동등한 비멤버함수가 있습니다. 이러한 함수들의 대부분은, 멤버함수의 이름 앞에 atomic_ 접두어를 붙인 형태로 존재합니다. (예를 들면, std::atomic_load()). 이러한 함수들은 각각의 원자적 타입에 대해 오버로딩 되어있습니다. 또한 memory-ordering 태그를 명시하기 위해, _explicit 접미어를 붙인 함수 형태도 제공합니다. (예를 들면, std::atomic_store(&atomic_var, new_value) VS std::atomic_store_explicit(&atomic_var, new_value, std::memory_order_release). 멤버 함수로는 this포인터를 통해 암시적으로 원자적 객체를 참조했었지만, 이 함수들은 원자적 객체의 포인터를 직접 첫번째 매개변수로 취함으로써 참조합니다.

예를들어, std::atomic_is_lock_free() 는 한 가지의 형태밖에 없습니다. (비록 각 원자적 타입에 대해 오버로딩 되어있지만요) 바로 std::atomic_is_lock_free(&a) 의 형태인데요, 이것은 a.is_lock_free()와 동일한 결과를 반환합니다. 마찬가지로 std::atomic_load(&a) 는 a.load() 와 동일하고, std::atomic_load_explicit(&a, std::memory_order_acquire)는 a.load(std::memory_order_acquire)와 동일합니다. 이 비멤버함수들은 C와의 호환성을 위해 디자인 되었습니다. 그래서 모든 함수들이 참조 대신 포인터를 사용합니다. 예를들어, compare_exchange_weak()와 compare_exchange_strong() 의 첫번째 매개변수(예측값) 는 레퍼런스 이지만, std::atomic_compare_exchange_weak() 의 두 번째 매개변수는 포인터 입니다. (첫 번째 매개변수는 원자적 객체의 포인터겠죠?) std::atomic_compare_exchange_weak_explicit() 또한 성공 시와 실패 시의 memory_ordering 을 따로 명시할 수 있습니다. (기본 값은 std::memory_order_seq_cst입니다.)

std::atomic_flag 와 관련된 연산은 그 이름에 “flag”가 들어갑니다. std::atomic_flag_test_and_set(), std::atomic_flag_clear() 처럼요. memory-ordering 태그를 지정하려면 뒤에 _explicit 을 붙여 std::atomic_flag_test_and_set_explicit() 가 됩니다.

C++ 표준 라이브러리는 또한 std::shared_ptr<>의 인스턴스를 원자적 스타일로 접근하는 비멤버함수도 제공합니다. 이것은 원자적 타입만 원자적 연산을 지원한다는 원칙을 깨는 것입니다. std::shared_ptr<>은 원자적 타입이 아니기 때문이지요. 그러나 C++표준을 만드는 사람들은 std::shared_ptr<>에 이러한 함수들을 제공하는 것이 충분히 중요하다고 느꼈습니다. load, store, exchange, 그리고 비교/교체에 해당하는 원자적 연산들은 첫번째 매개변수로 std::shared_ptr<>* 를 받을 수 있습니다.

std::shared_ptr<my_data> p;

void process_global_data()

{
	std::shared_ptr<my_data> local=std::atomic_load(&p);

	process_data(local);
}
	
void update_global_data()
{

	std::shared_ptr<my_data> local(new my_data);
	
	std::atomic_store(&p,local);

}

앞서 보았던 다른 연산들처럼, _explicit 을 붙이면 memory-ordering을 지정할 수 있습니다.그리고std::atomic_is_lock_free() 함수는 std::shared_ptr<>의 구현이 내부적으로 락을 사용하는지 확인할 때 사용될 수 있습니다.

도입부에 설명한 것처럼, 표준 원자적 타입들은 data race로부터 비롯된 미정의 동작을 피하는 것 뿐만이 아니라, 스레드간의 연산의 순서를 강제할 수 있게 해줍니다. 이렇게 순서를 강제하는 것은 std::mutex나 std::future<>같이 데이터를 보호하고 동기화하는 것을 위한 기능들의 기반입니다. 이것을 고려한 채, 이 챕터의 백미로 가봅시다. 다음 챕터에서는 동시성 측면에서의 메모리 모델에 대해 알아보며, 어떻게 원자적 연산들이 데이터 동기화에 사용되고, 또 연산의 순서를 강제하는지 자세히 알아봅니다.