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

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 7. 동시성 API

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

🔍 volatile과 std::atomic 개요

  • volatile은 오해를 너무 많이 받는다. 사실 volatile은 동시적 프로그래밍과는 무관하다.
  • Java와 C#에서는 volatile이 동시적 프로그램에 유용하며, C++에서도 일부 컴파일러는 동시적 소프트웨어에 적용할 수 있는 개념을 volatile에 부여한다(그런 컴파일러로 컴파일했을 때만). 오해를 해소하자는 취지에서 이번 장에서 논의한다.
  • 프로그래머들이 종종 volatile과 혼동하는 C++의 기능이 std::atomic이다.
    • std::atomic<int>, std::atomic<bool>, std::atomic<Widget*> 등의 인스턴스는 다른 스레드들이 반드시 원자적으로 인식하는 연산들을 제공한다.
    • 성공적으로 생성되고 나면, 그 객체에 대한 연산은 마치 뮤텍스로 보호되는 임계 영역 안에서 수행되는 것처럼 작동한다.
    • 그러나 이러한 원자적 연산은 실제로 뮤텍스를 사용할 때보다 더 효율적인 특별한 기계어 명령들로 구현되는 것이 보통이다.
std::atomic<int> ai(0);   // ai를 0으로 초기화한다

ai = 10;                  // 원자적으로 ai를 10으로 설정한다
std::cout << ai;          // 원자적으로 ai의 값을 읽는다
++ai;                     // 원자적으로 ai를 증가한다(11이 된다)
--ai;                     // 원자적으로 ai를 감소한다(10이 된다)
  • 이 문장들을 실행하는 동안 ai를 읽는 다른 스레드들이 보게 되는 값은 0이나 10, 11뿐이다(ai를 현재 스레드만 수정한다고 할 때).
  • 주목할 점 두 가지
    • std::cout << ai;에서 ai가 std::atomic 객체라는 점이 보장하는 것은 ai의 읽기가 원자적이라는 것뿐이다. 전체 문장이 원자적으로 처리된다는 보장은 없다. ai를 읽는 시점과 operator<<가 호출되어 출력되는 시점 사이에 다른 스레드가 ai를 수정할 수도 있다. 하지만 int에 대한 operator<<는 값 전달 매개변수를 사용하므로 행동에는 영향이 없다.
    • ++ai, --ai는 읽기-수정-쓰기(read-modify-write, RMW) 연산이지만 각각 원자적으로 수행된다. 일단 std::atomic 객체가 생성되면 그 객체에 대한 모든 멤버 함수는, 심지어 RMW 연산을 수행하는 멤버 함수도 다른 스레드들에게 반드시 원자적으로 보인다.

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

volatile int vi(0);   // vi를 0으로 초기화한다

vi = 10;              // vi를 10으로 설정한다
std::cout << vi;      // vi의 값을 읽는다
++vi;                 // vi를 증가한다(11이 된다)
--vi;                 // vi를 감소한다(10이 된다)
  • 이 코드를 실행하는 동안 다른 스레드들이 vi를 읽는다면 -12나 68, 4090727 등 그 어떤 값이라도 볼 수 있다. 이런 코드는 미정의 행동을 유발한다.
  • std::atomic도 아니고 뮤텍스로 보호되지도 않는 메모리에 기록자(writer)들과 판독자(reader)들이 동시에 접근하려는 상황을 자료 경쟁(data race)이라고 부른다.

⚠️ 원자성 차이: 카운터 예제

std::atomic 카운터와 volatile 카운터를 여러 스레드가 증가하는 상황을 보자.

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

동시에 실행되는 두 스레드에서 각 카운터를 한 번씩 증가한다.

