C++ Chapter 19.5 : 작업 기반 비동기 프로그래밍 async, future, promise

Date:     Updated:

카테고리:

태그:

인프런에 있는 홍정모 교수님의 홍정모의 따라 하며 배우는 C++ 강의를 듣고 정리한 필기입니다. 😀
🌜 [홍정모의 따라 하며 배우는 C++]강의 들으러 가기!


chapter 19. 모던 C++ 필수 요소들

작업 기반 비동기 프로그래밍 async, future, promise

#include <future>

  • Thread vs Future vs Promise
  • 스레드 기반의 병렬 처리와 **작업 기반의 병렬 처리**의 차이를 이해하고 비교해보자.


🔔 동기 Vs. 비동기

  • 동기적 실행
    • 👉 한번에 하나씩 순차적으로 실행. A 작업이 실행 중이라면 그 뒤의 작업들은 A 작업이 끝난 후에야 실행됨
  • 비동기적 실행
    • 👉 프로그램 실행이 여러갈래로 갈라져서 동시에 진행되는 것

동기적 실행

string txt = read("a.txt");                  // 5ms
string result = do_something_with_txt(txt);  // 5ms

do_other_computation();  // 5ms 걸림 (CPU 로 연산을 수행함)
  • 메모리(RAM)보다 하드디스크를 읽는 시간은 8 만배나 더 느리다.
    • read함수는 하드디스크를 읽기떄문에 매우 느릴 것이고
    • txt를 비롯하여 밑에 result와 함수도 read가 수행을 마칠 때까지 기다려야한다.
  • 이처럼 순차적으로(=동기적으로) 하나씩 실행할땐 비효율적이다.
    • 상당히 오래 걸리는 파일 읽기 작업이 끝나기 전 까지 CPU는 아무것도 하지 않은 채로 기다려야 하기 때문이다.
    • 위 코드는 총 5+5+5 = 15ms 걸림
  • read 함수의 실행으로 하드 디스크를 읽어 오는 동안 CPU가 놀지 않고 do_other_computation() 같은 다른 일을 먼저 하고있으면 어떨까?

비동기적 실행

void file_read(string* result) {
  string txt = read("a.txt");  // 1️⃣
  *result = do_something_with_txt(txt);
}

int main() {
  string result;
  thread t(file_read, &result);  // &result는 file_read 함수의 string* result 매개 변수에 대입될 인자
  do_other_computation();  // 2️⃣

  t.join();
}
  • t 스레드는 하드 디스크를 읽어오는 file_read 함수의 기능을 수행하는 역할이다.
    • thread t(file_read, &result)
      • string txt = read(“a.txt”) 수행 👉 5ms
      • *result = do_something_with_txt(txt) 수행 👉 5ms
  • t 스레드에게 하드 디스크를 읽는 작업을 시켜둔 동안 Main 스레드는 다른 일을 한다.
    • do_other_computation()
  • Main 스레드는 do_other_computation() 작업을 끝낸 후 t스레드의 하드 디스크를 읽는 작업이 다 끝나기를 기다림
    • t.join()
  • 총 걸린 시간 10 ms
    • Main 스레드가 do_other_computation() 을 수행하는 동안 t1 스레드는 string txt = read(“a.txt”)동시에 병렬적으로 수행하는데 Main 스레드가 하는 일은 하드 디스크를 읽어오는 read 함수 보다는 더 빠른 시간내에 마무리 되므로
      string txt = read(“a.txt”)연산시간인 5 ms 만 걸린다고 보면 된다.
    • 여기에 *result = do_something_with_txt(txt) 하는데 걸리는 시간 5 ms
  • 이처럼 프로그램 시행이 여러갈래로 갈라져서 동시에 진행되는 것을 비동기적 실행이라고 한다.
    • CPU는 한순간도 놀지 않았다.


🔔 스레드 기반 비동기적 실행 Vs. 작업 기반 비동기적 실행

