z Chapter 7. The Concurrency API :: C++, 그래픽 기술 블로그

35. Prefer task-based programming to thread-based

doAsyncWork라는 함수를 비동기적으로 실행하는 경우 방법은 크게 두 가지로, 하나는 std::thread 객체를 생성해서 그 객체에서 doAsyncWork를 실행하는 스레드 기반 프로그래밍과 doAsyncWork를 std::async에 넘겨주는 태스크 기반 프로그래밍으로 나뉘어 집니다. 이 둘의 근본적인 차이는, 태스크 기반 접근 방식은 좀 더 높은 수준의 추상을 체현한다는 점이며, 이를 통해 세부적인 스레드 관리에서 벗어날 수 있습니다.

 스레드는 크게 3가지 의미로 쓰일 수 있습니다.

  • 하드웨어 스레드 - 실제 계산을 수행하는 스레드
  • 소프트웨어 스레드 - 운영체제가 하드웨어 스레드들에서 실행되는 모든 프로세서와 일정 관리를 하는데 사용되는 스레드
  • C++ 표준 라이브러리의 std::thread - 하나의 C++ 프로세스 안에서 바탕 소프트웨어 스레드에 대한 핸들로 작용하는 스레드 이는 어떤 소프트웨어 스레드에도 대응되지 않을 수 있습니다.

 소프트웨어 스레드는 제한된 자원이므로, 이는 실행하려는 함수가 예외를 던질 수 없는 경우에도 한계 이상의 소프트웨어 스레드를 생성하려고 하면 std::system_error 예외를 던집니다. 가용 스레드가 모자라지 않더라도 실행 준비가 된 소프트웨어 스레드가 하드웨어 스레드보다 많은 상황인 과다구독(oversubscription)이 발생하면 OS에서는 하드웨어 상의 실행 시간을 여러 조각으로 나누어서 소프트웨어 스레드에 분배하고, 소프트웨어 스레드가 시간 조각을 시작할 때 문맥 전환(context switching)이 수행됩니다. 이런 경우 CPU 캐시 히트가 잘 일어나지 않게되거나 CPU 캐시가 오염될 가능성이 높아집니다. 이를 해결하기는 어려운데, 직접 해결하기 보다는 다른 누군가에 떠넘기는 방법을 택한다면 std::async을 선택해야 합니다.

 std::async은 std::thread와는 다르게 스레드를 생성하지 않을 수 있으며, 대신 지정된 함수를 결과가 필요한 스레드에서 실행하도록 만들 수 있습니다. 이는 문제가 아예 사라지는 것은 아니지만, 컴퓨터에서 일어나는 일들의 전반적인 상을 더 잘 아는 실행 스케줄러에게 맡기게 합니다.  한가지 주의할 점은 스레드를 생성하지 않을수도 있기 때문에 반응성에 문제가 있을 수 있습니다. 이 때에는 시동 방침을 넘겨주어 반드시 스레드를 생성하도록 하는 것이 좋습니다.

 스레드 기반 프로그래밍에 비해 과제 기반 프로그래밍이 스레드들을 일일이 관리해야하는 번거러움은 없으며, 비동기적으로 실행된 함수의 결과를 자연스럽게 조회할 수 있는 수단을 제공합니다. 하지만 스레드를 직접 다루어야 하는 경우도 존재합니다.

  • 바탕 스레드(소프트웨어 스레드) 적용 라이브러리의 API에 접근해야 하는 경우
  • 응용 프로그램의 스레드 사용량을 최적화해야 하는, 그리고 할 수 있어야 하는 경우
  • C++ 동시성 API가 제공하는 것 이상의 스레드 적용 기술을 구현해야 하는 경우

이것만은 잊지 말자!

  • std::thread API에서는 비동기적으로 실행된 함수의 반환값을 직접 얻을 수 없으며, 만일 그런 함수가 예외를 던지면 프로그램이 종료된다.
  • 스레드 기반 프로그래밍에서는 스레드 고갈, 과다구독, 부하 균형화, 새 플랫폼으로의 적응을 독자가 직접 처리해야 한다.
  • std::async와 기본 시동 방침을 이용한 태스크 기반 프로그래밍은 그런 대부분의 문제를 알아서 처리해준다.