/*----- 스레드 1 -----*/      /*----- 스레드 2 -----*/

      ++ac;                         ++ac;
      ++vc;                         ++vc;
  • 두 스레드 모두 끝난 후 ac의 값은 반드시 2이다. 각 증가 연산이 더 분해할 수 없는 원자적인 연산으로 실행되었기 때문이다.
  • 그러나 vc의 값은 2일 수도 있고 아닐 수도 있다. 증가 연산이 원자적으로 실행되지 않았기 때문이다. 하나의 증가 연산은 값을 읽고, 증가하고, 결과를 다시 기록하는 세 연산으로 이루어지는데, volatile은 이 세 연산이 원자적으로 진행된다는 보장이 없다. 예를 들어 다음과 같이 뒤섞여 진행될 수 있다.
    1. 스레드 1이 vc의 값을 읽는다. 그 값은 0이다.
    2. 스레드 2가 vc의 값을 읽는다. 그 값은 여전히 0이다.
    3. 스레드 1이 자신이 읽은 값 0을 증가하고, 그 결과인 1을 vc에 기록한다.
    4. 스레드 2가 자신이 읽은 값 0을 증가하고, 그 결과인 1을 vc에 기록한다.
  • 이러면 vc가 두 번 증가했지만 최종 값은 1이다.
  • 물론 이와는 다른 결과도 나올 수 있다. 일반적으로 vc의 최종 값은 예측할 수 없다. vc는 자료 경쟁에 관여하고 표준은 자료 경쟁을 미정의 행동으로 간주하므로, 어떤 코드를 생성할 것인지는 컴파일러 마음이다. 컴파일러는 자료 경쟁이 없는 프로그램이라면 유효할 최적화를 수행하며, 그 최적화는 자료 경쟁이 존재하는 프로그램에서는 예기치 못한, 예측할 수 없는 행동을 낳는다.

⚠️ 코드 재배치 제약 차이

동시성과 관련해서 std::atomic은 성공하지만 volatile은 실패하는 상황이 RMW 연산만은 아니다. 한 과제가 중요한 값을 계산하고, 둘째 과제가 그 값을 사용한다고 하자. 필요한 값이 준비되었음을 std::atomic<bool>로 알려주는 방법(항목 39)을 보자.

std::atomic<bool> valAvailable(false);

auto imptValue = computeImportantValue();   // 값을 계산한다

valAvailable = true;                        // 다른 과제에게 값이
                                            // 준비되었음을 알린다
  • 사람은 imptValue 대입이 반드시 valAvailable 대입보다 먼저 일어나야 함을 알지만, 컴파일러는 두 대입문을 단지 서로 독립적인 변수에 대한 두 대입으로 볼 뿐이다.
  • 일반적인 규칙으로, 서로 무관한 대입들의 순서를 컴파일러가 임의로 바꾸는 것은 적법하다.
a = b;      // a와 b, x, y는 각자 독립적인 변수
x = y;

// 컴파일러가 순서를 이렇게 바꿀 수도 있다
x = y;
a = b;
  • 컴파일러가 순서를 바꾸지 않아도 바탕 하드웨어가 바꿀 수도 있다(또는 다른 코어들에게는 순서가 바뀐 것처럼 보이게 할 수도 있다). 순서를 바꾸면 코드가 더 빨리 실행되는 경우가 있기 때문이다.
  • 그러나 std::atomic을 사용하면 코드의 순서 재배치에 대한 제약이 생긴다. 그중 하나는, 소스 코드에서 std::atomic 변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되지 않아야 한다는 것이다.
    • (각주) 이는 순차적 일관성(sequential consistency)을 사용하는 std::atomic에만 해당한다. C++11은 좀 더 유연한 코드 재배치 규칙들을 가진 다른 일관성 모형들(완화된(relaxed) 일관성 등)도 지원한다. 이를 이용하면 일부 하드웨어 아키텍처에서 좀 더 빠르게 실행되는 소프트웨어를 만들 수 있지만, 해당 소프트웨어를 제대로 작성하고 이해하고 유지보수하기가 훨씬 더 어려워진다. 가능하면 순차적 일관성을 고집하는 것이 바람직하다.
  • 따라서 valAvailable을 std::atomic으로 선언하면 필수적인 코드 순서 요구사항(모든 스레드가 imptValue의 변화를 valAvailable의 변화보다 먼저 볼 수 있어야 한다는 것)이 유지된다.

