스레드 생성과 관리
프로세스와 스레드를 구분하고 std::thread의 생성·인자 전달·join·detach로 실행 흐름의 수명을 관리합니다.
지금까지 단일 스레드(Single-threaded) 환경에서 사용하는 C++ 프로그래밍 기법들을 다뤘습니다.
이 장에서는 멀티스레딩(Multithreading)에서 스레드 생성, 동기화, 경쟁 조건, 공유 상태 관리를 다룹니다.
멀티스레딩은 프로그램이 여러 작업을 동시에 병렬로 실행할 수 있도록 하는 기술입니다.
이는 사용자 인터페이스의 반응성 향상, 복잡한 계산의 처리 속도 개선, 네트워크 통신 효율 증대 등 다양한 이점을 제공합니다.
C++11 표준부터는 std::thread를 비롯한 표준 라이브러리를 통해 멀티스레딩을 직접 지원하게 되면서, 플랫폼 독립적으로 멀티스레드 애플리케이션을 개발하는 것이 매우 쉬워졌습니다.
이번 장에서는 C++ 표준 라이브러리의 std::thread를 사용하여 스레드를 생성하고 관리하는 기본적인 방법을 학습하겠습니다.
C++20 · CH15-1
std::thread의 joinable 상태는 실행이 아니라 소유권 의무를 뜻한다
thread 객체는 실행 흐름의 handle을 소유한다. joinable 객체를 소멸시키면 terminate하므로 join, detach 또는 다른 객체로의 이동으로 소유권을 명시적으로 해소해야 한다.
- 인수 저장
thread 생성자는 인수를 decay-copy 또는 move해 새 실행 흐름에서 호출합니다.
- joinable 소유
실행이 이미 끝났어도 join이나 detach 전이면 객체는 joinable입니다.
- join 또는 이동
join은 완료를 기다려 handle을 회수하고, move는 의무를 새 객체로 넘깁니다.
- detach 주의
detach는 표준 daemon을 만드는 기능이 아니라 수명 연결을 끊습니다.
- jthread 우선 검토
C++20 `jthread`는 stop 요청과 자동 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, ...:실행_가능_객체에 전달될 인자들입니다. 이 인자들은 기본적으로 값으로 복사되어 스레드로 전달됩니다. 만약 참조로 전달하고 싶다면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의 생성자에 전달되는 인자들은 기본적으로 값으로 복사됩니다.
- 큰 객체 복사: 객체의 크기가 크면 복사 비용이 많이 들 수 있습니다.
- 참조 전달 불가: 함수가 참조 매개변수를 받는다면, 단순히 변수 이름만 넘겨서는 참조로 전달되지 않습니다. 임시 객체가 생성되어 복사본이 전달될 수 있습니다.
해결책- std::ref() 사용:
인자를 참조로 전달하고 싶다면 std::ref() 또는 std::cref() (const 참조) 래퍼를 사용해야 합니다.
#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) 문제에 매우 유의해야 합니다.
스레드가 참조하는 객체가 스레드 실행보다 먼저 소멸되지 않도록 객체의 생명 주기를 잘 관리해야 합니다.
예를 들어, 지역 변수를 참조로 전달하고 스레드가 그 변수가 소멸된 후에도 접근하려고 하면 정의되지 않은 동작이 발생합니다.
스레드 종료 대기: join()
std::thread::join() 함수는 해당 스레드가 종료될 때까지 호출한 스레드(보통 메인 스레드)의 실행을 블록(block)합니다.
이는 부모 스레드가 자식 스레드의 완료를 기다려야 할 때 사용됩니다.
join()이 호출된 후에는 스레드 객체가 더 이상 스레드 실행 흐름과 연관되지 않으므로, 다시 join()을 호출할 수 없습니다.
필수 사용: std::thread 객체가 소멸되기 전에 반드시 join() 또는 detach() 둘 중 하나는 호출해야 합니다.
그렇지 않으면 프로그램이 std::terminate를 호출하며 비정상 종료될 수 있습니다.
스레드 분리: detach()
std::thread::detach() 함수는 스레드를 생성한 std::thread 객체와 스레드의 실행 흐름을 분리(detach)합니다.
분리된 스레드는 데몬 스레드(Daemon Thread)가 되며, 더 이상 생성자 스레드에 의해 관리되지 않고 독립적으로 실행됩니다.
분리된 스레드는 백그라운드에서 실행되며, 프로그램이 종료될 때까지 계속 실행되거나, 완료되면 자동으로 자원이 정리됩니다.
사용 시 주의: 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의 수명 책임을 한 번에 정하는 일입니다.
스레드를 시작했다면 누가 언제 끝을 책임지는지 코드에서 드러나야 합니다.