-
[Effective Modern C++] std::atomic VS volatileC++/Effective Modern C++ 2023. 5. 12. 18:32
volatile과 std::atomic은 서로 다른 기능을 하는 도구입니다.
그러나 두 도구 모두 변수에 특정 속성을 부여하고, 최적화, 동기화와 관련된 도구이다보니 그 사용처가 혼동되고는 합니다.
이번 글에서는 두 도구의 차이를 중점적으로 살펴보도록 하겠습니다.
1. RMW(Read-Modify-Write) operation
더보기다음 예제를 보도록 하겠습니다.
std::atomic<int> ai(0); // ai = 0 ai = 10; // ai = 10 ++ai; // ai = 11 --ai; // ai = 10
위 코드의 std::atomic객체 ai에 대한 연산에 대하여, 관측 가능한 ai의 값은 주석과 동일한 0, 10, 11입니다.
std::atmoic객체는 객체에 대한 연산을 원자적으로 (더 이상 쪼갤 수 없음) 수행합니다.
하지만 volatile은 조금 다릅니다.
volatile int vi(0); // vi = 0 vi = 10; // vi = 10 ++vi; // vi = 11 --vi; // vi = 10
위 코드의 volatile 객체 vi에 대한 연산에 대하여, 관측 가능한 vi의 값은 특정할 수 없습니다.
vi의 메모리에 접근하는 Read와 Write사이에 순서가 정해지지 않아, Data race가 일어나기 때문입니다.
2. 컴파일러 최적화
더보기컴파일러는 코드에 대한 최적화를 수행합니다.
이 과정에서 몇몇 코드가 다른 코드로 바뀌거나, 코드의 실행 순서가 바뀌거나, 코드가 아예 사라질 수 있습니다.
하지만 컴파일러의 이러한 최적화가 독이 되는 상황이 존재합니다.
다음의 코드는 이전 글 (스레드 간 단발성 이벤트 통신) 의 예제와 유사한, 이벤트의 반응과 검출을 분리한 경우 중 검출 부분입니다.
bool valAvailable(false); auto imptValue = computeImportantValue(); valAvailable = true;
위 코드는 다른 과제가 사용할 특정 값을 계산하고, 그 값이 준비되었음을 알리는 코드입니다.
다른 과제는 valAvailable의 값을 검사하여, true가 될 경우 imptValue에 대한 특정 연산을 수행하는 코드가 될 것입니다.
이 경우, valAvailable을 true로 바꾸는 연산은 반드시 imptValue의 계산 이후에 수행되어야 합니다.
하지만 컴파일러의 관점에서 두 코드는 배정 연산 두 줄에 불과하므로, 두 코드의 순서가 변경되어도 무관합니다.
하지만 std::atmoic을 사용할 경우, 그러한 순서의 변경에 제약이 발생합니다.
제약 중 하나는 atomic변수를 기록하는 코드 이전에 나오는 코드들은, 그 코드 이후에 실행될 수 없다는 것 입니다.
따라서, 위 valAvailable을 atomic으로 선언할 경우, true배정과 imptValue계산의 순서가 변경되지 않음을 보장할 수 있습니다.
하지만 volatile은 그러한 제약이 가해지지 않습니다.
valAvailable을 volatile로 선언하더라도, 코드의 실행 순서는 컴파일러에 의해 변경될 수 있습니다.
3. MMIO (Momory-Mapped I/O)
더보기다음 코드를 보도록 하겠습니다.
auto y = x; y = x;
위 코드는 위와 아래가 동일한 연산을 수행합니다.
따라서 컴파일러에 의해 아래쪽 코드가 삭제될 수 있습니다.
x = 10; x = 20;
위 코드도 마찬가지입니다.
컴파일러에 의해 x = 10코드가 제거될 수 있습니다.
즉, 컴파일러는 불필요한 읽기, 쓰기 (이를 Redundant load, Dead stores라고 합니다) 를 제거하는 최적화를 수행합니다.
하지만 위와 같은 코드가 필요한 상황이 존재합니다.
위 변수가 점유하는 메모리가 일반적인 메모리가 아니라, 특별한 역할을 하는 하드웨어 메모리일 경우, 이야기가 조금 달라집니다.
문단 첫부분 y의 배정 연산의 경우, 코드상으로는 x에 변화가 없을 수 있지만, x가 특정 물리 값 (센서 값)이라고 할 경우, 첫 번째와 두 번째 값은 서로 다를 수 있습니다.
그 다음 x에 값을 대입하는 연산도 특정 하드웨어의 작동을 지시하는 코드라고 할 경우, 두 번의 배정 모두 필요한 연산일 수 있습니다.
volatile은 이러한 경우에 사용됩니다.
volatile이 선언된 변수는 컴파일러에 의해 최적화 되지 않습니다.
위의 최적화 가능성이 있는 두 코드 모두, volatile로 선언될 경우 코드 최적화가 발생하지 않습니다.
하지만 std::atomic은 그러한 규칙이 존재하지 않습니다.
atomic변수에 대하여, 컴파일러는 일반 변수와 동일하게 코드 최적화를 수행합니다.
4. std::atmoic::load, store
더보기std::atomic은 복사 불가능 객체입니다.
예를 들어, 다음과 같은 코드는 컴파일 에러가 발생합니다.
std::atomic<int> x; auto y = x; y = x;
이는 atomic의 특성 때문입니다.
위 auto y는 형식 연역에 의해 std::atomic<int>로 연역될 것 입니다.
따라서 두 번째 코드는 x의 값을 읽고, 그것을 y에 기록하는 연산인 것에 더하여, 그 연산이 원자적으로 수행되어야 합니다.
하지만 하드웨어 수준에서 이를 지원하지 않기 때문에, 표준 위원회는 std::atomic에 대한 복사를 지원하지 않기로 했습니다.
하지만 x를 y에 대입하는 것은 가능합니다.
std::atmoic<int> y(x.load()); y.store(x.laod());
std::atomic의 load 및 store연산은 읽기, 쓰기를 수행합니다.
위 코드의 경우, x의 값을 읽어 y를 초기화하고 (첫 번째 코드)
다시 x의 값을 읽어 y에 저장합니다 (두 번째 코드)
하지만 x를 읽는 연산과 y를 초기화하는 연산은 개별적인 함수 호출이기 때문에, 두 연산이 하나의 원자적 연산이 될 것이라고 기대할 수 없습니다.
컴파일러는 위 코드를 다음과 같이 최적화 할 수 있습니다.
{register} = x.load(); std::atomic<int> y({register}); y.store({register});
x의 값을 한 번만 읽어 레지스터에 저장하고, 그 값을 y의 초기화에 사용하고, 대입하는 것 입니다.
결과적으로 바로 위 코드와 비교하여, x에 대한 읽기를 한 번만 수행하는 것으로 최적화가 되지만, 위 3번 문단에 이어서 생각했을 경우, 위와 같은 최적화는 적합하지 않을 수 있습니다.
std::atomic과 volatile은 서로 그 용도가 완벽하게 다르다는 것을 알아보았습니다.
용도가 다르기 때문에, 두 키워드는 함께 사용하는 것 또한 가능합니다.
volatile std::atomic<int> vai;
위와 같은 변수는 MMIO를 사용하면서, 동기화 되어야 하는 멀티 스레드 프로그램에 사용될 변수로 적합할 것 입니다.
감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] insert와 emplace (0) 2023.06.13 [Effective Modern C++] 값 전달을 고려할만한 상황 (0) 2023.06.09 [Effective Modern C++] 스레드 간 단발성 이벤트 통신 (0) 2023.05.04 [Effective Modern C++] std::future의 소멸자 (0) 2023.04.19 [Effective Modern C++] std::thread를 unjoinable하게 만들어야 하는 이유 (0) 2023.04.14