valAvailable을 volatile로 선언하면 그런 코드 재배치 제약이 가해지지 않는다.

volatile bool valAvailable(false);

auto imptValue = computeImportantValue();

valAvailable = true;   // 다른 스레드들은 이 대입을
                       // imptValue 대입 이전에 볼 수 있다
  • 컴파일러가 대입의 순서를 바꿀 수 있고, 그렇게까지 하지는 않더라도 바탕 하드웨어에서 다른 코어들이 valAvailable의 변화를 imptValue의 변화 이전에 볼 가능성이 있는 기계어 코드를 생성할 수 있다.
  • 이 두 가지 문제점, 즉 연산의 원자성을 보장하지 않는다는 점과 코드 재배치에 대한 제약이 충분하지 않다는 점은 volatile이 동시적 프로그래밍에 유용하지 않은 이유를 잘 말해준다.

🔍 volatile은 어디에 유용한가: 특별한 메모리

  • 간단히 말해, volatile은 volatile이 적용된 변수가 사용하는 메모리가 보통의 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다.
  • ‘보통’ 메모리에는 메모리의 한 장소에 어떤 값을 기록하면 다른 어떤 값을 덮어쓰지 않는 한 그 값이 유지된다는 특성이 있다.
int x;

auto y = x;   // x를 읽는다
y = x;        // x를 다시 읽는다
  • 컴파일러는 둘째 줄, 즉 y에 대한 대입문을 제거해서 목적 코드를 최적화할 수 있다. 둘째 줄은 첫째 줄(y의 초기화)과 정확히 같은 일을 하기 때문이다.
  • 또한 보통의 메모리에는, 어떤 메모리 장소에 값을 기록한 후 한 번도 읽지 않고 같은 장소에 값을 다시 기록한다면 첫 번째 기록은 제거할 수 있다는(한 번도 사용되지 않았으므로) 특성이 있다.
x = 10;   // x를 기록한다
x = 20;   // x를 다시 기록한다
  • 컴파일러는 첫 문장을 제거할 수 있다. 따라서 다음 소스 코드는,
auto y = x;   // x를 읽는다
y = x;        // x를 다시 읽는다
x = 10;       // x를 기록한다
x = 20;       // x를 다시 기록한다
  • 컴파일러가 다음과 같이 취급할 수 있다.
auto y = x;   // x를 읽는다
x = 20;       // x를 기록한다
  • 이런 남아도는 적재(redundant loads)와 죽은 저장(dead stores)을 수행하는 코드를 사람이 직접 작성하지는 않겠지만, 합당해 보이는 소스 코드에 대해 컴파일러가 템플릿 인스턴스화와 인라인화, 다양한 순서 재배치 최적화를 적용하고 나면 이런 것들이 드물지 않게 생겨난다.
  • 이런 최적화들은 메모리가 보통의 방식으로 행동할 때에만 유효하다. 그러나 ‘특별한’ 메모리는 그렇지 않다. 가장 흔한 것은 메모리 대응 입출력(memory-mapped I/O)에 쓰이는 메모리다.
    • 그런 메모리에 대한 접근은 보통의 메모리(RAM)를 읽거나 쓰는 것이 아니라 외부 감지기나 디스플레이, 프린터, 네트워크 포트 같은 주변장치와 실제로 통신한다.
    • 만일 x가 이를테면 온도계가 보고하는 값이라면, 두 번째 x 읽기는 여분이 아니다. 첫 번째 읽기와 두 번째 읽기 사이에 온도가 변했을 수도 있기 때문이다.
    • 만일 x가 무선 신호 전송기의 제어 포트에 해당한다면, x = 10; x = 20;은 무선으로 명령을 보내는 작업일 수 있으며 10과 20은 각각 다른 명령에 대응될 것이다. 최적화 때문에 첫 대입문이 제거되면 전송되는 명령들이 달라진다.
  • volatile은 해당 코드가 특별한 메모리를 다룬다는 점을 컴파일러에게 알려주는 수단이다. 즉 “이 메모리에 대한 연산들에는 그 어떤 최적화도 수행하지 말라”는 지시다.
