C++20 stop_token·stop_source·stop_callback: jthread와 협력적 취소 구현
이 글의 핵심
C++20 stop_source·stop_token·stop_callback의 역할과 std::jthread로 협력적 취소를 구현하는 방법을 정리합니다. 워커 루프에서 취소 확인하기, condition_variable_any와 연동해 대기 중인 스레드 깨우기, 콜백 수명 주의점을 다룹니다.
stop_token이란?
std::stop_token 은 C++20에서 도입된 스레드 중단 요청 메커니즘입니다. 스레드에게 중단을 요청하며, 스레드는 주기적으로 중단 요청을 확인하여 안전하게 종료할 수 있습니다. std::jthread와 함께 사용하면 자동으로 통합됩니다.
#include <thread>
void worker(std::stop_token stoken) {
while (!stoken.stop_requested()) {
// 작업
}
}
std::jthread t(worker);
t.request_stop(); // 중단 요청
왜 필요한가?:
- 협력적 중단: 스레드가 안전하게 종료할 수 있도록 협력
- 전달 가능한 취소 상태: 전역 플래그 대신 함수 인자로 넘기는 값이라 범위가 분명함
- 콜백 지원: 중단 시 자동으로 콜백 실행
- 표준화: 일관된 중단 패턴
C++에는 다른 스레드를 강제로 멈추는 표준 방법이 없습니다. 스레드를 외부에서 죽이면 그 스레드가 잡고 있던 뮤텍스, 할당한 메모리, 쓰다 만 파일이 모두 일관성 없는 상태로 남기 때문입니다. 그래서 C++20 이전에는 대부분 std::atomic<bool> 플래그를 만들어 워커가 주기적으로 확인하게 했고, stop_token도 원리는 같습니다. 차이는 이 패턴을 표준 부품으로 만들었다는 것입니다. 플래그를 전역 변수로 두면 어떤 스레드가 어떤 플래그를 봐야 하는지가 코드에 드러나지 않고, 대기 중인 스레드를 깨우는 방법, 취소 시 정리 작업을 등록하는 방법을 매번 직접 만들어야 합니다. stop_token은 함수 인자로 전달되어 취소 범위가 시그니처에 보이고, condition_variable_any와 stop_callback이 이 토큰을 이해하므로 대기와 정리까지 같은 방식으로 처리됩니다.
// ❌ 전역 플래그: 타입 불안전, 경쟁 조건
std::atomic<bool> g_stop = false;
void worker() {
while (!g_stop) {
// 작업
}
}
// ✅ stop_token: 타입 안전, 표준화
void worker(std::stop_token stoken) {
while (!stoken.stop_requested()) {
// 작업
}
}
stop_token 구성 요소:
- std::stop_source: 중단 요청을 발행하는 객체
- std::stop_token: 중단 요청을 확인하는 객체 (복사 가능)
- std::stop_callback: 중단 시 실행될 콜백 등록
std::stop_source source;
std::stop_token token = source.get_token();
// 중단 요청
source.request_stop();
// 중단 확인
if (token.stop_requested()) {
// 중단 처리
}
세 타입은 하나의 공유 중단 상태를 서로 다른 권한으로 바라보는 객체입니다. stop_source는 쓰기 권한(중단 요청), stop_token은 읽기 권한(요청 확인)을 가지며, 둘 다 복사하면 같은 상태를 가리키는 핸들이 늘어날 뿐입니다. 이 분리 덕분에 워커 함수에는 토큰만 넘겨 “스스로 중단을 요청할 수 없게” 만들 수 있습니다. 중단 요청은 한 번 설정되면 되돌릴 수 없으므로, 작업을 다시 시작하려면 새 stop_source를 만들어야 합니다. 기본 생성한 std::stop_token은 어떤 상태와도 연결되지 않아 stop_possible()이 false이고 영원히 중단 요청을 받지 않는다는 점도 알아 두세요.
기본 사용
void worker(std::stop_token stoken) {
while (!stoken.stop_requested()) {
std::cout << "작업 중..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::cout << "중단됨" << std::endl;
}
int main() {
std::jthread t(worker);
std::this_thread::sleep_for(std::chrono::seconds(1));
t.request_stop();
}
std::jthread t(worker);에서 worker는 stop_token을 첫 번째 매개변수로 받으므로, jthread가 자기 내부 stop_source의 토큰을 자동으로 넘깁니다. 사실 이 예제에서는 t.request_stop()을 호출하지 않아도 됩니다. jthread의 소멸자가 먼저 request_stop()을 호출한 뒤 join()하기 때문입니다. 명시적으로 호출하는 것은 “언제 멈추라고 했는지”를 코드에 드러내는 의미가 있습니다. 한 가지 주의할 점은 워커가 sleep_for(100ms)로 자는 동안에는 중단 요청을 확인하지 못한다는 것입니다. 이 예제에서는 최대 100ms 늦게 종료될 뿐이지만, 대기 시간이 길다면 뒤의 condition_variable_any 방식처럼 중단 요청에 즉시 깨어나는 대기가 필요합니다.
실전 예시
예시 1: 조건 변수
void worker(std::stop_token stoken) {
std::mutex mtx;
std::condition_variable_any cv;
while (true) {
std::unique_lock lock(mtx);
if (cv.wait(lock, stoken, []{ return hasWork(); })) {
processWork();
}
if (stoken.stop_requested()) {
break;
}
}
}
이 예제는 wait의 형태만 보여 주는 코드이고, mtx와 cv가 함수 지역 변수라서 다른 스레드가 notify할 방법이 없다는 점에 주의하세요. 실제로는 작업 큐와 함께 워커들이 공유하는 멤버나 객체에 두어야 합니다. 핵심은 cv.wait(lock, stoken, pred) 오버로드입니다. 이 함수는 조건자가 참이 되거나 중단이 요청되면 반환하고, 반환값은 조건자의 최종 결과입니다. 그래서 false가 반환되면 “일이 생겨서가 아니라 중단 요청 때문에 깼다”는 뜻입니다. 내부적으로는 토큰에 stop_callback을 등록해 중단 요청 시 조건 변수를 깨우는데, 이 과정에서 락과 대기를 안전하게 연결해야 하므로 std::condition_variable이 아니라 임의의 락을 받을 수 있는 condition_variable_any에만 이 오버로드가 있습니다. 직접 stop_callback에서 cv.notify_all()을 호출하는 방식으로 흉내 내면, 워커가 조건을 검사한 직후와 대기에 들어가기 직전 사이에 알림이 끼어들어 깨우기를 놓치는(lost wakeup) 경쟁이 생기기 쉽습니다.
예시 2: stop_source
#include <stop_token>
std::stop_source ssource;
std::stop_token stoken = ssource.get_token();
void worker(std::stop_token st) {
while (!st.stop_requested()) {
// 작업
}
}
int main() {
std::thread t(worker, stoken);
std::this_thread::sleep_for(std::chrono::seconds(1));
ssource.request_stop();
t.join();
}
std::thread로도 같은 패턴을 쓸 수 있다는 것을 보여 주는 예제입니다. 다만 std::thread는 소멸자에서 자동으로 중단을 요청하거나 조인하지 않으므로, request_stop()과 join() 사이에 예외가 발생하면 joinable() 상태의 스레드가 소멸되어 std::terminate가 호출됩니다. 기존 코드베이스가 std::thread를 쓰고 있어 당장 바꿀 수 없는 경우에 쓸 만한 방식이고, 새 코드라면 jthread가 더 안전합니다.
예시 3: stop_callback
void worker(std::stop_token stoken) {
std::stop_callback callback(stoken, []() {
std::cout << "중단 콜백" << std::endl;
});
while (!stoken.stop_requested()) {
// 작업
}
}
stop_callback의 콜백은 워커 스레드가 아니라 request_stop()을 호출한 스레드에서, 그 호출 안에서 동기적으로 실행됩니다. 위 예제라면 메인 스레드가 t.request_stop()을 부르는 순간 메인 스레드에서 “중단 콜백”이 출력됩니다. 이 성질 때문에 콜백은 소켓을 닫거나 조건 변수를 깨우는 것처럼 블로킹된 워커를 풀어 주는 용도에 적합합니다. 워커가 recv()에 막혀 있다면 콜백에서 소켓을 shutdown해 recv가 반환되게 만드는 식입니다. 반대로 콜백 안에서 오래 걸리는 작업을 하면 request_stop()을 호출한 쪽이 그만큼 멈춥니다.
예시 4: 여러 스레드
int main() {
std::stop_source ssource;
std::vector<std::jthread> threads;
for (int i = 0; i < 5; i++) {
threads.emplace_back([stoken = ssource.get_token(), i]() {
while (!stoken.stop_requested()) {
std::cout << "스레드 " << i << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
}
std::this_thread::sleep_for(std::chrono::seconds(1));
ssource.request_stop(); // 모든 스레드 중단
}
여기서 람다는 stop_token 매개변수를 받지 않으므로 jthread는 자기 내부 토큰을 넘기지 않고, 각 스레드는 캡처한 ssource의 토큰만 봅니다. 그래서 jthread의 소멸자가 호출하는 request_stop()은 이 람다에게 아무 영향이 없습니다. ssource.request_stop()을 빠뜨리면 main이 끝날 때 threads의 소멸자가 조인하려고 기다리지만 워커는 멈추지 않아 프로그램이 종료되지 않습니다. 외부 stop_source를 공유하는 방식은 여러 스레드를 한 번에 멈추기 좋지만, jthread의 자동 중단은 포기하는 선택이라는 점을 기억해 두세요. 두 가지를 모두 원한다면 람다가 jthread의 토큰을 받고, 외부 소스에는 stop_callback으로 각 스레드의 request_stop()을 연결하는 방법이 있습니다.
stop_callback
std::stop_source source;
std::stop_token stoken = source.get_token(); // 기본 생성한 stop_token은 상태가 없어 콜백이 절대 실행되지 않음
std::stop_callback callback(stoken, []() {
std::cout << "중단 요청됨" << std::endl;
});
source.request_stop(); // 이 호출 안에서 콜백 실행
stop_callback은 RAII 객체라서 등록 해제도 소멸자가 합니다. 이때 표준은 한 가지 중요한 보장을 합니다. 다른 스레드에서 콜백이 실행 중인 동안 stop_callback이 소멸되면, 소멸자는 콜백 실행이 끝날 때까지 기다립니다. 덕분에 콜백이 참조하는 지역 변수가 콜백 실행 도중 사라지는 일은 막아 주지만, 반대로 콜백이 소멸자를 실행 중인 스레드가 잡고 있는 뮤텍스를 기다리면 서로를 기다리는 데드락이 됩니다. 같은 스레드에서 콜백 안에서 자기 자신을 소멸시키는 경우는 기다리지 않습니다.
자주 발생하는 문제
문제 1: 체크 누락
// ❌ 중단 체크 안함
void worker(std::stop_token stoken) {
while (true) {
// 중단 불가
}
}
// ✅ 주기적 체크
void worker(std::stop_token stoken) {
while (!stoken.stop_requested()) {
// 작업
}
}
문제 2: 블로킹 연산
// ❌ 블로킹 연산
void worker(std::stop_token stoken) {
while (!stoken.stop_requested()) {
blockingOperation(); // 중단 불가
}
}
// ✅ 타임아웃
void worker(std::stop_token stoken) {
while (!stoken.stop_requested()) {
if (tryOperation(100ms)) {
// 처리
}
}
}
협력적 취소의 가장 큰 약점이 바로 이 블로킹 연산입니다. stop_token은 플래그일 뿐이라, 워커가 read(), accept(), std::future::get() 같은 호출 안에 막혀 있으면 중단 요청을 확인할 기회가 없습니다. 해결책은 두 가지입니다. 하나는 이 예제처럼 타임아웃이 있는 버전으로 바꿔 주기적으로 루프를 돌게 하는 것이고, 다른 하나는 stop_callback으로 블로킹 원인 자체를 해소하는 것입니다(소켓 닫기, 파이프에 한 바이트 쓰기, 조건 변수 깨우기). 타임아웃 방식은 단순하지만 타임아웃 길이만큼 종료가 늦어지고 짧게 잡으면 CPU를 쓰며 깨어나는 횟수가 늘어납니다. 저는 종료 지연이 체감되는 서비스라면 콜백 방식을, 도구성 프로그램이라면 타임아웃 방식을 쓰는 편입니다. 가장 흔한 실수는 “jthread로 바꿨으니 이제 종료가 잘 되겠지”라고 생각하고 블로킹 호출을 그대로 두는 것인데, 그러면 소멸자의 join()에서 프로그램이 멈춘 것처럼 보이게 됩니다.
문제 3: 콜백 수명
void worker(std::stop_token stoken) {
int value = 10;
// ✅ 이 경우는 안전: callback이 value보다 나중에 선언되어 먼저 소멸됨
std::stop_callback callback(stoken, [&value]() {
std::cout << value;
});
}
// ❌ 위험: 참조 대상보다 오래 사는 곳에 콜백을 보관
struct Holder {
std::optional<std::stop_callback<std::function<void()>>> cb;
};
void registerCallback(Holder& h, std::stop_token st) {
int value = 10;
h.cb.emplace(st, [&value] { std::cout << value; });
} // value는 여기서 사라지지만 콜백은 h에 남아 나중에 실행될 수 있음
// ✅ 값으로 캡처하거나, 콜백이 참조 대상보다 먼저 소멸되게 수명을 맞춤
콜백 수명 문제의 핵심은 “콜백 객체가 캡처한 참조보다 오래 사는가”입니다. 같은 스코프에 지역 변수와 stop_callback을 차례로 선언하면 C++의 역순 소멸 규칙에 따라 콜백이 먼저 해제되므로 참조 캡처도 안전합니다. 위험한 것은 콜백을 멤버 변수나 컨테이너처럼 더 넓은 수명을 가진 곳에 저장하면서 지역 변수를 참조로 캡처하는 경우입니다. 이때 중단 요청이 나중에 오면 콜백은 이미 사라진 스택 메모리를 읽습니다.
문제 4: 여러 토큰
std::stop_source source1, source2;
void worker(std::stop_token st1, std::stop_token st2) {
while (!st1.stop_requested() && !st2.stop_requested()) {
// 작업
}
}
토큰을 두 개 넘기는 방식은 폴링에는 동작하지만, condition_variable_any::wait처럼 토큰을 하나만 받는 API와는 함께 쓸 수 없습니다. 이런 경우에는 두 원본을 하나로 합친 새 stop_source를 만들고, 각 원본 토큰에 stop_callback을 등록해 합친 소스의 request_stop()을 호출하게 연결하는 방식이 일반적입니다. “앱 전체 종료”와 “이 요청의 취소”처럼 여러 수준의 취소를 조합할 때 자주 쓰이는 패턴이며, 콜백 객체들이 합친 소스보다 먼저 소멸되도록 수명을 관리하는 것이 핵심입니다.
jthread와 통합
// jthread는 자동으로 stop_token 전달
std::jthread t([](std::stop_token stoken) {
while (!stoken.stop_requested()) {
// 작업
}
});
t.request_stop();
// 자동 조인
jthread vs thread + stop_token:
| 특징 | std::thread | std::jthread |
|---|---|---|
| 자동 조인 | ❌ 수동 | ✅ 자동 |
| stop_token | ❌ 수동 생성 | ✅ 자동 전달 |
| 중단 요청 | ❌ 수동 구현 | ✅ request_stop() |
| 사용 편의성 | 복잡 | 간단 |
실무 비교:
// ❌ std::thread: 복잡
std::stop_source source;
std::stop_token token = source.get_token();
std::thread t([token]() {
while (!token.stop_requested()) {
// 작업
}
});
source.request_stop();
t.join(); // 수동 조인
// ✅ std::jthread: 간단
std::jthread t([](std::stop_token stoken) {
while (!stoken.stop_requested()) {
// 작업
}
});
t.request_stop();
// 소멸자에서 자동 조인
jthread 동작 원리:
// jthread의 개념적 구현
class jthread {
std::stop_source ssource_;
std::thread thread_;
public:
template<typename F>
jthread(F&& f) {
thread_ = std::thread(std::forward<F>(f), ssource_.get_token());
}
void request_stop() {
ssource_.request_stop();
}
~jthread() {
if (thread_.joinable()) {
request_stop();
thread_.join();
}
}
};
실무 패턴
패턴 1: 워커 스레드 풀
class WorkerPool {
std::vector<std::jthread> workers_;
public:
WorkerPool(size_t numWorkers) {
for (size_t i = 0; i < numWorkers; ++i) {
workers_.emplace_back([i](std::stop_token stoken) {
while (!stoken.stop_requested()) {
if (auto task = getTask()) {
processTask(*task);
} else {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
std::cout << "워커 " << i << " 종료\n";
});
}
}
// 소멸자에서 자동으로 모든 워커 중단
};
std::vector<std::jthread>가 소멸될 때 원소는 하나씩 차례로 소멸되므로, 각 스레드는 “중단 요청 → 조인”을 순서대로 거칩니다. 워커가 작업 하나를 처리하는 데 오래 걸리면 앞 스레드의 조인을 기다리는 동안 뒤 스레드들은 중단 요청조차 받지 못하므로, 전체 종료 시간이 워커 수만큼 늘어날 수 있습니다. 빠르게 종료해야 한다면 소멸자 본문에서 먼저 모든 워커에 request_stop()을 호출한 뒤 멤버 소멸로 조인이 일어나게 하면, 모든 워커가 동시에 정리를 시작합니다. 빈 큐에서 10ms씩 자며 폴링하는 부분은 작업이 없을 때도 CPU를 조금씩 쓰고 최대 10ms 반응 지연이 생기므로, 실제 풀이라면 조건 변수 대기로 바꾸는 것이 좋습니다.
패턴 2: 타임아웃과 중단
#include <future>
template<typename F>
bool runWithTimeout(F&& f, std::chrono::milliseconds timeout) {
std::promise<void> done;
auto fut = done.get_future();
std::jthread t([&f, &done](std::stop_token stoken) {
f(stoken);
done.set_value(); // 작업 완료 알림
});
if (fut.wait_for(timeout) == std::future_status::ready) {
return true; // 제한 시간 안에 완료
}
t.request_stop();
return false; // 타임아웃 (소멸자의 join은 f가 중단 요청에 응답할 때까지 기다림)
}
// 사용
bool success = runWithTimeout([](std::stop_token stoken) {
while (!stoken.stop_requested()) {
// 작업
}
}, std::chrono::seconds(5));
이 절의 원래 코드는 흔히 보이는 실수 두 가지를 담고 있어서 위처럼 고쳤습니다. 첫째, t.joinable()은 “스레드가 아직 실행 중인가”가 아니라 “아직 조인하지 않았는가”를 뜻하므로, 작업이 끝났어도 join() 전에는 항상 true입니다. 그래서 원래 코드는 무조건 타임아웃을 반환했습니다. 둘째, sleep_for(timeout)은 작업이 일찍 끝나도 제한 시간을 다 채울 때까지 기다립니다. 완료 여부는 promise/future나 조건 변수로 알려 받아야 하고, wait_for는 작업이 끝나는 즉시 반환합니다. 타임아웃 후에도 f가 중단 요청을 무시하면 jthread 소멸자의 조인에서 멈추므로, “타임아웃”은 어디까지나 중단을 요청하는 시점이지 스레드를 강제로 끝내는 시점이 아니라는 점을 기억해야 합니다.
패턴 3: 리소스 정리
class ResourceManager {
std::jthread cleanupThread_;
public:
ResourceManager() {
cleanupThread_ = std::jthread([this](std::stop_token stoken) {
std::stop_callback cleanup(stoken, [this]() {
std::cout << "리소스 정리 중...\n";
releaseResources();
});
while (!stoken.stop_requested()) {
performMaintenance();
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
}
void releaseResources() {
// 리소스 정리
}
void performMaintenance() {
// 유지보수 작업
}
};
이 패턴에는 두 가지 함정이 있습니다. 먼저 stop_callback의 releaseResources()는 워커가 아니라 소멸자를 실행하는 스레드에서 호출되므로, 그 순간 워커가 performMaintenance()로 같은 리소스를 쓰고 있다면 데이터 레이스가 됩니다. 정리 작업은 콜백보다 루프가 끝난 뒤 워커 스레드 안에서 하는 편이 안전합니다. 다음으로 멤버 선언 순서입니다. 멤버는 선언의 역순으로 소멸되므로, ResourceManager에 다른 멤버를 추가할 때 jthread보다 뒤에 선언하면 그 멤버가 먼저 파괴된 뒤에 스레드가 조인되어, 마지막 순간의 워커가 이미 파괴된 멤버를 사용할 수 있습니다. 스레드를 가진 클래스에서는 jthread 멤버를 가장 마지막에 선언해 가장 먼저 멈추고 조인되게 하는 것이 관례입니다. 또 sleep_for(1s) 때문에 종료가 최대 1초 늦어지므로, 앞에서 설명한 condition_variable_any 대기로 바꾸면 즉시 종료됩니다.
jthread와 stop_token의 관계 (C++20)
std::jthread는 스레드를 생성할 때 내부 std::stop_source를 하나 두고, 실행 함수가 다음 형태면 std::stop_token을 자동으로 넘깁니다.
void f(std::stop_token)void f(std::stop_token, Args...)(추가 인자는 그 뒤)
호출 측에서는 request_stop() 으로 협력적 취소를 요청하며, jthread가 소멸될 때 조인을 시도합니다(구현은 자동 조인·중단 요청 정책을 따릅니다). 반면 std::thread는 stop_token과 무관이며, 직접 stop_source를 만들어 람다에 넘겨야 같은 패턴을 흉내 낼 수 있습니다.
stop_source, stop_token, stop_callback 역할 정리
| 타입 | 역할 |
|---|---|
stop_source | 취소를 발행한다. request_stop() 호출 시 연결된 모든 토큰이 “중단됨”으로 바뀐다. 복사 시 같은 상태를 공유하는 소스가 늘어난다. |
stop_token | 읽기 전용 뷰. stop_requested(), stop_possible() 등으로 확인한다. 가볍게 복사해 여러 스레드·알고리즘에 넘기기 좋다. |
stop_callback | 토큰에 콜백을 등록한다. 등록 시점에 이미 request_stop()이 됐다면 즉시 콜백이 실행될 수 있다. RAII: 소멸 시 등록 해제(구현 정의 세부는 표준 참고). |
std::stop_source src;
std::stop_token tok = src.get_token();
std::stop_callback cb(tok, [] { std::cout << "stop requested\n"; });
src.request_stop(); // 콜백 실행, 이후 tok.stop_requested() == true
협력적 취소 패턴 (왜 “강제 종료”가 아닌가)
표준 stop_token은 스레드를 즉시 죽이지 않습니다. 워커가 루프마다 stop_requested()를 확인하거나, 블로킹 연산을 중단 가능한 버전(타임아웃, condition_variable_any::wait의 stop_token 오버로드 등)으로 바꿔야 합니다.
권장 패턴:
- 짧은 단위 작업으로 나누고 매 단위 끝에 취소 확인.
- 대기는
wait_for/wait_until또는condition_variable_any+stop_token조합. - 정리가 필요하면
stop_callback으로 “락 해제·로그 flush” 등을 등록(콜백 안에서는 데드락 나기 쉬운 락을 조심).
실전 워커 스레드 템플릿
// TaskQueue는 예시 이름입니다. pop_timeout이 없다면 아래 루프 패턴만 참고하세요.
void worker_loop(std::stop_token st, TaskQueue& q) {
using namespace std::chrono_literals;
while (!st.stop_requested()) {
std::optional<Task> t = q.pop_timeout(50ms, st);
if (!t) {
if (st.stop_requested()) break;
continue;
}
process(*t);
}
drain_remaining_work_optional(q); // 정책에 따라
}
pop_timeout이 없다면 조건 변수 대기에 stop_token을 연동하거나, 짧은 sleep 뒤 stop_requested()를 폴링하는 방식이 현실적인 타협입니다.
C++20에서 함께 쓰는 기능
- std::jthread: 자동
stop_token전달·조인 관례. - std::condition_variable_any:
wait계열에stop_token을 받는 오버로드가 있어, 깨우기 없이도 취소에 반응하기 쉽습니다. - std::stop_callback: 취소 시 리소스 회수·메트릭 기록.
이들은 “프로세스 kill”이 아니라 애플리케이션 레벨 취소를 표준 형태로 맞추기 위한 도구입니다.
FAQ
Q1: stop_requested()와 stop_possible()은 어떻게 다른가요?
A: stop_requested()는 중단 요청이 이미 들어왔는지를, stop_possible()은 앞으로 중단 요청이 들어올 가능성이 있는지를 알려 줍니다. 기본 생성한 토큰이거나 연결된 stop_source가 모두 사라져 아무도 요청할 수 없게 되면 stop_possible()이 false가 되므로, 라이브러리 함수는 이 값으로 취소 확인이나 콜백 등록 자체를 건너뛰어 비용을 아낄 수 있습니다.
Q2: jthread에 넘기는 함수의 첫 인자가 stop_token이 아니면 어떻게 되나요?
A: 컴파일은 되지만 jthread는 토큰을 넘기지 않고 일반 스레드처럼 함수를 실행합니다. 소멸자는 여전히 request_stop()과 join()을 호출하지만 함수가 토큰을 볼 방법이 없으므로, 무한 루프라면 조인에서 프로그램이 멈춥니다. 토큰이 첫 번째 매개변수여야 한다는 점을 잊고 뒤쪽에 두면 같은 증상이 나타납니다.
Q3: jthread와 어떤 관계인가요?
A: std::jthread는 자동으로 stop_token을 생성하고 전달합니다. 람다의 첫 번째 인자가 stop_token이면 자동으로 전달됩니다.
Q4: 성능 영향은?
A: 매우 가볍고 빠릅니다. stop_requested() 호출은 원자적 플래그 확인만 수행합니다.
Q5: stop_callback은 언제 실행되나요?
A: request_stop()을 호출한 스레드에서, 그 호출 안에서 동기적으로 실행됩니다. 이미 중단 요청이 있었다면 stop_callback을 생성하는 스레드에서 생성자 안에서 즉시 실행됩니다. request_stop()은 처음 호출한 한 번만 콜백들을 실행하고, 이후 호출은 false를 반환할 뿐입니다.
std::stop_source source;
source.request_stop();
std::stop_callback cb(source.get_token(), []() {
std::cout << "즉시 실행\n";
});
// 출력: "즉시 실행"
Q6: 여러 스레드가 같은 stop_token을 공유할 수 있나요?
A: 가능합니다. stop_token은 복사 가능하며, 모든 복사본이 같은 중단 상태를 공유합니다.
std::stop_source source;
auto token = source.get_token();
std::jthread t1([token]() { /* ... */ });
std::jthread t2([token]() { /* ... */ });
source.request_stop(); // 모든 스레드에 중단 요청
Q7: std::async나 std::future도 stop_token으로 취소할 수 있나요?
A: 표준 std::async와 std::future에는 취소 기능이 없습니다. 작업 함수가 stop_token을 인자로 받게 만들고 호출하는 쪽이 stop_source를 가지고 있는 방식으로 직접 연결해야 합니다. 이 때문에 취소가 중요한 비동기 코드에서는 jthread나 스레드 풀과 stop_token을 조합하는 경우가 많습니다.
관련 글: std::jthread, std::thread, Condition Variable.
stop_token은 스레드에게 중단을 요청하고 안전하게 종료할 수 있게 하는 C++20 메커니즘입니다.
같이 보면 좋은 글
- C++20 std::jthread: 자동 join과 stop_token으로 스레드 협조적 중단하기
- C++20 std::span 심화: 정적·동적 extent, subspan, 읽기 전용 뷰
- C++20 std::barrier와 std::latch: 스레드 동기화 지점 만들기
- C++ Branch Prediction
- C++ Calendar & Timezone
- 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기