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

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 7. 동시성 API

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

🔍 스레드 핸들로서의 std::thread와 미래 객체

  • 항목 37에서 설명했듯이, 합류 가능 std::thread는 바탕 시스템의 실행 스레드에 대응된다.
  • 그와 비슷하게, 지연되지 않은 과제(항목 36)에 대한 미래 객체도 시스템 스레드에 대응된다.
  • 따라서 std::thread 객체와 미래 객체 모두 시스템 스레드에 대한 핸들(handle)이라고 할 수 있다.
  • 그런데 두 소멸자는 아주 다르게 행동한다.
    • 합류 가능 std::thread를 파괴하면 프로그램이 종료된다(암묵적 join과 암묵적 detach가 더 나쁜 선택이기 때문, 항목 37).
    • 미래 객체의 소멸자는 어떨 때에는 마치 암묵적으로 join을 수행한 것 같은 결과를, 어떨 때에는 마치 암묵적으로 detach를 수행한 것 같은 결과를 낸다. 프로그램이 종료되는 일은 없다.

🔍 호출자, 피호출자, 그리고 공유 상태

  • 미래 객체는 피호출자가 결과를 호출자에게 전송하는 통신 채널의 한쪽 끝이다.
    • 피호출자(보통은 비동기적으로 실행되는)는 계산 결과를 그 통신 채널에 기록한다(보통은 std::promise 객체를 통해서).
    • 호출자는 미래 객체를 이용해서 그 결과를 읽는다.
    • (참고) 항목 39는 미래 객체가 관여하는 통신 채널을 다른 목적으로 사용할 수도 있음을 설명한다. 이번 항목에서는 피호출자가 결과를 호출자에게 전달하는 메커니즘으로만 고려한다.
호출자  <--- (미래 객체) ---------- (std::promise, 보통은) ---  피호출자
  • 그렇다면 피호출자의 결과는 어디에 저장될까?
    • 피호출자의 std::promise에는 저장할 수 없다. 호출자가 get을 호출하기 전에 피호출자의 실행이 끝날 수 있고, std::promise는 피호출자의 지역 범위에 있어서 피호출자가 완료되면 함께 파괴되기 때문이다.
    • 호출자의 미래 객체에도 저장할 수 없다. std::future로 std::shared_future를 생성할 수 있는데(그러면 피호출자 결과의 소유권이 std::future에서 std::shared_future로 이전된다), 원본 std::future가 파괴된 후에도 std::shared_future를 여러 번 복사할 수 있기 때문이다. 복사를 지원하지 않는 결과 타입(이동 전용 타입 등)이 있을 수 있고, 결과의 수명이 그것을 참조하는 마지막 미래 객체만큼은 유지되어야 하므로, 잠재적으로 다수인 미래 객체 중 어떤 것에 결과를 담을지 결정하기가 마땅치 않다.
  • 따라서 둘 다의 바깥에 있는 장소, 즉 공유 상태(shared state)에 결과를 담는다.
    • 일반적으로 공유 상태는 힙 기반 객체로 표현되나, 그 타입과 인터페이스, 구현은 표준이 구체적으로 명시하지 않는다. 표준 라이브러리 작성자가 원하는 방식으로 구현할 자유가 있다.
호출자  <--- (미래 객체) --- [ 공유 상태: 피호출자의 결과 ] <--- (std::promise, 보통은) --- 피호출자

🔍 미래 객체 소멸자의 행동 규칙

미래 객체 소멸자의 행동은 그 미래 객체와 연관된 공유 상태가 결정한다.

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

이 규칙들은 복잡해 보이지만 사실은 간단한 “정상” 행동과 그에 대한 단 하나의 예외일 뿐이다.

  • 정상 행동: 미래 객체의 소멸자가 미래 객체를 파괴한다. 바탕 스레드를 합류시키지도, 탈착하지도, 실행하지도 않는다. 그냥 미래 객체의 자료 멤버들만 파괴한다.
    • (엄밀히 말하면 하는 일이 하나 더 있다. 소멸자는 미래 객체와 피호출자의 std::promise가 함께 참조하는 공유 상태 안의 참조 횟수를 감소한다. 이 참조 횟수는 라이브러리가 공유 상태의 파괴 시점을 판단하는 데 쓰인다. 항목 19 참고.)
  • 예외는 다음 조건들을 모두 만족하는 미래 객체에 대해서만 일어난다.
    • 미래 객체가 std::async 호출에 의해 생성된 공유 상태를 참조한다.
    • 과제의 시동 방침이 std::launch::async이다(항목 36). 실행시점 시스템이 선택한 경우와 std::async 호출 시 명시적으로 지정한 경우 모두 포함된다.
    • 미래 객체가 공유 상태를 참조하는 마지막 미래 객체이다.
      • std::future의 경우에는 이 조건이 항상 성립한다.
      • std::shared_future의 경우, 미래 객체가 파괴되는 동안 같은 공유 상태를 다른 std::shared_future가 참조하고 있다면, 파괴되는 미래 객체는 정상 행동을 따른다.
  • 이 모든 조건이 성립할 때에만 소멸자는 특별한 행동, 즉 비동기적으로 실행되는 과제가 완료될 때까지 소멸자의 실행이 차단되는 행동을 보인다. 실용적으로는 std::async로 생성한 과제를 실행하는 스레드에 대해 암묵적 join을 호출하는 것에 해당한다.
  • 이 예외를 흔히 “std::async에서 비롯된 미래 객체의 소멸자는 차단된다”라고 요약하기도 한다. 일차적인 근사로는 맞지만, 종종 그 이상이 필요하다.

