일반적으로 std::async를 호출해서 어떤 함수나 함수 객체를 실행한다는 것은 그 함수를 비동기적으로 실행하겠다는 의도가 깔려 있다.

하지만 항상 그런 의미일 필요는 없고 std::async 호출은 함수를 어떤 시동 방침에 따라 실행한다는 좀 더 일반적인 의미를 가진다.

 

◾ std::launch::async

넘겨진 함수(f)는 반드시 비동기적(다른 스레드)으로 실행된다.

 

◾ std::launch::deferred

넘겨진 함수(f)는 std::async가 돌려준 future 객체에 대해 get이나 wait가 호출될 때에만 실행될 수 있다.

get이나 wait가 호출될 때까지 지연되며, 호출 시 f는 동기적으로 실행된다.

즉, 호출자는 f가 실행 종료될 때까지 차단되며 get이나 wait가 호출되지 않으면 f는 절대 실행되지 않는다.

 

std::async의 기본 시동 방침은 두 방침을 OR 연산자로 결합한 것이다.

 

auto fut1 = std::async(f);

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

위 둘은 완전히 같은 의미이다. 두 방침을 모두 가지고 있기 때문에 유연성을 가진다.

그런데 그 유연성 때문에 기본 방침으로 f를 실행하면 실행 시점의 예측이 불가능해진다.

 

auto fut = std::async(f);

예를 들어 위의 문장이 스레드 t에서 실행된다고 했을 때,

 

◾ f는 지연 실행될 수도 있으므로, f가 t와 동시에 실행될지 예측하는 것이 불가능하다.

f가 fut에 대해 비동기적으로 실행될지 예측하는 것이 불가능하다.

◾ fut에 대한 get이나 wait 호출이 일어난다는 보장이 없을 수도 있으므로, f가 반드시 실행될 것인지 예측하는 것이 불가능하다.

 

이런 유연성은 만료 시한이 있는 wait 기반 루프에도 영향을 미친다. 지연된 작업에 대해 wait_for나 wait_until을 호출하면 std::future_status::deferred라는 값이 반환되기 때문이다.

 

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가 지연된다면 fut.wait_for는 항상 std::future_status::deferred를 반환하고 두 값은 절대 같지 않기 때문에 루프를 영원히 돌게된다. 심지어 이런 버그는 부하가 아주 많이 걸리지 않는 이상 드러나지 않아서 발견하기도 어려울 수 있다.

 

생각보다 이런 문제의 해결책은 간단한데, std::async 호출이 돌려준 future 객체를 이용해서 해당 작업이 지연되었는지 점검하고 만약 지연되었다면 시간 만료 기반 루프에 진입시키지 않으면 된다.

실제로 작업 지연여부를 직접 알아내는 방법은 없지만 wait_for 같은 시간 만료 기반 함수를 호출해서 반환값이 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) {
        ...
    }
}

 

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

 

◾ 작업이 get이나 wait를 호출하는 스레드와 반드시 동시적으로 실행되어야 하는 것은 아니다.

◾ 여러 스레드 중 어떤 스레드의 thread_local 변수들을 읽고 쓰는지가 중요하지 않다.

◾ std::async가 돌려준 future 객체에 대해 get이나 wait가 반드시 호출된다는 보장이 있거나, 작업이 전혀 실행되지 않아도 괜찮다.

◾ 작업이 지연된 상태일 수도 있다는 점이 wait_for나 wait_until을 사용하는 코드에 반영되어 있다.

 

위 조건중 하나라도 충족하지 않는다면 비동기적으로 실행하도록 강제할 필요가 있다.

 

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

그런데 매번 시동 방침을 지정하는 것은 번거롭다.

 

template<typename F, typename... Ts> // C++11
inline std::future<typename std::result_of<F(Ts...)>::type> reallyAsync(F&& f, Ts&&... params) {
    return std::async(std::launch::async, std::forward<F>(f), std::forward<Ts>(params)...);
}