volatile int x;

auto y = x;   // x를 읽는다
y = x;        // x를 다시 읽는다(최적화로 제거할 수 없음)

x = 10;       // x를 기록한다(최적화로 제거할 수 없음)
x = 20;       // x를 다시 기록한다
  • x가 메모리에 대응된 입출력 장소에 해당하는 경우(또는 여러 프로세스가 공유하는 메모리 장소에 대응된 경우 등)에는 정확히 이런 식으로 행동해야 한다.
  • (각주) 깜짝 퀴즈: 위 예제에서 y의 타입은 int일까, volatile int일까? y는 auto로 선언되었으므로 타입은 항목 2의 규칙들에 따라 추론된다. 비참조/비포인터 타입을 선언할 때는 const와 volatile 한정자가 제거된다. 따라서 y의 타입은 그냥 int이다. 이는 y에 대한 남아도는 읽기들과 기록들을 컴파일러가 제거할 수 있음을 뜻한다. 이 예에서 x가 volatile이므로 컴파일러는 반드시 y의 초기화와 대입을 둘 다 수행해야 한다. 따라서 x를 처음 읽을 때와 두 번째로 읽을 때의 값이 다를 수 있다.

⚠️ 특별한 메모리에는 std::atomic이 적합하지 않다

  • 특별한 메모리를 다룰 때에는 남아도는 적재들과 죽은 저장들로 보이는 연산들을 반드시 유지해야 한다. 이는 이런 종류의 작업에 std::atomic이 적합하지 않다는 점을 말해준다.
  • C++ 표준은 std::atomic에 대한 그러한 남아도는 연산들을 컴파일러가 제거하는 것을 허용한다. 개념적으로 컴파일러는 다음과 같은 코드를
std::atomic<int> x;

auto y = x;   // 개념적으로 x를 읽는다(아래 설명 참고)
y = x;        // 개념적으로 x를 다시 읽는다(아래 설명 참고)

x = 10;       // x를 기록한다
x = 20;       // x를 다시 기록한다
  • 다음과 같이 최적화할 수 있다. 특별한 메모리에 대해서는 이것이 허용할 수 없는 행동임이 명백하다.
auto y = x;   // 개념적으로 x를 읽는다(아래 설명 참고)
x = 20;       // x를 기록한다
  • 그런데 x가 std::atomic일 때에는 위의 두 문장 모두 컴파일되지 않는다.
auto y = x;   // 오류!
y = x;        // 오류!!
  • 이는 std::atomic의 복사 연산들이 삭제되었기 때문이다(항목 11 참고). 삭제된 데에는 그럴 만한 이유가 있다.
    • x로 y를 초기화하는 문장이 오류가 아니라면, x가 std::atomic이므로 y의 타입도 std::atomic으로 추론될 것이다(항목 2).
    • std::atomic의 큰 장점은 객체에 대한 모든 연산이 원자적이라는 것인데, x를 이용한 y의 복사 생성 연산이 원자적이려면 컴파일러는 x를 읽고 y를 기록하는 작업을 하나의 원자적 연산으로 수행하는 코드를 생성해야 한다.
    • 대체로 그런 원자적 연산을 하드웨어 수준에서 지원하지는 않기 때문에, 표준 위원회는 std::atomic 타입에 대해 복사 생성을 지원하지 않기로 했다. 복사 대입 역시 같은 이유로 삭제되었다.
    • (이동 연산들은 std::atomic에 대해 명시적으로 선언되어 있지 않으므로, 항목 17에서 설명한 컴파일러의 특수 멤버 함수 생성 규칙들에 따르면 std::atomic은 이동 생성도, 이동 대입도 제공하지 않는다.)
  • 그렇다고 x의 값을 y에 넣는 것이 불가능한 것은 아니다. std::atomic의 멤버 함수 load(원자적으로 읽는다)와 store(원자적으로 기록한다)를 사용하면 된다.
