Post List

2015년 3월 16일 월요일

item 40 : Concurrency에는 std::atomic 을, 특수 메모리에는 volatile을 사용하자.

Effective Modern C++ - Scott Meyers
Item 40: Use std::atomic for concurrency, volatile for special Memory.

item 40. Concurrency에는 std::atomic을 특수 메모리에는 volatile을 사용하자.

* Intro

volatile과 std::atomic<T>은 사용 목적이 다르다. 이번 item에서는 그것에 대해서 집중적으로 얘기하고자 한다.

* std::atomic<T>

std::atomic<T>은 다른 Thread로부터 atomic 하게 작업하는 것을 보장해준다.
마치 mutex와 동작이 비슷하지만, 하나의 type에 대한 연산 과 멤버함수만 임계영역 (Critical Section)에 있는 것처럼 동작하는 것이 가능하다.
하지만, mutex는 무조건 lock을 걸어서 동작하지만,
std::atomic<T>은 lockless로도 동작이 가능하여 (컴파일러에 따라 다르다.)
보다 훨씬 더 효율적인 기계어로 구현될 수도 있다.

std::atomic<int> A(0); // Initialize A to 0
= 10;                // atomicalley set A to 10
std::cout << A;        // atomically read A's value
++A;                   // atomically increment A
--A;                   // atomically decrement A

위 Thread 말고 다른 Thread에서는 ai 값을 수정하지 않는다고 가정했을 때,
위 문장이 실행되는 동안, 다른 Thread는 값을 0, 10, 11 인 상태로만 읽을 수 있다.

이 예제는 두 가지 측면에서 주목할 가치가 있다.

1. std::cout << A에서 std::atomic<int>로 선언된 라는 사실은 A를 읽을 때 atomic이라는 것을 보장한다.
전체 실행문이 atomic이라는 것은 아니다.
A의 값을 읽는 시간과 << 연산자로 표준출력 (Standard Output)에 기록(Write)하는 시간 사이에 다른 Thread에서 A의 값을 수정할 수 있다.
그건 위 실행문에 영향을 미치지 않는다.
왜냐하면 << 연산자는 By-Value로 int 값을 output으로 전달하기 때문이다.
(출력된 값은 A로 부터 읽은 값)
여기에서 이해해야할 중요한점은 위 실행문에서 atomic은 A에서 읽는 것에 지나지 않는다를 점이다.

2. 두번째로 주목할 점은 A의 증가와 감소이다.
이건 Read-Modify-Write (RMW) 연산이다. 물론 atomic으로 실행된다.
이것이 std::atomic<T>의 가장 멋진 특징이다.
std::atomic<T> 개체가 한번 만들어지면, RMW 연산을 포함한 모든 멤버함수들이 다른 Thread들로 부터 atomic을 보장받는다.

* volatile

이와는 대조적으로 volatile을 사용한 code는 멀티쓰레드에서 사실상 아무것도 보장해주지 않는다.

volatile int V(0); // Initialize V to 0
V = 10;            // Set V to 10
std::cout << V;    // Read V's value
++V;               // Increment V
--V;               // Decrement V