36. Specify std::launch::async if asynchronicity is essential

std::async의 호출은 비동기적으로 작업을 수행하겠다는 의도가 깔려 있지만, 그러나 std::async의 호출이 항상 그런 의미일 필요는 없습니다. std::async 호출은 함수를 어떤 시동 지침(launch policy)에 따라 실행한다는 좀 더 일반적인 의미를 가집니다. 시동 방침에는 두 가지인데, std::launch 범위의 enum에 정의된 열거자들을 이용해서 지정 가능합니다.

  • std::launch::async
    시동 방침을 지정하면 f는 반드시 비동기적으로, 다시 말해서 다른 스레드에서 실행됩니다.
  • std::launch::deferred
    시동 방침을 지정하면 f는 std::async가 돌려준 미래 객체(std::future)에 대해 get이나 wait가 호출될 때까지 지연되다가, get이나 wait가 호출되면 f는 동기적으로 실행됩니다. 즉, 호출자는 f의 실행이 종료될 때까지 차단되며, get이나 wait가 호출되지 않으면 f는 결코 실행되지 않습니다.

기본 시동 방침은 둘을 OR로 결합한 것 입니다. 다시 말하면 함수는 비동기적으로 실행될 수도 동기적으로 실행될 수도 있다. 그런데 위와 같은 특성 때문에 문제가 발생할 수 있다.

  • 함수가 지연 실행될 수도 있으므로, 비동기로 실행될지 예측이 불가능합니다.
  • 함수를 get 이나 wait 을 호출하는 스레드와 다른 스레드에서 함수가 실행될지 알 수 없습니다.
  • get이나 wait 호출이 일어난다는 보장이 없기 때문에 함수가 실행되지 않을 수 있습니다.

 이러한 유연한 스케줄링이 스레드 지역 저장소(TLS)를 읽거나 쓰는 코드가 있을때, 언제 접근하는지 예측이 불가능하기 때문에, thread_local 변수들과도 궁합이 잘 맞지 않습니다. 또한 wait 기반 루프에도 영향을 미치기도 합니다. wait_for  wait_until 을 사용하면 std::future_status::deffered 라는 값이 반환되기 때문이다. 이 때문에 무한 루프가 발생하기도 한다.

using namespace std::literals;

void f()
{
  std::this_thread::sleep_for(1s);
}

auto fut = std::async(f);

while (fut.wait_for(100ms) !=
       std::future_status::ready)
{
  ...
}

 f 가 기존 스레드와 동시에 실행되다면 문제가 발생하지 않지만 f가 지연되는 경우에는 fut.wait_for 의 결과가 항상 std::future_status::deffered를 반환하여 무한 루프가 발생하게 됩니다. 해결 방법은 std::async이 돌려준 미래 겍체를 이용해서 해당 과제가 지연되었는지 점검하고, 지연되었다면 시간 만료 기반 루프에 진입하지 않게 하는 방법입니다. 과제의 지연 여부를 미래 객체로부터 알아내는 방법은 없기에,시간 만료 기반 함수를 호출하여 확인 뒤 wait나 get을 통해 동기적으로 호출합니다. 다음과 같이 구현이 가능합니다.

auto fut = std::async(f);

if (fut.wait_for(0s) ==
    std::future_status::deffered)
{
  ...
} else {
  while (fut.wait_for(100ms) !=
         std::future_status::ready) {
    ...
  }
  
  ...
}
  

 이제 지연이 되더라도 코드는 문제 없이 동작하게 된다. 이에 따라 기본 시동 방침은 다음의 상황에서만 적합하다는 것을 알 수가 있습니다.

  • get 이나 wait 를 호출하는 스레드와 반드시 동시적으로 실행되어야 하는 것은 아닐때
  • thread_local 변수들을 읽소 쓰는지가 중요하지 않을때
  • futrure 객체에 대해 get 이나 wait 이 반드시 호출된다는 보장이 있거나 실행되지 않아도 괜찮은 경우
  • wait_for  wait_until 을 사용하는 코드에 지연 될 수 있다는 조건이 반영된 경우

 위 조건 중에서 하나라도 부합하지 않은 경우에는 반드시 비동기로 수행되도록 변경해야 하며 기본 시동방침을 std::launch::async로 사용하는 함수는 다음과 같이 작성 가능합니다.