둘 다 비동기적 실행이다.

  • std::thread만 사용하는 스레드 기반 비동기적 실행
    • 👉 스레드를 통해 어떤 일을 겪게 될지 알 수 없다. 병렬 처리로 어떤 값을 받게될지 알 수 없다.
  • std::async 를 사용하는 작업 기반 비동기적 실행
    • 👉 어떤 일을 할 것인가, 미래에 어떤 값을 받아낼 것인가에 더 관심이 있다. 병렬 처리간에 값을 주고 받기가 쉽다.
    • 스레드의 결과를 리턴으로 받는다.

멀티 스레딩

	// multi-threading
	{
		int result;
		std::thread t([&] {result = 1 + 2; });  // 모든 바깥 변수를 레퍼런스로 받으므로 result 값이 변화한다.
		t.join();

		cout << result << '\n';  
	}

result은 스레드 밖에 있기 때문에 result 입장에서 스레드를 통해 어떤일을 겪을지 알 수 없다.

어떤 스레드가 이 작업을 하게 될지를 중점으로 둔다.

스레드 t의 작업으로 result 값이 3 이 됐다는 것을 알 수 없다. 만약 여러 스레드들이 병렬로 처리 될 때 스레드 t의 작업의 결과로 result 값이 3 이 됐다는 것을 다른 스레드들이 알고 이 결과를 가지고 작업해야 하는 일이 생긴다면 차질이 생길 것이다. 모르니까! 그래서 result를 출력하는 작업을 하기 위해선 t1 스레드가 result 을 write 하는 작업을 해야하므로 t.join(), t1 스레드가 끝날 때 까지 기다려야 한다.

작업 기반 std::async, std::future

	// task-based parallellism
	{
		std::future<int> future = std::async([] {return (1 + 2);});  //  auto future 쓰는게 더 간편
		cout << future.get() << '\n';  // 3 출력
	}

std::async 👉 처리 결과를 리턴하는 스레드라고 볼 수 있다.

std::async의 결과가 futrue에 리턴될 때까지 future.get() 작업은 실행하지 않고 기다린다.

어떤 작업(Task)을 할 것인가를 중점으로 둔다.

  • std::aync
    • 스레드보다 선호되는 추세다.
    • 처리 결과를 std::future 객체로 리턴한다.
      • 일반적인 return 과는 다르다
        • return 1 + 2 작업 앞에 더 오래 걸리는 복잡한 작업이 있다면 return 1 + 2 결과가 바로 future에 저장된다는 보장이 없다.
        • 즉, 리턴 값은 현재 받지 못하는 값일 수도 있음을, 미래에 받을 수도 있음을 염두해두고 기다린다.
    • 미래에 std::async 가 일처리를 다 끝내고 리턴값을 받을 때까지 기다렸다가 그제서야 future.get()을 수행한다.
      • 람다 함수의 리턴 타입이 void 인데도 불구하고 내부에서 return 1 + 2 하는 것을 확인할 수 있다. std::aync은 람다 함수 리턴 타입과 상관없이 return을 사용하기도 한다.
  • Main스레드가 return 1 + 2작업은 std::async에게 병렬적으로 맡겨 놓고 future.get()을 출력하는 일을 바로 실행할 수 있음에도 불구하고 return 1 + 2 작업이 끝날 때까지 자동으로 기다려 준다.
    • join 하는거나 마찬가지!
  • future변수는 정확하게 async를 통해 자신이 어떤 일을 겪게 될지를 정확하게 알 수 있다.


🔔 std::promise

std::futurestd::thread 스레드를 함게 사용하려면 반드시 std::future를 받을 수 있는 존재인 std::promise가 필요하다.✨

async가 아닌 스레드를 사용할 때, 병렬 처리를 하는 동안 미래의 데이터(future)를 받은 후에 작업을 수행하려면 promise를 통해 스레드가 미래에 원하는 데이터를 돌려 주겠다는 약속 해야 한다. ✨즉, 스레드의 연산 결과를 promise을 통해 future에 담아둔다.✨

  • promise 👉 생산자-소비자 패턴에서 마치 생산자 (producer) 의 역할을 수행
  • future 👉 소비자 (consumer) 의 역할을 수행
