KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 26. 10:07

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

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

 

std::async 그 함수를 비동기적으로 실행하겠다는 의도를 품고 있지만, 항상 그런 의미일 필요는 없다. 사실 std::async 는  함수를 어떤 시동 방침(launch policy) 에 따라 실행한다는 좀 더 일반적인 의미를 가진다. 시동 방침은 std::launch 범위의 enum 에 저장된 열거자들을 이용해 지정할 수 있다. 함수 f 를 std::async 를 통해 실행한다고 가정해 보자.

  • std::launch::async : f 는 반드시 비동기적으로, 다시 말해서 다른 스레드에서 실행된다.
  • std::launch::deferred : f 는 std::async 가 돌려준 미래 객체(std::future) 에 대해 get 이나 wait 가 호출될 때에만 실행될 수 있다. 다시 말해, f 는 그러한 호출이 일어날 때까지 지연된다(deferred). get 이나 wait 가 호출되면 f 는 동기적으로 실행된다. 즉, 호출자는 f 의 실행이 종료될 때까지 차단된다. get 이나 wait 가 호출되지 않으면 f 는 결코 실행되지 않는다.

기본 시동 방침은 위를 OR 로 결합한 것이다. 따라서 아래 코드의 두 줄은 같은 의미를 가진다!

auto fut1 = std::async(f);

auto fut2 = std::async(
  std::launch::async |
  std::launch::deferred,
  f);

 

그런데 std::async 를 기본 시동 방침과 함께 사용하면 몇 가지 흥미로운 영향이 생긴다. 예를 들어 다음 문장이 스레드 t 에서 실행된다고 하자.

auto fut = std::async(f);

 이 때 생길수 있는 상황은 다음과 같다.

  • f 가 지연 실행될 수도 있으므로, f 와 t 가 동시에 실행될지 예측하는 것이 불가능하다.
  • f 가 fut 에 대해 get 이나 wait 을 호출하는 스레드와는 다른 스레드에서 실행될지 예측하는 것이 불가능하다.
  • fut 에 대한 get 이나 wait 호출이 일어난다는 보장이 없으므로, f 가 반드시 실행될 것인지 예측하는 것이 불가능할 수도 있다.

기본 시동 방침의 스케줄링 유연성이 thread_local 변수들의 사용과는 궁합이 잘 맞지 않는 경우도 많다. f 에 그런 스레드 지역 저장소(thread-local storage, TLS) 를 읽거나 쓰는 코드가 있다고 할 때, 그 코드가 어떤 스레드의 지역 변수에 접근할지 예측할 수 없기 때문이다. 즉, 위에서 f 의 TLS 는 독립적인 스레드의 것일 수도 있고, fut 에 대해 get 이나 wait 를 호출하는 스레드의 것일 수도 있다.

이 유연성은 만료 시간이 있는 wait 기반 루프에도 영향을 미친다. 지연된 과제에 대해 wait_for 이나 wait_until 을 호출하면 std::future_status::deferred 라는 값이 반환되기 때문이다. 이 때문에, 다음 코드의 루프가 무한 실행될 수도 있다.

// C++14 의 시간 접미사 활용 (항목 34 참고)
using namespace std::literals;

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

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

// f 의 실행이 끝날 때까지 루프 반복
while (fut.wait_for(100ms) !=
  std::future_status::ready)
{
  ...
}

f 가 std::async 를 호출한 스레드와 동시에 실행된다면(즉, f 를 시동 방침 std::launch::async 로 실행했다면), f 자체가 무한 실행되지 않는 이상 문제가 없다. 하지만 f 가 지연된다면, fut.wait_for 는 항상 std::future_status::deferred 를 돌려준다. 따라서 루프가 절대 종료되지 않는다.

위 코드는 아래와 같이 수정할 수 있다.

// 과제 지연시
if (fut.wait_for(0s) ==
  std::future_status::deferred)
{
  // fut 에 wait 이나 get 을 적용해서
  // f 를 동기적으로 호출
  ... 
} 
else 
{
  while (fut.wait_for(100ms) !=
    std::future_status::ready)
  {
    // fut 이 준비되기 전까지 동시적 작업 수행
    ...
  }
  // fut 이 준비됨
  ...
}

 

이런 고려 사항을 통해, std::async 를 사용하는 것은 다음의 조건들이 모두 성립할 때에만 적합하다는 결론을 내릴 수 있다.

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

이 조건 중 하나라도 성립하지 않으면, std::async 가 주어진 과제를 진정으로 비동기적으로 실행하도록 강제할 필요가 있다. 그렇게 하는 방법은, std::launch::async 를 첫 인수로 지정해서 std::async 를 호출하는 것이다. 

auto fut = std::async(std::launch::async, f);

함수를 호출할 때마다 std::launch::async 를 사용하지 않아도 되게 도와주는 템플릿 함수를 정의해보자.

// C++11 버전. C++14 에서는 리턴 타입을 auto 로 대체 가능
template<typename F, typename... Ts>
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& t, Ts&&... params)
{
  return std::async(
    std::launch::async,
    std::forward<F>(f),
    std::forward<Ts>(params)...);
}

...

// f 를 비동기적으로 실행한다;
// std::async 가 예외를 던질 상황이면
// 이 함수도 같은 예외를 던진다
auto fut = reallyAsync(f);

 

Comments