[C++14] async

2025. 4. 13. 19:09C++

C++ 11에는 동시성을 언어와 표준 라이브러리에 도입했다.

기존에는 운영체제 별로 제공하는 API를 직접 사용했었다. 하지만 표준을 활용하면 플랫폼 종속적이지 않게 동시성 프로그래밍을 할 수 있다.

 

스레드 기반과 테스크 기반 프로그래밍

doSomething이라는 함수를 비동기로 실행하고자 할 때, 스레드 기반과 테스크 기반 중 하나를 택할 수 있다.

전자는 새로운 스레드를 하나 생성해서 해당 함수를 진입 함수로 설정하거나 진입 함수 안에서 호출시키는 것이고,

후자는 std::async(doSomething)처럼 async 함수에 넘겨주는 방식이다.
테스크 기반에서 async에 전달한 함수 객체는 하나의 테스크(task)로 간주된다.

 

보통은 테스크 기반 방식이 우월하다.

그 이유는 다음과 같다.

1. 스레드 기반 호출에서는 그 반환값에 접근할 방법이 없다.(윈도우의 경우 윈도우 API를 사용하면 접근 가능하다고 알고 있지만 표준에는 없는 듯) 그러나 테스크 기반 접근 방식에서는 간단하게 반환값에 접근할 수 있다. async가 반환하는 future 객체에 get이라는 멤버 함수를 호출하면 테스크의 결과나 예외를 얻을 수 있다.

2. 스레드 기반보다 테스크 기반이 더 추상화 되어있어서 사용하기 편하다.

3. 스레드 기반은 해당 프로그램이 동작하는 머신의 하드웨어 스펙(CPU 코어 수)을 고려해서 프로그래밍 해야 한다.(과다 구독이나 가용 스레드가 모자라는 경우 고려해야 함) 하지만 테스크 기반은 스레드 관리 부담을 표준 라이브러리 구현자들에게 떠넘길 수 있다.

 

하지만 스레드 기반이 적합한 경우도 존재한다.

1. 직접적으로 플랫폼 API에 접근해야 하는 경우 (ex. CPU 친화도 설정 등)

이 때, std::thread는 native_handle이라는 멤버 함수를 제공한다. 테스크 기반에는 이러한 기능이 없다.

2. 프로그램의 스레드 사용량을 최적화해야 하는 경우 

프로그램이 실행될 하드웨어 스펙이 고정적인 경우 (서버 머신) 스레드 기반으로 최적화할 수 있다.

 

async의 launch policy

async는 항상 새로운 스레드를 생성하지 않을 수도 있다.
(항상 생성하는 표준 라이브러리도 있다고 함. 플랫폼마다 테스트를 해봐야 한다.)
async를 사용할 때 launch policy를 설정해줘야 한다.

쉽게 생각해서 어떤 정책으로 비동기 실행을 수행할건지 명시하는 것이다. 정책은 다음과 같다.

  • std::launch::async
    전달된 테스크는 반드시 비동기적으로, 다시말해 다른 스레드에서 수행된다.
  • std::launch::deferred
    전달된 테스크는 std::async가 반환한 future객체에 대해 get이나 wait이 호출될 때에만 실행될 수 있다.
    다시 말해, 테스크는 그러한 호출이 일어날 때까지 지연된다(deferred). get이나 wait이 호출되면 테스크는 동기적으로 실행된다. 즉, 호출자는 테스크가 실행 완료 될 때까지 블락된다. get이나 wait이 호출되지 않으면 테스크는 결코 실행되지 않는다.
  • default launch policy
    launch policy를 명시하지 않았다면 자동으로 std::launch::async | std::launch::deferred가 둘 다 OR 결합한 방식으로 동작한다. 결과적으로 테스크는 동기적일 수도 있고 비동기적일 수도 있다.

default launch policy를 사용하면 유연하게 표준이 알아서 동작하기 때문에 편하지만, 우려할 점이 있다.
어떤 스레드에서 해당 테스크가 수행될지가 비결정적이라는 점이다.
따라서 thread_local 변수를 활용할 수 없으며, wait이나 get이 호출되는 것이 보장되지 않으면 영원히 테스크가 실행되지 않을 수 있다.
따라서 이러한 점을 고려해서 default launch policy를 쓰거나 std::launch::async를 명시해서 async 함수를 호출해야 한다.

 

std::thread들을 모든 경로에서 합류 불가능하게 만들어라

std::thread는 합류 가능(joinable)하거나 합류 불가능(unjoinable)한 상태를 가질 수 있다.

