38. 스레드 핸들 소멸자들의 다양한 행동 방식을 주의하라
joinable std::thread 를 파괴하면 프로그램이 종료된다.
하지만 미래 객체의 소멸자는 언제는 암묵적 join, 언제는 암묵적 detach 의 동작을 한다.
이러한 스레드 핸들 소멸자의 동작 확인을 위해, 확인해야하는 상태가 있다.
위 그림처럼 미래 객체는 호출자에서, 피호출자 결과를 받을 수 있는 채널이다. ( 통신 채널의 한쪽 끝 )
그렇다면 피호출자 결과는 어디에 저장될까?
- 후보 1) 피호출자
- 호출자가 미래 객체.get 호출하기 전에 피호출자가 종료될수 있음
- 따라서 피호출자에 저장 불가능
- 후보 2) 호출자 미래 객체
- std::future 로 std::shared_future 를 생성하면 미래객체 결과의 소유권이 이전됨
- 원본 호출자의 미래 객체가 파괴된 뒤에도 std::shared_future 를 여러번 복사할 수 있음
- 복사 불가능한 결과 형식이 있다면, 대응하는 여러 미래 객체 중 피호출자의 결과를 어디에 저장할지 불확실
- 따라서 호출자 미래 객체에 저장 불가능
결국 피호출자의 결과는 공유 상태 (shared state) 에 담긴다.
미래 객체의 소멸자 행동
미래 객체의 소멸자 행동은 공유상태가 결정한다.
- 비동기 과제에 대한 공유 상태를 참조하는 마지막 미래 객체 소멸자는 과제 완료까지 차단됨
- 미래 객체의 소멸자가 비동기 백그라운드 스레드에 대해, 암묵적 join 수행
- 다른 모든 미래 객체 소멸자는 해당 미래 객체를 파괴함
- 백그라운드 스레드에 암묵적 detach 수행하는 것과 유사
예시)
#include<iostream>
#include<chrono>
#include<future>
void task() {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "task complete" << std::endl;
}
int main() {
auto fut = std::async(std::launch::async, task);
return 0;
}
위 예시에서 std::launch::async policy 로 과제 생성되었으므로, 백그라운드에서 비동기적으로 동작한다.
즉시 main 스코프가 종료되며 std::future 객체의 소멸자가 불린다.
이 미래 객체 소멸자는 비동기 과제에 대해 공유 상태를 참조한다.
따라서 과제 완료까지 암묵적 join 이 수행되며, "task complete" 로그가 출력된다.
launch policy 를 std::launch::deferred 로 변경해보자.
이 때 미래 객체 소멸자는 공유 상태는 여전히 참조하지만, 공유 상태는 비동기 백그라운드 스레드가 없다는 것을 인지하고 있다.
따라서 std::future 소멸자가 미래 객체를 파괴하며 즉시 종료된다.
다시 정리해보자.
미래 객체의 소멸자 행동은, 정상동작과 하나의 예외 케이스라고 생각하면 된다.
정상 동작은 미래 객체의 자료구조를 파괴하고, 공유상태 안의 참조횟수를 감소시킨다.
예외는 다음 케이스에서만 나타난다.
- 미래 객체가 std::async 호출에 의해 생성된 공유 상태 참조함
- 과제 시동 방식이 std::launch::async 임 (시스템이 선택한 방식, 명시적 지정 방식 모두 포함)
- 미래 객체가 공유상태 참조하는 마지막 미래 객체 (std::future 는 항상 성립 )
왜 그러한 예외 케이스가 필요할까?
표준 위원회는 암묵적 detach 도 피하고 싶고, 필수적인 프로그램 종료도 피하고 싶었다.
그에 따라 차선책인 암묵적 join 이라는 타협을 선택했다.
일반적으로 소멸자의 특별한 행동 (암묵적 join)은 공유 상태가 std::async 에서 비롯된 경우에만 일어난다.
하지만 그 외의 원인으로 공유 상태가 생성될 수 있다.
예시로 std::packaged_task 의 사용을 알아보자.
이 객체는 주어진 함수를 비동기적으로 실행할 수 있게 포장하며, 실행결과는 공유 상태에 저장된다.
예시)
int calcValue(); // 실행할 함수
std::packaged_task<int()> pt(calcValue); // 비동기 실행을 위해 calcValue 포장
auto fut = pt.get_future(); // 미래 객체 얻음
std::packaged_task 는 std::async 스레드와 연결되어 있지 않으므로, fut 은 공유 상태를 참조하지 않는다.
따라서 미래 객체의 소멸자는 정상적으로 행동한다.
예시)
{
std::packaged_task<int()> pt(calcValue); // 비동기 실행을 위해 calcValue 포장
auto fut = pt.get_future(); // 미래 객체 얻음
std::thread t(std::move(pt)); // pt 를 스레드 t 에서 실행
...
}
이제 std::thread 객체 t에 미래 객체가 이동되었다.
그 이후 t 동작을 예상하고, 소멸자 행동을 분석하자
- t 에 아무일도 없음
- joinable 스레드이므로 프로그램 종료됨
- t 에 join 수행
- join 수행된다면 미래 객체 소멸자에서는 암묵적 join 필요없음
- t 에 detach 수행
- detach 수행된다면 미래 객체 소멸자에서 암묵적 join 필요없음
위와 같은 케이스에서 종료, join, detach 결정은 해당 std::thread 조작하는 코드에서 내려진다.
따라서 미래 객체의 암묵적 join 고려할 필요 없다.