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()) {
        // 작업
    }
}

구성 요소는 세 가지입니다. 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 <thread>
#include <chrono>
#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();  // source와 연결된 토큰이어야 콜백이 실행될 수 있음

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();
// 자동 조인
특징std::thread + stop_sourcestd::jthread
조인직접 join() 호출소멸자가 request_stop() 후 join()
stop_tokenstop_source를 직접 만들어 전달내부 소스의 토큰을 자동 전달
중단 요청source.request_stop()t.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의 동작을 단순화하면 다음과 같습니다. 실제 구현은 함수가 stop_token을 받을 수 있을 때만 토큰을 넘기고, 받을 수 없으면 인자만 넘깁니다.

// 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() 검사와 sleep_for(timeout)으로 구현하는 경우가 흔한데, 두 가지가 잘못되어 있습니다. 첫째, 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 대기로 바꾸면 즉시 종료됩니다.

stop_source, stop_token, stop_callback 역할

타입역할
stop_source취소를 발행합니다. request_stop()을 호출하면 연결된 모든 토큰이 중단 요청 상태가 됩니다. 복사하면 같은 상태를 공유하는 소스가 늘어납니다.
stop_token읽기 전용 뷰입니다. stop_requested(), stop_possible()으로 상태를 확인하며, 가볍게 복사해 여러 스레드에 넘길 수 있습니다.
stop_callback토큰에 콜백을 등록합니다. 등록 시점에 이미 중단이 요청된 상태라면 생성자 안에서 즉시 콜백이 실행됩니다. 소멸자가 등록을 해제합니다.
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

std::jthread는 내부에 stop_source를 하나 두고, 실행 함수가 void f(std::stop_token) 또는 void f(std::stop_token, Args...) 형태면 토큰을 첫 번째 인자로 자동으로 넘깁니다. 소멸자는 스레드가 조인 가능하면 request_stop()을 호출한 뒤 join()합니다. std::thread는 stop_token과 무관하므로 stop_source를 직접 만들어 넘겨야 같은 패턴을 쓸 수 있습니다.

표준 stop_token은 스레드를 즉시 죽이지 않습니다. 작업을 짧은 단위로 나눠 단위마다 취소를 확인하고, 대기는 wait_for/wait_until이나 condition_variable_any의 stop_token 오버로드처럼 중단에 반응하는 형태로 바꾸며, 블로킹을 풀거나 정리를 알려야 하면 stop_callback을 등록합니다. 콜백은 다른 스레드에서 실행되므로 그 안에서 잡는 락이 데드락을 만들지 않는지 확인해야 합니다.

실전 워커 스레드 템플릿

// 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()를 폴링하는 방식이 현실적인 타협입니다.

참고 자료

같이 보면 좋은 글