스토리텔링 개발자

[Effective Modern C++] 40. std::atomic, volatile 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 40. std::atomic, volatile

김디트 2025. 4. 24. 11:26
728x90

항목 40. 동시성에는 std::atomic을 사용하고, volatile은 특별한 메모리에 사용하라

 

 

 

volatile
  • 사실 동시적 프로그래밍과는 무관하다.
    • 하지만 다른 언어에서는 volatile이 동시적 프로그램에 유용하다.
    • C++에서도 일부 컴파일러는 동시적 소프트웨어에 적용할 수 있을 수도 있다.
  • volatile은 다중 스레드에서 거의 아무것도 보장하지 않는다!
volatile int vi(0);

vi = 10;

std::cout << vi;

++vi;

--vi;
  • 위 코드를 실행하는 동안 다른 스레드들은 vi의 값을 0, 10, 11만 본다고 보장할 수 없다.
    • 이처럼 writer와 reader가 동시에 접근하려고 하면 자료 경쟁(data race)이 발생할 수 있다.
  • C++에서는 std::atomic를 사용해야 한다.

 

 

 

std::atomic
  • 다른 스레드들이 반드시 원자적으로 인식하는 연산들을 제공한다.
  • std::atomic 객체를 사용하면, 연산은 마치 뮤텍스로 보호되는 임계영역(critical section) 안에서 수행되듯 동작한다.
    • 하지만 실제 뮤텍스를 사용할 때보다 좀 더 효율적인 특별한 기계어 명령들로 구현되는 것이 보통이다.
std::atomic<int> ai(0); // ai를 0으로 초기화한다.

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

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

++ai; // 원자적으로 ai를 증가한다.

--ai; // 원자적으로 ai를 감소한다.
  • 다음 코드를 실행하는 동안 ai를 읽는 다른 스레드들이 보게 되는 값은 0, 10, 11 뿐이다.
    • 다른 값은 불가능하다.
  • 이 예에서 주목할 점 두 가지
    • std::cout << ai; 문장에서 원자적으로 처리되는 건 ai 읽기 처리 뿐이다.
      • 전체 문장이 원자적으로 처리된다는 보장은 없다.
      • ai의 값을 읽는 시점 / operator<<가 호출되어 기록되는 시점 사이에 다른 스레드가 ai의 값을 수정할 수 있다.
      • 하지만 operator<<는 값 전달을 하기 때문에 사실 문제는 없는 코드.
    • 증가 연산, 감소 연산은 읽기-수정-쓰기(read-modify-write, RMW) 연산이지만 합쳐서 원자적으로 수행된다.

 

 

 

다중 스레드 환경에서의 동작 차이
std::atomic<int> ac(0); // 원자적 카운터
volatile int vc(0); // 휘발성 카운터
  • 그리고 동시에 실행되는 두 스레드에서 각 카운터를 증가시킨다.

  • std::atomic의 경우 반드시 2가 된다.
  • volatile의 경우 2가 아닐 수 있다.
    • 아래는 안 좋게 진행된 케이스
      1. 스레드 1이 vc의 값을 읽는다.(값 : 0)
      2. 스레드 2가 vc의 값을 읽는다.(값 : 0)
      3. 스레드 1이 읽은 값을 증가시켜 vc에 기록한다.(값 : 0 -> 1, 기록 : 1)
      4. 스레드 2가 읽은 값을 증가시켜 vc에 기록한다.(값 : 0 -> 1, 기록 : 1)
      5. 결과적으로 vc는 1이 된다.

 

 

 

컴파일러의 문장 순서 재배치 제약
std::atomic<bool> valAvailable(false);

