728x90
반응형

atomic과 volatile의 차이점에 대해 알아보자.

atomic vs volatile

std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 데이터를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다. 보통 뮤텍스보다 더 효율적인 기계어 명령들로 구현된다.

아래의 예시를 보자.

std::atomic<int> ai(0); // ai를 0으로 초기화

ai = 10; // 원자적으로 ai를 10으로 설정

std::cout << ai; // 원자적으로 ai의 값을 읽음

++ai; // 원자적으로 ai 증가 11
--ai; // 원자적으로 ai 감소 10

우리는 두 가지를 주목해야 한다.

첫째, "std::cout << ai"에서, std::atomic 객체가 보장하는 것은 ai의 읽기가 원자적이라는 것뿐이다. ai의 값을 읽는 시점과 operator<< 가 호출되는 시점 사이에 다른 스레드가 ai의 값을 수정할 수도 있다.

둘째, 마지막 두 문장처럼 증감 연산은 읽기-수정-쓰기(Read-Modify-Write:RMW) 연산이지만 각각 원자적으로 수행된다.

이처럼 일단 atomic 객체가 생성되고 나면, 그 객체에 대한 모든 멤버 함수는 혹은 RMW연산들을 수행하는 멤버 함수도 다른 스레드들에게는 반드시 원자적으로 보인다는 것이다.

 

하지만 volatile을 사용하는 다음 코드는 다중 스레드 문맥에서 거의 아무것도 보장하지 않는다.

volatile int vi(0);

vi = 10;

std::cout << vi;

++vi;
--vi;

이 코드를 실행하는 동안 vi의 값을 다른 스레드들이 읽는다면, 그 스레드들은 어떤 값이라도 확인할 수 있다. 그리고 여러 스레드에서 증가 연산을 했는데 실제로는 딱 한 번만 증가하는 기이한 현상도 생길 수 있다. 메모리에 기록자들과 판독자들이 동시에 접근하려 해서 자료 경쟁이 일어나기 때문이다. 이런 코드는 미정의 행동을 유발한다.

 

또, 일반적으로 서로 무관한 대입의 순서를 컴파일러나 바탕 하드웨어가 임의로 바꾸는 것은 적법하다. 왜냐면, 순서를 바꾸면 코드가 더 빨리 실행되는 경우가 있기 때문이다.

그러나 atomic을 사용하면 이러한 코드 순서 재배치에 대한 제약들이 생긴다. 그런 제약 중 하나는 소스 코드에서 atomic변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되지 않아야 한다는 것이다.

하지만 volatile은 그런 제약이 없다.

아래의 예시를 보자

// atomic - 순서 못바뀜
std::atomic<bool> valAvailable(false); 
auto imptValue = computeImportantValue();
valAvailable = true;

// volatile - 순서 바뀔 수 있음
volatile bool valAvailable(false); 
auto imptValue = computeImportantValue(); 
valAvailable = true; // 다른 스레드들은 이 대입을 imptValue 대입 이전에 볼 수 있다.

 

그렇다면 volatile은 언제 사용해야 하는지 보자.

int x;
 
auto y = x;
y = x;

x = 10;
x = 20;

위의 코드에서, "y = x " 문과 "x = 10" 이 불필요해 보이지만, 사실 이게 특별한 명령일 수도 있다. 이런 경우, x를 volatile로 만들어 주어야 한다.

volatile한정사는 해당 코드가 특별한 메모리를 다룬 다는 점을 컴파일러에게 알려주는 수단이다.

즉, 컴파일러는 volatile 메모리에 대한 연산들에는 어떤 최적화도 수행하지 않는다.

그리고 항목 2의 규칙들에 따라 비 참조, 비포인터 타입을 선언할 때는 const와 volatile한정사가 제거된다는 점을 기억하자.

 

만약 x를 "std::atomic <int> x;"로 바꾸면, 컴파일이 실패한다. "y = x" 문장이 실패하기 때문인데, 이는 std::atomic의 복사 연산들이 삭제되었기 때문이다(복사 생성과 복사 배정 둘 다). 대신 다음과 같이 수정하면 컴파일이 된다.

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

load와 store은 원자적인 연산이지만, 두 문장이 각자 하나의 원자적 연산으로 실행되리라고 기대할 수는 없다. 컴파일러는 x 값을 레지스터에 저장해 이러한 코드를 최적화할 수도 있다.

레지스터 = x.load();
 
std::atomic<int> y(레지스터);
y.store(레지스터);

 

그리고 std::atomic과 volatile 은 용도가 다르므로, 함께 사용하는 것도 가능하다.

volatile std::atomic<int> vai;

vai에 대한 연산들은 원자적이며, 최적화에 의해 제거될 수 없다.

 

요약

  • std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 데이터를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다.
  • volatile은 읽기와 기록을 최적화로 제거하지 말아야 하는 메모리를 위한 것으로, 특별한 메모리를 다룰 때 필요한 도구이다.
  • std::atomic과 volatile의 용도가 다르므로, 함께 사용하는 것도 가능하다.
728x90
반응형