이 코드가 실행중일 때 다른 쓰레드에서 값을 읽을 경우 그 값은 0, 10, 11 이외에 다른 값일 수도 있다. 어떤 값일 줄은 며느리도 모른다.
왜냐면 V는 std::atomic<T>도 아니고 mutex도 아니어서 경쟁 상태(data race)가 발생한다.
(http://ko.wikipedia.org/wiki/경쟁_상태 참고하도록)

* volatile vs std::atomic in Multi-thread Program

std::atomic<T> 과 volatile이 멀티쓰레드 프로그램에서 어떻게 다른지에 대한 예로,
간단한 카운터를 생각해보자. 일단 0으로 초기화 한다.

std::atomic<int> A(0); // atomic   counter
volatile int     V(0); // volatile counter

그런 다음 두개의 쓰레드에서 동시에 증가시켜 보자.

/*--- Thread 1 --- */     /*--- Thread 2 --- */
     ++A;                     ++A;
    ++V;                     ++V;

두 쓰레드가 끝난 뒤, A의 값 (std::atomic<int>) 은 2가 되어야 한다. 왜냐면 각각의 증가연산은 atomic으로 실행되었기 때문이다.
반면 V의 값은 2가 아닐 수 있다. 왜냐면 증가 연산이 atomic으로 이루어지지 않았기 때문이다.
그 연산은 V의 값을 읽어서 증가시킨 후 다시 V에 기록을 하는데, 이 3가지 연산이 volatile 개체에는 atomic을 보장해주지 않는다.
그래서 다음과 같은 경우가 발생 할 수 있다.

1. Thread 1 이 V를 읽음 : 0
2. Thread 2 가 V를 읽음 : 0
3. Thread 1 이 0 -> 1로 증가시키고 V에 기록
4. Thread 2 가 0 -> 1로 증가시키고 V에 기록

V값을 두 번 증가 시켰어도 결과적으로 1이 될 것이다.

이것만이 가능한 결과가 아니다. V는 경쟁상태에 있게 되므로 최종값의 예측이 불가능하다.
컴파일러는 경쟁상태에서 빠지는 코드에 대해서는 최적화를 수행하지 않는다.
최적화를 했다가는 예기치 않은 작업을 하게 될 가능성이 있기 때문이다.
(그나마 최적화를 전혀 하지 않는 것이 작성자가 의도한 대로 동작할 확률을 조금이라도 높여준다고 판단한다.)

* Compiler Optimization

RMW 연산만 std::atomic<T>에서 성공하고 volatile에서 실패하는 사례인건 아니다.

1번 Task에서 계산한 정보를 2번 Task에서 사용하는 경우를 생각해보자.
1번 Task에서 계산한 값은 2번 Task에게 전달 되어야 한다.
std::atomic<bool>을 사용하여서 구현해보자.

std::atomic<bool> isAvailable(false); // it's unavailable
int Value = Func();
isAvailable = true; // tell other task it's available

사람이 봤을 땐 각각 변수에 할당하는 순서가 중요하다고 판단하지만,
컴파일러가 판단하기에는 그냥 서로 독립적인 두개의 할당일 뿐이다.
컴파일러는 서로 관계가 없는 작업에 대해서는 순서를 바꿔서 실행할 수 있다.

a = b;
x = y;

위 코드를 컴파일러는 아래로 실행 할 수도 있다.

x = y;
b = a;

컴파일러가 순서를 바꾸지 않더라도,
하드웨어에서 더 빠른 실행을 위해서 순서를 바꿀 수도 있으며,
순서를 바꾸지 않는다 하더라도 다른 Core에 할당 하여 실행 시킬 수도 있다.

하지만 std::atomic<T>은 이런 Code Reorder와 다른 Core에서 실행시키는 것을 제한시킨다.
Value와 isAvailable의 할당 순서 및 같은 하드웨어에서 동작하도록 코드를 생성한다.
isAvailable을 std::atomic<T>으로 선언하면 순서를 유지해주도록 보장한다.

반면 isAvailable을 volatile로 선언하면 Reorder를 제한하도록 하지 못한다.

volatile isAvailable(false);
int Value = Func();
isAvailable = true;

컴파일러가 할당 순서를 바꿀 수도 있으며,
그렇지 않다 하더라도 하드웨어에서 서로 다른 Core에 할당하여 그 순서를 보장 받을 수 없게 될 수도 있다.

* Special Memory

이제 volatile이 concurrent 프로그램에 적합하지 않다는 것은 알겠고...
그럼 어떨때 써야 할까 ?
volatile은 컴파일러에게 그 메모리는 "Normal"하지 않을 수 있다고 말해주는 것이다.

"Normal" 메모리는 거기에 값을 기록하면, 다른 값을 덮어쓰기 전까지는 계속 유지가 되는 특징이 있다.

int x;

auto y = x;
y = x;

똑같은 할당이 연속으로 2번 있으면 코드를 최적화하기위해 한번의 할당을 제거 할 수가 있다.

"Normal" 메모리의 또 다른 특징은 그 메모리에 값을 기록하고 읽지 않은 상태에서 다른 값을 기록하면 전에 값은 제거된다.

x = 10;
x = 20;

위 Code에서 컴파일러가 첫번째 할당을 제거 할 수 있다.
즉 아래 코드는

auto y = x;
y = x;

x = 10;
x = 20;

컴파일러가 다음과 같이 실행된다.

auto y = x;
x = 20;

컴파일러는 합리적으로 소스코드를 분석하여
1. template를 인스턴스화 하고,
2. inline 처리를 하고,
3. 여러가지 Reordering등 최적화를 해서 중복 로드와 Dead Store 를 제거 할 수 있다.

이러한 최적화는 "Normal" 메모리에서만 유효하다.
"Special" 메모리는 해당되지 않는다.
"Special" 메모리는 memory-mapped I/O에 사용되는 메모리를 말한다.
이런 메모리는 대부분 주변장치와의 통신에 사용된다.
(외부 센서나 디스플레이, 프린터, 네트워크 포트 등...)
이런 맥락에서 다시 중복 읽기 와 쓰기 코드를 본다면

auto y = x;
y = x;

x가 온도 센서라면, 첫번 째 읽은 값과 두번 째 값이 같지 않다.

x = 10;
x = 20;

x가 무선 송신하는 포트라면 10을 넣는 것과 20을 넣는 것은 다른 명령어이다.

volatile은 컴파일러에게 이건 "Special" 메모리라고 알려주는 것이다.
즉, 어떠한 최적화 작업도 허용하지 않는다는 뜻이다.


auto y = x;
y = x;

x = 10;
x = 20;

x가 매핑 메모리 (또는 프로세스 간의 공유에 사용되는 매핑 메모리) 인 경우 우리가 원하는 방향대로 정확히 동작한다.

"Special" 메모리에 대해서는 중복 읽기와 죽은 저장이 보존 되어야 한다.
반면 컴파일러는 std::atomic<T>에 대해서는 이런류의 중복 연산을 제거 할 수 있다.
심지어 위 Code는 std::atomic<T>에서는 컴파일 되지도 않는다.

std::atomic<int> x(0);

auto y = x;
y = x;

x = 10;
x = 20;

이걸 최적화하면 다음과 같다.

std::atomic<int> x(0);

auto y = x;

x = 10;

왜냐면 std::atomic<T>에는 COPY 연산자가 없다.
std::atomic<T>의 장점으로 모든 연산이 다 atomic으로 처리 된다는 것을 들 수 있다.
하지만 x에서 y로 복사 연산이 atomic이어야 하므로, 컴파일러는 x를 읽어서 y에 기록하는 것은 1 atomic 연산으로 처리되도록 code를 생성해야 한다.
하드워어적으로 그게 불가능하므로, std::atomic<TCOPY 연산은 제공 될 수가 없다.
이것이 x를 y로 대입하는 것이 컴파일되지 않는 이유이다.
std::atomic<T>에 대한 MOVE 생성자, 연산자 또한 제공하지 않는다.)

