스레드 만들고 관리하기
실행 흐름을 여러 개 띄운다는 건 무슨 뜻인가? std::thread로 스레드를 만들고 join/detach로 수명을 관리하는 법, 그리고 C++20 jthread의 자동 join과 협력적 취소.
동시성 이야기는 잠금이나 신호가 아니라 그 앞에서 시작해야 한다 — 애초에 실행 흐름을 어떻게 여러 개 띄우는가. 잠금은 “이미 여러 스레드가 돌고 있다”를 전제하기 때문이다. 이번 편은 그 전제, 즉 스레드를 만들고 수명을 관리하는 법을 다룬다.
스레드란 무엇인가
보통의 프로그램은 하나의 실행 흐름을 갖는다. main에서 시작해 한 줄씩 순서대로 내려간다. 스레드(thread) 는 이 실행 흐름을 여러 개로 늘리는 것이다. 여러 스레드가 같은 프로세스 안에서, 같은 메모리를 공유하며, 동시에(또는 번갈아) 실행된다.
메모리를 공유한다는 점이 힘이자 위험이다. 스레드끼리 데이터를 주고받기 쉽지만, 동시에 같은 데이터를 만지면 문제가 생긴다. 그 문제를 다루는 게 이후 편들의 잠금과 동기화다. 이번 편은 그 앞 단계인, 스레드를 안전하게 띄우고 정리하는 법에 집중한다.
std::thread — 실행 흐름 띄우기
C++11부터 표준 라이브러리가 std::thread를 제공한다. 생성자에 호출 가능한 것(함수, 람다, 함수 객체)을 넘기면 그 즉시 새 스레드가 실행을 시작한다.
#include <thread>
#include <iostream>
void work() {
std::cout << "다른 스레드에서 실행 중\n";
}
int main() {
std::thread t(work); // 이 순간 새 스레드가 시작된다
t.join(); // t가 끝날 때까지 기다린다
}
std::thread t(work) 한 줄이 새 실행 흐름을 만들고, 그 흐름은 main과 동시에 진행된다. 람다도 똑같이 넘길 수 있다.
std::thread t([]{
std::cout << "람다로 실행\n";
});
t.join();
join과 detach — 반드시 하나는 골라야 한다
std::thread 객체가 소멸될 때, 그 스레드에 대해 join도 detach도 하지 않았다면 프로그램은 std::terminate로 즉시 죽는다. 이건 C++의 의도적인 설계다. “띄운 스레드를 어떻게 처리할지 명시적으로 정하라”는 강제다.
join()은 그 스레드가 끝날 때까지 현재 스레드를 멈춰 기다린다. 스레드가 남긴 결과나 부수 효과가 필요할 때, 그리고 스레드가 참조하는 자원이 살아 있어야 할 때 쓴다.
std::thread t(work);
// ... 다른 일 ...
t.join(); // t가 끝나야 다음 줄로 간다
detach()는 스레드를 떼어내 독립적으로 돌게 둔다. thread 객체와의 연결이 끊기고, 그 스레드는 알아서 실행되다 끝난다.
std::thread t(work);
t.detach(); // 이제 t는 배후에서 독립적으로 실행
detach는 다루기 까다롭다. 떼어낸 스레드가 main보다 오래 살면, main이 끝나며 프로세스가 종료될 때 그 스레드가 중간에 잘려나가거나, 이미 파괴된 자원을 참조해 정의되지 않은 동작을 일으킬 수 있다. 그래서 실무에서는 대부분 join을 쓰고, detach는 수명을 확실히 통제할 수 있을 때만 신중하게 쓴다.
join의 함정 — 예외
여기 미묘한 문제가 있다. join을 호출하기 전에 예외가 나면 어떻게 될까.
void process() {
std::thread t(work);
doSomethingThatMayThrow(); // 여기서 예외가 나면
t.join(); // 이 줄에 도달하지 못한다
}
예외가 던져지면 t.join()을 건너뛴 채 t가 소멸되고, join도 detach도 안 된 스레드이므로 프로그램이 std::terminate로 죽는다. 다음 편에서 볼 mutex를 손으로 lock/unlock하다 예외에 unlock을 빠뜨려 데드락이 나는 것과 똑같은 구조의 문제다. 정리(join)를 모든 탈출 경로에서 보장해야 하는데 손으로는 빠뜨린다.
해법도 같다. RAII로 감싸는 것이다. 스레드를 멤버로 들고 소멸자에서 join하는 래퍼를 만들면 된다. 그리고 C++20은 아예 그걸 표준으로 만들어 두었다.
std::jthread — 자동 join과 취소 (C++20)
std::jthread는 std::thread의 개선판이다. 이름의 j는 joining을 뜻한다. 두 가지가 다르다.
첫째, 소멸자에서 자동으로 join한다. 스코프를 벗어나면 알아서 join되므로, join을 빠뜨려 terminate가 나는 일도, 예외 경로를 걱정할 일도 없다. RAII가 내장된 것이다.
void process() {
std::jthread t(work);
doSomethingThatMayThrow(); // 예외가 나도
} // 소멸자가 자동으로 join
둘째, 협력적 취소(cooperative cancellation) 를 지원한다. std::stop_token을 통해 “이제 그만두라”는 신호를 스레드에 보낼 수 있다. 함수의 첫 인자로 std::stop_token을 받으면 jthread가 자동으로 넘겨준다.
#include <thread>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
// 반복 작업
}
// 루프를 빠져나와 깔끔하게 종료
}
int main() {
std::jthread t(worker);
// ... 다른 일 ...
t.request_stop(); // 중단 요청. worker가 스스로 확인하고 종료
} // 소멸자에서 request_stop 후 join
“협력적”이라는 말이 핵심이다. request_stop()은 강제로 스레드를 죽이지 않는다. 그저 “멈춰달라”는 플래그를 세울 뿐이고, 스레드가 스스로 stop_requested()를 확인해 빠져나와야 한다. 스레드를 외부에서 강제 종료하는 것은 자원을 정리하지 못한 채 잘려나갈 위험이 있어 어느 언어에서도 권장되지 않는다. 협력적 취소는 그 대신 안전하게 종료 지점을 스레드 자신에게 맡긴다.
jthread의 소멸자는 자동으로 request_stop()을 부른 뒤 join한다. 그래서 위 예에서 명시적 request_stop()이 없어도, 스코프를 벗어날 때 중단 요청과 join이 함께 일어난다.
정리하면, C++20 이후 기본 선택은 jthread다. 자동 join으로 안전하고, 취소 메커니즘이 내장돼 있다. thread는 이 동작이 필요 없거나 세밀한 수동 제어가 필요할 때만 쓴다.
인자 전달 — 값 복사와 std::ref
스레드 함수에 인자를 넘기려면 thread 생성자에 이어서 적으면 된다. 주의할 점은 인자가 기본적으로 복사된다는 것이다.
void printValue(int x) {
std::cout << x << "\n";
}
std::jthread t(printValue, 42); // 42가 복사되어 전달
참조로 넘기고 싶어도, 그냥 넘기면 복사돼 버린다. 원본을 공유하려면 std::ref로 명시적으로 감싸야 한다.
void increment(int& x) {
++x;
}
int value = 0;
std::jthread t(increment, std::ref(value)); // value를 참조로 전달
t.join();
// 이제 value는 1
std::ref 없이 increment(value)처럼 넘기면 컴파일 에러가 나거나(참조 인자의 경우) 복사본만 바뀌고 원본은 그대로다. 참조 전달은 반드시 std::ref(또는 const 참조엔 std::cref)를 거쳐야 한다.
여기엔 수명 함정도 있다. 참조로 넘긴 원본이 스레드보다 먼저 사라지면, 스레드는 사라진 것을 가리키게 된다. jthread의 자동 join이 이 위험을 크게 줄여준다 — 원본을 담은 스코프가 끝나기 전에 join되도록 배치하면 되기 때문이다.
얼마나 많은 스레드를 만들까
스레드는 공짜가 아니다. 각각 스택 메모리를 차지하고, 만들고 없애는 비용과 서로 전환하는 비용이 있다. 코어 수보다 훨씬 많은 스레드를 만들면 오히려 전환 비용에 성능이 깎인다.
하드웨어가 동시에 굴릴 수 있는 스레드 수는 힌트로 물어볼 수 있다.
unsigned n = std::thread::hardware_concurrency();
// 이 머신이 동시 실행 가능한 스레드 수(0이면 미확정)
실무에서는 스레드를 매번 만들고 버리기보다, 미리 몇 개 만들어 두고 작업을 나눠 맡기는 스레드 풀을 쓴다. 그때 각 워커에게 작업을 전달하는 통로가 바로 다음 편들에서 다룰 잠금과 조건 변수 기반의 작업 큐다.
정리
- 스레드 — 같은 메모리를 공유하는 여러 실행 흐름. 공유가 힘이자 위험이다.
- std::thread — 생성 즉시 실행 시작. 소멸 전에 반드시
join또는detach중 하나를 해야 한다(안 하면terminate). - join vs detach — 대부분
join(끝날 때까지 대기).detach는 수명 통제가 확실할 때만. - 예외 함정 — 손으로 하는 join은 예외 경로에서 빠지기 쉽다. RAII로 감싸야 한다.
- std::jthread (C++20) — 소멸자에서 자동 join + 협력적 취소(
stop_token). C++20 이후의 기본 선택. - 인자 전달 — 기본은 복사. 참조로 넘기려면
std::ref/std::cref. - 스레드 수 — 코어 수를 넘겨 남발하면 전환 비용으로 손해. 실무에선 스레드 풀.
실행 흐름을 여러 개 띄웠으니, 이제 그것들이 같은 데이터를 만질 때의 문제가 남는다. 다음 편에서는 그 공유 자원을 안전하게 잠그는 법 — mutex와 RAII 기반 잠금을 다룬다.