본문 바로가기

C++ 실전 강좌

[C++] Volatile 키워드


Volatile  !!!

C++ 을 10년 넘게 사용하고 있지만, 정말 사용하지 않는 키워드 입니다.
하지만 멀티스레드 환경에서 한번 쯤 고려해 봐야할 키워드 이기도 합니다.


MSDN에 보면 다음과 같이 정의 되어 있습니다.

 

The volatile keyword is a type qualifier used to declare that an object can be modified in the program by something such as the operating system, the hardware, or a concurrently executing thread.

volatile은 해당 객체(변수)가 OS나 하드웨어 또는 다른 스레드에 의해 변경될 수 있다고 알려 주는 키워드 입니다.

 
이는 컴파일러에게 해당 객체의 값이 언제든지 변경될 수 있으니, 최적화를 통해 해당 값을 미리 예상하지 말고, 항상 해당 객체를 참조하는 시점에 값을 매번 다시 읽도록 해 줍니다.

왜 이런 예약어가 필요할까요?

보통 컴파일러는 소스 코드를 분석하여, 해당 객체(변수)에 대한 값이 변경되지 않을 것 같으면, 이 값을 미리 저장해 두었다 사용하는 방법으로 최적화를 합니다. 하지만, 소스상으로는 변경이 없지만, 실제로 변경이 일어날 수 있는 환경이 있습니다.

1. MMIO (Memory-Mapped I/O)  : 하드웨어가 직접 메모리 변수 값을 변경하는 경우
2. ISR (Interrupt Service Routine)  : OS 등에서 인터럽트 처리를 위해 중간에 호출되는 함수에서 값을 변경하는 경우
3. Multi-Thread                          : 멀티스레드의 경우,  현재 실행되는 스레드에 의해 값이 변경되는 경우

하드웨어 드라이버 개발을 하거나, OS 개발을 하지 않는, 대부분의 경우에는 멀티스레드 환경 때문에 사용하는 경우가 많습니다.

또한 컴파일러에 따라 구현 방법이 달라질 수 있음으로 사용하는 컴파일러 문서를 참조하는 것이 좋습니다.

MSDN에 따르면 마이크로소프트의 경우는 다음과 같이 구현하고 있습니다.

Objects declared as volatile are not used in certain optimizations because their values can change at any time. The system always reads the current value of a volatile object at the point it is requested, even if a previous instruction asked for a value from the same object. Also, the value of the object is written immediately on assignment.

Also, when optimizing, the compiler must maintain ordering among references to volatile objects as well as references to other global objects. In particular,

A write to a volatile object (volatile write) has Release semantics; a reference to a global or static object that occurs before a write to a volatile object in the instruction sequence will occur before that volatile write in the compiled binary.

A read of a volatile object (volatile read) has Acquire semantics; a reference to a global or static object that occurs after a read of volatile memory in the instruction sequence will occur after that volatile read in the compiled binary



volatile 로 선언된 객체는 항상 값이 변경될 수 있으므로 몇 가지 최적화 과정을 하지 않습니다. volatile 객체로 선언하면, 시스템은 이미 이전 명령어 수행과정에서 값을 읽어 왔어도, 항상 해당 객체값이 필요할 때, 새로 현재값을 다시 읽어 옵니다. 또한 해당 객체의 값을 변경하는 경우에도 (캐쉬나 레지스트리 등에 저장 후 모든 객체 사용이 끝났다고 생각되는 순간에 저장하는 것이 아니라)  바로 저장합니다.


또한 최적화 과정의 경우, 컴파일러는 모든 volatile 객체와 전역 객체간의 참조 순서를 관리합니다. 특히,

volatile 객체에 값을 저장하는 것은 해제(Release) 의미가 있습니다 :  명령어 순서상으로 volatile 객체에 값을 저장하기 전에 있는 전역 또는 스태틱 객체에 대한 참조는, (컴파일된 바이너리에서는) volatile 변수 저장 전에 참조가 이루어 지도록 (최적화) 합니다. 

volatile 객체에서 값을 읽는 것은 획득(Acquire) 의미가 있습니다 : 명령어 순서상으로 volatile 객체에서 값을 읽은 후에 있는 전역 또는 스태틱 객체에 대한 참조는, (컴파일된 바이너리에서는) volatile 변수값을 읽은 후에 참조가 이루어 지도록 (최적화) 합니다.


MSDN의 사용 예를 보면 위의 설명이 조금 이해될 수도 있습니다.

