SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++/Concurrency

[Concurrency] std::async - 기존 함수 그대로 thread에 적용(2)

보리남편 김 주부 2023. 11. 21. 09:00

(return이 있는) 기존 함수를 수정 없이 thread로 사용하기(2)😆

참고 : 본 글은 promise/future 모델, packaged_task에 대한 이해를 해야만 std::async를 이해할 수 있기에 두 내용을 모르고 있다면 아래 내용을 공부한 뒤에 본 글을 읽어주기 바란다.

2023.10.25 - [L C++] - [thread] Promise /Future 모델 필요성 및 사용 방법

2023.11.14 - [L C++] - [thread] packaged_task - 기존 함수 그대로 thread에 적용(1)


그럼 기존 함수를 수정 없이 스레드로 동작시킬 수 있는 std::async에 대해 알아보자.  

 

std::async


std::async 🆚 std::packaged_task 차이

std::pakaged_task는 보관해 놨다가 주스레드에서 호출(ex :  task(1,2)) 혹은 새로운 스레드에서 수행하지만 std::async는 함수를 즉시 스레드로 수행하며, 별도 스레드(std::thread)로 생성할 필요가 없다.

 

std::async를 사용한 예제 1를 보자.

예제 1
std::async를 사용하여 add 함수를 수행하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <future>
#include <chrono>
using namespace std::literals;

int add(int a, int b)
{
	std::this_thread::sleep_for(3s);
	return a + b;
}

int main()
{
	std::future<int> ft = std::async(&add, 1, 2);

	std::cout << "continue main" << std::endl;

	int ret = ft.get();

	std::cout << "result : " << ret << std::endl;
}

 

결과
continue main
result : 3

 

 

실행결과를 보면 add 함수를 std::thread로 실행하지 않았는데 ft.get()에서 결과 값을 대기하고 있다.

 

std::async 특징

1. 스레드 풀을 사용하고 있어, 사용자가 std::thread를 사용하지 않아도 std::async() 내부적으로 스레드로 수행한다.
2. std::packaged_task 와 마찬가지로 std::future<>로 결과를 얻는다.

 

중요코드 설명

std::future<int> ft = std::async(&add, 1, 2);
함수 명과 인자를 async에 넣어 호출하면 future로 return 하기에 호출 즉시 future<>로 받아야 한다.

 

std::launch option


std::async에 첫 번째 인자로 두 가지 옵션을 전달할 수 있는데 옵션의 기능에 대해 알아보자.

 

Bit Explanation
std::launch::async 새로운 스레드로 실행 // enable asynchronous evaluation
std::launch::deferred 지연된 실행(주스레드에서 실행) // enable lazy evaluation

* 참고 : Bit mask 타입이라 OR 조건으로도 사용이 가능하다.

 

예제 2
std::async의 첫 번째 인자로 launch option을 넣은 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <future>
using namespace std::literals;

int add(int a, int b)
{
    std::cout << "add : " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(2s); // 2초 대기
    return a + b;
}
int main()
{
    std::future<int> ft = std::async( std::launch::deferred, add, 10, 20);
    //std::future<int> ft = std::async(add, 10, 20);

    std::cout << "continue main : " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(2s); // 2초 대기

    int ret = ft.get();

    std::cout << "result : " << ret << std::endl;
}

 

결과
결과
continue main : 0x160010
add : 0x160010
result : 30

 

 

std::launch::deferred 옵션은 지연된 실행을 하는데 주 스레드에 결과가 필요할 때 호출(ft.get()하는 순간 호출) 된다. 결과를 보면 async 수행 시 새로운 스레드가 생성이 안되고 주 스레드에서 실행된 것을 확인할 수 있다.

(영상을 보면 주스레드에서 실행이 되어서 주 스레드에서 2초, add 수행 시 2초를 대기하다 보니 4초 뒤에 결과가 출력된다.)

 

std::launch option을 아래와 같이 제거하고 다시 실행해 보자.

As-is
std::future<int> ft = std::async( std::launch::deferred, add, 10, 20);
//std::future<int> ft = std::async(add, 10, 20);

To-be
//std::future<int> ft = std::async( std::launch::deferred, add, 10, 20);
std::future<int> ft = std::async(add, 10, 20);

 

결과
continue main : 0x160010
add : 0x6d2f10
result : 30

 

 

std::launch option을 생략하면 default로 동작하게 되며,

디폴트 값 : std::launch::async | std::launch::deferred (OR 동작을 함)
=> 스레드가 있는 시스템에서는 스레드로 수행
=> 스레드가 지원되지 않은 시스템에서는 지연된 실행

 

실행결과를 보면 이전 실행과 달리 별도 스레드에서 실행이 되었고, (체감되는지 모르겠지만) 주 스레드와 새로운 스레드에서 대기를 동시에 하기 때문에 2초 만에 결과 값이 출력된다. 

 

추가로 알아봅시다.


ft.get()을 호출하지 않으면 어떻게 동작할까?

 

예제 3
std::async 수행 후 결과 값을 받는 ft.get()을 호출하지 않고 종료하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <future>
using namespace std::literals;

int add(int a, int b)
{
    std::cout << "add : " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(2s);

    std::cout << "finish add" << std::endl;
    return a + b;
}
int main()
{
    std::future<int> ft = std::async(add, 10, 20);
    std::cout << "main : " << std::this_thread::get_id() << std::endl;

    //ft.get(); // ???
}

 

