사실 volatile은 동시적 프로그래밍과 무관하다. 다만 다른 언어에서는 유용한 경우가 있고 C++에서도 일부 컴파일러가 동시적 소프트웨어에 적용할 수 있는 의미론을 volatile에 부여한다.

 

volatile와 std::atomic을 혼동하는 경우가 종종 있다.

std::atomic은 다른 스레드들이 반드시 원자적으로 인식하는 연산들을 제공한다. 외부에서 봤을때는 std::atomic 객체에 대한 연산은 마치 뮤텍스에 의해 보호되는 임계 영역에서의 연산처럼 보인다.

 

std::atomic<int> ai(0);
ai = 10; // 원자적으로 10으로 설정
std::cout << ai; // 원자적으로 ai를 읽음
++ai; // 원자적으로 1 증가
--ai; // 원자적으로 1 감소

위 문장들을 실행하는 동안 ai를 읽는 스레드들이 보는 값은 0, 10, 11 세가지밖에 없다.

 

일단 몇가지 짚고 넘어가자.

ai를 출력하는 문장에서 ai가 std::atomic 객체라는 점이 보장되는 것은 읽기가 원자적이라는 것 뿐이다. 전체 문장이 원자적으로 처리된다는 보장은 없다.

ai를 읽는 시점과 operator<<가 호출되는 사이에 다른 스레드가 ai의 값을 수정할 수도 있다. 다만 이 문장에서는 그런 상황이 발생되어도 행동에 영향을 미치지 않을 뿐이다. 다만 이 개념을 이해하고 넘어가야 한다.

 

증감 연산에 대한 부분은 읽기-수정-쓰기 과정이 순차적으로 일어나는 연산이고 각각 원자적으로 수행된다.

일단 std::atomic 객체가 생성되면 해당 객체의 모든 멤버 함수는 다른 스레드들에게 반드시 원자적으로 보이게 된다.

 

 

volatile int vi(0);
vi = 10;
std::cout << vi;
++vi;
--vi;

하지만 volatile을 사용하는 경우에는 다중 스레드 환경에서 아무것도 보장하지 않는다.

위 코드를 실행하는 동안 vi의 값을 다른 스레드들이 읽는다면 어떤 값이라도 볼 수 있게 되고 그 값으로 무언가를 하게된다면 미정의 행동을 유발하게 된다.

volatile은 std::atomic도 아니고 뮤텍스도 아니기 때문에 값이 전혀 보호받지 못한다.

 

 

std::atomic<int> ac(0); // 원자적 카운터
volatile int vc(0); // 휘발성 카운터

두 카운터 변수가 두개의 스레드에서 증가 연산이 이뤄진다고 했을 때, ac의 값은 원자적 연산이 이뤄졌기 때문에 반드시 2라는 것이 보장된다.

하지만 vc는 읽기-수정-쓰기의 연산이 원자적으로 이뤄지지 않고 두 스레드에서 동시에 이뤄질수도 있기 때문에 값이 2일수도, 1일수도 있다.

두 스레드에서 vc를 읽어서 1을 증가시킨 값을 기록하게 되는데 만약 동시에 읽어서 양쪽 다 0으로 읽게되면 둘 다 1을 대입하기 때문에 vc의 최종 값은 1이 될수도 있다.

일반적으로 실행할때마다 결과가 다르기 때문에 vc의 값은 예측할 수 없다.

 

 

std::atomic과 volatile의 차이는 RMW 연산뿐만 아니라 코드 재배치 제약에 관한 문제도 있다.

 

std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true;

이전 항목에서 다뤘던 공유 플래그에 의한 두 작업의 통신이다.

위 코드를 보면 반드시 imptValue가 먼저 대입되고 이후 valAvailable의 대입이 이뤄져야 한다는 것을 알 수 있다.

하지만 컴파일러는 두 대입문을 서로 독립적인 변수에 대한 대입으로 볼 뿐이라서 대입 연산의 순서를 임의로 바꿀수도 있다.

std::atomic을 사용하면 코드 재배치에 대한 제약이 생겨서 std::atomic 변수를 기록하는 문장 이전의 코드들은 그 뒤에 실행되도록 재배치되지 않는다.

 