std::atomic<int> y(x.load());   // x를 읽는다
y.store(x.load());              // x를 다시 읽는다
  • 이 코드가 컴파일되긴 하지만, x를 읽는 연산(x.load()를 통한)과 그 값으로 y를 초기화하는 연산 또는 y에 저장하는 연산이 개별적인 함수 호출들이므로 두 문장이 각각 하나의 원자적 연산으로 실행되리라고 기대할 이유가 없다.
  • 컴파일러가 x의 값을 두 번 읽는 대신 첫 번째 읽기에서 얻은 값을 레지스터에 저장해서 이러한 코드를 ‘최적화’할 수도 있다.
레지스터 = x.load();              // x를 레지스터로 읽어 들인다
std::atomic<int> y(레지스터);     // y를 레지스터 값으로 초기화한다
y.store(레지스터);                // 레지스터 값을 y에 저장한다
  • (옮긴이) 원서 소스 코드에서는 변수 이름이 register였는데, C++14에서도 register는 여전히 C++의 예약어이다. 현대적인 컴파일러에게 저장 부류 지정자로서의 register는 사실상 빈칸과 같다. 그래서 C++17에서는 저장 부류 지정자로서의 register가 제거될 예정이다. 그러나 auto처럼 나중에 다른 의미로 쓰일 수도 있다는 이유로, 그리고 기존 코드를 깨뜨리지 않기 위해 register라는 단어 자체는 C++의 예약어로 남을 전망이다.
  • 결과적으로 x는 한 번만 읽힌다. 따라서 이는 특수 메모리를 다룰 때에는 반드시 피해야 하는 종류의 최적화이다(volatile 변수에 대해서는 이런 최적화가 허용되지 않는다).

🔍 std::atomic과 volatile의 구분

  • std::atomic은 동시적 프로그래밍에 유용하나, 특별한 메모리의 접근에는 유용하지 않다.
  • volatile은 특별한 메모리의 접근에 유용하나, 동시적 프로그래밍에는 유용하지 않다.
  • 둘의 용도가 다르므로 함께 사용하는 것도 가능하다.
volatile std::atomic<int> vai;   // vai에 대한 연산들은
                                 // 원자적이며, 최적화에
                                 // 의해 제거될 수 없다
  • 이 코드는 vai가 여러 스레드가 동시에 접근할 수 있는 메모리 대응 입출력 장소일 때 유용할 것이다.

💡 load/store 사용에 관한 참고

  • 꼭 필요한 경우가 아니어도 std::atomic의 load, store 멤버 함수를 사용하는 개발자들이 있다. 그렇게 하면 관련된 변수들이 ‘보통’의 변수가 아니라는 사실이 소스 코드에 명백히 드러나기 때문이다.
  • 그러한 사실을 강조하는 것이 비합리적인 일은 아니다. 일반적으로 std::atomic 변수에 접근하는 것은 std::atomic이 아닌 변수에 접근하는 것보다 훨씬 느리며, std::atomic을 사용하면 컴파일러는 최적화를 위한 몇몇 코드 재배치 기법들(std::atomic이 없었다면 적용할 수 있었을)을 적용할 수 없게 된다.
  • 따라서 load와 store를 호출하면 잠재적인 확장성(scalability) 병목 지점들을 식별하는 데 도움이 된다. 정확성의 관점에서는, 다른 스레드들과 정보를 주고받는 데 쓰이는 어떤 변수에 대한 store 호출이 소스 코드에 없다는 것은 애초에 그 변수를 std::atomic으로 선언했어야 했는데 그러지 못했다는 문제점을 말해주는 것일 수도 있다.
  • 그러나 이는 대체로 스타일상의 문제이며, std::atomic과 volatile의 선택과는 상당히 다른 문제이다.

🧐 정리

  • std::atomic은 뮤텍스 보호 없이 여러 스레드가 접근하는 자료를 위한 것으로, 동시적 소프트웨어의 작성을 위한 도구이다.
  • volatile은 읽기와 기록을 최적화로 제거하지 말아야 하는 메모리를 위한 것으로, 특별한 메모리를 다룰 때 필요한 도구이다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기