template<typename F, typename... Ts>
inline auto reallyAsync(F&& f, Ts&&... params)
{
  return std::async(std::launch::async,
                    std::forward<F>(f),
                    std::forward<Ts>(params)...);
}
 

이것만은 잊지 말자!

  • std::async의 기본 시동 방침은 과제의 비동기적 실행과 동기적 실행을 모두 허용한다.
  • 그러나 이러한 유연성 때문에 thread_local 접근의 불확실성이 발생하고, 태스크가 절대로 실행되지 않을 수도 있고, 시간 만료 기반 wait 호출에 대한 프로그램 논리에도 영향이 미친다.
  • 태스크를 반드시 비동기적으로 실행해야 한다면 std::launch::async를 지정하라.​

37. Make std::threads unjoinable on all paths

모든 std::thread 객체는 합류 가능(joinable) 상태이거나 합류 불가능(unjoinable) 상태입니다. 합류 가능 std::thread는 백그라운드 실행 스레드 중 현재 실행 중이거나 실행 중 상태로 전이할 수 있는 스레드에 대응되며, 차단된 상태이거나 실행 일정을 기다리는 중인 백그라운드 스레드에 해당하는 std::thread 그리고 실행이 완료된 백그라운드에 해당하는 std::thread 객체도 합류 가능으로 간주됩니다. 합류 불가능 std::thread는 말 그대로 합류할 수 없는 std::thread로, 그러한 객체로는 다음과 같은 것들이 있습니다.

  • 기본 생성된 std::thread - 그런 std::thread 객체에는 실행할 함수가 없으므로 백그라운드 스레드와는 대응되지 않습니다.
  • 다른 std::thread 객체로 이동된 후의 std::thread 객체 - 이동의 결과로 원본 std::thread에 대응되는 백그라운드 스레드는 대상 std::thread의 백그라운드 스레드가 됩니다.
  • join에 의해 합류된 std::thread -  join 이후의 std::thread 객체는 실행이 완료된 백그라운드 스레드에 대응되지 않습니다.
  • detach에 의해 탈착된 std::thread - detach는 std::thread 객체와 그에 대응되는 백그라운드 사이의 연결을 끊습니다.

 스레드의 합류 가능성이 중요한 이유는 만일 합류 가능한 스레드의 소멸자가 호출되면 프로그램 실행이 종료되기 때문입니다. 만약 스레드에 대해서 join 혹은 detach를 수행해주지 않고서 스레드가 소멸되면은 문제가 생기게 된다는 의미입니다. 만약 프로그램에서 암묵적으로 join 혹은 detach를 하게 해준다면 더 상황이 나빠질 수 있습니다

[암묵적 join]

스레드의 소멸자가 스레드의 완료를 기다리게 하는 것으로 추적하기 어려운 성능 이상을 만들어 낼 수 있습니다.

[암묵적 detach]

스레드의 소멸자가 객체와 바탕 실행 스레드 사이의 연결을 끊게 만드는 것입니다. 만약 바탕 실행 스레드의 지역 변수를 스레드 객체가 참조하여 함수를 실행하고 있고, 바탕 실행 스레드가 detach로 인해 기다리지 않고 종료될 때  변수가 사라지므로, 스레드 객체에서는 문제가 발생합니다. 

 결국 두가지 경우 모두 적절하지 않으므로 프로그램을 종료하는 것으로 마무리 되었고, 이를 보완하기 위하여 std::thread를 위한 RAII 클래스인 ThreadRAII 클래스를 다음과 같이 만들어 줄 수 있습니다.

class Thread RAII {
 public:
     enum class DtorAction { join, detach };