template<typename F, typename... Ts> // C++14
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를 지정하라.

함수를 비동기적으로 실행하는 방법은 크게 두가지로 나뉜다.

 

int doAsyncWork();

std::thread t(doAsyncWork); // 스레드(thread) 기반 프로그래밍

auto fut = std::async(doAsyncWork); // 작업(task) 기반 프로그래밍

스레드 객체에 함수를 넘겨서 실행하는 스레드 기반 프로그래밍과 std::async에 함수 객체를 넘기는 방법이다.

대체로 작업 기반 접근방식이 스레드 기반 접근방식보다 우월하다.

 

일단 스레드 기반 프로그래밍은 넘겨준 함수의 반환값을 돌려받을 수 없지만 작업 기반 접근방식은 반환값을 돌려받을 수 있다. 작업 기반 접근방식이 반환값을 돌려받을 수 있는 이유는 std::async가 돌려주는 future객체에 get이라는 함수가 존재하기 때문에 가능하다. 게다가 넘겨준 함수가 예외를 방출한다면 더욱 더 중요하다. get을 통해 예외에도 접근할 수 있기 때문이다.

반면에 스레드 기반 프로그래밍에서는 넘겨준 함수가 예외를 던지면 std::terminate 호출을 통해 프로그램이 죽게된다.

 

또한 스레드 기반 프로그래밍과 작업 기반 프로그래밍의 좀 더 근본적인 차이는 작업 기반 프로그래밍의 더 높은 추상화에 있다.

 

일단 스레드라는 용어의 의미들을 짚고 넘어가보자.

 

◾ 실제 계산을 수행하는 스레드를 뜻하는 하드웨어 스레드

◾ OS가 하드웨어 스레드들에서 실행되는 모든 프로세서와 일정을 관리하는 데 사용하는 소프트웨어 스레드

◾ C++ 표준 라이브러리의 std::thread

 

소프트웨어 스레드는 한정적인 자원이기 때문에 시스템이 제공할 수 있는 스레드보다 더 많은 스레드를 생성하려고 하면 std::system_error 예외가 발생한다. 설령 함수가 noexcept라고 해도 예외가 발생한다.

 

소프트웨어 스레드가 부족한 상황을 처리하는데에도 std::async가 유용하게 쓰일 수 있다.

스레드 기반 프로그래밍으로 처리하려면 연쇄적인 문제가 계속 발생하기 때문에 쉽지 않다.

하지만 이런 여러가지 문제들을 std::async에 떠넘기면 매우 편리해진다.

 

auto fut = std::async(doAsyncWork);

스레드 관리의 책임을 C++ 표준 라이브러리 구현자로 떠넘겨버린다.

이러면 가용 스레드 부족에 의해 예외를 받을 가능성이 크게 줄어든다.

std::thread나 std::async나 둘 다 스레드를 요청하는데 무슨 차이가 있길래 스레드 부족 예외를 받을 가능성이 크게 줄어들까?

 

그 이유는 std::async가 상황에 따라 스레드를 생성하지 않을 수도 있기 때문이다. 새로운 스레드를 생성하는 대신 get이나 wait을 호출하는 스레드에서 실행하라고 스케줄러에게 요청할 수 있다. 이 때 스케줄러는 상황에 맞춰 선택하게 된다.

 

다만 위와 같은 함수의 결과가 필요한 스레드에서 실행하는 기법은 부하 불균형 문제가 여전히 발생할 수 있다. 아예 사라지는 것은 아니고 대체로 스케줄러가 전반적인 상황을 더 잘 알고있을 가능성이 높을 뿐이다.

또한 실행중인 스레드 중 반응성이 좋아야 하는 스레드가 어떤 것인지도 알 수 없기 때문에 std::launch::async라는 시동 방침(launch policy)을 std::async에 넘겨주는 것이 좋다.

 

 

