스토리텔링 개발자

[Effective Modern C++] 35. 태스크 기반 프로그래밍(task-based programming) 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 35. 태스크 기반 프로그래밍(task-based programming)

김디트 2025. 4. 9. 11:15
728x90

항목 35. 스레드 기반 프로그래밍보다 태스크 기반 프로그래밍을 선호하라

 

 

 

함수 비동기 호출
  • doAsyncWork라는 함수를 비동기로 실행한다고 하면..
  • 방법은 두 가지이다.
    1. std::thread 객체를 생성
      • 스레드 기반(thread-based) 프로그래밍
    2. std::async를 사용
      • 태스크 기반(task-based) 프로그래밍
int doAsyncWork();

// 스레드 기반
std::thread t(doAsyncWork);

// 태스크 기반
auto fut = std::async(doAsyncWork);

 

 

 

두 방법의 차이점
  • doAsyncWork는 리턴값이 있는데...
    • 스레드 기반에서는 여기에 접근할 방법이 없다.
    • 태스크 기반에서는 async가 리턴한 객체(future 객체)를 통해 접근할 수 있다.
  • doAsyncWork가 예외를 발생시킨다면...
    • 스레드 기반은 바로 죽는다.
    • 태스크 기반은 future 객체로 예외에 접근할 수 있다.
  • 태스크 기반 접근방식이 좀 더 높은 수준의 추상을 체현한다.

 

 

 

동시적 C++ 소프트웨어에서의 '스레드'의 세 가지 의미
  1. 하드웨어 스레드
    • 실제 계산을 수행하는 스레드
  2. 소프트웨어 스레드
    • 운영체제가 하드웨어 스레드들에서 실행되는 모든 프로세서와 일정을 관리하는데 사용하는 스레드
    • os 스레드, 시스템 스레드라고도 한다.
  3. C++ 표준 라이브러리의 std::thread
    • C++ 프로세스 안에서 std::thread 객체는 소프트웨어 스레드 핸들로 작용한다.
    • 즉, null 핸들을 나타내기도 한다.(소프트웨어 스레드와 대응이 되지 않은 상태)

 

 

 

스레드의 예외
  • 소프트웨어 스레드는 자원이 제한되어 있으므로 부족하면 예외가 발생할 수 있다.
int doAsyncWork() noexcept; // 예외를 던질 수 없더라도

std::thread t(doAsyncWork); // 사용 가능한 소프트웨어 스레드가 없다면 예외 발생!
  • 해결법 1. 그냥 현재 스레드에서 doAsyncWork를 실행시킨다.
    • 현재 스레드에 부하(load)가 과중하게 걸릴 수 있다.
    • 만일 현재 스레드가 GUI 스레드면 사용자 입력 반응성 문제가 발생할 수 있다.
  • 해결법 2. 기존의 소프트웨어 스레드가 끝나길 대기한다.
    • 기존 스레드가 doAsyncWork의 작업을 기다리고 있다면 교착 상태(deadlock)에 걸릴 수 있다.

 

 

 

oversubscription 문제
  • 실행 준비가 된 소프트웨어 스레드가 하드웨어 스레드보다 많은 상황.
  • oversubscription이 발생하면
    • 스레드 스케줄러는 하드웨어상의 실행 시간을 여러 조각으로 나누어서(time slice) 소프트웨어 스레드들에게 배분한다.
    • 한 소프트웨어 스레드에 부여된 time slice가 끝나고 다른 소프트웨어 스레드의 time slice가 시작할 때 문맥 전환(context switch)이 수행된다.
    • 이 문맥 전환은 시스템의 전반적인 스레드 관리 부담을 증가시킨다.
    • 문맥 전환 시 이전 time slice와 이후 time slice가 다른 하드웨어 스레드에 있으면 이 부담은 더 커진다.
      • CPU 캐시에 쓸만한 캐싱이 없으므로(cold) CPU에 다시 캐싱을 진행한다.
      • 다음번에 같은 하드웨어 스레드 코어에서 실행될 가능성이 큰 기존 스레드들에 대한 CPU 캐시들이 오염된다.
  • oversubscription는 피하기 어렵다.
    • 소프트웨어 스레드 개수와 하드웨어 스레드 개수의 이상적인 비율이란 건 없기 때문이다.
    • 문맥 전환 비율, 소프트웨어 스레드가 CPU 캐시를 얼마나 효율적으로 사용하는가 여부에도 의존한다.
      • 해당 하드웨어에 맞게 응용 프로그램을 잘 커스텀하더라도, 다른 컴퓨터에서 잘 동작한다는 보장이 없다.

 

 

 

std::async를 사용한다면
  • 이런 문제들을 떠넘기는 좋은 수단이 바로 std::async이다.
// 스레드 관리 부담을 표준 라이브러리 구현자들에게 떠넘긴다.
auto fut = std::async(doAsyncWork);
  • 스레드 관리 책임을 C++ 표준 라이브러리 구현자로 옮긴다.
  • std::thread나 std::async나 무슨 차이가 있을까?
    • 어차피 시스템이 제공할 수 있는 것보다 많은 소프트웨어 스레드를 요청한다는 근본적인 문제가 있는데...
    • std::async는 이런 상황에서 그 함수를 결과가 필요한 스레드에서 실행하라고 스케줄러에게 요청할 수 있다.
  • 하지만 이 기법을 적용하면 앞서 말한 부하 불균형(load balancing)이 생길 수 있다.
    • 하지만 스케줄러가 독자보다 그 문제들을 더 상세히 알고 있을 가능성이 크다.
  • GUI 스레드 반응성 문제 역시 여전할 수 있다.
    • 스케줄러로서는 반응성이 좋아야 하는 스레드가 뭔지 알 수 없다.
    • std::launch::async라는 시동 방침(launch policy)를 std::async에 넘겨줄 수 있다.
    • 그러면 현재 스레드와 다른 스레드에서 실행된다.(항목 36 참조)
  • 최신 스레드 스케줄러는 여러 새로운 방식으로 개선되고 있다.
    • 태스크 방식은 이 업데이트를 지속적으로 받을 수 있지만
    • std::thread를 직접 다루는 경우에는 독자가 직접 그를 반영해야 한다.

 

 

 

스레드 기반 프로그래밍이 필요한 경우
  • 바탕 스레드 적용 라이브러리의 API에 접근해야 하는 경우
    • C++ 동시성 API는 저수준 플랫폼 고유 API를 이용해서 구현된다.
    • 이를 위해 std::thread 객체는 native_handle이라는 멤버 함수를 제공한다.
    • std::future에는 이에 해당하는 기능이 없다.
  • 응용 프로그램의 스레드 사용량을 최적화해야 하는, 그리고 할 수 있어야 하는 경우
    • 즉, 하드웨어 특성들이 미리 정해진 컴퓨터만을 위한 최적화가 필요한 경우.
  • C++ 동시성 API가 제공하는 것 이상의 스레드 적용 기술을 구현해야 하는 경우
    • 이를테면 스레드 풀을 제공하지 않는 특정 플랫폼을 위해 스레드 풀을 직접 구현해야 하는 경우.
728x90
Comments