     ThreadRAII(std::thread&& t, DtorAction a) : action(a), t(std::move(t)) {}
     ~ThreadRAII()
     { 
         if (t.joinable()) {
             if (action == DtorAction.join) t.join();
             else t.detach();
         }
     }


     ThreadRAII(ThreadRAII&&) = default;
     ThreadRAII& operator=(ThreadRAII&&) = default;


     std::thread& get() { return t; }


 private:
     DtorAction action;
     std::thread t;
 };​
  • 생성자는 std::thread 오른값만을 받습니다.
  • 멤버 초기화 목록은 자료 멤버들이 선언된 순서를 따르기에, std::thread 객체를 맨 마지막에 선언하여 해당 함수를 즉시 실행하는 경우에 대한 대응을 해주었습니다.
  • std::thread 객체에 접근 가능한 get 함수를 통해 직접 사용 가능하기에 std::thread 인터페이스를 따로 구현하지 않았습니다.
  • 소멸자에서는 std::thread 객체에 대해 합류 가능인지 부터를 확인합니다.

이것만은 잊지 말자!

  • 모든 경로에서 std::thread를 합류 불가능으로 만들어라.
  • 소멸시 join 방식은 디버깅하기 어려운 성능 이상으로 이어질 수 있다.
  • 소멸시 detach 방식은 디버깅하기 어려운 미정의 행동으로 이어질 수 있다.
  • 자료 멤버 목록에서 std::thread 객체를 마지막에 선언하라.​

38. Be aware of varying thread handle destructor behavior

합류 가능 std::thread는 백그라운드 시스템의 실행 스레드에, 지연되지 않은 과제에 대한 미래 객체는 시스템 스레드에 대응됩니다. 따라서 std::thread 객체와 std::future 객체 모두 시스템 스레드에 대한 핸들이라고 할 수 있다.

 하지만 이 둘의 객체 소멸자들은 다르게 행동합니다. 합류 가능 std::thread를 파괴하면 프로그램이 종료되지만, 미래 객체의 소멸자는 어떨 때에는 마치 암묵적으로 join을 수행한 것 같은 결과를 내고 어떨 때에는 마치 암묵적으로 detach를 수행한 것 같은 결과를 내며 프로그램이 종료되지는 않습니다.

 미래 객체는 피호출자가 결과를 호출자에게 전송하는 통신 채널의 한쪽 끝에 해당합니다. 피호출자의 결과 저장 장소가 고민이 될 수 밖에 없는데, 호출자의 미래 객체에는 std::shared_future로 결과의 소유권이 넘어가거나, 복사되지 않는 형식이 존재할 수도 있는 문제로 인해 불가능하며, 피호출자에 연관된 std::promise 객체는 피호출자의 지역 범위에 있으며, 피호출자가 완료되면 파괴되므로 불가능합니다.

 호출자에 연관된 객체도, 피호출자에 연관된 객체도 피호출자의 결과를 담기에는 적합하지 않아 바깥에 있는 장소인 바로 공유된 상태(shared state), 줄여서 공유 상태에 담게 됩니다. 일반적으로 공유 상태는 힙 기반 객체로 표현되나, 그 형식과 인터페이스, 구현은 표준이 구체적으로 명시하지 않습니다. 이러한 공유 상태의 존재가 중요한 것은, 미래 객체 소멸자의 행동을 그 미래 객체와 연관된 공유 상태가 결정하기 때문입니다.

  • std::async를 통해서 시동된 비지연 태스크에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 태스크가 완료될 때까지 차단됩니다. 본질적으로, 그런 미래 객체의 소멸자는 태스크가 비동기적으로 실행되고 있는 스레드에 대해 암묵적인 join을 수행합니다.
  • 다른 모든 미래 객체의 소멸자는 그냥 해당 미래 객체를 파괴합니다. 비동기적으로 실행되고 있는 태스크의 경우 이는 백그라운드 스레드에 암묵적 detach를 수행하는 것과 비슷합니다. 지연된 과제를 참조하는 마지막 미래 객체의 경우 이는 그 지연된 태스크가 절대로 실행되지 않음을 뜻합니다.