#include <iostream>
#include <future>
#include <thread>
using namespace std;

int main()
{
	// future and promsise
	{	
		std::promise<int> prom;
		auto future = prom.get_future();

		auto t = std::thread([](std::promise<int>&& prom)
		{
            prom.set_value(1 + 2);
        }, std::move(prom));
		
		cout << future.get() << endl;
		t.join();
	}
}

스레드의 결과를 future에 담으려 할 땐, promise에 결과를 담아야 한다. 그러려면 promise 를 R-Value Reference 으로 스레드가 수행할 함수의 인수로 넘겨주어야 한다.

  • prom
    • std::promise<int> prom
    • 안에 future 객체를 담고 있다. 미래에 결과를 이 곳에 담겠단느 약속.
      • 스레드는 async와 다르게 future를 리턴하지 않기 때문에 스레드의 처리 결과를 future타입으로 prom 객체에 저장할 수 있도록 코드를 짜야 한다.
  • future
    • std::future<int> future = prom.get_future()
    • 미래의 연산 결과를 담을 future 객체
  • t 스레드
    • 첫 번째 인수 👉 스레드가 수행할 작업인 람다 함수.
      • {prom.set_value(1 + 2);}
      • prom람다 함수 내부에서 R-Value Reference 타입으로 매개 변수 `prom`에서 받아 여기에 연산 결과를 담는다.
        • prom.set_value(1 + 2)
      • R-Value Reference 로 prom을 받는 이유는 빠른 속도를 가진 얕은 복사를 위해 그런 것으로 추측해본다..!
    • 두 번째 인수 👉 첫번째 인수로 넘긴 람다 함수에 필요한 인수다. std::promise<int>&& prom = std::move(prom)
      • std::move(prom)
      • 람다 함수에서 내부에서 R-Value Reference 타입의 매개 변수 prom에 바깥에서 정의한 prom을 담아야 하므로 R-value로 넘겨주어야 한다.
      • 이처럼 스레드 내에서 promise를 사용하려면 std::thread의 두번째 인수로 promise를 R-value 타입으로 넘겨 주어야 한다.
  • future.get()
    • 앞서 연산하여 prom에 담아둔 prom.set_value(1 + 2) 값이 future에 남긴다.
    • prom.set_value(1 + 2) 연산을 담당한 t 스레드가 작업을 다 끝낼 때까지, 즉 t스레드의 연산 결과가 담길 future 값이 담길 때까지 Main 스레드는 *cout << future.get() << endl* 을 실행하지 않고 동기적으로 기다린다.
      • 비동기적으로 일을 처리하되 특정 Task에 대해서는 그 결과를 사용하기 위해 Task가 완수되기를 기다리는 것이다.
    • t스레드로부터 연산 결과를 받으면 그제서야 Main 스레드는 cout « future.get() « endl 을 실행한다.
  • t.join()
    • Main 스레드는 future.get()에서 t스레드가 수행한 prom 값을 이미 기다리기 때문에 join 해주는게 큰 의미는 없지만
    • 그래도 스레드긴 스레드이기 때문에 join() 해주는게 안정적.
#include <iostream>
#include <future>
#include <thread>
using namespace std;

int main()
{
	// future and promsise
	{	
		std::promise<int> prom;
		auto future = prom.get_future();

		auto t = std::async([](std::promise<int>&& prom)
			{prom.set_value(1 + 2);	}, std::move(prom));
		
		cout << future.get() << endl;
		//t.join();
	}
}

위 코드처럼 스레드를 async로 대체해도 문제 없다. 다만 이런 경우엔 std::thread가 아니므로 join 해 줄 필요가 없고, 사실 async 자체가 future를 리턴하기 때문에 굳이 promise를 사용할 필요가 없다. 문제 없는 코드긴 하지만 굳이..? 싶은 코드.