std::atomic<T>의 멤버한수인 loadstore를 사용하면 x의 값을 y로 전달하는 것이 가능하다.
load 멤버 함수는 std::atomic<T>의 값을  store 멤버 함수가 atomic하게 쓰는 동안 atomic하게 읽는다.

std::atomic<int> x(0);
std::atomic<int> y(x.load());
y.store(x.load());

x에서 값을 읽는 것은 (x.load()를 통해서) y에 값을 저장하는 것과는 별도의 함수 콜이라는 사실이 전체 명령어에 대해서 하나의 atomic 연산일 필요가 없다는 것을 명백히 해준다.

컴파일러는 x의 값을 두번 읽는 대신에 레지스터에 저장하는 것으로 "최적화"할 수 있다.

register = x.load();
std::atomic<int> y(register);
y.store(register);

그결과 x로 부터 한번만 읽는 이런식의 최적화는 "Special" 메모리에 대해서는 금지되어야 한다. (최적화는 volatile변수에 대해서는 허가되지 않는다.)

이제 위 내용들을 한번 정리해본다면,

std::atomic<T>concurrent 프로그래밍에는 유용하지만, "Sepcial" 메모리를 제어하는데는 그렇지 않다.

volatile"Special" 메모리를 제어하는데는 유용하지만 concurrent 프로그래밍에는 그렇지 않다.

std::atomic<T>과 volatile은 목적자체가 다르기 때문에, 같이 사용 할 수는 있다.

volatile std::atomic<int> VA;

이건 VA가 매핑 메모리 I/O에 위치한 경우 멀티 쓰레드에서 concurrent하게 제어되어야 할 경우 유용하다.

어떤 개발자들은 필요하지 않을 때에도 std::atomic<T>load store 멤버 함수를 사용하는 것을 더 선호한다.
왜냐하면 소스코드에서 해당 변수가 std::atomic<T다라고 명확하게 만들어주기 때문이다.
std::atomic<T>을 사용하면 컴파일러가 Code Reordering 등의 최적화작업을 못하게기 때문에 더 느려지긴 한다.
std::atomic<T>의 store 와 load 를 사용하는 것은 소스코드를 읽는데 도움의 줘서
나중에 일어날 지도 모르는 잠재적인 위험을 예방하는 도움이 된다.
변수에 store를 사용하지 않는 것은 다른 쓰레드와의 작업에서
std::atomic<T>으로 선언했어야 하는데 선언하지 않았다 라는 것을 의미할 수도 있다.

Things to Remember

std::atomic<T>은 멀티 쓰레드에서 mutex를 사용하지 않고 data를 제어하는데 사용된다.
 concurrent 소프트웨어를 작성하기 위한 도구이다.


volatile은 Read/Write 작업에 대해서 최적화를 하면 안되는 메모리에 사용된다.
 "Special" 메모리를 위한 도구이다.


댓글 없음:

댓글 쓰기