C++ · 심화
RAII·템플릿·동시성 설계
std::thread 와 jthread - 스레드 수명 관리
thread 생성·join·detach 함정, jthread 와 stop_token, 스레드에 인자 전달(참조 함정), 예외와 스레드
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 주문 검증의 성공과 실패를 값으로 표현했다. 이제 주문 배치를 별도 스레드(thread)에서 처리한다. 일을 다른 실행 흐름에 맡기면 호출자는 먼저 다음 문장으로 진행할 수 있다. 그 대신 작업이 언제 끝나는지, 작업이 사용하는 객체를 누가 소유하는지, 실패를 어디에서 확인하는지를 함께 설계해야 한다.
이 장의 중심은 처리 속도가 아니라 수명이다. 주문 목록을 작업에 넘기고, 검증을 통과한 주문의 체결 로그를 모은 뒤, 작업이 끝났음을 확인하고 결과를 읽는다. 여러 스레드가 같은 작업 큐를 동시에 수정하는 문제는 다음 장에서 다룬다. 여기서는 작업 중인 스레드만 결과를 쓰고, 호출자는 종료를 기다린 다음 읽는 구조를 사용한다.
std::thread의 생성과join(),detach()가 수명에 미치는 영향을 설명한다.- 스레드에 값과 참조를 전달할 때 복사되는 대상과 살아 있어야 하는 객체를 구분한다.
std::jthread와std::stop_token으로 종료 책임과 중단 요청을 표현한다.- 작업 내부의 예외를 저장하고, 종료를 기다린 호출자에게 전달한다.
문제 상황
작은 주문 처리 엔진은 접수한 주문들을 순서대로 검증하고 체결 로그를 만든다. 지금까지는 호출자가 이 작업 전체를 직접 실행했다. 주문 배치 처리를 별도 스레드에 맡기면 호출자는 다른 준비 작업을 진행할 수 있다. 그러나 함수 끝에서 지역 주문 목록을 파괴해도 되는지부터 다시 따져야 한다.
예를 들어 접수 함수가 지역 벡터를 만들고, 그 벡터를 참조하는 작업을 시작한 뒤 반환한다고 하자. 작업이 시작되기 전에 접수 함수가 끝날 수도 있다. 작업은 이미 파괴된 벡터를 읽게 된다. 반대로 스레드 객체를 지역 변수로 두기만 해도 해결되지 않는다. 연결된 실행을 가진 std::thread가 소멸하면 프로그램은 std::terminate()를 호출한다.
검증 실패도 새로운 경계를 만난다. 호출자에서 스레드 생성문을 try로 감쌌다고 해서 작업 함수가 던진 예외를 받을 수 있는 것은 아니다. 별도 실행 흐름에서 빠져나온 예외는 호출자의 호출 스택으로 이동하지 않는다. 주문 하나의 잘못된 수량을 보고하려다가 프로세스 전체가 종료될 수 있다.
따라서 이번 설계는 세 가지 약속을 둔다. 주문 목록은 작업이 값으로 소유한다. 결과 객체는 호출자가 소유하되 작업이 끝나기 전에는 읽지 않는다. 작업 예외는 작업 안에서 잡아 결과 객체에 보관한다. 이 약속을 코드의 생성 순서와 종료 순서로 확인할 수 있어야 한다.
스레드 객체와 실행의 수명
생성은 실행을 시작하고, join은 끝을 기다린다
실행할 함수를 받는 std::thread 생성자는 새 실행 흐름을 시작한다. 생성 이후 호출자와 작업 중 어느 쪽이 먼저 진행할지는 정해져 있지 않다. 생성 직후의 다음 줄에서 작업에 필요한 데이터를 채우겠다는 설계는 성립하지 않는다. 필요한 입력은 생성 전에 준비해야 한다.
#include <thread>
void validate_batch()
{
// 이 함수에서 사용할 입력은 시작 전에 준비한다.
}
int main()
{
std::thread worker(validate_batch);
worker.join();
}
join()은 연결된 실행이 끝날 때까지 호출자를 기다리게 한다. 정상적으로 반환하면 스레드 객체는 더 이상 그 실행과 연결되어 있지 않다. 또한 작업에서 수행한 쓰기를 종료 후 호출자가 확인할 수 있는 동기화 경계가 된다. 따라서 다른 접근이 없다면 작업만 쓰던 결과를 join() 이후 호출자가 읽을 수 있다.
joinable()은 작업이 지금 실행 중인지를 알려 주는 함수가 아니다. 객체가 회수하거나 분리해야 할 실행과 연결되어 있는지를 나타낸다. 작업 함수가 이미 반환했더라도 join()이나 detach()를 하지 않았다면 여전히 참이다. 기본 생성된 객체, 이동되어 실행을 넘겨준 객체, 회수가 끝난 객체는 연결된 실행이 없다.
연결된 실행이 없는 객체에 join()을 호출하거나 자기 자신을 기다리게 해서는 안 된다. join()은 잘못된 사용 등으로 예외를 던질 수 있다. 여러 코드 경로에서 스레드를 정리한다면 어느 경로가 회수 책임을 갖는지 먼저 정해야 한다. 무조건 모든 곳에 join()을 추가하면 중복 회수 문제가 생긴다.
| 시점 | joinable() | 남은 책임 |
|---|---|---|
| 기본 생성 직후 | 거짓 | 연결된 실행이 없다 |
| 작업 실행 중 | 참 | 회수 또는 분리가 필요하다 |
| 작업 반환 후, 회수 전 | 참 | 여전히 회수 또는 분리가 필요하다 |
| join 또는 detach 성공 후 | 거짓 | 그 실행에 대한 연결이 없다 |
detach는 수명 보장이 아니다
detach()는 스레드 객체와 실행의 연결을 끊는다. 작업이 끝나기를 기다리지 않으며, 작업이 참조하는 지역 변수의 수명을 늘려 주지도 않는다. 분리된 실행 자체의 자원은 실행 종료 시 정리되지만, 그 실행이 사용하는 데이터까지 자동으로 안전해지는 것은 아니다.
분리 후에는 그 객체로 join()할 수 없다. 작업의 완료, 실패 보고, 프로그램 종료 시 정리 순서를 별도의 방법으로 설계해야 한다. main()이 반환할 때 분리된 작업의 완료가 보장되는 것도 아니다. 체결 로그를 끝까지 기록해야 하는 주문 엔진에서 분리는 회수 책임을 없애는 간단한 대안이 되지 않는다.
여기서 RAII를 적용할 대상은 스레드 객체의 메모리만이 아니다. 그 객체가 맡고 있는 실행의 종료 책임이다. std::thread는 연결된 실행을 남긴 채 소멸하는 실수를 드러내지만, 소멸자가 대신 기다려 주지는 않는다. 이 차이가 예외 경로에서 특히 중요하다.
인자 전달과 참조의 수명
C++20의 스레드 생성자는 전달받은 호출 대상과 인자를 내부 저장소에 값으로 보관한다. 이 과정에서 참조나 최상위 const 같은 속성이 제거된 형태로 저장된다. 일반적인 객체를 왼값으로 넘기면 복사하고, 이동 가능한 객체를 std::move로 넘기면 이동할 수 있다. 저장용 복사나 이동에서 발생한 예외는 생성자를 호출한 쪽으로 전달된다.
이 사실은 함수 호출과 스레드 시작을 구분하게 한다. 보통 함수에 벡터를 넘길 때는 매개변수가 참조인지 확인하면 된다. 스레드 생성에서는 그보다 앞서 인자를 저장하는 단계가 있다. 작업 함수가 비상수 왼값 참조를 요구하는데 원래 객체만 그대로 넘기면, 원본을 참조하는 대신 호출 조건을 만족하지 못해 컴파일 오류가 날 수 있다.
#include <functional>
#include <thread>
#include <vector>
void collect(std::vector<int>& values)
{
values.push_back(101);
}
int main()
{
std::vector<int> values;
std::thread worker(collect, std::ref(values));
worker.join();
return values.size() == 1 ? 0 : 1;
}
std::ref는 원본을 참조하도록 명시하는 참조 래퍼(reference wrapper)를 만든다. 래퍼 자체는 복사할 수 있지만 원본의 수명을 연장하지 않는다. 위 코드에서는 values가 먼저 생성되고, 작업을 회수한 후에야 파괴된다. 호출자가 작업 도중 벡터를 읽거나 수정하지 않는다는 조건도 함께 지킨다.
참조 캡처를 사용한 람다도 같은 책임을 가진다. 람다 객체를 스레드 내부로 복사한다고 해서 참조 대상까지 복사되는 것은 아니다. 포인터, std::string_view, 다른 비소유 뷰 역시 값으로 전달했다는 이유만으로 대상 데이터의 수명이 보장되지 않는다. 복사된 것이 소유 객체인지 주소와 길이뿐인지를 확인해야 한다.
완성 코드에서는 주문 벡터를 이동해 작업이 소유하도록 한다. 결과 객체만 std::ref로 전달한다. 입력 소유권은 작업으로 보내고, 출력의 수명은 호출자가 보장하는 형태다. 이동 후의 원본 벡터는 유효하지만 내용에 의존하지 않는다. 특히 비었다고 가정해 후속 로직을 구성하지 않는다.
참조를 사용한다는 것과 동시에 접근해도 된다는 것은 별개다. 작업이 결과 벡터에 원소를 추가하는 동안 호출자가 size()만 읽어도 데이터 경쟁(data race)이 생길 수 있다. 읽는 함수라는 이유로 안전해지지 않는다. 이 장에서는 join() 전 접근을 금지해 그 문제를 피한다.
jthread의 회수와 협력적 중단
소멸자는 중단을 요청한 뒤 기다린다
C++20의 std::jthread는 연결된 실행이 있을 때 소멸자에서 중단을 요청하고 join()한다. 따라서 중간 문장이 예외를 던져 범위를 빠져나가더라도 실행을 회수할 수 있다. 단, 스레드가 참조하는 데이터가 그 회수보다 먼저 파괴되지 않도록 선언 순서를 맞춰야 한다.
작업의 호출 형태가 첫 번째 인자로 std::stop_token을 받을 수 있으면 std::jthread는 자신의 중단 상태에 연결된 토큰을 전달한다. 그런 호출 형태가 아니면 토큰 없이 호출할 수 있는지 확인한다. 토큰을 받지 않는 일반 함수도 실행할 수 있지만, 그 함수는 이 경로로 전달된 중단 요청을 확인할 수 없다.
소멸 시 자동 회수된다는 이유로 정상 경로의 join()이 불필요한 것은 아니다. 배치를 끝까지 처리하고 싶다면 명시적으로 join()해 완료를 기다리는 편이 의도를 잘 드러낸다. 곧바로 범위를 끝내 소멸자에 맡기면, 소멸자가 중단부터 요청하므로 작업이 일부만 처리하고 반환할 수 있다.
지역 변수는 생성의 역순으로 파괴된다. 결과 객체를 먼저 선언하고 이를 참조하는 std::jthread를 나중에 선언하면, 예외로 범위를 나가더라도 스레드 객체가 먼저 소멸해 작업을 회수한다. 반대 순서에서는 자동 회수가 있어도 결과 객체의 수명이 부족할 수 있다.
중단 요청은 작업 코드가 확인한다
request_stop()은 작업을 강제로 종료하지 않는다. 중단 요청 상태를 설정하며, 작업은 토큰의 stop_requested()로 이를 확인한다. 이런 방식을 협력적 중단(cooperative cancellation)이라고 한다. 토큰 확인은 일반 결과 벡터를 동시에 읽어도 된다는 허가가 아니다. 중단 상태의 전달과 결과 데이터의 접근 규칙은 구분해야 한다.
중단 상태는 한 번 요청되면 되돌리지 않는다. 새 배치를 독립적으로 시작하려면 새 작업의 중단 상태를 사용해야 한다. 또한 request_stop()의 반환값은 이번 호출이 처음으로 요청 상태를 설정했는지를 나타낸다. 작업이 요청을 확인했는지, 몇 건을 처리했는지, 이미 종료했는지는 알려 주지 않는다.
주문 엔진에서는 주문 하나를 시작하기 전에 토큰을 확인한다. 요청이 이미 와 있으면 다음 주문을 시작하지 않고 반환한다. 확인을 통과한 직후 요청이 도착하면 현재 주문은 계속 처리될 수 있다. 중단 경계는 코드가 정하는 것이며, 이미 남긴 로그를 되돌리는 기능은 아니다.
작업이 요청을 확인하지 않거나 반환하지 않는 입출력 호출 안에 머무르면 std::jthread의 소멸자도 계속 기다릴 수 있다. 자동 회수는 종료 시간의 상한을 보장하지 않는다. 긴 작업에서는 확인 지점을 적절히 나누고, 대기 작업에는 별도의 종료 방법이 있는지 살펴야 한다. 여기서는 주문 사이의 확인만으로 충분한 짧은 배치를 사용한다.
예외는 실행 흐름을 건너 자동 전달되지 않는다
작업 함수 밖으로 예외가 빠져나오면 std::terminate()가 호출된다. 이는 std::thread와 std::jthread에 공통인 규칙이다. join()도 작업 예외를 대신 던지지 않는다. 자동 회수와 예외 전달은 서로 다른 기능이다.
완성 코드에서는 작업의 본문을 try로 감싸고, catch (...)에서 std::current_exception()으로 예외를 저장한다. 호출자는 join() 이후 std::rethrow_exception()으로 같은 예외를 다시 던져 처리한다. 이때도 결과 객체에 쓰는 쪽은 작업 하나이며, 호출자는 회수 이후에만 읽는다.
도메인의 예상 가능한 검증 실패를 앞 장의 값 표현으로 반환하는 설계도 가능하다. 여기서는 스레드 경계를 넘는 예외 전달을 보여 주기 위해 잘못된 수량을 예외로 보고한다. 중단은 별도의 상태로 기록한다. 실패, 중단 확인, 정상적인 배치 완료가 같은 사건이 아님을 유지한다.
완성 코드
다음 프로그램을 main.cpp로 저장한다. 체결은 외부 시스템 호출 없이 문자열 로그를 추가하는 것으로 표현한다. 기본 배치는 두 주문을 처리한 뒤 잘못된 수량을 발견한다. 추가 배치는 시작 직후 중단을 요청한다. 추가 배치가 몇 건을 처리했는지는 실행 순서에 따라 달라질 수 있으므로 출력에 포함하지 않는다.
#include <exception>
#include <functional>
#include <iostream>
#include <stdexcept>
#include <stop_token>
#include <string>
#include <thread>
#include <utility>
#include <vector>
// [1] 작업이 소유할 입력 값이다.
struct Order {
int id;
int quantity;
};
// [2] 작업 종료 후 호출자가 읽을 결과다.
struct BatchResult {
std::vector<std::string> logs;
bool stopped = false;
std::exception_ptr failure;
};
// [3] jthread가 토큰을 앞에 넣어 호출한다.
void process_orders(std::stop_token stop,
std::vector<Order> orders,
BatchResult& result) noexcept
{
try {
for (const Order& order : orders) {
// [4] 다음 주문을 시작하기 전에 중단을 확인한다.
if (stop.stop_requested()) {
result.stopped = true;
return;
}
// [5] 검증 실패 이전의 로그는 그대로 남긴다.
if (order.quantity <= 0) {
throw std::runtime_error(
"주문 " + std::to_string(order.id)
+ ": 수량은 양수여야 한다");
}
result.logs.push_back(
"체결: 주문 " + std::to_string(order.id)
+ ", 수량 " + std::to_string(order.quantity));
}
} catch (...) {
// [6] 작업 밖으로 예외를 내보내지 않는다.
result.failure = std::current_exception();
}
}
int main()
{
try {
// [7] 참조할 결과를 스레드보다 먼저 만든다.
BatchResult primary;
std::vector<Order> orders{
{101, 2},
{102, 1},
{103, 0}
};
std::jthread worker(
process_orders, std::move(orders), std::ref(primary));
// [8] 정상 경로에서는 중단 요청 없이 완료를 기다린다.
worker.join();
std::cout << "기본 배치: 체결 로그 "
<< primary.logs.size() << "건\n";
for (const std::string& log : primary.logs) {
std::cout << log << '\n';
}
// [9] 예외를 호출자의 실행 흐름에서 다시 처리한다.
if (primary.failure) {
try {
std::rethrow_exception(primary.failure);
} catch (const std::exception& error) {
std::cout << "배치 오류: " << error.what() << '\n';
}
}
// [10] 독립된 추가 배치를 시작한다.
BatchResult extra;
std::jthread cancellable(
process_orders,
std::vector<Order>{{201, 3}, {202, 4}},
std::ref(extra));
// [11] 요청과 종료 확인은 별개의 동작이다.
cancellable.request_stop();
cancellable.join();
if (extra.failure) {
std::rethrow_exception(extra.failure);
}
std::cout << "추가 배치: 중단 요청 후 회수 완료\n";
return 0;
} catch (const std::exception& error) {
// [12] 생성 실패 등 호출자 쪽 예외도 보고한다.
std::cerr << "실행 실패: " << error.what() << '\n';
return 1;
} catch (...) {
std::cerr << "실행 실패: 알 수 없는 예외\n";
return 1;
}
}
줄별 해설
주석 번호를 기준으로 입력의 소유권, 결과의 접근 시점, 예외의 이동 경로를 살펴본다. 특히 객체가 선언된 줄과 join()이 있는 줄을 함께 읽어야 수명 보장을 확인할 수 있다.
| 표시 | 코드의 역할 | 확인할 조건 |
|---|---|---|
| [1] | 주문 번호와 수량을 값으로 저장한다 | 외부 객체를 가리키는 멤버가 없다 |
| [2] | 로그, 중단 확인, 예외를 보관한다 | 작업 중에는 호출자가 접근하지 않는다 |
| [3] | 토큰과 소유 입력, 참조 출력을 받는다 | 결과 객체가 작업보다 오래 산다 |
| [4] | 새 주문을 시작하기 전에 반환할지 정한다 | 현재 주문을 강제로 끊지는 않는다 |
| [5] | 검증 후 체결 로그를 추가한다 | 앞서 성공한 주문의 로그는 유지된다 |
| [6] | 작업 예외를 저장한다 | 작업 본문에서 예외가 빠져나가지 않는다 |
| [7]~[8] | 입력을 이동하고 완료를 기다린다 | 스레드가 결과보다 먼저 소멸한다 |
| [9] | 회수 후 저장된 예외를 다시 던진다 | 작업의 쓰기가 끝난 뒤 읽는다 |
| [10]~[11] | 추가 배치에 중단을 요청하고 회수한다 | 요청만으로 종료했다고 판단하지 않는다 |
| [12] | 호출자에게 전달된 예외를 보고한다 | 작업 예외는 별도로 전달해야 한다 |
[3]의 주문 벡터는 값 매개변수다. 생성자에 전달한 벡터는 내부 저장소로 이동하고 작업 호출에서도 값으로 전달된다. Order에는 비소유 참조가 없으므로 작업은 원래 지역 벡터의 내용에 의존하지 않는다. 반면 result는 원본 결과 객체를 가리킨다. 이 차이를 std::move와 std::ref가 호출 지점에서 드러낸다.
[3]의 noexcept 자체가 예외를 처리해 주는 것은 아니다. 본문의 try와 [6]의 처리기가 문자열 생성과 로그 추가에서 발생한 예외까지 잡기 때문에 그 약속을 지킬 수 있다. 예외를 저장하는 std::current_exception()과 예외 포인터의 대입은 예외를 던지지 않는다. 처리기 안에서 새 로그 문자열을 만들지 않은 이유도 여기에 있다.
[5]는 배치 전체를 하나의 되돌릴 수 있는 작업으로 만들지 않는다. 세 번째 주문이 실패해도 첫 두 로그는 남는다. 실제 체결 시스템에서는 외부 체결과 로그 기록 사이의 실패도 별도 설계가 필요하다. 이 예제의 성공 의미는 해당 주문의 로그가 결과 벡터에 추가되었다는 데 한정된다.
[7]에서 결과 객체가 스레드보다 먼저 선언되어 있다는 점은 예외 경로에도 적용된다. [8]에 도달하기 전에 범위를 빠져나가더라도, 생성이 끝난 worker의 소멸자가 먼저 실행을 회수한다. 다만 스레드 생성 자체가 실패하면 작업을 시작한 것으로 취급할 수 없으며, 그 예외는 바깥 처리기로 전달된다.
[11] 이후 extra.stopped가 거짓일 수도 있다. 작업이 중단 요청 전에 모든 주문을 끝냈거나 마지막 확인 지점을 이미 지났을 수 있기 때문이다. 참이면 작업이 확인 지점에서 요청을 관찰하고 반환했다는 뜻이다. 호출자가 요청했다는 사실과 작업이 요청을 관찰했다는 사실을 구분하기 위해 이 필드는 작업만 기록한다.
실행 결과
C++20의 std::jthread와 중단 토큰을 지원하는 표준 라이브러리가 필요하다. 컴파일러가 C++20 문법을 받아들여도 설치된 표준 라이브러리가 해당 기능을 제공하지 않으면 빌드할 수 없다. 지원되는 macOS 또는 Linux 환경에서 다음과 같이 실행한다.
c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o orders
./orders
정상적인 자원 할당과 출력이 가능한 환경에서 예상 출력은 다음과 같다. 작업 스레드가 직접 출력하지 않으므로 두 실행 흐름의 출력이 섞이지 않는다.
기본 배치: 체결 로그 2건
체결: 주문 101, 수량 2
체결: 주문 102, 수량 1
배치 오류: 주문 103: 수량은 양수여야 한다
추가 배치: 중단 요청 후 회수 완료
마지막 줄은 추가 배치가 특정 주문에서 멈췄다고 주장하지 않는다. 중단을 요청했고, 이어서 실행 종료를 확인했다는 뜻이다. 스케줄링 순서가 달라져 추가 배치가 일부 또는 전부 처리되어도 이 문장은 성립한다. 일정 시간 잠든 뒤 요청하면 특정 개수에서 멈출 것이라고 가정하는 테스트는 만들지 않는다.
실무에서 자주 틀리는 것
예외 경로에서 std::thread의 회수를 빠뜨린다
다음은 의도적으로 잘못된 코드 조각이다. 설정 검사가 예외를 던지면 join()에 도달하지 않는다. 작업이 이미 끝났더라도 객체가 연결된 상태이면 소멸 과정에서 std::terminate()가 호출된다.
void run()
{
std::thread worker([] {});
check_configuration(); // 예외가 발생할 수 있다.
worker.join();
}
회수를 소멸자에 연결하려면 std::jthread를 사용한다. 정상 경로에서는 명시적으로 기다리고, 예외 경로에서는 소멸자가 기다린다. 아래 작업은 짧게 반환하므로 중단 토큰이 필요하지 않다.
void run()
{
std::jthread worker([] {});
check_configuration();
worker.join();
}
실제 작업이 길다면 토큰 확인도 설계해야 한다. 타입 이름을 바꾸는 것만으로 반환하지 않는 작업의 종료 문제가 해결되지는 않는다.
detach로 지역 참조의 수명 문제를 덮는다
다음 코드는 logs가 파괴된 뒤에도 분리된 작업이 접근할 수 있다. 이 코드가 가끔 정상처럼 보이는 것은 수명 보장의 근거가 아니다.
void launch()
{
std::vector<std::string> logs;
std::thread worker([&logs] {
logs.push_back("체결 준비");
});
worker.detach();
}
함수 안에서 완료할 작업이라면 결과를 먼저 선언하고 회수한 뒤 반환한다. 작업 완료 후에도 결과가 필요하므로 이 예에서는 벡터를 반환한다.
std::vector<std::string> launch()
{
std::vector<std::string> logs;
std::jthread worker([&logs] {
logs.push_back("체결 준비");
});
worker.join();
return logs;
}
위 수정은 수명 문제를 고친 코드 조각이다. 로그 추가의 할당 실패까지 호출자에게 전달해야 한다면 완성 코드처럼 작업 내부에서 예외를 저장한다. 함수를 즉시 반환하면서 작업을 유지해야 한다면 결과와 스레드를 함께 소유할 더 긴 수명의 객체부터 설계해야 한다.
참조 매개변수만 보고 원본이 전달된다고 생각한다
다음 호출은 원본 정수를 자동으로 참조 전달하지 않는다. 저장된 인자로 비상수 왼값 참조 매개변수를 만족시킬 수 없어 컴파일 오류가 난다.
void mark_ready(int& value)
{
value = 1;
}
int ready = 0;
std::thread worker(mark_ready, ready); // 잘못된 전달이다.
worker.join();
원본을 수정하는 것이 의도라면 std::ref를 사용하고, 원본을 읽기 전에 회수한다. <functional>을 직접 포함해야 한다.
int ready = 0;
std::thread worker(mark_ready, std::ref(ready));
worker.join();
std::cout << ready << '\n';
작업이 끝났는지 알아보려고 ready를 반복해서 읽는 방식으로 바꾸면 별개의 동기화 문제가 생긴다. 참조 래퍼는 동기화 도구가 아니다.
호출자의 catch가 작업 예외를 받는다고 생각한다
다음 코드에서 작업이 던진 예외는 바깥 catch로 들어오지 않는다. 해당 처리기는 생성이나 호출자 쪽 동작에서 발생한 예외를 받을 수 있을 뿐이다.
try {
std::jthread worker([] {
throw std::runtime_error("주문 검증 실패");
});
worker.join();
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
}
작업 안에서 예외를 잡고, 회수한 뒤 호출자가 다시 던져야 한다. 예외 포인터도 작업 중에는 호출자가 읽지 않는다.
std::exception_ptr failure;
std::jthread worker([&failure] {
try {
throw std::runtime_error("주문 검증 실패");
} catch (...) {
failure = std::current_exception();
}
});
worker.join();
try {
if (failure) {
std::rethrow_exception(failure);
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
}
한눈에 보기
| 도구 | 보장하는 것 | 따로 설계할 것 |
|---|---|---|
| std::thread | 별도 실행 흐름을 소유한다 | 모든 경로의 회수 또는 분리 |
| join() | 실행 종료를 기다리고 동기화한다 | 작업 예외의 전달 |
| detach() | 객체와 실행의 연결을 끊는다 | 데이터 수명과 완료 확인 |
| std::jthread 소멸자 | 연결된 실행에 중단을 요청하고 회수한다 | 작업의 반환 가능성과 참조 수명 |
| request_stop() | 중단 요청 상태를 설정한다 | 작업의 확인 지점과 종료 확인 |
| std::ref | 원본을 참조하도록 전달한다 | 원본 수명과 동시 접근 규칙 |
| std::exception_ptr | 나중에 다시 던질 예외를 보관한다 | 안전한 저장과 읽기 시점 |
호출자는 작업을 시작했다는 사실만으로 입력을 파괴하거나 결과를 읽을 수 없다. 소유권을 넘겼는지, 참조 대상이 살아 있는지, 종료 확인이 끝났는지를 각각 확인해야 한다. 다음 장에서는 작업이 끝나기 전에도 여러 실행 흐름이 데이터를 주고받을 수 있도록 뮤텍스와 조건 변수로 작업 큐를 구성한다.
연습 문제
- 완성 코드에서
worker.join()을 삭제하고 바로 기본 배치의 로그를 출력하면 어떤 문제가 생기는지 설명하라. 함수 끝에서std::jthread가 자동으로 회수된다는 점도 고려하라. - 추가 배치를 회수한 뒤
extra.stopped가 거짓이었다. 중단 요청 기능이 실패했다고 판단할 수 있는지 설명하고, 중단을 관찰하지 않아도 성립하는 실행 순서를 두 가지 제시하라. - 완성 코드의 주문 103의 수량을 5로 바꿔라. 기본 배치의 예상 출력을 쓰고, 작업 예외 전달 코드가 없어져도 되는지 설명하라.
- 추가 배치의 종료 결과를 회수 후 한 줄로 보고하도록 바꿔라. 저장된 예외가 있으면 실패, 중단을 확인했으면 중단, 둘 다 아니면 완료로 분류하라. 가능한 출력과 출력이 결정되는 조건을 설명하라.
정답과 해설
-
작업의 로그 추가와 호출자의 벡터 읽기가 겹칠 수 있다. 작업이 예외를 저장하는 시점과
primary.failure를 읽는 시점도 동기화되지 않는다. 소멸자의 회수는 범위를 빠져나갈 때 일어나므로 그보다 앞선 접근을 보호하지 못한다. 출력 전에join()을 유지하거나, 스레드 객체의 내부 범위가 끝난 뒤 결과를 읽어야 한다. 후자를 선택하면 소멸자가 중단부터 요청한다는 의미 차이도 반영해야 한다. -
실패라고 판단할 수 없다. 첫째, 두 주문 처리가 모두 끝난 다음 호출자가 요청할 수 있다. 둘째, 작업이 마지막 주문 앞의 확인을 통과한 직후 요청이 들어오면 마지막 주문을 처리하고 반복문을 빠져나갈 수 있다. 두 경우 모두 요청 상태는 설정될 수 있지만 작업은 이를 관찰하지 않는다. 종료 여부는
join()으로 확인하며,stopped는 중단 요청을 보고 반환했는지만 기록한다. -
기본 배치에서는 세 주문이 모두 로그에 추가되고 검증 오류 줄이 사라진다.
기본 배치: 체결 로그 3건 체결: 주문 101, 수량 2 체결: 주문 102, 수량 1 체결: 주문 103, 수량 5추가 배치의 출력은 기존과 같다. 입력이 올바르더라도 문자열 생성과 벡터의 메모리 할당 등은 예외를 던질 수 있으므로 작업 예외를 다루는 책임은 남는다. 예외 전달 코드를 제거하려면 다른 실패 처리 정책을 먼저 정해야 한다.
-
cancellable.join()다음의 보고 부분을 아래처럼 바꿀 수 있다. 실패 분류를 직접 출력하는 정책이므로 기존의extra.failure재던지기와 완료 출력 부분을 함께 대체한다.if (extra.failure) { std::cout << "추가 배치: 실패\n"; } else if (extra.stopped) { std::cout << "추가 배치: 중단\n"; } else { std::cout << "추가 배치: 완료\n"; }정상적인 자원 조건에서는 중단 또는 완료가 나올 수 있다. 작업이 확인 지점에서 요청을 관찰하면 중단이고, 모든 주문을 끝내면 완료다. 처리 중 예외가 저장되었다면 실패다. 이 출력은 실행 순서에 따라 달라지므로 한 가지 고정 출력만 정답으로 삼지 않는다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.