즉 소멸자는 바탕 스레드를 join도 detach도 그 무엇도 실행하지 않습니다. 그냥 미래 객체의 자료 멤버들만 파괴합니다. 이러한 행동에 대한 예외는 다음 조건을 모두 만족하는 미래 객체에 대해서만 일어납니다.

  • 미래 객체가 std::async 호출에 의해 생성된 공유 상태를 참조합니다.
  • 과제의 시동 방침이 std::launch::async입니다.
  • 미래 객체가 공유 상태를 참조하는 마지막 미래 객체입니다.

이 조건을 모두 만족하면 미래 객체의 소멸자는 비동기적으로 살행되는 과제가 완료될 때까지 소멸자의 실행을 차단시킵니다. 이는 std::async로 생성된 과제를 실행하는 스레드에 대해 암묵적 join을 호출하는 것과 동일합니다.

 미래 객체가 std::async 호출에 의해 생긴 공유 상태를 참조하는 지를 판단할 수 있는 수단이 제공되지 않으므로, 임의의 미래 객체에 대한 소멸자가 비동기적으로 실행되는 과제의 완료를 기다리느라 차단될 것인지를 알아내는 것은 불가능합니다. 물론 주어진 미래 객체가 특별한 소멸자 행동을 유발하는 조건들을 만족하지 않음을 미리 알 수 있다면 미래 객체의 소멸자가 차단되는 일이 없을 것을 확신할 수 있습니다.

{
  std::packaged_task<int()> pt (calcValue);
  
  auto fut = pt.get_future();
  std::thread t(std::move(pt));
  ...
};

여기서 주목할 코드는 ...인데 어떤 일이 일어나는 지를 크게 3가지로 나누어 생각해보겠습니다.

  • t에게 아무 일도 일어나지 않으면 t는 합류 가능한 스레드이므로 프로그램이 종료됩니다.
  • t에 대해 join을 수행하면 fut의 소멸자에서 join을 수행할 필요가 없어지므로, 차단될 이유가 없습니다.
  • t에 대해 detach를 수행하면, fut의 소멸자에서 그것을 수행할 필요가 없습니다.

따라서 std::packaged_task에 의해 만들어진 공유 상태를 참조하는 미래 객체가 존재한다면, std::thread를 조작하는 코드에서 내려지기 때문에 소멸자의 특별한 행동을 고려한 코드를 작성할 필요가 없습니다.

이것만은 잊지 말자!

  • 미래 객체의 소멸자는 그냥 미래 객체의 자료 멤버들을 파괴할 뿐이다.
  • std::async를 통해 시동된 비지연 태스크에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 그 ​태스크가 완료될 때까지 차단된다.

39. Consider void futures for one-shot event communication

 스레드들 간의 이러한 스레드간 통신을 처리하는 방식 중 하나는 조건 변수(condition variable)를 사용하는 것입니다. 조건을 검출하는 태스크를 검출 태스크(detecting task)라 부르고 그 조건에 반응하는 태스크를 반응 태스크(reacting task)라고 부르기로 할 때, 조건변수를 이용한 전략은 간단합니다. 

std::condition_variable cv;
std::mutex m;

// 검출 과제
{
  ...
  cv.notify_one();
}

// 반응 과제
{
  ...
  {
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk);
    ...
  }
  ...
}

검출 태스크 쪽의 코드는 아주 간단한 구조이며, 반응 태스크의 코드는 이보다 조금 복잡한데, 이는 조건변수에 대해 wait를 호출하기 전에 먼저 std::unique_lock 객체를 통해서 mutex를 잠가야 하기 때문입니다. 이 코드가 잘 작동하긴 하지만, 검출 과제와 반응 과제에 그런 접근 제어가 필요 없는 경우에도 mutex가 사용된다는 점이 설계에 문제가 있음을 암시합니다. 더욱이 반드시 처리해야 할 문제점이 두 가지 더 존재합니다.

  • 만일 반응 태스크가 wait를 실행하기 전에 검출 태스크가 조건 변수를 통지하면 반응 태스크는 그 통지를 놓치게 되며, 그러면 영원히 통지를 기다리게 됩니다.
  • wait 호출문은 가짜 기상(spurious wakeup)을 고려하지 않는다. 가짜 기상 문제를 제대로 처리하려면, 기다리던 조건이 정말로 발생했는지 확인해야 하며, 깨어난 후 가장 먼저 하는 일이 바로 그러한 확인이어야 합니다. 과제 간 통신을 조건 변수를 이용해서 수행하는 것이 적절한 경우가 많기는 하지만 적어도 지금 예는 이 경우에 해당하지 않습니다.하지만 이는 검출 과제의 몫이며 반응 과제는 판단하지 못할 것입니다.

이를 해결하기 위해 두가지 방법을 소개 합니다.

[bool 플래그]

 일반적인 코드의 경우는 flag가 true가 될 때까지 무한 루프를 돌게 됩니다. 즉, 쓸데없는 자원 낭비를 한다는 의미이다. 이를 방지하기 위해서는 wait을 std::condition_variable과 함께 사용하면 됩니다.

std::condition_variable cv;
std::mutex m;

bool flag(false);

{
  ...
  {
    std::lock_guard<std::mutex> g(m);
    flag = true;
  }
}

{
  ...
  {
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, [] { return flag; });
    ...
  }
  ...
}

 위와 같은 방법으로는 지속적인 폴링도 일어나지 않으며, 가짜 기상이 발생하더라도 wait 상태를 유지하여 이전의 문제는 해결하였지만 mutex 를 사용해야 한다는 점과 bool 변수까지 써야한다는 점은 무언가 깔끔하지 않습니다. 또한 반응 과제에서 wait 이 먼저 호출되어야 한다는 점은 변하지 않았습니다.

[std::promise]

 미래 객체를 통해 검출 과제와 반응 과제에 대한 통신을 하면 앞선 항목에서 살펴보았던 문제를 모두 해결할 수 있습니다. 이때 미래 객체에 대한 설정을 알릴 뿐 값을 주지는 않으므로 std::promise<void>를 사용하면 됩니다. 

std::promise<void> p;

// 검출 과제
p.set_value();

// 반응 과제
p.get_future().wait();

 주의해야할 점은 힙 메모리 관리를 해야한다는 점과 공유 상태가 존재한다는 점입니다. 다른 단점으로는 std::promise를 한 번만 설정할 수 있다는 점이 있습니다. 즉, std::promise와 미래 객체 사이의 통신 채널은 여러 번 되풀이해서 사용할 수 없는 단발성 매커니즘입니다. 이는 여러 번 통신에 사용할 수 있는 조건변수 설계나 플래스 기반 설계와의 주목할 만한 차이점입니다. 스레드를 한 번만 유보한다면, void 미래 객체를 이용하는 설계가 합리적인 선택입니다.

std::promise<void> p;

// 반응 과제
void react();

// 검출 과제
void detect()
{
  ThreadRAII tr(
    std::thread([]
                {
                  p.get_future().wait();
                  react();
                }),
    ThreadRAII::DtorAction::join
  );
  ...
  p.set_value();
  ...
}

더 나아가 반응 과제가 여러 개이어도 문제가 없습니다.

std::promise<void> p;

void detect()
{
  auto sf = p.get_furue().share();
  
  std::vector<std:;thread> vt;
  
  for (int i = 0; i < threadsToRun; ++i) {
    vt.emplace_back([sf]{ sf.wait();
                          react(); });
  }
  
  ...
  p.set_value();
  ...
  
  for (auto& t : vt) {
    t.join();
  }
}

 