스레드 기반 프로그래밍에 비해 작업 기반 프로그래밍은 스레드를 일일이 관리해야 하는 수고로움이 없다. 또한 비동기적으로 실행된 함수의 결과를 자연스럽게 조회할 수 있는 수단 역시 존재한다.

다만 스레드를 직접 다루는 것이 적합한 경우도 존재한다.

 

◾ 바탕 스레드 적용 라이브러리의 API에 접근해야 하는 경우

◾ 응용 프로그램의 스레드 사용량을 최적화해야 하는, 그리고 할 수 있어야 하는 경우

◾ C++ 동시성 API가 제공하는 것 이상의 스레드 적용 기술을 구현해야 하는 경우

 

위의 경우들은 흔치 않은 경우라서 대부분은 작업 기반 설계를 사용하는 것이 바람직하다.

 

◾ std::thread API에서는 비동기적으로 실행된 함수의 반환값을 직접 얻을 수 없으며, 만일 그런 함수가 예외를 던지면 프로그램이 종료된다.

◾ 스레드 기반 프로그래밍에서는 스레드 고갈, 과다구독(oversubscription), 부하 균형화, 새 플랫폼으로의 적응을 독자가 직접 처리해야 한다.

◾ std::async와 기본 시동 방침을 이용한 작업 기반 프로그래밍은 그런 대부분의 문제를 알아서 처리해준다.

C++11에서는 람다가 거의 항상 std::bind보다 나은 선택이고 C++14에서는 거의 항상 수준이 아니라 무조건 좋은 선택이다. 게다가 std::bind보다 람다가 가독성이 더 좋다.

그 이유를 알아보도록 하자.

 

using Time = std::chrono::steady_clock::time_point;
enum class Sound { Beep, Siren, Whistle };
using Duration = std::chrono::steady_clock::duration;

void setAlarm(Time t, Sound s, Duration d); // t시간 뒤에 소리 s를 d만큼 출력

일정 시간 뒤에 알람이 울리는 함수가 있다.

 

auto setSoundL = [](Sound s) {
    using namespace std::chrono;
    setAlarm(steady_clock::now() + hours(1), s, seconds(30));
};

1시간 뒤에 30초간 울리는 알람을 실행하지만 경보음은 미리 지정되지 않아서 setAlarm에 대해 소리만 전달해주는 인터페이스를 람다로 작성하면 위와 같다.

 

auto setSoundL = [](Sound s) {
    using namespace std::chrono;
    using namespace std::literals;
    
    setAlarm(steady_clock::now() + 1h, s, 30s);
};

덧붙여서 C++14는 C++11의 사용자 정의 리터럴 기능에 기초하는 표준 접미사들을 이용하여 코드를 더 간결하게 만들 수 있다.

 

using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;

auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);

바인드로 작성하면 위와 같은 코드가 된다. 그런데 잘 작동하지 않는다.

람다식에 있는 setAlarm의 평가식(steady_clock::now() + 1h)은 setAlarm이 호출되는 시점에 평가되기 때문에 setAlarm 호출 1시간 뒤 알람이 울리지만 std::bind는 바인드 객체가 생성될 때 평가되어서 setAlarm의 호출이 아닌 std::bind를 호출하는 시점을 기준으로 1시간 뒤 알람이 울린다.

 

이 문제를 바로잡으려면 바인드 객체가 생성되는 시점이 아니라 실제 setAlarm을 호출하는 시점까지 평가를 지연시켜야 한다. 그러기 위해서는 std::bind안에 두 개의 함수 호출을 포함시켜야 한다.

 

using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;

auto setSoundB = std::bind(setAlarm,
                            std::bind(std::plus<steady_clock::time_point>(), // C++14, std::plus<>()
                                      std::bind(steady_clock::now), hours(1)),
                            _1,
                            seconds(30));

현재 시간과 1시간을 더하는 바인드 객체를 매개변수로 넘겨주면 setAlarm이 호출되는 시점으로 지연평가된다.

어찌저찌 해결된것처럼 보이지만 setAlarm 함수를 오버로딩 하게되면 또 문제가 생긴다.

 

