[Modern C++] 항목 38: 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 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를 통해 시동된 비지연 과제에 대한 공유 상태를 참조하는 마지막 미래 객체의 소멸자는 그 과제가 완료될 때까지 차단된다.
댓글남기기