ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 왜 Task 기반의 비동기 프로그래밍인가?
    Modern C++/Task 기반 비동기 프로그래밍 2020. 8. 27. 21:18

    개발을 시작할 때만 해도 C++에서 스레드를 지원하지 않아 이를 직접 개발해서 사용해 왔다. C++ 11 이 후에 스레드가 표준 라이브러리로 추가되었다. 하지만 C++ 도 자바나 Swift, Kotolin과 같이 스레드를 태스크 내부에 숨기고 태스크를 이용해 많은 부분 개발이 된다. 

    개발자는 더 이상 하드웨어 내 코어의 수에 따라 스레드를 관리하거나 스레드 풀을 구현하고 스레드가 데이터의 관리등을 위해 동기화 처리 문제등에서 조금은 벗어 날 수 있다. C++ 에서 비동기 프로그래밍을 위해서 스레드나 task를 사용한다는 사실은 ModernC++ 을  사용하는 사람은 대부분 알 수 있다. 

    #include <iostream>
    #include <thread>
    #include <future>
    int main() {
        std::thread t([]() {
            for (size_t i = 0; i < 5; i ++) {
                std::cout << "thread: " << i << std::endl;
            }
        });
    
        auto fut = std::async([]() {
            size_t i = 0;
           for (i = 0; i < 5; i++) {
               std::cout << "task: " << i << std::endl;
           }
           return i;
        });
    
        std::cout << "return : " << fut.get() << std::endl;
        t.join();
        return 0;
    }
    

    위는 메인 함수에서 스레드와 task를 실행하는 코드로 가장 큰 차이점은 thread는 값을 반환하지 못하고  task는 std::future 를 통해 값을 반환 받을 수 있다는 점이다. task는 내부적으로 람다를 main 스레드에서 동작할 지, 아니면 다른 스레드를 생성해 동작할 지는 알지 못한다. 

    이는 task에 두가지 policy가 있는 데, 이는 std::async 함수의 첫번째 인자인 policy로 std::launch::async 와 std::launch::defered 이다. 

    en.cppreference.com 에는 이를 다음과 같이 설명하고 있다. 

     

    policy - bitmask value, where individual bits control the allowed methods of execution

    std::launch::async  : enable asyncronous evaluation

    std::launch::deferred : enable lazy evalution 

     

    정책 필드는 비트 마스크 값으로 메서드의 실행을 제어할 수 있도록하는 데 사용되고, std::launch::async는 비동기 연산을 수행하고  std::launch::deferred 지연 연산을 수행할 수 있다고 되어 있다. std::launch::async는 내부적으로 스레드를 생성하고 이를 통해서 명식적으로 비동기 연산을 수행하지만 std::launch::deferred 는 스레드를 생성하지 않고 현재스레드에서 fut.get()을 호출 시 이를 수행하는 지연 연산을 수행할 수 있다는 의미이다. std::aync() 함수는 디폴드 값으로 std::launch::asyn | std::launch::deferred로 이는 비동기적으로 수행 될 수 도 있고, fut.get() 이 호출 시 동기적으로 이를 수행할 수 있다는 의미이다. 이는 실행 시점에서의 상황에 따라 동기적으로도 사용될 수 있다는 의미이다.

    #include <iostream>
    #include <thread>
    #include <future>
    int main() {
        std::thread t([]() {
            for (size_t i = 0; i < 5; i ++) {
                std::cout << "thread[" << std::this_thread::get_id() << "]" << i << std::endl;
            }
        });
    
        auto fut = std::async([]() {
            size_t i = 0;
           for (i = 0; i < 5; i++) {
               std::cout << "task[" << std::this_thread::get_id() << "]" << i << std::endl;
           }
           return i;
        });
    
        size_t ret = fut.get();
        std::cout << "ret [" << std::this_thread::get_id() << "]" << ret << std::endl;
        t.join();
        return 0;
    }
    

    위의 결과를 보면 메인 스레드를 포함하여 세 개의 스레드가 생성되고 비동기적으로 3개의 스레드가 동작하고 있음을 확인할 수 있다. 

     

    위 처럼 많은 수의 스레드에서 오는 컨텍스트 스위칭 과정에서 오는 오버헤드나 시스템에 지원 가능한 스레드를 생성할 경우 프로세스가 std::system_error 예외를 던지고 죽는 문제에 대한 고민은 컴파일러 개발자가  담당하게 된다. 따라서 명시적으로 테스크가 반드시 비동기적으로 동작해야 할 때 std::launch::async로 설정하고 나머지는 디폴트 값으로 사용하는 게 좋다. 만약 명시적으로 std::launch::deferred로 설정할 경우에 대해서 살펴보자. 

    위의 코드 중 std::aync 함수 호출을 아래와 같이 수정하고 실행 해보면 

    auto fut = std::async(std::launch::deferred, []() {
           ...
        });

    위와 같이 task를 수행하는 스레드와 메인 스레드의 ID 값이 x0x1030cde00 으로 동일함을  알 수 있다.

    이제 task 가 반환한 값을 반환하지 않는다면 어떻게 동작하는 지 살펴보기 위해 태스크의 반환 값을 출력하는 곳을 아래와 같이 수정하고 실행해 보자.

        //size_t ret = fut.get();
        //std::cout << "ret [" << std::this_thread::get_id() << "]: " << ret << std::endl;
        std::cout << "Main [" << std::this_thread::get_id() << "]: " << std::endl;

    위의 결과르 보면 놀랍게도 task가 실행되지 않음을 알 수 있다. 이는 task가 deferred로 동작할 경우 task 가 작업을 future 객체가 get()을 호출하기 전까지 지연시키는 데 만약 이를 호출하지 않는다면 해당 작업을 수행하지 않는다는 의미로 이는 task가 작업한 수행결과를 사용하지 않는다면 동작할 의미가 없다고 실행 스케줄러가 판단하고 이를 실행하지 않는다.

     

    std::async(std::launch::asyc, [](){}) 를 호출 할 때 어떻게 스레드를 통해서 값을 반환 할 수 있을까? 이는 std::future 객체와  std::promise 객체를 이용해 아래와 같이 구현되어 있기 때문에 task의 반환 값을 사용할 수 있게된다. 

    #include <iostream>
    #include <thread>
    #include <future>
    int main() {
        std::promise<int> promise;
        //나중에 promise 를 통해서 결과 값을 전달 받을 future 객체를 설정 
        std::future<int> future = promise.get_future();
        
        //스레드는 공유 메인 스레드와 공유 가능한 영역의 promise 객체의 주소를 인수로 받음 
        std::thread t([](std::promise<int>* p) {
            size_t i = 0;
            for(i = 0; i < 5; i++) {
                std::cout << "task[" << std::this_thread::get_id() << "]: " << i << std::endl;
            }
            //promise 에 task 수행 결과 값을 세팅  
            p->set_value(i);
        }, &promise);
        
        //future.get()을 통해 promise 에서 set_value를 호출할 때 까지 대기 
        std::cout << "return: " << future.get() << std::endl;
        t.join();
        return 0;
    }
    

    물론 task의 사용이 스레드 부족이나, CPU의 로드 부하를 완전하게 해결할 수는 없다. 하지만 스레드 관리를 직접 해야하는 번잡함을 피하고  비동기적으로 수행한 작업에 대한 결과를 받을 수 있다는 점에서 스레드가 꼭 필요한 경우가 아니라면 task를 통해서 작업을 수행하는 게 낫다. 

    댓글

Designed by Tistory.