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

 

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로 초기화 캡쳐를 흉내 낼 수 있다.

참조 캡쳐를 사용하는 경우에 클로저가 지역 변수나 매개변수의 수명보다 오래 지속되면 클로저 안의 참조는 대상을 잃게된다.

 

using FilterContainer = std::vector<std::function<bool(int)>>;

FilterContainer filters;

void addDivisorFilter() {
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();
    
    auto divisor = computeDivisor(calc1, calc2);
    filters.emplace_back([&](int value) { return value % divisor == 0; });
} // divisor 소멸

지역변수 divisor를 참조하여 클로저를 filters에 추가해주었는데 addDivisorFilter가 반환되면 divisor 역시 소멸하기 때문에 미정의 행동을 유발하게 된다.

 

별도로 저장하지 않고 해당 함수 내에서 즉시 사용하고 클로저가 소멸된다면 참조가 대상을 잃는 일은 발생하지 않는다.

그렇다고 하더라도 다른 사람이 코드가 유용해보여서 복사해서 가져다 쓰는 경우에는 참조를 잃는 일이 발생할 가능성이 다시 생긴다. 명시적인 캡쳐가 아니라 아닌 기본 캡쳐이기 때문에 않기 때문에 divisor의 수명을 한눈에 알아보기 어렵기 때문이다.

 

이를 해결하는 한 가지 방법은 기본 캡쳐모드를 참조(&)가 아닌 값(=)으로 사용하는 것이다.

그런데 또 완벽하지도 않은게 값으로 캡쳐한 대상이 포인터라면 외부에서 삭제할 가능성이 존재하기 때문이다.

 

class Widget {
public:
    void addFilter() const;
    
private:
    int divisor;
};

void Widget::addFilter() const {
    filters.emplace_back([=](int value) { return value % divisor == 0; });
}

우선은 각설하고 다시 본론으로 돌아와서 이번에는 참조가 아닌 값으로 캡쳐한다.

divisor의 값이 클로저 안으로 복사되기 때문에 안전하지 않을까? 싶겠지만 전혀 아니다.

 

캡쳐는 오직 람다가 생성된 범위 안에서 보이는 static이 아닌 지역 변수에만 적용된다. divisor는 멤버 변수이기 때문에 캡쳐가 되지 않는다. 기본 캡쳐이든 명시적 캡쳐이든 전혀 상관이 없다. 오히려 기본 캡쳐를 빼버리면 컴파일 에러가 발생한다.

 

캡쳐도 되지않고 그렇다고 캡쳐를 빼버리면 컴파일 에러가 발생하는데 그 이유는 컴파일러가 클로저 안의 divisor를 this->divisor로 취급하기 때문이다.

분명히 값으로 캡쳐했지만 내부적으로 this포인터로 참조하기 때문에 divisor는 객체의 수명에 의해 제한되어버린다.

 

해결법은 없을까?

 

void Widget::adFilter() const {
    auto divisorCopy = divisor;
    
    filters.emplace_back([divisorCopy](int value){ return value % divisorCopy == 0; });
    // [divisor = divisor](int value){ return value % divisorCopy == 0; } C++14
    // divisor를 클로저에 복사
}

의외로 간단하다. 지역 복사본을 만들어서 그걸 넘겨주면 되는 것이다. C++14는 초기화 캡쳐가 가능하다.

 

기본 값 캡쳐 모드는 클로저 밖에서 일어나는 자료의 변화로부터 격리되어 있을거라는 오해를 부를 수 있는데 전혀 아니다. 전역 또는 이름공간에서 정의된 클래스나 객체, static으로 선언된 객체는 캡쳐 모드와 무관하게 클로저 안에서도 사용할 수 있다.

 

◾ 기본 참조 캡쳐모드는 참조가 대상을 잃을 위험이 있다.

◾ 기본 값 캡쳐모드는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.

+ Recent posts