스레드 생성과 관리
프로세스와 스레드를 구분하고 std::thread의 생성·인자 전달·join·detach로 실행 흐름의 수명을 관리합니다.
지금까지 단일 스레드(Single-threaded) 환경에서 사용하는 C++ 프로그래밍 기법들을 다뤘습니다.
이 장에서는 멀티스레딩(Multithreading)에서 스레드 생성, 동기화, 경쟁 조건, 공유 상태 관리를 다룹니다.
멀티스레딩은 프로그램이 여러 작업을 동시에 병렬로 실행할 수 있도록 하는 기술입니다.
이는 사용자 인터페이스의 반응성 향상, 복잡한 계산의 처리 속도 개선, 네트워크 통신 효율 증대 등 다양한 이점을 제공합니다.
C++11 표준부터는 std::thread를 비롯한 표준 라이브러리를 통해 멀티스레딩을 직접 지원하게 되면서, 플랫폼 독립적으로 멀티스레드 애플리케이션을 개발하는 것이 매우 쉬워졌습니다.
이번 장에서는 C++ 표준 라이브러리의 std::thread를 사용하여 스레드를 생성하고 관리하는 기본적인 방법을 학습하겠습니다.
동시성은 여러 작업의 진행을 겹치게 구성하는 일이고, 병렬성은 그 작업들을 실제로 동시에 실행하는 일입니다. 코어가 하나여도 스레드를 번갈아 실행할 수 있으며, 스레드 수를 늘린다고 계산이 반드시 빨라지지는 않습니다. 생성 비용, 잠금 대기, 실제 CPU·I/O 병목을 함께 봅니다. 각 스레드의 스택이 따로 있다는 사실도 다른 스레드가 전달받은 포인터로 그 스택 객체를 읽을 수 없다는 뜻은 아닙니다. 참조를 넘기면 객체의 수명과 동기화까지 책임져야 합니다.
예제 실행 준비와 출력 순서
아래에서 main을 가진 코드 블록 하나씩 별도 파일에 저장합니다. 이 절의 예제는 C++11 이상, 뒤의 shared_mutex·scoped_lock 예제까지 따라가려면 C++17 이상이 필요합니다. GCC나 Clang의 Linux 환경에서는 g++ -std=c++17 -pthread thread.cpp -o thread_demo로 빌드하고 ./thread_demo로 실행할 수 있습니다. MSVC 등 다른 도구에서는 해당 C++ 표준과 스레드 라이브러리를 지원하는 프로젝트 설정을 사용합니다.
스레드 두 개의 출력 순서나 한 줄 안의 출력 조각 순서는 스케줄링에 따라 달라질 수 있습니다. sleep_for는 완료를 증명하는 수단이 아닙니다. 아래 예제에서 모든 작업이 끝났다는 근거는 마지막 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 종료를 기다려 소유 의무를 해소한다. |
|
|
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 vs. Thread)
멀티스레딩을 이해하기 전에, 먼저 프로세스(Process)와 스레드(Thread)의 개념을 명확히 알아야 합니다.
-
프로세스 (Process)
- 운영체제로부터 자원(메모리 공간, CPU 시간, 파일 핸들 등)을 할당받아 실행되는 프로그램의 인스턴스입니다.
- 각 프로세스는 독립적인 메모리 공간을 가집니다. 다른 프로세스의 메모리에 직접 접근할 수 없습니다 (IPC: Inter-Process Communication 필요).
- 보통 하나의 프로세스 내에 하나 이상의 스레드가 존재합니다.
-
스레드 (Thread)
- 프로세스 내에서 실제로 코드를 실행하는 가장 작은 단위입니다. 경량 프로세스(lightweight process)라고도 불립니다.
- 동일한 프로세스 내의 모든 스레드는 프로세스의 메모리 공간(코드, 데이터, 힙 영역)을 공유합니다.
- 각 스레드는 고유한 실행 흐름(Program Counter), 레지스터 세트, 스택을 가집니다.
- 메모리 공간을 공유하기 때문에 스레드 간 통신(Inter-Thread Communication)은 프로세스 간 통신보다 훨씬 효율적입니다. 하지만 동시에 공유 자원에 접근할 때 문제가 발생할 수 있어 동기화(Synchronization) 메커니즘이 필요합니다.
- 병렬성(Parallelism): 다중 코어 CPU 환경에서 여러 스레드가 동시에 다른 코어에서 실행되어 실제 병렬 처리를 통해 작업 완료 시간을 단축할 수 있습니다.
- 응답성(Responsiveness): 긴 작업을 백그라운드 스레드에서 처리하는 동안, 메인 스레드는 사용자 인터페이스를 계속 업데이트하여 프로그램이 멈춘 것처럼 보이지 않게 합니다.
- 자원 활용 효율성: I/O 작업(네트워크 통신, 파일 읽기/쓰기)처럼 CPU를 사용하지 않고 기다려야 하는 시간에 다른 스레드가 CPU를 사용하여 전체적인 자원 활용도를 높일 수 있습니다.
std::thread로 스레드 생성하기
C++11부터 <thread> 헤더에 정의된 std::thread 클래스를 사용하여 새로운 스레드를 생성할 수 있습니다.
std::thread 객체를 생성할 때, 새로운 스레드에서 실행될 함수(또는 함수 객체, 람다)와 해당 함수의 인자들을 생성자의 인자로 전달합니다.
std::thread t(실행_가능_객체, 인자1, 인자2, ...);실행_가능_객체: 함수 포인터, 함수 객체(Functor), 람다 표현식 등이 될 수 있습니다.인자1, 인자2, ...:실행_가능_객체에 전달될 인자들입니다. 이 인자들은 기본적으로 decay한 값으로 복사 또는 이동되어 스레드로 전달됩니다. 만약 참조로 전달하고 싶다면std::ref()를 사용해야 합니다.
#include <iostream>
#include <thread> // std::thread를 위해
#include <chrono> // std::chrono::seconds를 위해
#include <string> // std::string을 위해
// 새로운 스레드에서 실행될 함수 (인자 없음)
void workerFunctionNoArgs() {
std::cout << "Worker thread (no args) started.\n";
std::this_thread::sleep_for(std::chrono::seconds(2)); // 2초 대기
std::cout << "Worker thread (no args) finished.\n";
}
// 새로운 스레드에서 실행될 함수 (인자 있음)
void workerFunctionWithArgs(int id, const std::string& message) {
std::cout << "Worker thread " << id << " received message: " << message << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Worker thread " << id << " finished.\n";
}
int main() {
std::cout << "Main thread started.\n";
// 1. 인자 없는 함수로 스레드 생성
std::thread t1(workerFunctionNoArgs);
// 2. 인자 있는 함수로 스레드 생성
// 인자는 값으로 복사되어 전달됨.
std::thread t2(workerFunctionWithArgs, 1, "Hello from main!");
std::cout << "Main thread doing some work...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
// 스레드 t1과 t2가 작업을 마칠 때까지 기다림
// 중요: join() 또는 detach() 중 하나는 반드시 호출해야 합니다!
t1.join(); // t1 스레드가 종료될 때까지 메인 스레드 대기
t2.join(); // t2 스레드가 종료될 때까지 메인 스레드 대기
std::cout << "Main thread finished. All worker threads joined.\n";
return 0;
}스레드 인자 전달: 값 vs. 참조
스레드 함수에 인자를 전달할 때 주의해야 할 점이 있습니다.
std::thread의 생성자에 전달되는 인자들은 기본적으로 decay한 값으로 복사 또는 이동됩니다.
- 큰 객체 복사: 객체의 크기가 크면 복사 비용이 많이 들 수 있습니다.
- 참조 전달 불가: 함수가 참조 매개변수를 받는다면, 단순히 변수 이름만 넘겨서는 참조로 전달되지 않습니다. 임시 객체가 생성되어 복사본이 전달될 수 있습니다.
해결책: std::ref() 사용:
인자를 참조로 전달하고 싶다면 std::ref() 또는 std::cref() (const 참조) 래퍼를 사용해야 합니다.
#include <chrono>
#include <iostream>
#include <thread>
#include <string>
#include <vector> // std::vector를 위해
#include <functional> // std::ref를 위해
// 참조로 int를 수정하는 함수
void modifyValue(int& value) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
value += 100;
std::cout << "Inside thread: value after modification = " << value << std::endl;
}
// 참조로 벡터에 추가하는 함수
void addElementToVector(std::vector<int>& vec, int element) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
vec.push_back(element);
std::cout << "Inside thread: vector size = " << vec.size() << std::endl;
}
int main() {
std::cout << "Main thread started.\n";
int my_int = 10;
std::cout << "Original my_int: " << my_int << std::endl;
// my_int를 참조로 스레드에 전달
std::thread t1(modifyValue, std::ref(my_int)); // std::ref 사용
std::vector<int> my_vec = {1, 2, 3};
std::cout << "Original my_vec size: " << my_vec.size() << std::endl;
// my_vec을 참조로 스레드에 전달
std::thread t2(addElementToVector, std::ref(my_vec), 4); // std::ref 사용
t1.join();
t2.join();
std::cout << "After t1 joined: my_int = " << my_int << std::endl; // my_int가 변경되었음을 확인
std::cout << "After t2 joined: my_vec size = " << my_vec.size() << std::endl; // my_vec이 변경되었음을 확인
std::cout << "Main thread finished.\n";
return 0;
}주의: 참조로 인자를 전달할 때는 댕글링 참조(Dangling Reference) 문제에 매우 유의해야 합니다.
스레드가 참조하는 객체가 스레드 실행이 끝나기 전에 소멸되지 않도록 객체의 생명 주기를 잘 관리해야 합니다.
예를 들어, 지역 변수를 참조로 전달하고 스레드가 그 변수가 소멸된 후에도 접근하려고 하면 정의되지 않은 동작이 발생합니다.
값 인자와 참조 인자를 작은 수로 비교하기
긴 작업 없이, 스레드 생성 시 저장한 값과 원본 객체를 구별해 보겠습니다. 아래 코드는 독립된 완전한 프로그램입니다.
#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';
}첫 스레드는 생성 시점의 10을 자체 인자로 받으므로 copy=110을 출력합니다. 메인의 n = 20은 그 복사본을 바꾸지 않습니다. join 뒤에는 original=20, 참조 인자를 받은 둘째 스레드까지 join한 뒤에는 after ref=120이 나옵니다. 둘째 스레드가 수정하는 동안 메인은 n을 읽지 않으며, 지역 변수는 두 스레드가 끝날 때까지 살아 있습니다.
독립적으로 값과 소유 의무 판단하기
같은 프로그램에서 처음 값을 7, 메인이 대입하는 값을 11, 두 스레드의 증가량을 모두 3으로 바꿨다고 하겠습니다. 세 출력값은 무엇인가요? copied.join()이 끝난 뒤 작업은 완료됐다는 사실과 copied.joinable()의 결과도 구별해 답해 보세요. 별도 상황으로, 실행은 끝났지만 아직 join하지 않은 t를 std::thread u(std::move(t))로 이동했다면 어느 객체에 소멸 전 의무가 남나요?
풀이 확인
복사 인자는 7이므로 copy=10, 원본은 original=11, 참조 수정 뒤에는 after ref=14입니다. 성공한 join 뒤 copied.joinable()은 false입니다. 이미 실행이 끝났더라도 join 전의 객체는 joinable일 수 있으며, 이동 뒤에는 t가 비joinable이고 u가 그 실행 흐름의 소유 의무를 이어받습니다. u도 성공한 join 등으로 의무를 해소해야 합니다.
스레드 종료 대기: join()
std::thread::join() 함수는 해당 스레드가 종료될 때까지 호출한 스레드(보통 메인 스레드)의 실행을 블록(block)합니다.
이는 부모 스레드가 자식 스레드의 완료를 기다려야 할 때 사용됩니다.
join()이 호출된 후에는 스레드 객체가 더 이상 스레드 실행 흐름과 연관되지 않으므로, 다시 join()을 호출할 수 없습니다.
소유권 의무: joinable인 std::thread 객체는 소멸 전에 join·detach하거나 다른 객체로 이동해 의무를 넘겨야 합니다. 기본 생성되거나 이미 이동된 비joinable 객체는 그대로 소멸할 수 있습니다.
그렇지 않으면 프로그램이 std::terminate를 호출하며 비정상 종료될 수 있습니다.
스레드 분리: detach()
std::thread::detach() 함수는 스레드를 생성한 std::thread 객체와 스레드의 실행 흐름을 분리(detach)합니다.
분리된 스레드는 해당 std::thread 객체로 join할 수 없게 됩니다. 표준의 별도 “데몬 스레드” 종류로 바뀌는 것은 아닙니다.
분리된 스레드는 백그라운드에서 실행되며, 프로그램이 종료될 때까지 계속 실행되거나, 완료되면 자동으로 자원이 정리됩니다.
사용 시 주의: detach()된 스레드는 더 이상 제어할 수 없습니다.
예를 들어, join()을 호출하여 스레드의 완료를 기다릴 수 없습니다.
detach()된 스레드가 여전히 부모 스레드의 자원에 접근하려고 할 경우 댕글링 참조 문제가 발생할 가능성이 높습니다.
#include <iostream>
#include <thread>
#include <chrono>
void independentWorker() {
std::cout << "Independent worker thread started.\n";
std::this_thread::sleep_for(std::chrono::seconds(3));
std::cout << "Independent worker thread finished.\n";
}
void joinableWorker() {
std::cout << "Joinable worker thread started.\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Joinable worker thread finished.\n";
}
int main() {
std::cout << "Main thread started.\n";
// 1. detach() 스레드
std::thread detached_thread(independentWorker);
detached_thread.detach(); // 스레드를 분리
std::cout << "Main thread created detached thread.\n";
// 2. join() 스레드
std::thread joinable_thread(joinableWorker);
std::cout << "Main thread created joinable thread.\n";
std::cout << "Main thread doing brief work...\n";
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// detach()된 스레드는 더 이상 joinable() 하지 않음
if (detached_thread.joinable()) {
std::cout << "Detached thread is joinable (this won't print).\n";
} else {
std::cout << "Detached thread is not joinable.\n"; // 출력됨
}
// joinable() 확인 후 join()
if (joinable_thread.joinable()) {
std::cout << "Main thread waiting for joinable thread to finish...\n";
joinable_thread.join(); // joinable_thread가 끝날 때까지 대기
std::cout << "Joinable thread joined.\n";
} else {
std::cout << "Joinable thread is not joinable (this won't print).\n";
}
// 만약 detached_thread가 main 함수보다 더 오래 실행될 경우
// main 함수 종료 시 프로그램이 종료되면서 detached_thread도 강제 종료될 수 있음.
// 이는 detached_thread 내에서 자원 정리 등에 문제가 될 수 있으므로,
// detach는 매우 신중하게 사용해야 함.
std::cout << "Main thread finished.\n";
return 0;
}기타 스레드 관련 함수
std::this_thread::get_id(): 현재 실행 중인 스레드의 고유 ID를 반환합니다.std::this_thread::sleep_for(duration): 현재 스레드를 지정된 시간 동안 일시 중지시킵니다.std::this_thread::yield(): 스케줄러에게 다른 실행 기회를 양보하는 힌트를 줍니다. 특정 스레드 실행이나 동기화를 보장하지 않습니다.
std::thread 관리는 인자 전달, join, detach의 수명 책임을 한 번에 정하는 일입니다.
스레드를 시작했다면 누가 언제 끝을 책임지는지 코드에서 드러나야 합니다.