결과
main : 0x752f60
add : 0x160010
finish add

 

정상적으로 잘 동작한다.

결과를 보고 이상한 포인트를 찾았나요?
다음 예제를 실행해 보면 이상한 포인트를 확인할 수 있습니다.

 

예제 4
예제 3과 똑같이 promise/future 모델로 ft.get()을 호출하지 않고 종료하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <future>
using namespace std::literals;

void foo(std::promise<int>&& p)
{
    std::cout << "start foo" << std::endl;
    std::this_thread::sleep_for(3s);
    std::cout << "finish foo" << std::endl;
    p.set_value(10);
}
int main()
{
    std::promise<int> p;
    std::future<int> ft = p.get_future();

    std::thread t(foo, std::move(p));
    t.detach();
//  ft.get(); // ????

}

 

결과
_

 

이 코드의 실행결과는 예제 3과 달리 아무것도 출력하지 않는다. 이와 같이 주 스레드(메인 함수)가 종료되면 새로 생성된 스레드는 자동으로 종료되기에 foo 함수는 실행되지 않는다.

하지만 예제 3의 결과를 다시 보면 예제 4와 동일하게 주 스레드(메인 함수)가 종료되었음에도(?) add 함수가 정상적으로 수행이 된다.

결과
main : 0x752f60
add : 0x160010
finish add

 

왜 이렇게 동작을 할까? 결론부터 얘기하면 이 것은 async의 동작 특성인데 눈에 보이지 않지만 아래와 같이 동작을 하게 된다.

- main() 함수가 종료되는 순간 ft 객체는 파괴됨 => 소멸자 호출
- 소멸자에서 ft.get()으로 대기
  => 즉, 실행 중인 스레드가 종료될 때까지 대기(내부적으로 join을 호출하여 대기하고 있음)
  if (_M_thread.joinable())
    _M_thread.join();

 

다시 말해 async는 함수의 종료를 안전하게 대기하는 특성을 가지고 있다.(스레드 관점의 동작, join)

cf. 그럼 promise는?
- main() 함수가 종료되는 순간 ft 객체는 파괴됨 => 소멸자 호출
- 소멸자에서 get() 수행 안 함 => 그래서 대기 없이 종료

* 다시 말해, promise는 스레드의 관점이 아닌 p.set_value()의 결과를 얻는 것에만 관심이 있다.

 

async의 return 값을 받지 않으면 어떻게 동작할까?

 

우선을 예제를 실행해 보자.

 

예제 5
std::async를 수행 후 future<>를 받지 않고 종료하는 프로그램 코드이다.
#include <iostream>
#include <thread>
#include <chrono>
#include <future>
using namespace std::literals;

int add(int a, int b)
{
    std::cout << "add : " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(2s);

    std::cout << "finish add" << std::endl;
    return a + b;
}
int main()
{
 //std::future<int> ft = 
     std::async(add, 10, 20);

    std::cout << "main : " << std::this_thread::get_id() << std::endl;

}

 

async는 return 값 누락 시 nodiscard 속성에 의해 return 값이 누락되어 있음을 경고로 알리게 설계되어 있다. (경고이다 보니 실행은 된다.)

async3-1.cpp:18:28: warning: ignoring return value of 'std::future<typename std::__invoke_result<typename std::decay<_Tp>::type, typename std::decay<_Args>::type ...>::type> std::async(_Fn&&, _Args&& ...) [with _Fn = int (&)(int, int); _Args = {int, int}; typename __invoke_result<typename decay<_Tp>::type, typename decay<_Args>::type ...>::type = int; typename decay<_Tp>::type = int (*)(int, int)]', declared with attribute 'nodiscard' [-Wunused-result]
   18 |     std::async(add, 10, 20);
      |                            ^
In file included from D:\work\C++\concurrent\class\autoever\DAY2\02_async3-1.cpp:4:
D:/MinGW64/include/c++/13.2.0/future:1828:5: note: declared here
 1828 |     async(_Fn&& __fn, _Args&&... __args)
      |     ^~~~~

Build finished with warning(s).

 

결과
add : 0xc3000
finish add
main : 0x90010

 

이번에는 새로운 스레드가 먼저 수행되었다. 왜 이렇게 동작을 할까?

예제 3과 똑같은 관점이지만 아래와 같이 동작하기 때문이다.

async()가 반환한 것은 future<> 의 임시객체
=> 임시객체는 다음문장으로 내려가기 전에 파괴된다.
future 소멸자에서 get()이 수행되기 때문에 새로운 스레드가 종료될 때까지 (std::async(add, 10, 20) 여기서) 대기.
=> 대기가 종료되면 주 스레드의 다음 줄이 수행된다.

 

add함수가 별도의 스레드로 수행하긴 했지만 이처럼 주스레드에서 결과 값을 대기하면 별도스레드로 동작한 이점이 사라지기에 async 수행 시 return 값(future)을 바로 받아야 하는 이유가 여기에 있다.

 

참고


1. 스레드 풀 : https://change-words.tistory.com/entry/Tread-Pool

 

2. return이 없는 함수도 std::async를 사용할 수 있으며, return을 std::future<void>로 받으면 된다.

 

* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하였습니다.

728x90