CodeGym /행동 /C++ SELF /std::jthread 및 std::stop_token

std::jthread 및 std::stop_token

C++ SELF
레벨 68, 레슨 3
사용 가능

1. 왜 std::jthread가 필요한가

멀티스레드 코드를 처음 작성할 때, 종종 이렇게 보입니다: "스레드를 실행시켜서 뭔가 하게 하고, 나중에 어떻게든 처리하겠지". 문제는 std::thread가 매우 엄격하다는 점입니다: 스레드 객체가 스레드가 여전히 실행되는 동안 소멸되면, 표준 라이브러리는 여러분이 무엇을 원했는지 추측하기보다 프로세스를 비정상 종료시키는 쪽을 택합니다. 언어 관점에서는 이것이 때때로 작동하는 것보다 정직한 동작입니다.

std::jthread는 초보자에게 더 친절한 대안으로 등장했습니다: 기본 동작으로 안전한 시나리오를 만들려는 시도입니다. 아이디어는 간단합니다: 스레드가 리소스라면, 파일이나 컨테이너, 스마트 포인터처럼 RAII 스타일로 관리하자는 것입니다 — 객체를 만들면 리소스를 획득하고, 범위를 벗어나면 리소스를 해제합니다.

std::thread 대 std::jthread: RAII 스타일의 스레드

std::thread와 std::jthread를 "수동"과 "자동" 모드로 보면, std::thread에서는 불을 끄고, 문을 잠그고, 고양이에게 밥을 주는 것(즉, join()을 호출하거나 의도적으로 detach()하는 것)을 직접 잊지 말아야 합니다. std::jthread에서는 고양이가 여전히 여러분의 책임이지만, 적어도 문은 혼자 닫힙니다: std::jthread 객체가 소멸될 때 스레드는 적절히 종료(join()를 통해)되며, 추가로 중단 요청이 전송됩니다(이 부분은 stop_token에서 다룹니다).

요점을 표로 정리하면 — 무엇을 직접 선택해야 하는지 기억하기 쉽습니다.

속성
std::thread
std::jthread
객체가 소멸될 때 스레드가 아직 실행 중이면 어떻게 되는가 프로세스 비정상 종료 (불쾌한 놀람) 올바른 대기(놀람은 없음, 다만 "오래 기다릴 수 있음")
직접 join()을 호출해야 하나 예, 거의 항상 가능하지만 대부분 필요 없음
친절한 취소 지원 직접, 자체 메커니즘으로 stop_token을 통해 내장됨
복사 가능 여부 불가 (move-only) 불가 (move-only)
핵심 아이디어 “너 성인이다, 스스로 책임져라” “기본적으로 안전하게 하자”

작고 기술적인(조금은 까다롭지만 유용한) 사실: 표준 문서에서도 thread/jthread 생성자 인자에 대한 요구사항(예: 매개변수는 move-constructible이어야 한다)은 논의됩니다. 이는 스레드에 대한 소유권과 이동 모델이 단순한 구현 세부사항이 아니라 설계의 일부임을 보여주는 좋은 표시입니다.

2. std::jthread 실행: scope를 벗어날 때 안전한 종료

std::jthread를 처음 접하면 흔히 하는 생각은: "이건 거의 std::thread와 같다"입니다. 맞습니다: 함수나 람다와 인자를 전달하는 방식은 동일합니다. 하지만 핵심 차이는 실행 시점이 아니라 scope를 벗어날 때 드러납니다.

가장 짧은 예: 스레드는 변수와 동일한 수명


#include <iostream>
#include <thread>

int main() {
    {
        std::jthread t([] {
            std::cout << "worker: hi\n"; // worker: hi
        });
        std::cout << "main: inside scope\n"; // main: inside scope
    } // t 소멸 -> 스레드는 보장되어 종료됨

    std::cout << "main: after scope\n"; // main: after scope
}

범위 내부에서의 "worker"와 "main" 출력 순서는 여전히 바뀔 수 있다는 점을 주의하세요(비결정성은 그대로입니다). 하지만 scope를 벗어난 후에는 "스레드가 아직 실행 중인데 우리는 떠났다"는 상황은 확실히 발생하지 않습니다.

join()는 여전히 존재하며 때로는 필요하다

RAII가 개발자가 명시적으로 "여기서 기다려"라고 하는 것을 금지하지는 않습니다. 때때로 이는 코드 가독성을 위해 유용합니다: 우리가 어디에서 대기하는지 명확하게 보이기 때문입니다.

#include <iostream>
#include <thread>

int main() {
    std::jthread t([] {
        std::cout << "worker: done\n"; // worker: done
    });

    t.join(); // 명시적 대기 지점
    std::cout << "main: continue\n"; // main: continue
}

4. std::stop_token: 협력적 스레드 중단

std::jthread의 RAII 부분이 "스레드를 종료하는 것을 깜빡한 문제"를 해결한다면, 또 다른 흔한 문제는 해결하지 못합니다: "내 스레드가 사실은 무한하거나 매우 오래 걸리는데, 중단을 요청할 수 있어야 한다"는 문제입니다.