람다는 전혀 문제 없이 컴파일러가 어떤 함수를 호출할지 선택할 수 있지만 std::bind는 함수의 이름만 넘겨받기 때문에 어떤 버전을 호출해야 하는지 선택하지 못해서 컴파일 에러가 발생한다.

 

using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);

auto setSoundB = 
    std::bind(static_cast<SetAlarm3ParmType>(setAlarm),
              std::bind(std::plus<>(),
                        std::bind(steady_clock::now),
                        1h),
              _1,
              30s);

이런 경우 적절한 함수 포인터로 캐스팅하여 전달해주면 중의성이 해소되어 정상적으로 컴파일 된다.

 

그런데 이제는 성능상의 차이가 생기기 시작한다.

 

setSoundL(Sound::Siren);
setSoundB(Sound::Siren);

람다 안의 setAlarm은 인라인화될 가능성이 크지만 바인드 객체에서 setAlarm의 호출은 setAlarm의 함수 포인터를 통해 이루어지므로 인라인화될 가능성이 낮다.

 

이번에 다룬 상황에서는 setAlarm 호출 하나만 이루어지지만 좀 더 복잡한 코드가 되면 람다의 장점이 더욱 두드러진다.

 

// 람다
auto betweenL = [lowVal, highVal](const auto& val){ return lowval <= val && cal <= highVal; };

// std::bind
using namespace placeholders;

auto betweenB = 
    std::bind(std::logical_and<>(),
              std::bind(std::less_equal<>(), lowVal, _1),
              std::bind(std::less_equal<>(), _1, highVal));

만약 C++11이라면 템플릿 매개변수가 죄다 지정되어야 해서 코드가 더 복잡해진다. 람다 역시 auto 매개변수를 받지 못하기 때문에 타입 지정이 필요하지만 애초에 코드 길이가 차이가 난다.

 

여담으로 std::bind에 전달되는 placeholders가 아닌 매개변수들은 값으로 저장될까 아니면 참조로 저장될까?

답은 "값으로 전달된다" 이다. 이건 std::bind의 작동 방식을 알고 있어야만 알 수 있는 정보이다. std::bind의 호출 구문으로는 알아낼 수 없다.

하지만 람다는 캡쳐 구문에 명시되어 있기 때문에 한눈에 알 수 있다.

 

C++14에 들어서는 람다가 std::bind보다 무조건 좋고 C++11의 경우라도 대부분 람다가 좋지만 이동 캡쳐나 보편 참조가 적용된 함수의 경우에는 std::bind의 사용이 정당해진다.

 

◾ std::bind를 사용하는 것보다 람다가 더 읽기 쉽고 표현력이 좋다. 그리고 더 효율적일 수 있다.

◾ C++14가 아닌 C++11에서는 이동 캡쳐를 구현하거나 객체를 템플릿화된 함수 호출 연산자에 묶으려 할 때 std::bind가 유용할 수 있다.

auto f = [](auto x){ return normalize(x); };

C++14부터는 초기화 캡쳐뿐만 아니라 매개변수로 auto를 사용하는 일반화 람다도 지원한다.

 

class 컴파일러가_만든_어떤_클래스_이름 {
public:
    template<typename T>
    auto operator()(T x) const { return normalize(x); }
};

컴파일러에 의해 만들어진 클로저 클래스의 operator()는 위와 같은 모습일 것이다.

만약 normalize가 왼값과 오른값을 다른 방식으로 처리한다면 이 람다는 항상 왼값을 전달하기 때문에 제대로 작성되지 않은 것이다.

제대로 전달하려면 완벽 전달을 사용해야 한다.

 

auto f = [](auto&& x){ return normalize(std::forward<???>(x)); };

5장에서 완벽 전달을 다룰때는 함수 템플릿 안에서 다룬거라 forward<T>를 사용했는데 람다는 어떻게 해야할까?