🔔 std::thread Vs. std::async 비교

  • std::thread
    • 어떤 스레드가 끝나기를 기다리려면 join 함수를 통해 대기해야 한다.
  • std::async
    • 알아서 future 객체를 리턴하므로 스레드처럼 promise를 사용할 필요가 없다.
    • 소멸자가 끝나는 것을 알아서 대기를 해준다.
      • 따로 명시하지 않아도 자동으로 future 객체를 사용하기 전까지는 대기한다.
    • 주의 📢 asyncfuture로 받지 않을 경우 sequential이라 생각하고 순서대로 진행이 된다.

async

{
		auto f1 = std::async([] {
			cout << "Async 1 start\n";
			this_thread::sleep_for(std::chrono::seconds(2)); // 2초 대기
			cout << "Async 1 end\n";
		});
		auto f2 = std::async([] {
			cout << "Async 2 start\n";
			this_thread::sleep_for(std::chrono::seconds(1)); // 1초 대기
			cout << "Async 2 end\n";
		});

		cout << "Main function\n";  // 메인 스레드가 실행

		// async는 thread와 달리 join이 없음. 내부적으로 구동 원리 차이가 있음. 
	}
💎출력💎

Main function
Async 1 start
Async 2 start
Async 2 end
Async 1 end
  • f1, f2future 이다.
  • 비동기적 실행
    • 미래의 연산 결과를 f1에 담기로 약속이 되므로 그 전까진 비동기적으로 다른 일인 f2 작업을 진행한다.
      • 1초만 기다리는 f2 작업이 먼저끝남
    • Main 스레드가 작업하는 Main 출력이 먼저 시작되는 것을 볼 수 있다.
  • 만약 f1.get() 혹은 f2.get() 처럼 미래의 연산 결과를 사용해야 하는 경우에는 작업이 다 끝날때까지 기다린다.

future 를 안 받는 async 의 경우

주의 📢 asyncfuture로 받지 않을 경우 sequential이라 생각하고 순서대로 진행이 된다.

{
		std::async([] {
			cout << "Async 1 start\n";
			this_thread::sleep_for(std::chrono::seconds(2)); // 2초 대기
			cout << "Async 1 end\n";
		});
		std::async([] {
			cout << "Async 2 start\n";
			this_thread::sleep_for(std::chrono::seconds(1)); // 1초 대기
			cout << "Async 2 end\n";
		});

		cout << "Main function!\n";
	}
💎출력💎

Main function
Async 1 start
Async 1 end
Async 2 start
Async 2 end
  • f1, f2future를 리턴하지 않는 경우
    • 출력 결과를 보면 그냥 순서대로, 동기적으로 실행되는 것을 볼 수 있다.


thread

{
		auto f1 = std::thread([] {
			cout << "Async 1 start\n";
			this_thread::sleep_for(std::chrono::seconds(2)); // 2초 대기
			cout << "Async 1 end\n";
		});
		auto f2 = std::thread([] {
			cout << "Async 2 start\n";
			this_thread::sleep_for(std::chrono::seconds(1)); // 1초 대기
			cout << "Async 2 end\n";
		});

		cout << "Main function\n";  // 메인 스레드가 실행

        f1.join();
        f2.join();

		// async는 thread와 달리 join이 없음. 내부적으로 구동 원리 차이가 있음. 
	}
💎출력💎

Async 1 start
Async 2 start
Main function
Async 2 end
Async 1 end
💎혹은 이렇게 출력되기도 한다💎

Async 1 start
Main function
Async 2 start
Async 2 end
Async 1 end
  • f1, f2는 스레드 thread 이다.
    • join 필수
  • Main 스레드의 작업이 먼저 실행되었던 async 와 달리 f1 스레드부터 먼저 시작되는 것을 알 수 있다.
    • f1, f2 스레드 시작 후 각자 sleep 한 후 Main 스레드가 작업하는 Main function 출력이 세번째로 됨
    • 이처럼 async와 thread는 내부적으로 실행순서가 다름


🔔 참고



🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우 
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄

맨 위로 이동하기

댓글남기기