여기서 중요한 단어는 "요청"입니다. 표준 C++에는 스레드를 안전하고 정확하게 '강제 종료'하는 버튼이 없습니다. 이는 언어 설계자가 "생각을 못했다"는 뜻이 아닙니다. 강제 중단은 불변식(리소스 보유, 작업 중간 상태, 데이터 쓰기 등)을 거의 확실히 깨뜨리므로 프로그램을 반고장 상태로 남길 수 있습니다.

std::stop_token은 협력적(협업형) 취소를 도입합니다:

  • 외부 어딘가에서 중단 요청을 보냅니다,
  • 스레드 내부에서 주기적으로 "중단 요청이 왔는가?"를 확인합니다,
  • 그리고 안전한 지점에서 종료합니다.

중단 프로토콜의 간단한 다이어그램

flowchart TD
    A[main: jthread 생성] --> B[worker: 작업 루프]
    B --> C{stop_requested?}
    C -- 아니오 --> B
    C -- 예 --> D[worker: 안전하게 종료]
    A --> E["main: request_stop()"]
    E --> C

다이어그램의 요지는 중단이 "마법 같은 명령"이 아니라 프로토콜라는 것입니다.

5. std::jthread가 어떻게 stop_token을 스레드 함수로 전달하는가

가장 실용적인 부분: std::jthread는 호출 가능 객체(함수/람다)가 std::stop_token을 받을 수 있으면, 스스로 그 토큰을 전달할 수 있습니다.

초보자가 놓치기 쉬운 중요한 점: 토큰은 호출 가능 객체가 std::stop_token 매개변수를 가지고 있을 때만 전달됩니다(보통 첫 번째 매개변수). 매개변수가 없으면 jthread는 함수를 평소처럼 실행하지만, "중단을 요청해도" 무의미합니다: 스레드가 요청을 확인하지 않기 때문입니다.

중단 가능 스레드 예

#include <iostream>
#include <stop_token>
#include <thread>

void worker(std::stop_token st) {
    while (!st.stop_requested()) {
        // 작업을 흉내냄
        std::cout << "tick\n"; // tick
        std::this_thread::sleep_for(std::chrono::milliseconds{50});
    }
    std::cout << "worker: stop observed\n"; // worker: stop observed
}

사용 예:

#include <chrono>
#include <thread>

int main() {
    std::jthread t(worker);

    std::this_thread::sleep_for(std::chrono::milliseconds{130});
    t.request_stop(); // 중단을 요청함

    // main에서 나갈 때 RAII가 모든 것을 정리하므로 join을 쓸 필요가 없음
}

중단을 확인하지 않는 스레드와 그 위험성

토큰을 확인하지 않는 무한 루프를 상상해 보세요. 그러면 jthread가 소멸될 때 "프로토콜"로 request_stop()와 join()을 호출하겠지만, 스레드는 절대 나가지 않기 때문에 join()는 무한히 대기합니다. 이것은 매우 전형적인 함정입니다: "jthread를 사용했으니 안전하겠지"라는 착각이죠. jthread는 더 안전하지만 마법사가 아닙니다.

6. 실습: 콘솔 애플리케이션의 백그라운드 진행 표시기

단순한 "tick" 수준에 머무르지 않고, 이전에 개발했던 작은 콘솔 앱(CLI 유틸리티, 명령과 로그 등)에 해당 주제를 섞어 보겠습니다. 흔한 과제는 "오래 걸리는 명령(네트워크, 파일, 계산 등)이 있고 사용자에게 프로그램이 살아 있음을 보여주고 싶다"는 것입니다.

중요: 우리는 지연을 동기화 수단으로 사용하지 않습니다. 아래의 sleep_for은 오직 작업을 흉내내고 예제를 관찰하기 쉽게 하기 위한 것입니다.

중단 가능한 백그라운드 '스피너'

#include <chrono>
#include <iostream>
#include <stop_token>
#include <thread>

void spinner(std::stop_token st) {
    const char frames[] = {'|', '/', '-', '\\'};
    std::size_t i = 0;

    while (!st.stop_requested()) {
        std::cout << "\rworking... " << frames[i % 4] << std::flush;
        ++i;
        std::this_thread::sleep_for(std::chrono::milliseconds{80});
    }

    std::cout << "\rworking... done\n"; // working... done
}

여기에는 초보자가 흔히 범하는 두 가지 작은 요령이 있습니다. 첫째, "\r"은 캐리지 리턴으로 줄의 시작으로 돌아가 같은 줄을 다시 그리므로 콘솔에 수백 줄을 찍지 않습니다. 둘째, std::flush는 출력을 즉시 화면에 표시하도록 강제합니다.

애플리케이션의 '오래 걸리는 작업'

#include <chrono>
#include <thread>

void simulate_long_task() {
    std::this_thread::sleep_for(std::chrono::milliseconds{600});
}

합치기: 스피너를 jthread로 실행하고 작업을 수행한 뒤 중단 요청

#include <thread>

int main() {
    std::jthread ui(spinner);

    simulate_long_task();

    ui.request_stop(); // 스피너에게 종료를 요청함
}