// 아래 두 문장은 순서가 지켜져야 한다.
auto imptValue = computeImportantValue(); // 값 계산
valAvailable = true; // 다른 task에게 값이 준비되었음을 알린다.
  • 값 계산이 valAvailable에 true를 할당하는 것보다 우선적으로 일어나야 함을 우리는 안다.
    • 하지만 컴파일러는 두 라인을 독립적인 변수에 대한 할당으로 볼 뿐이다.
    • 일반적으로는 서로 무관한 할당은 컴파일러가 순서를 임의로 바꾸는 것이 적법하다.
  • std::atomic을 사용하면 코드의 순서 재배치에 대한 제약들이 생긴다.
    • 소스 코드에서 std::atomic 변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되면 안된다.
  • volatile로 선언하면 코드 재배치 제약이 가해지지 않는다.
    • 컴파일러가 순서를 멋대로 바꿔버릴 수 있다.

 

 

 

volatile
  • 적용된 변수가 사용하는 메모리가 보통의 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다.
  • '보통' 메모리
    • 메모리의 한 장소에 어떤 값을 기록하면 다른 어떤 값을 덮어쓰지 않는 한 그 값이 유지된다.
      • auto y = x;
        y = x; // 컴파일러가 최적화로 해당 할당문을 제거할 수 있다.
    • 메모리에 어떤 값을 할당한 후 그 값을 한 번도 읽지 않고 다른 값을 할당한다면 첫 번째 기록은 제거할 수 있다.
      • x = 10; // 컴파일러는 최적화로 다음 문장을 제거할 수 있다.
        x = 20;
  • '특별한' 메모리
    • 보통 메모리처럼 행동하지 않는다.
    • 메모리 대응 입출력(memory-mapped I/O)
      • 보통의 메모리를 읽고 쓰는 게 아니라,
      • 외부 감지기, 디스플레이, 프린터, 네트워크 포트같은 주변장치와 실제로 통신한다.
      • // 만일 x가 온도계가 보고하는 값이라면
        auto y = x;
        y = x; // 이 시점에 x의 값은 변경되었을 수 있다.
        
        // 만일 x가 무선 신호 전송기의 포트라면
        x = 10;
        x = 20; // 다른 명령을 요청하는 작업일 수 있다.
  • 즉, volatile로 선언되었을 때,
    • 컴파일러는 이 메모리에 대한 연산들에는 그 어떤 최적화도 수행하지 말라는 지시로 생각한다.
  • 특별한 메모리에 대해서는 std::atomic이 적합하지 않다.
    • C++ 표준은 std::atomic에 대한 남아도는 연산들을 컴파일러가 제거하는 걸 허용한다.
    • 그리고 만일 x가 std::atomic이라면 아래 문장은 컴파일되지 않는다.
    • auto y = x;
      y = x;
    • std::atomic의 복사 연산들은 삭제되었기 때문이다.(항목 11 참조)
    • x, y 모두 std::atomic 타입이라면, x에서 읽기, y에 쓰기 연산이 모두 원자적이어야 한다.
      • 하지만 이 두 연산을 원자적으로 처리하는 건 하드웨어 수준에서 지원하지 않는다.

 

 

 

std::atomic 변수들로 읽기-쓰기 처리하기
  • load와 store 함수를 사용하면 된다.
std::atomic<int> y(x.load());

y.store(x.load());
  • 다만 이 경우 읽기와 쓰기를 합쳐서 원자적으로 수행되진 않는다.
  • 또한 이 경우 컴파일러가 x의 값을 두번 읽는 걸 최적화할 수도 있다.
// 컴파일러가 x를 한 번만 읽도록 수정한 코드
레지스터 = x.load();

std::atomic<int> y(레지스터);

y.store(레지스터);
  • 헌데 이건 특수 메모리에서는 반드시 피해야 하는 종류의 최적화이다.

 

 

 

정리
  • std::atomic은 동시적 프로그래밍에 유용하지만, 특별한 메모리의 접근에는 유용하지 않다.
  • volatile은 특별한 메모리의 접근에 유용하나, 동시적 프로그래밍에는 유용하지 않다.
  • 즉, 둘의 용도가 다르므로 함께 사용하는 것도 가능하다.
volatile std::atomic<int> vai; // 원자적이며, 최적화로 제거될 수 없다.

 

728x90
Comments