[Modern C++] 항목 36: 비동기성이 필수일 때에는 std::launch::async를 지정하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 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를 지정하라.
댓글남기기