이 솔루션의 아름다움(그리고 함정)을 주목하세요. 한편으로 코드는 짧고 깔끔합니다: 스피너는 작업과 "나란히" 존재합니다. 다른 한편으로, 이는 스피너가 올바르게 작성되었기 때문에만 작동합니다: 스피너는 정기적으로 stop_requested()를 확인합니다.

왜 이것이 '제어된 종료'인가

이 강의에서 강조하고 싶은 jthread + stop_token의 핵심 의미는 다음과 같습니다: 우리는 스레드 종료를 두 가지 차원에서 제어한다는 것입니다.

첫 번째 차원 — 수명 관리는 RAII를 통해 이루어집니다. std::jthread 객체는 특정 scope 안에 존재하며, 이는 백그라운드 활동의 명확한 경계를 제공합니다. 이는 컴퓨터에게만 유용한 것이 아니라 사람에게도 유용합니다: 코드를 읽을 때 "여기서 백그라운드가 시작되고 여기서 끝난다"가 분명해집니다.

두 번째 차원 — 중단 관리는 프로토콜을 통해 이루어집니다. "스레드가 알아서 언젠가 죽겠지" 대신 외부 코드가 request_stop()를 호출할 수 있고, 내부 코드는 토큰을 주기적으로 확인해 안전한 지점에서 종료해야 한다는 명확한 합의가 있습니다.

또 하나 중요한 제한을 짚고 넘어가겠습니다: stop_token은 중단 문제만 해결합니다. 스레드 간 데이터 공유를 안전하게 하는 문제를 해결하지는 않습니다. 동시에 공용 변수를 읽거나 쓰기 시작하면 이는 별개의 문제이며, 여기서는 mutex나 atomic에 대한 간단한 언급 이상으로 다루지 않습니다.

7. std::jthread와 std::stop_token 사용 시 전형적인 실수

실수 #1: std::jthread가 모든 스레드를 "스스로 중단"한다고 생각하는 것.
jthread는 scope를 벗어날 때 중요한 작업을 대신 해주지만, 여러분의 생각을 읽지는 못합니다. 스레드 함수가 토큰을 확인하지 않는 무한 루프라면 jthread는 정직하게 종료를 기다립니다. 최선의 경우에는 "종료 시점에서 정지"를 겪고, 최악의 경우에는 프로그램이 닫히지 않는 이유를 오래 찾게 됩니다.

실수 #2: request_stop()를 호출하면 즉시 중단이 일어날 것이라고 기대하는 것.
request_stop()는 스레드를 '죽이는' 것이 아닙니다. 이는 요청입니다. 스레드는 stop_requested() 확인 지점에 도달했을 때만 이를 인식합니다. 만약 확인 주기가 10초마다라면 중단 반응도 최대 10초 걸릴 수 있습니다. 이는 정상이며, 확인 빈도를 통해 응답성 대 코드 단순성의 균형을 스스로 결정하게 됩니다.

실수 #3: sleep_for을 동기화 수단으로 사용하는 것.
가끔 "나는 100ms 잠자면 스레드가 확실히 변수 업데이트를 할 것이다"라고 생각하기 쉽습니다. 이는 매우 위험합니다: 다른 머신, 다른 부하, 스케줄러의 다른 상태에서는 모두 무너질 수 있습니다. sleep_for는 작업을 흉내내고 데모용으로는 괜찮지만, 데이터 접근 규칙을 보장하지 않으며 프로그램을 올바르게 만들지 않습니다.

실수 #4: 지역 변수에 대한 참조를 사용하는 스레드를, 변수의 수명을 고려하지 않고 실행하는 것.
심지어 jthread를 사용해도 스레드가 지역 변수에 대한 참조를 유지하고 그 변수가 스레드 종료보다 먼저 사라지면 위험한 코드를 작성할 수 있습니다. 이 강의에서는 스레드가 "같은 scope 안에 존재"하거나 충분히 오래 존재하는 데이터를 사용하도록 예제를 구성했습니다. 그 경계를 벗어나면 누가 데이터를 소유하고 누가 그들이 여전히 살아 있음을 보장하는지 스스로 정직하게 물어봐야 합니다.

실수 #5: stop_token을 '만능 멀티스레딩 도구'로 만드는 것.
stop_token은 하나의 문제만 해결합니다: 협력적 취소. 콘솔 출력이 섞이지 않게 하거나 컨테이너를 경쟁으로부터 보호하거나 아키텍처를 대체하지는 않습니다. 이 점을 명확히 의식하면 jthread + stop_token는 "수명 경계가 있는 백그라운드 활동"에 대해 훌륭하고 간단하며 안전한 도구가 됩니다.

1
과제
C++ SELF, 레벨 68, 레슨 3
잠금
스레드 자동 대기
스레드 자동 대기
1
과제
C++ SELF, 레벨 68, 레슨 3
잠금
협조적 카운터
협조적 카운터
1
과제
C++ SELF, 레벨 68, 레슨 3
잠금
작동 스피너
작동 스피너
1
과제
C++ SELF, 레벨 68, 레슨 3
잠금
jogi jongnyo
jogi jongnyo
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION