[Modern C++] 항목 36: 비동기성이 필수일 때에는 std::launch::async를 지정하라

게시:     수정

카테고리:

태그: ,

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

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

📦 7. 동시성 API

👉🏻 항목 36: 비동기성이 필수일 때에는 std::launch::async를 지정하라

🔍 시동 방침(launch policy)

  • std::async 호출은 항상 비동기 실행을 뜻하지 않는다. 함수(또는 호출 가능한 객체)를 어떤 시동 방침에 따라 실행한다는 더 일반적인 의미를 가진다.
  • 표준이 제공하는 시동 방침은 두 가지이며, std::launch 범위 있는 enum의 열거자로 지정한다(항목 10).
  • 함수 f를 std::async로 실행한다고 할 때,
    • std::launch::async: f는 반드시 비동기적으로, 즉 다른 스레드에서 실행된다.
    • std::launch::deferred: f는 std::async가 돌려준 미래 객체에 대해 get이나 wait가 호출될 때에만 실행될 수 있다.
      • f의 실행은 그러한 호출이 일어날 때까지 지연(deferred)된다.
      • get이나 wait가 호출되면 f는 동기적으로 실행된다. 즉 호출자는 f의 실행이 종료될 때까지 차단된다.
      • get이나 wait가 호출되지 않으면 f는 결코 실행되지 않는다.
    • (각주) 사실 중요한 것은 get이나 wait가 호출되는 미래 객체가 아니라, 그 미래 객체가 가리키는 공유 상태이다(항목 38). 다만 일반적으로 std::async가 돌려준 미래 객체에 대한 호출을 기준으로 이야기한다.

🔍 기본 시동 방침

  • 두 방침 모두 std::async의 기본 시동 방침(시동 방침을 명시하지 않았을 때 쓰이는 것)이 아니다. 기본 시동 방침은 둘을 OR로 결합한 것이다. 즉 다음 두 호출은 완전히 같은 의미이다.
auto fut1 = std::async(f);             // 함수 f를 기본
                                       // 시동 방침으로
                                       // 실행한다

auto fut2 = std::async(std::launch::async |    // 함수 f를 비동기로
                       std::launch::deferred,  // 또는 지연해서
                       f);                     // 실행한다
  • 결과적으로 기본 시동 방침에서 f는 비동기적으로 실행될 수도 있고 동기적으로 실행될 수도 있다.
  • 이러한 유연성 덕분에 std::async와 표준 라이브러리의 스레드 관리 구성요소들이 스레드의 생성과 파괴, 과다구독 회피, 부하 균형화의 책임을 떠맡을 수 있다(항목 35).

⚠️ 기본 시동 방침의 문제점

다음 문장이 스레드 t에서 실행된다고 하자.

auto fut = std::async(f);   // 기본 시동 방침으로 f를 실행한다
  • f가 지연 실행될 수도 있으므로, f가 t와 동시에 실행될지 예측하는 것이 불가능하다.
  • f가 fut에 대해 get이나 wait를 호출하는 스레드와는 다른 스레드에서 실행될지 예측하는 것이 불가능하다.
  • 프로그램의 모든 가능한 경로에서 fut에 대한 get이나 wait 호출이 일어난다는 보장이 없을 수도 있으므로, f가 반드시 실행될 것인지 예측하는 것이 불가능할 수도 있다.

① thread_local 변수와의 궁합 문제

  • f에 스레드 지역 저장소(TLS)를 읽거나 쓰는 코드가 있다면, 어떤 스레드의 지역 변수에 접근할지 예측할 수 없다.
auto fut = std::async(f);   // f의 TLS가 독립적인 스레드의
                            // 것일 수도 있고, fut에
                            // 대해 get이나 wait를
                            // 호출하는 스레드의 것일 수도
                            // 있다

② 시간 만료 기반 wait 루프의 문제

  • 지연된 과제(항목 35)에 대해 wait_for나 wait_until을 호출하면 std::future_status::deferred라는 값이 반환된다. 그래서 언젠가는 끝날 것처럼 보이는 다음 루프가 실제로는 무한히 실행될 수도 있다.
using namespace std::literals;        // C++14의 시간 접미사들을
                                      // 위해; 항목 34를 보라

void f()                              // f는 1초간 수면 후
{                                     // 반환된다
  std::this_thread::sleep_for(1s);
}

auto fut = std::async(f);             // f를 비동기적으로
                                      // (개념상으로는) 실행한다

while (fut.wait_for(100ms) !=         // f의 실행이 끝날 때까지
       std::future_status::ready)     // 루프를 반복한다...
{                                     // 그런데 실행이 끝나지 않을
  …                                   // 수도 있다!
}
  • f가 std::async를 호출한 스레드와 동시에 실행된다면(즉 std::launch::async로 실행했다면) f 자체가 무한히 실행되는 것이 아닌 이상 문제는 없다.
  • 그러나 f가 지연된다면 fut.wait_for는 항상 std::future_status::deferred를 돌려준다. 그 값은 std::future_status::ready와는 절대 같지 않으므로 루프는 절대 종료되지 않는다.
  • 이런 버그는 부하가 아주 많이 걸리지 않으면 드러나지 않을 수 있어서 개발과 단위 검사 도중에는 간과하기 쉽다. 부하가 많이 걸리면 과다구독이나 스레드 고갈이 나타나고, 그러면 과제가 지연될 가능성이 아주 커진다.

해결책: 지연 여부를 먼저 점검

  • 미래 객체를 이용해서 해당 과제가 지연되었는지 점검하고, 지연되었다면 시간 만료(timeout) 기반 루프에 진입하지 않게 한다.
  • 과제의 지연 여부를 미래 객체로부터 직접 알아내는 방법은 없다. 대신 wait_for 같은 시간 만료 기반 함수를 호출하되, 만료 시간을 0으로 해서 호출한다. 이때 목적은 실제로 뭔가를 기다리는 것이 아니라 반환값이 std::future_status::deferred인지만 보는 것이다.
auto fut = std::async(f);                   // 이전과 동일

if (fut.wait_for(0s) ==                     // 만일 과제가
    std::future_status::deferred)           // 지연되었으면...
{
  …                                         // ...fut에 wait나 get을 적용해서
                                            // f를 동기적으로 호출한다

} else {                                    // 과제가 지연되지 않았다

  while (fut.wait_for(100ms) !=             // 무한 루프는 불가능
         std::future_status::ready) {       // 하다(f가 완료된다고
                                            // 가정할 때)

    …                                       // 과제가 지연되지도 않았고 준비되지도 않았으므로
                                            // 준비될 때까지 동시적 작업을 수행한다
  }

  …                                         // fut이 준비되었다
}

✅ 기본 시동 방침이 적합한 조건

어떤 과제에 대해 기본 시동 방침과 함께 std::async를 사용하는 것은 다음 조건들이 모두 성립할 때에만 적합하다.

  • 과제가 get이나 wait를 호출하는 스레드와 반드시 동시적으로 실행되어야 하는 것은 아니다.
  • 여러 스레드 중 어떤 스레드의 thread_local 변수들을 읽고 쓰는지가 중요하지 않다.
  • std::async가 돌려준 미래 객체에 대해 get이나 wait가 반드시 호출된다는 보장이 있거나, 과제가 전혀 실행되지 않아도 괜찮다.
  • 과제가 지연된 상태일 수도 있다는 점이 wait_for나 wait_until을 사용하는 코드에 반영되어 있다.

🔍 비동기 실행 강제: std::launch::async 지정

  • 위 조건 중 하나라도 성립하지 않는다면 std::async가 주어진 과제를 진정으로 비동기적으로 실행하도록 강제할 필요가 있다. 그 방법은 std::launch::async를 첫 인수로 지정해서 std::async를 호출하는 것이다.
auto fut = std::async(std::launch::async, f);   // f를 비동기적으로
                                                // 시동한다
  • 매번 시동 방침을 명시하지 않아도 되도록 도우미 함수를 작성할 수 있다. 다음은 C++11 버전이다.
template<typename F, typename... Ts>
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params)            // 비동기적 f(params...)
{                                             // 호출을 위한 미래 객체를
  return std::async(std::launch::async,       // 돌려준다
                    std::forward<F>(f),
                    std::forward<Ts>(params)...);
}
  • 이 함수는 호출 가능 객체 f와 0개 이상의 매개변수들로 이루어진 매개변수 팩 params를 받아서 std::async에 완벽하게 전달한다(항목 25).
  • 이때 std::launch::async를 시동 방침으로 지정한다. std::async처럼 params로 f를 호출한 결과를 나타내는 std::future 객체를 돌려준다.
  • 결과의 타입은 타입 특성 std::result_of를 적용한 결과가 바로 그 타입이다(항목 9).
  • 사용법은 std::async와 같다.
auto fut = reallyAsync(f);          // f를 비동기적으로 실행한다;
                                    // std::async가 예외를 던질
                                    // 상황이라면 이 함수도 같은
                                    // 예외를 던진다
  • C++14에서는 reallyAsync의 반환 타입을 auto로 추론할 수 있어서 함수 선언이 더 간단해진다.
template<typename F, typename... Ts>
inline
auto                                          // C++14
reallyAsync(F&& f, Ts&&... params)
{
  return std::async(std::launch::async,
                    std::forward<F>(f),
                    std::forward<Ts>(params)...);
}
  • 이 버전에서는 reallyAsync가 다른 일은 하지 않고 그냥 std::launch::async 시동 방침으로 std::async를 호출할 뿐이라는 점을 더 명확히 알 수 있다.

🧐 정리

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

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

댓글남기기