아래 예제는 volatile 메모리의 2가지 기능을 사용하고 있습니다.

1. volatile 메모리에 대한 모든 읽기 쓰기 권한을 통해 뮤텍스(mutex) 처럼 사용할 수 있습니다.

2. 전역 데이타(global data)에 대한 참조는 volatile 메모리에 대한 저장 과정(volatile wirte) 다음으로 이동(최적화)될 수 없습니다. 이를 통해 마치 크리티컬섹션(cirtical section)으로 들어 가는 것과 같은 효과를 얻을 수 있습니다.

3. 전역 데이타에 대한 참조는 volatile 메모리에 대한 읽기 과정(volatile read) 앞으로 이동(최적화)될 수 없습니다. 이를 통해 크리티컬 섹션에서 나가는 것과 같은 효과를 얻을 수 있습니다.

// volatile.cpp
// compile with: /EHsc /O2
#include <iostream>
#include <windows.h>
using namespace std;

volatile bool Sentinel = true;
int CriticalData = 0;

unsigned ThreadFunc1( void* pArguments ) {
   while (Sentinel)
      Sleep(0);   // volatile spin lock

   // CriticalData load guaranteed after every load of Sentinel
   cout << "Critical Data = " << CriticalData << endl;
   return 0;
}

unsigned  ThreadFunc2( void* pArguments ) {
   Sleep(2000);
   CriticalData++;   // guaranteed to occur before write to Sentinel
   Sentinel = false; // exit critical section
   return 0;
}

int main() {
   HANDLE hThread1, hThread2;
   DWORD retCode;

   hThread1 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc1,
      NULL, 0, NULL);
   hThread2 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)&ThreadFunc2,
      NULL, 0, NULL);
   if (hThread1 == NULL || hThread2 == NULL) {
      if (hThread1 != NULL) CloseHandle(hThread1);
      if (hThread2 != NULL) CloseHandle(hThread2);
      cout << "CreateThread failed." << endl;
      return 1;
   }

   retCode = WaitForSingleObject(hThread1,3000);

   CloseHandle(hThread1);
   CloseHandle(hThread2);

   if (retCode == WAIT_OBJECT_0 && CriticalData == 1 )
      cout << "Success" << endl;
   else
      cout << "Failure" << endl;
}


위의 예는 volatile 변수 Sentinel 를 이용하여 마치, 크리티컬 섹션(Critical Section)을 사용한 효과를 얻을 수 있음을 보여줍니다.

ThreadFunc1 의 경우 while(Sentinel) 문에 의해 Sentinel 이 false 가 될때 까지 기다립니다.

만약 volatile로 선언하지 않고, 컴파일러가 판단하기에 CriticalData 변수와 Sentinel 변수가 서로 의존성이 없다고 판단한다면, 최적화 과정에서 수행 순서가 바뀔 위험이 있게 됩니다.

하지만, Sentinel을 volatile로 선언하면,
전역 변수에 대한 참조는 반드시 volatile read 앞으로 이동될 수 없으므로, ThreadFunc1에서는 Sentinel이 false 가 되지 않는 한 Critical Data 값을 읽어 오지 않게 됩니다. (마치 enter cirtical section 효과를 갖게 됩니다. Acquire semantics)

또한, 전역 변수에 대한 참조가 volatile write 뒤로 이동될 수 없으므로, ThreadFunc2에서는 항상 Critical Data 값이 변경된 후 Sentinel 값이 변경되도록 됩니다. (leave cirtical section 효과, Release semantics)

사실 위의 예를 실제 Visual Studio 2008로 컴파일해 보면 volatile 선언과 상관 없이 같은 결과를 보여 줍니다.
하지만, 이는 컴파일러가 (운이 좋게) 최적화를 올바르게 한 결과일 뿐이지 항상 같은 결과를 보이도록 최적화 한다는 보장은 없습니다.

이럴 경우에 사용하는 것이 volatile 입니다.



참고로,
같은 의미로 클래스 멤버 함수를 volatile로 선언할 수 있습니다.
이는 const 키워드와 비슷 하다고 생각하면 됩니다. 즉, 객체 자체가 volatile로 생성된 경우에는, volatile로 선언된 함수만을 호출하는 것이 안전합니다.

예를 들면 다음과 같습니다.
class A
{
...
  void MemberFunc1() volatile;
};

int main()
{
    volatile A aaa;
    aaa.MemberFunc1();
}

이 부분에 대해서는 기회가 되면 다시 한번 자세히 다루어 보도록 하겠습니다.