합류 가능한 상태는 thread가 실행 중이거나 실행 상태로 전이할 수 있는 스레드의 상태를 말한다.(실행 중이거나 블락되어 있는 스레드)

합류 불가능한 상태는 다음과 같다.

1. 기본 생성된 std::thread

2. 다른 std::thread로 이동 처리된 std::thread 객체

3. 이미 join함수로 합류된 std::thread 객체

4. detach 함수로 분리된 std::thread 객체

 

std::thread의 합류 가능성이 중요한 이유 하나는, 만일 합류 가능한 스레드의 소멸자가 호출되면 프로그램이 종료되기 때문이다.

이렇게 동작하는 이유는 이 방식이 아닌 다른 두 방식이 더 나쁘기 때문이다. 다른 두 방식을 알아보자.

1. 암묵적으로 join해버리기
std::thread의 소멸자가 비동기 실행 스레드의 완료를 기다리게 하는 것이다. 이는 추적하기 어려운 성능 이상이 나타날 수 있다. 

2. 암묵적 detach

이는 더 디버깅하기가 까다롭다. std::thread가 소멸했음에도 detach된 스레드가 계속해서 실행하며 메모리에 접근한다면 얼마나 까다로운 디버깅일지 상상도 하기 싫다.

따라서 명시적으로 join을 해서 모든 std::thread가 합류 불가능한 상태로 만들어야 한다.

RAII 방식으로 join이나 detach를 소멸자에서 자동으로 수행하도록 하는 기능인 std::jthread가 존재하지만 C++20에 추가된 기능이다.

 

스레드 핸들 소멸자들의 다양한 행동 방식

std::thread와 std::launch::async 정책으로 수행된 async에서 반환된 future 객체는 모두 시스템 스레드의 핸들이라고 할 수 있다. 그러한 관점에서 std::thread와 future 객체의 소멸자가 다르게 동작한다는 점은 공부할만하다.

우선 std::thread는 위에서 설명했듯이 소멸자에서 join되지 않았다면 프로그램이 종료되어버린다.
반면에 future 객체의 소멸자는 어떨 때에는 암묵적으로 join을 수행한 것 같은 결과를 내고 어떨 때에는 마치 암묵적으로 detach를 수행한 것 같은 결과를 낸다. 프로그램이 종료되는 일은 없다.

우선 비동기 정책으로 async를 호출한 경우 호출자와 피호출자에서 결과값의 반환은 어떻게 이루어지는지 알아야 한다.
이러한 결과 값은 어디에 저장되는 것일까?

결론부터 말하면 shared_ptr의 컨트롤 블록처럼 힙 기반의 공유 상태 메모리에 저장된다.
이렇게 하는 이유는 피호출자의 std::promise에 저장하기에는 먼저 피호출자 스레드가 종료되면 지역 객체인 std::promise가 파괴되기 때문에 불가능하고 그렇다고 std::future에 직접 저장하기에는 std::shared_future처럼 결과가 여러번 복사가 될 수 있는데 만일 결과 값이 이동만 가능한 데이터라고 한다면  다수의 future 객체 중 어떤 것에 피호출자의 결과를 담아야 할지 결정하기가 마땅치 않기 때문이다.

이러한 공유 상태의 존재가 중요한 이유는 future 객체 소멸자의 행동을 그 future 객체와 연관된 공유 상태가 결정하기 때문이다. 공유 상태에는 shared_ptr의 컨트롤 블록의 참조 카운트같은 참조 카운트가 존재한다는 사실을 기억하자.

  • std::async를 통해서 시동된 비지연 테스크에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 테스크가 완료될 때까지 블락된다. 본질적으로, 그런 future 객체의 소멸자는 테스크가 비동기적으로 실행되고 있는 스레드에 대해 암묵적인 join을 수행한다.
  • 다른 모든 future 객체의 소멸자는 그냥 해당 future 객체를 파괴한다. 비동기적으로 실행되고 있는 테스크의 경우 이는 백그라운드 스레드에 암묵적 detach를 수행하는 것과 비슷하다. 지연된 테스크를 참조하는 마지막 future 객체의 경우 이는 그 지연된 테스크가 절대로 실행되지 않음을 뜻한다.

 

학습 자료 : Effective Modern C++ (스콧 마이어스)

'C++' 카테고리의 다른 글

std::chrono 간단 정리  (0) 2025.05.30
[STL] 자료구조들 정리  (0) 2025.03.28
Virtual 함수의 동작  (0) 2025.03.10