이것만은 잊지 말자!

  • 간단한 이벤트 통신을 수행할 때, 조건변수 기반 설계에는 여분의 뮤텍스가 필요하고, 검출 태스크와 반응 태스크의 진행 순서에 제약이 있으며, 이벤트가 실제로 발생했는지를 반응 태스크가 다시 확인해야 한다.
  • 플래그 기반 설계를 사용하면 그런 단점들이 없지만, 대신 차단이 아니라 폴링이 일어난다는 단점이 있다.
  • 조건변수와 플래그를 조합할 수도 있으나, 그런 조합을 이용한 통신 메커니즘은 필요 이상으로 복잡하다.
  • std::promise와 미래 객체를 사용하면 이러한 문제점들을 피할 수 있지만, 그런 접근방식은 공유 상태에 힙 메모리를 사용하며, 단발성 통신만 가능하다.​

40. Use std::atomic for concurrency, volatile for special memory

 

 std::atomic 은 원자성을 보존해주기 위해서 사용되며, volatile 은 컴파일러가 최적화하며 특정 라인을 수행하지 않는 경우가 없도록 해줍니다.

 

std::atomic<int> int ai(0);
ai = 10;
++ai;
--ai;

std::cout << ai;

atomic<int>를 사용하면 문장들을 실행하는 동안 ai를 원자적으로 계산하게 됩니다. 여기서 주목할 점이 두개가 있는데, std::atomic<int>가 보장하는 것은 ai의 읽기가 원자적일 뿐 전체 문장이 원자적이지는 않다는 점입니다. 두번째로는, 증가 연산 및 감소 연산은 읽기-수정-쓰기 연산이지만 각각 원자적으로 수행해준다는 점입니다.

volatile int vi(0);
++vi;
--vi;

std::cout << vi;

 volatile을 사용하는 코드의 경우 다중 스레드 문맥에서 거의 아무것도 보장하지 않습니다. 이 코드를 실행하는 동안 vi 값은 -12, 68과 같은 다양한 값으로 관측가능합니다.위 코드의 출력 결과는 당연히 0이며 컴파일러에서 최적화를 하며 이 둘이 서로 사라질 수 있습니다. 그런데 만약 임베디드나 특정한 상황에서 저 계산식이 사라지면 안된다면, 해당 코드에 대해 컴파일러의 최적화를 막기 위하여 volatile을 사용합니다. 하지만 이는 data race 문제의 해결을 의미하지는 않습니다. 반대로 std::atomic에서는 무의미한 계산을 무시할 수 있습니다.

 이와 같이 동시성과 관련해서는 std::atomic은 성공하지만 volatile은 실패하는 상황이 존재합니다.

 또다른 차이점으로는 코드 재배치라는 컴파일러 최적화가 있습니다. 컴파일러는 코드 라인의 순서를 임의대로 바꾸어 레지스터에 적재하고 내리는데 사용하는 시간을 최적화시킵니다. 그런데 std::atomic을 사용하면 코드의 순서 재배치에 대한 제약들이 생기며, 그런 제약 중 하나는, 소스 코드에서 std::atomic 변수를 기록하는 문장 이전에 나온 그 어떤 코드도 그 문장 이후에 실행되지 않아야 한다는 것입니다. (이는 순차적 일관성(sequential consistency)를 사용하는 std::atomic에만 해당한다.)

 이와 같이 남아도는 적재와 죽은 저장을 효율적인 코드로 변모시켜 효율성을 올리는 작업을 컴파일러에서 수행을 합니다.

 volatile은 특별한 메모리를 다룰 때 사용이되며, 이러한 메모리는 최적화를 수행하지 않습니다.

 간단히 말하자면, volatile은 volatile이 적용된 변수가 사용하는 메모리가 보통의 방식으로 행동하지 않는다는 점을 컴파일러에게 알려주는 역할을 한다. 즉, 컴파일러는 이를 "이 메모리에 대한 연산들에는 그 어떤 최적화도 수행하지 말라"는 지시라고 생각한다.

 

 좀 더 std::atomic 의 특징들을 살펴보자. std::atomic 은 복사와 이동 연산을 지원하지 않는다. 그 이유는 간단하다. 원자성을 보존해야 하는게 주 목적인데 복사를 위해 읽고 기록하는 작업을 원자적으로 계산하는 하드웨어는 지원하지 않기 때문이다. 다행히 복사를 하는 방법이 가능하다.

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

 

이것만은 잊지 말자!

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

+ Recent posts