KoreanFoodie's Study

Effective Modern C++ | 항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라

GoldGiver 2022. 10. 26. 10:08

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 38 : 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라

핵심 :

1. 미래 객체의 소멸자는 그냥 미래 객체의 자료 멤버들을 파괴할 뿐이다.
2. std::async 를 통해 시동된 비지연 과제에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 그 과제가 완료될 때까지 차단된다(기다린다).

 

합류 가능 std::thread 는 바탕 시스템의 실행 스레드에 대응된다. 그와 비슷하게 지연되지 않은 과제에 대한 미래 객체도 시스템 스레드에 대응된다. 따라서 std::thread 객체와 미래 객체 모두 시스템 스레드에 대한 핸들(handle) 이라고 할 수 있다.

그런데 미래 객체의 소멸자는 어떨 때는 암묵적으로 join 을 수행한 것 같은 결과를 내고, 어떨 때에는 마치 암묵적으로 detach 를 수행한 것 같은 결과를 낸다. 그렇다고 프로그램이 종료되는 일은 없다.

우선, 미래 객체는 피호출자가 결과를 호출자에게 전송하는 통신 채널의 한 쪽 끝이라는 점을 주목하자.

그런데 해당 미래 객체를 호출자와 피호출자에 저장하는 것은 적절치 못하다(피호출자의 경우, 완료되면 std::promise 가 파괴되고, 호출자의 경우, 전달받은 해당 std::future 이 또다른 호출자에게 사용될 수 있음). 따라서 공유 상태(shared state) 라고 부른는 제 3 의 장소에 미래 객체의 결과를 저장한다.

이제 미래 객체 소멸자의 행동을 그 미래 객체와 연관된 공유 상태가 결정하게 된다. 구체적으로 말하면 다음과 같다.

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

간단히 말하면, 차단이 되는 예외적인 상황을 제외하면 그냥 미래 객체의 소멸자가 미래 객체를 파괴한다는 것이다. 그것이 전부이다. 미래 객체를 파괴하는 정상 행동에 대한 예외는 다음 조건들을 모두 만족하는 미래 객체에 대해서만 일어난다.

  • 미래 객체가 std::async 호출에 의해 생성된 공유 상태를 참조
  • 과제의 시동 방침이 std::launch::async 임(명시적이든 컴파일러가 암묵적으로 선택했든 상관없음)
  • 미래 객체가 공유 상태를 참조하는 마지막 미래 객체임(std::future 이거나, std::shared_future 의 경우, 다른 미래 객체가 파괴되는 동안 같은 공유 상태를 다른 std::shared_future 가 참조하고 있으면, 파괴되는 미래 객체는 그냥 자료 멤버들만 파괴한다)

위의 조건들을 만족할 경우, 암묵적으로 join 이 호출된다고 보면 된다.

 

임의의 미래 객체에 대해 그 소멸자가 비동기적으로 실행되는 과제의 완료를 기다리느라 차단될 것인지를 알아내는 것은 불가능하다. 예시를 보자.

// 이 컨테이너는 소멸자에서 차단될 수도 있다;
// 컨테이너에 담긴 하나 이상의 미래 객체들이 std::async 를 통해
// 시동된 비지연 과제에 대한 공유 상태를 참조할 수도 있기 때문
std::vector<std::future<void>> futs;

// Widget 객체의 소멸자가 차단될 수도 있다
class Widget {
public:
  ...
private:
  std::shared_future<double> fut;
};

소멸자의 특별한 행동은 공유 상태가 std::async 호출에서 비롯된 경우에만 일어날 수 없지만, 다른 원인으로도 공유 상태가 생성될 수 있다. 그 중 하나가 std::packaged_task 의 사용이다. std::packaged_task 객체는 주어진 함수(또는 호출 가능 객체) 를 비동기적으로 실행할 수 있도록 '포장' 하는데, 포장된 함수의 실행 결과는 공유 상태에 저장된다. 그 공유 상태를 참조하는 미래 객체를 얻으려면 다음처럼 std::packaged_task 의 get_future 함수를 호출하면 된다.

{
  // 실행할 함수
  int calcValue();

  // 비동기적 실행을 위해 calcValue 포장
  std::packaged_task<int()>
  pt(calcValue);

  // pt 에 대한 미래 객체를 얻는다
  auto fut = pt.get_future();

  // std::packaged_task 는 복사 불가; 오른값으로 캐스팅해야 함
  std::thread t(std::move(pt));
  
  ...
}

이 경우에는 미래 객체 fut 가 std::async 호출로 만들어진 공유 상태를 참조하지 않음이 명확하므로, 해당 소멸자는 정상적으로 작동한다. 

위의 "..." 부분에서는 크게 3 가지 일이 일어날 수 있다.

  • t 에서 아무 일도 일어나지 않음 : t 는 합류 가능 스레드가 되어, 프로그램이 종료됨
  • t 에 대해 join 수행 : 호출 코드에서 join 을 수행하므로 fut 의 소멸자에서 join 을 수행할 필요가 없으며, 따라서 차단될 이유가 없음
  • t 에 대해 detach 수행 : 위의 join 과 마찬가지로, fut 의 소멸자에서 그것을 수행할 필요가 없음

즉, std::packaged_task 에 의해 만들어진 공유 상태를 참조하는 미래 객체가 있다면, 소멸자의 특별한 행동을 고려한 코드를 작성할 필요가 없다. 종료와 join, detach 에 대한 결정은 이미 해당 std::thread(일반적으로 std::packaged_task 가 실행되는) 를 조작하는 코드에서 내려지기 때문이다.

 

Comments