스레드 생성과 관리
프로세스와 스레드를 구분하고 std::thread의 생성·인자 전달·join·detach로 실행 흐름의 수명을 관리합니다.
지금까지의 프로그램은 한 번에 한 가지 일만 했습니다. 큰 데이터를 나누어 동시에 계산하거나, 파일을 내려받는 동안 화면이 멈추지 않게 하려면 여러 흐름을 동시에 실행해야 합니다. 하나의 프로그램 안에서 동시에 실행되는 흐름을 스레드(thread)라고 하며, C++11부터 <thread> 헤더의 std::thread로 만들 수 있습니다.
이 장에서는 멀티스레딩의 기초를 다룹니다. 이 글은 스레드를 만들고 인자를 넘기는 방법, 스레드가 끝나기를 기다리는 join과 떼어 놓는 detach, 그리고 std::thread 객체가 지는 소유 의무를 봅니다. 아래 그림은 std::thread 객체가 실행 흐름을 소유하는 상태가 어떻게 바뀌는지를 보여 줍니다.
std::thread의 joinable 상태는 실행이 아니라 소유권 의무를 뜻한다
기본 비joinable→실행 흐름 소유→성공 join으로 의무 해소의 정상 경로가 명확하다. detach/move는 대안 경로로 유지하고 실행 완료와 joinable을 구분한다.
C++ · THREAD OWNERSHIP
joinable은 실행 여부보다 소유권 의무를 뜻한다
std::thread 객체는 실행 흐름의 handle을 소유한다. 실행이 끝나도 joinable일 수 있으며, joinable 객체의 소멸은 terminate를 호출한다.
기본 객체 → 실행 시작
기본 객체는 비joinable이다. 새 실행 흐름을 시작한 객체는 joinable이며 실행이 완료되어도 의무는 남는다.
join() · 완료까지 대기
성공한 join은 실행 완료를 기다리고 현재 객체를 비joinable로 만든다.
detach() · 수명 연결 분리
성공한 detach 뒤 객체는 비joinable이다. 실행 흐름은 독립적으로 계속할 수 있지만 프로그램 종료 전 완료를 보장하지 않는다.
move · 의무 이전
원본은 비joinable, 대상은 joinable이 된다. 대상도 join/detach 등의 의무를 해소해야 한다.
move 대입의 대상이 이미 joinable이면 terminate한다. std::ref로 전달한 대상은 스레드가 사용하는 동안 살아 있어야 한다. detach는 표준 daemon을 만드는 기능이 아니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 핸들 준비 기본 thread는 실행 흐름을 소유하지 않는다. |
|
|
| 스레드 소유 실행 흐름을 시작한 thread가 소유권을 가진다. |
|
|
| 성공 join 종료를 기다려 소유 의무를 해소한다. |
|
|
std::thread의 joinable 상태는 실행이 아니라 소유권 의무를 뜻한다
기본 비joinable→실행 흐름 소유→성공 join으로 의무 해소의 정상 경로가 명확하다. detach/move는 대안 경로로 유지하고 실행 완료와 joinable을 구분한다.
C++ · THREAD OWNERSHIP
joinable은 실행 여부보다 소유권 의무를 뜻한다
std::thread 객체는 실행 흐름의 handle을 소유한다. 실행이 끝나도 joinable일 수 있으며, joinable 객체의 소멸은 terminate를 호출한다.
기본 객체 → 실행 시작
기본 객체는 비joinable이다. 새 실행 흐름을 시작한 객체는 joinable이며 실행이 완료되어도 의무는 남는다.
join() · 완료까지 대기
성공한 join은 실행 완료를 기다리고 현재 객체를 비joinable로 만든다.
detach() · 수명 연결 분리
성공한 detach 뒤 객체는 비joinable이다. 실행 흐름은 독립적으로 계속할 수 있지만 프로그램 종료 전 완료를 보장하지 않는다.
move · 의무 이전
원본은 비joinable, 대상은 joinable이 된다. 대상도 join/detach 등의 의무를 해소해야 한다.
move 대입의 대상이 이미 joinable이면 terminate한다. std::ref로 전달한 대상은 스레드가 사용하는 동안 살아 있어야 한다. detach는 표준 daemon을 만드는 기능이 아니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 핸들 준비 기본 thread는 실행 흐름을 소유하지 않는다. |
|
|
| 스레드 소유 실행 흐름을 시작한 thread가 소유권을 가진다. |
|
|
| 성공 join 종료를 기다려 소유 의무를 해소한다. |
|
|
프로세스와 스레드
실행 중인 프로그램 하나를 프로세스(process)라고 하며, 운영체제는 프로세스마다 따로 된 메모리 공간을 줍니다. 한 프로세스 안의 스레드들은 그 메모리를 함께 쓰므로, 같은 변수에 바로 접근해 데이터를 쉽게 주고받을 수 있습니다. 대신 여러 스레드가 같은 데이터를 동시에 바꾸면 결과가 꼬이는 문제가 생기며, 이는 다음 글에서 다룹니다.
운영체제 교재의 스레드의 개념에서 자세히 다루듯, 스레드마다 자기만의 호출 스택이 있어 지역 변수는 따로 가지지만, 전역 변수와 동적으로 만든 객체는 함께 봅니다. 스레드가 몇 개든 실제로 동시에 실행되는 개수는 CPU의 코어 수에 달려 있고, 어느 스레드를 언제 실행할지는 운영체제가 정합니다. 그래서 여러 스레드가 화면에 출력하면 출력 순서는 실행할 때마다 달라질 수 있습니다.
std::thread로 스레드 만들기
std::thread의 생성자에 실행할 함수와 그 함수의 인자를 넘기면, 새 스레드가 그 함수를 실행하기 시작합니다. 함수 대신 함수 객체나 람다도 넘길 수 있습니다.
#include <iostream>
#include <numeric>
#include <thread>
#include <vector>
int main() {
std::vector<int> data(100);
std::iota(data.begin(), data.end(), 1);
long long left = 0;
long long right = 0;
std::thread t1([&] { left = std::accumulate(data.begin(), data.begin() + 50, 0LL); });
std::thread t2([&] { right = std::accumulate(data.begin() + 50, data.end(), 0LL); });
t1.join();
t2.join();
std::cout << left << ' ' << right << ' ' << left + right << '\n';
}1275 3775 5050std::iota는 data를 1부터 100까지 채우고, 두 스레드가 앞의 50개와 뒤의 50개를 동시에 더합니다. 두 스레드는 서로 다른 변수 left와 right에만 쓰고, main은 join()으로 두 스레드가 끝나기를 기다린 뒤에 결과를 읽으므로 값이 꼬이지 않습니다. MSYS2의 GCC는 따로 옵션 없이 std::thread를 쓸 수 있고, Linux의 GCC에서는 g++ -std=c++20 -pthread처럼 -pthread를 붙여 빌드하는 것이 안전합니다.
인자를 넘기는 방법
std::thread에 넘긴 인자는 새 스레드가 쓸 수 있도록 복사되거나 이동되어 저장됩니다. 그래서 스레드 함수가 참조 매개변수를 받더라도, 변수를 그대로 넘기면 원본이 아니라 저장된 사본이 쓰입니다. 원본을 참조로 넘기려면 함수 객체에서 본 std::ref로 감쌉니다.
#include <functional>
#include <iostream>
#include <thread>
int main() {
int n = 10;
std::thread copied([](int value) {
std::cout << "copy=" << value + 100 << '\n';
}, n);
n = 20;
copied.join();
std::cout << "original=" << n << '\n';
std::thread referred([](int& value) {
value += 100;
}, std::ref(n));
referred.join();
std::cout << "after ref=" << n << '\n';
}copy=110
original=20
after ref=120첫 스레드는 만들 때 n의 값 10을 복사해 받으므로, main이 그 뒤에 n을 20으로 바꿔도 copy=110을 출력합니다. std::ref(n)으로 넘긴 둘째 스레드는 원래의 n을 참조해 120으로 바꾸고, main은 join()한 뒤에 그 값을 읽습니다. 참조로 넘긴 변수는 스레드가 쓰는 동안 살아 있어야 하며, 스레드가 바꾸는 동안 다른 스레드가 같은 변수를 읽거나 쓰면 안 됩니다.
join과 소유 의무
join()은 그 스레드가 끝날 때까지 호출한 스레드를 기다리게 합니다. 실행 흐름을 가진 std::thread 객체를 joinable하다고 하며, joinable()로 확인할 수 있습니다. 스레드의 실행이 이미 끝났더라도 join()하기 전의 객체는 여전히 joinable입니다.
joinable한 std::thread 객체가 그대로 소멸되면 std::terminate가 호출되어 프로그램이 끝납니다. 그래서 그림처럼 객체가 사라지기 전에 join()으로 기다리거나, detach()로 떼어 놓거나, 다른 객체로 이동해 의무를 넘겨야 합니다. 이 세 가지는 차례로 모두 하는 것이 아니라 하나를 고르는 것이며, 그 뒤 원래의 객체는 joinable이 아니게 됩니다. 예외가 나서 join()에 도달하지 못해도 같은 일이 생기므로, 이런 실수를 막으려면 C++20의 std::jthread를 씁니다.
#include <iostream>
#include <thread>
int main() {
{
std::jthread worker([] { std::cout << "working\n"; });
}
std::cout << "after block\n";
}working
after blockstd::jthread는 소멸할 때 아직 joinable이면 스스로 join()하므로, 블록을 벗어날 때 worker가 끝나기를 기다린 뒤 after block이 출력됩니다. 정상 반환이든 예외든 블록을 벗어나면 기다리므로 std::thread보다 안전합니다. 소멸할 때 멈춤을 요청하는 기능도 있는데, 이는 조건 변수와 함께 쓰는 흐름에서 다시 봅니다.
더 깊이: 소멸자가 join도 detach도 하지 않는 이유
C++11을 만들던 시기의 초안에서는 joinable한 std::thread가 소멸할 때 스레드를 떼어 놓도록(detach) 되어 있었습니다. 한스 뵘(Hans-J. Boehm)은 N2802에서 이 방식의 위험을 지적했습니다. 스레드를 만든 뒤 join() 전에 예외가 나면, 떼어진 스레드가 스택 풀기로 이미 사라진 지역 변수에 계속 써서 디버깅하기 거의 불가능한 메모리 손상이 생긴다는 것입니다.
그렇다고 소멸자가 대신 join()하게 하면, 예외로 블록을 빠져나가는 길에 스레드가 끝나기를 기다리느라 예상하지 못한 지연이나 교착 상태가 생길 수 있습니다. 이 논문은 소멸자의 동작을 join()으로 바꾸는 안과 terminate()로 바꾸는 안을 함께 제시했고, 최종 C++11 표준은 joinable한 객체의 소멸에 std::terminate를 부르는 쪽을 택했습니다. 그래서 std::thread를 쓰는 쪽이 모든 경로에서 join()이나 detach()를 직접 골라야 합니다.
C++20의 std::jthread는 이 문제에 다른 답을 냈습니다. 소멸할 때 아직 joinable이면 먼저 request_stop()으로 멈춤을 요청한 뒤 join()하므로, 스레드가 멈춤 요청을 확인하도록 만들어 두면 기다림이 길어지는 문제를 줄이면서도 예외 경로에서 안전하게 정리됩니다.
detach와 그 위험
detach()는 std::thread 객체와 실행 흐름의 연결을 끊습니다. 떼어 놓은 스레드는 혼자 계속 실행되고 끝나면 자원이 정리되지만, 그 뒤로는 그 스레드를 기다리거나 끝났는지 확인할 방법이 없습니다. main이 끝나 프로그램이 종료되면 떼어 놓은 스레드도 작업 도중에 함께 끝납니다.
떼어 놓은 스레드가 main의 지역 변수를 참조로 쓰고 있었다면, 그 변수가 사라진 뒤에 접근해 정의되지 않은 동작이 됩니다. 언제 끝날지 알 수 없는 스레드가 공유 데이터를 쓰게 되므로 정확한 프로그램을 만들기 어렵습니다. 그래서 detach()는 꼭 필요한 경우가 아니면 쓰지 않고, 끝날 때까지 기다릴 수 있도록 join()이나 std::jthread를 씁니다.
스레드 옮기기와 그 밖의 함수
std::thread는 복사할 수 없고 이동만 할 수 있어, 스마트 포인터의 std::unique_ptr처럼 실행 흐름을 하나의 객체만 소유합니다. std::thread u(std::move(t));로 옮기면 t는 joinable이 아니게 되고 u가 의무를 넘겨받으며, std::vector<std::thread>에 여러 스레드를 담아 두었다가 차례로 join()할 수도 있습니다.
| 함수 | 하는 일 |
|---|---|
std::this_thread::get_id() | 지금 실행 중인 스레드의 식별자 |
std::this_thread::sleep_for(시간) | 지정한 시간만큼 현재 스레드를 멈춤 |
std::this_thread::yield() | 다른 스레드에 실행을 양보하겠다는 힌트 |
std::thread::hardware_concurrency() | 동시에 실행할 수 있는 스레드 수의 힌트, 알 수 없으면 0 |
hardware_concurrency()는 알 수 없을 때 0을 돌려줄 수 있으므로, 스레드 수를 정할 때는 0인 경우를 따로 처리합니다. sleep_for로 다른 스레드가 끝날 때까지 기다리게 하는 것은 정확하지 않으므로, 기다릴 때는 join()이나 다음 글들의 동기화 도구를 씁니다.
정리
- 스레드는 한 프로세스 안에서 동시에 실행되는 흐름이며, 메모리를 함께 쓰고 실행 순서는 운영체제가 정합니다.
std::thread의 생성자에 함수와 인자를 넘기면 새 스레드가 실행을 시작합니다.- 인자는 복사되거나 이동되어 저장되므로, 원본을 참조로 넘기려면
std::ref를 쓰고 원본이 살아 있게 합니다. - joinable한
std::thread가 그대로 소멸되면std::terminate가 호출되므로,join,detach, 이동 가운데 하나로 의무를 정리합니다. - C++20의
std::jthread는 소멸할 때 스스로join해 예외가 나도 안전합니다. detach한 스레드는 기다리거나 확인할 수 없고 지역 변수를 쓰면 위험하므로, 꼭 필요할 때만 씁니다.
연습 문제
1. 값 바꾸기와 joinable
thread_args.cpp에서 처음 값을 7로, main이 대입하는 값을 11로, 두 스레드가 더하는 값을 모두 3으로 바꾸면 세 줄은 무엇을 출력하나요? copied.join()이 끝난 뒤 copied.joinable()은 무엇인가요?
해설 보기
첫 스레드는 7을 복사해 받으므로 copy=10, main의 값은 original=11, 참조로 3을 더한 뒤에는 after ref=14입니다. join()이 성공하면 객체는 실행 흐름과의 연결이 끝나므로 joinable()은 false입니다.
2. join을 잊으면
int main() { std::thread t([] {}); }처럼 아무 일도 하지 않는 스레드를 만들고 join하지 않은 채 main이 끝나면 어떻게 되나요?
해설 보기
스레드가 이미 끝났더라도 t는 join()하기 전이어서 joinable이므로, main이 끝나며 t가 소멸될 때 std::terminate가 호출되어 프로그램이 비정상적으로 끝납니다. t.join();을 추가하거나 std::jthread로 바꾸면 정상적으로 끝납니다.
3. 나누는 위치 바꾸기
parallel_sum.cpp에서 두 스레드가 나누는 위치를 50에서 30으로 바꾸면, 출력은 무엇인가요?
해설 보기
앞의 1부터 30까지의 합은 30 × 31 / 2 = 465이고, 31부터 100까지의 합은 5050 - 465 = 4585이므로 465 4585 5050이 출력됩니다. 나누는 위치가 바뀌어도 전체 합은 같으며, 두 스레드가 서로 다른 변수에만 쓰고 main이 join() 뒤에 읽는다는 점이 결과를 정확하게 만듭니다.
다음 글 뮤텍스와 락에서는 여러 스레드가 같은 데이터를 동시에 바꿀 때 생기는 데이터 경쟁과, 이를 막는 뮤텍스와 락, 그리고 교착 상태를 다룹니다.