정답은 decltype이다. 왼값이 전달되었다면 decltype(x)는 왼값 참조가 산출되고 오른값이 전달되었다면 decltype(x)는 오른값 참조가 산출된다.

 

auto f = [](auto&& x){ return normalize(std::forward<decltype(x)>(x)); };

auto f = [](auto&&... xs ){ return normalize(std::forward<decltype(xs)>(xs)...); };

C++14의 람다는 가변 인수를 지원하기 때문에 임의 개수의 매개변수들도 완벽 전달이 가능해진다.

 

◾ std::forward를 통해서 전달할 auto&& 매개변수에는 decltype을 사용하라.

종종 값 캡쳐모드나 참조 캡쳐모드 둘 다 사용이 마땅치 않은 경우가 있다. 예를 들어 이동 전용 객체(unique_ptr, future 등)를 클로저 안으로 들여오려는 경우이다.

C++11에서는 방법이 없지만 C++14에서는 초기화 캡쳐라는것을 지원함으로써 가능해졌다. 초기화 캡쳐는 기본 캡쳐모드를 표현할 수 없지만 애초에 직전 항목에서 언급했다시피 기본 캡쳐모드는 피해야 하기 때문에 큰 의미가 없다.

 

초기화 캡쳐는 클로저 클래스에 속한 자료 멤버의 이름과 그 자료 멤버를 초기화하는 표현식을 지정할 수 있다.

 

class Widget {
...
};

auto pw = std::make_unique<Widget>();

auto func = [pw = std::move(pw)]{ ... };

캡쳐 부분에서 '=' 를 기준으로 좌변은 클로저 클래스의 자료 멤버, 우변은 초기화 표현식이다.

둘다 이름이 같지만 좌변과 우변의 범위가 다르다. 좌변은 클로저 클래스의 범위이고 우변은 람다가 정의되는 지점의 범위와 동일하다.

 

auto func = [pw = std::make_unique<Widget>()]{ ... };

별다른 사용 없이 바로 클로저에서 사용할거라면 위와 같이 초기화도 가능하다.

C++11에서는 어떤 표현식의 결과를 캡쳐하는것이 불가능했지만 C++14는 가능해졌다. 

 

만약 C++11에서 이동 캡쳐를 구현하고 싶다면 방법이 존재한다.

캡쳐할 객체를 std::bind가 산출하는 함수 객체로 이동하고 캡쳐된 객체에 대한 참조를 람다에 넘겨주면 된다.

 

std::vector<double> data;

auto func = [data = std::move(data)]() { ... }; // C++14

auto func = std::bind([](const std::vector<double>& data) { ... }, std::move(data)); // C++11 우회

bind에 의해 함수 객체가 산출되고(바인드 객체) 바인드 객체가 호출되면 이동 생성된 data의 복사본이 람다에 전달된다.

data는 move에 의해 오른값이 되었지만 람다의 매개변수가 왼값 참조이기 때문에 이동 생성된 data의 복사본이 전달되게된다.

 

기본적으로 람다로부터 만들어진 클로저 클래스의 operator() 멤버 함수는 const이다. 그래서 람다 본문 안에서 클로저의 모든 자료 멤버는 const가 되지만 이동 생성된 data의 복사본은 const가 아니다.

변경 가능한 람다를 사용하고 싶다면 mutable로 선언하면 된다. 이 경우 매개변수의 const 한정자도 없애줘야 한다.

 

바인드 객체는 전달된 모든 인수의 복사본을 저장하므로 클로저와 바인드 객체의 수명은 동일하다.

사실 std::bind보다 람다를 선호하는 편이 좋다. C++14 이상이라면 맘 편하게 초기화 캡쳐를 사용하면 된다.

 

◾ 객체를 클로저 안으로 이동할 때에는 C++14의 초기화 캡쳐를 사용하라.

◾ C++11에서는 직접 작성한 클래스나 std::bind로 초기화 캡쳐를 흉내 낼 수 있다.

+ Recent posts