volatile의 사용 여부는 코드 재배치에 대한 제약이 가해지지 않기 때문에 컴파일러에 의해 두 대입 연산의 순서가 바뀔수도 있다.

 

 

결과적으로 volatile은 연산의 원자성을 보장하지 않고 코드 재배치에 대한 제약이 충분하지 않다는 점을 통해 동시성 프로그래밍에 유용하지 않다는 결론을 이끌어낼 수 있다.

그러면 대체 volatile은 어디에 유용할까?

간단하게 얘기하면 volatile이 적용된 변수가 사용하는 메모리는 일반적인 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다.

일반적인 메모리에는 메모리의 한 장소에 어떤 값을 기록하면 값을 덮어쓰지 않는 한 그 값이 유지된다.

 

auto y = x;
y = x;

위의 코드는 같은 일을 수행하기 때문에 컴파일러가 두번째 대입문을 제거해서 최적화 시킬 수 있다.

 

auto y = x;
y = x;
x = 10;
x = 20;

다른 값들이 대입되어도 해당 값들이 한 번도 사용되지 않는다면 첫 번째 기록을 제거할 수 있다는 특성도 있다.

 

auto y = x;
x = 20;

즉, 컴파일러가 이렇게 취급할 수 있다는 것이다.

불필요한 코드를 사람이 작성할일이 있을까 싶지만 사람이 직접 작성하지 않더라도 템플릿 인스턴스화와 인라인화, 순서 재배치 최적화 등을 거치고 나면 드물지 않게 컴파일러에 의해 생겨날 수도 있는 것이다.

이러한 최적화는 메모리가 '일반적인' 방식으로 행동할 때에만 이뤄지는거고 '특별한' 메모리는 언급한 최적화가 이루어지지 않는다.

 

흔한 예시로 메모리 대응 입출력(memory-mapped I/O)이 있다. 이 메모리는 일반적인 메모리를 읽거나 쓰는 것이 아닌 PC 주변장치들과 통신하게 된다.

 

auto y = x;
y = x;

만약 x가 온도계가 보고하는 실시간 값이라면 컴파일러가 보기엔 같은 값을 대입하는것으로 보여서 최적화를 수행하겠지만 실제로는 중복 행동이 아니기 때문에 최적화가 이뤄져서는 안된다.

 

이 때, volatile를 붙여주면 해당 변수가 특별한 메모리를 다룬다는 점을 컴파일러에게 알려주어서 최적화에서 제외되도록 한다.

 

std::atomic 객체 역시 최적화 대상에 포함되기 때문에 특별한 메모리를 다루게 된다면 std::atomic은 적합하지 않게 된다.

 

std::atomic<int> x;

auto y = x;
y = x;

x = 10;
x = 20;

/* 최적화 이후 */
auto y = x;
x = 20;

개념적으로는 위의 코드가 최적화가 이뤄질 것 같지만 컴파일 자체가 안된다.

원자적인 복사 생성 연산을 하드웨어 수준에서 지원하지 않아서 std::atomic의 복사 연산들이 삭제되었기 때문이다.

그렇다고 값을 넣는것이 불가능하다는 것은 아니고 std::atomic의 멤버 함수 load와 store를 사용하면 된다.

 

 

기나긴 얘기 끝에 다다른 결론은 다음과 같다.

 

◾ std::atomic은 동시적 프로그래밍에 유용하나, 특별한 메모리의 접근에는 유용하지 않다.

◾ volatile은 특별한 메모리의 접근에 유용하나, 동시적 프로그래밍에는 유용하지 않다.

 

std::atomic과 volatile의 용도가 다르므로 같이 사용하는것도 당연히 가능하다.

 

volatile std::atomic<int> vai;

vai에 대한 연산들은 원자적이고, 최적화에 의해 제거되지 않게 된다. 이런 코드는 여러 스레드가 동시에 접근할 수 있는 메모리 대응 입출력 장소일 때 유용하게 사용할 수 있다.

 

◾ std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 자료를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다.

◾ volatile은 읽기와 기록을 최적화로 제거하지 말아야 하는 메모리를 위한 것으로, 특별한 메모리를 다룰 때 필요한 도구이다.

+ Recent posts