왜 이런 예외가 필요한가?

  • 표준 위원회는 암묵적 detach와 관련된 문제들(항목 37)을 피하려 했으나, 필수적인 프로그램 종료(합류 가능 std::thread에 적용된, 항목 37)라는 급진적인 방침을 채택하는 것도 꺼렸다. 그래서 암묵적 join이라는 타협안을 선택했다.
  • 이 결정을 두고 논쟁이 없었던 것은 아니며, C++14에서 이러한 행동 방식을 폐기하자는 주장도 있었다. 그러나 결국 변화는 일어나지 않았으며, C++14의 미래 객체 소멸자 행동은 C++11과 동일하다.

⚠️ 소멸자가 차단될지 알아낼 수 없다

  • 미래 객체 API는 주어진 미래 객체가 std::async 호출에 의해 생긴 공유 상태를 참조하는지를 판단할 수 있는 수단을 제공하지 않는다.
  • 따라서 임의의 미래 객체에 대해 그 소멸자가 과제의 완료를 기다리느라 차단될 것인지를 알아내는 것은 불가능하다.
// 이 컨테이너는 소멸자에서 차단될 수도 있다; 컨테이너에 담긴
// 하나 이상의 미래 객체들이 std::async를 통해 시동된 비지연 과제에
// 대한 공유 상태를 참조할 수도 있기 때문이다.
std::vector<std::future<void>> futs;   // std::future<void>는 항목 39를 보라

class Widget {                         // Widget 객체의 소멸자가
public:                                // 차단될 수도 있다
  …

private:
  std::shared_future<double> fut;
};
  • 물론 주어진 미래 객체가 특별한 소멸자 행동을 유발하는 조건들을 만족하지 않음을 미리 알 수 있다면(이를테면 프로그램의 논리에 따라), 소멸자가 차단되는 일이 없을 것을 확신할 수 있다.
  • 예를 들어 특별한 행동은 공유 상태가 std::async 호출에서 비롯된 경우에만 일어날 수 있다. 그러나 그 외의 여러 원인으로도 공유 상태가 생성될 수 있는데, 그중 하나가 std::packaged_task의 사용이다.
  • std::packaged_task 객체는 주어진 함수(또는 호출 가능 객체)를 비동기적으로 실행할 수 있도록 ‘포장’하는데, 포장된 함수의 실행 결과는 공유 상태에 저장된다. 그 공유 상태를 참조하는 미래 객체는 get_future 함수를 호출하면 얻는다.
int calcValue();                          // 실행할 함수

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

auto fut = pt.get_future();               // pt에 대한 미래 객체를 얻는다
  • 이 경우에는 미래 객체 fut가 std::async 호출로 만들어진 공유 상태를 참조하지 않음이 명확하므로, 해당 소멸자는 정상적으로 행동한다.

🔍 std::packaged_task 객체의 실행과 소멸자

  • 성공적으로 생성한 std::packaged_task 객체(위의 예에서는 pt)는 임의의 스레드(std::thread)에서 실행할 수 있다.
    • std::async 호출을 통해서 실행할 수도 있지만, std::async를 이용해서 과제를 실행할 것이라면 std::packaged_task 객체를 생성할 이유가 없다. 어차피 std::async가 과제의 실행 일정을 결정하기 전에 수행하는 작업에는 std::packaged_task가 수행하는 모든 것이 포함되기 때문이다.
  • std::packaged_task 객체는 복사할 수 없으므로, pt를 std::thread 생성자에 넘겨줄 때에는 반드시 rvalue로 캐스팅해야 한다(std::move를 통해서, 항목 23 참고).
std::thread t(std::move(pt));             // pt를 스레드 t에서 실행
  • 문장들을 다음처럼 하나의 블록 안에 넣으면 이해하기가 좀 더 쉽다.
{                                         // 블록의 시작

  std::packaged_task<int()>
    pt(calcValue);

  auto fut = pt.get_future();
  std::thread t(std::move(pt));

  …                                       // 아래 설명 참고

}                                         // 블록의 끝
  • 여기서 주목할 코드는 std::thread 객체 t의 생성과 블록의 끝 사이에 있는 "…"이다. 이 부분에서 t에 대해 어떤 일이 일어나는가에 따라 크게 세 가지로 나눌 수 있다.
    • t에 아무 일도 일어나지 않는다. 범위(블록)의 끝에서 t는 합류 가능 스레드이므로 프로그램이 종료된다(항목 37 참고).
    • t에 대해 join을 수행한다. 애초에 호출 코드에서 join을 수행한다면 fut의 소멸자에서는 join을 수행할 필요가 없으며, 따라서 차단될 이유가 없다.
    • t에 대해 detach를 수행한다. 마찬가지로, 애초에 호출 코드에서 detach를 수행한다면 fut의 소멸자에서 그것을 수행할 필요가 없다.
  • 다른 말로 하면, std::packaged_task에 의해 만들어진 공유 상태를 참조하는 미래 객체가 있다면, 소멸자의 특별한 행동을 고려한 코드를 작성할 필요가 없다. 종료와 합류, 탈착에 관한 결정은 이미 해당 std::thread(일반적으로 std::packaged_task가 실행되는)를 조작하는 코드에서 내려지기 때문이다.

🧐 정리

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

Cpp 카테고리 내 다른 글 보러가기

댓글남기기