Modern C++

[C++11] std::thread의 생성과 생명 주기

dev-ohdam 2024. 7. 16. 01:06

이번 시간에는 C+11 부터 제공되는 멀티스레드 인터페이스인 Thread에 관해 알아볼 것이다.

 

C+11 thread를 사용하기 위해선 thread 헤더파일을 추가하는 것으로 시작할 수 있다.

#include <thread>

 

 이전 게시글에서도 언급했지만, 스레드는 프로세스를 처리되는 독립적인 실행 단위이다.

이때 이 실행 단위를 콜러블 유닛, Callable Unit(호출 가능한 유닛)이라고 한다.

 

 콜러블 유닛은 함수처럼 동작하는 개체들을 이라면 모두 해당이 된다. C++ 함수를 시작으로, 람다, 클래스 객체 또한 콜러블 유닛이 될 수 있다.

 

자, 그렇다면 이제 스레드를 생성해서 직접 한 번 사용해보자.

#include <iostream>
#include <thread>

using namespace std;

void Task()
{
	cout << "Do Task\n"
}

int main()
{
	
    std::thread t1(Task); thread 객체 생성 + 초기화 방법 1
    
    std::thread t2; // thread 객체 생성
    t2 = std::thread(Task); // thread 초기화 방법 2
    
}

 

 다음 코드를 간단하게 살펴보면, 콜러블 유닛에 해당하는 함수 Task와

thread 타입을 통해 생성한 스레드 객체 t1, t2를 초기화 과정을 확인할 수 있다.

 

스레드 객체를 초기화하는 방법은 다음과 같다.

1. 객체 생성과 동시에, 콜러블 유닛을 생성자 인자로 넘겨주는 방식.

2. 객체를 먼저 생성한 이후, 나중에 이동 연산을 통해 thread 객체를 넘겨주는 방식.

 

thread(const thread&) = delete;

 

 여기서 첫 번째 키 포인트는 스레드 객체는 복사 생성자가 존재하지 않다는 점이다.

이 방식은 매우 합리적인 구현이다.

 

 스레드는 우리가 제어하지 못할 경우, 예측할 수 없는 현상(undefined-behavior)를 발생할 수 있다.

만약 복사 생성이 가능하게 된다면, 복사 과정에서 생성된 또 다른 스레드에 관해, 우리는 예기치 못한 오류를 야기시킬 수 있다.

 

두 번째 키 포인트는, 콜러블 유닛이 전달되지 않은 스레드 객체는 빈 깡통에 불과하다.

그렇기 때문에 스레드 객체를 생성 후, 초기화 해주지 않는다면 아무런 동작을 수행하지 않는다.

그렇기 때문에 초기화 방식 2번이 가능한 것이다.

 

다음으로 thread에게 넘겨줄 수 있는 콜러블 유닛의 형태는 어떤 식으로 코딩할 수 있는지 살펴보자.

 

// ...

void CallableFunc()
{
  cout << "Callable Unit 함수 객체 형태\n"
}

class CallableObject
{
public:
	void operator()()
    {
    	cout << "Callable Unit 클래스 객체 형태"
    }
}

int main()
{
	thread t1(CallableFunc);
 	thread t2(CallableObject());
	thread t3([]()
    {
    	cout << " Callable Unit 람다 함수 형태\n"
    }
    );


}

 

 여기서 신경 쓸 점은, CallableObject 처럼 객체의 형태로 넘겨줄 때는 ()에 관한 연산자 오버로딩 작업이 필요하다는 점이다.

 

*** 위 코드에서 생성된 스레드는 총 4개이다 (main 스레드  + t1, t2, t3)

 

 

 다음으로 스레드 객체의 라이프 사이클에 관해 알아볼 것이다.

기본적으로 스레드의 라이프 사이클은 콜러블 유닛의 종료와 함께 마감된다.

그리고 부모 스레드는 자식 스레드의 라이프 사이클을 책임진다.

 

즉, main 스레드가 종료됨과 동시에 t1 , t2, t3 또한 종료된다.

여기에서 발생하는 큰 문제점이 존재한다.

 

 만약 t1, t2, t3의 작업이 마무리되지 않은 상태에서 메인 스레드가 먼저 종료된다면?

이 또한 예측할 수 없는 결과를 야기한다.

 

그렇기 때문에, 자식 스레드의 입장에서, 이러한 현상을 막을 수 있도록, 본인의 라이프 사이클과 관련해 다음 함수를 제공한다.

 

1. std::thread::join() : 해당 스레드(= 자식 스레드)의 콜러블 유닛이 끝날 때까지 기다린다.

2. std::thread::detach() : 해당 스레드를 부모 스레드로 부터 독립적으로 분리한다. 이렇게 분리된 형태의 스레드를 데몬 스레드라고 일컫는다.

 

 join() 을 통해, 우리는 main 스레드로 하여금, 자식 스레드가 모두 작업을 완료할 때까지 기다린 후, 종료할 수 있게 된다.

그리고 스레드 객체 자체도, join과 detach를 호출하지 않을 경우, 스레드 객체의 소멸자에 std::terminate를 호출하여 

예외를 발생시켜 프로세스를 종료하도록 구현해 놓았다.

 

이때, join과 detach를 호출하지 않은 스레드를 joinable(join 가능한) 상태라고 일컫는다.

std::thread::joinable() 함수를 통해, 해당 스레드 객체가 joinable 상태인지 확인할 수 있다.

 

 여기서 주의해야할 사실은, detach()의 경우, 해당 스레드 객체가 데몬 스레드의 형태로 동작하며, join을 호출했을 때와

달리 해당 스레드가 다 끝날 때 까지 기다려주지 않는다. 

 

 그렇기 때문에 main 스레드가 종료 될 경우, 이 스레드 객체는 완료 여부에 관계없이 소멸될 수 있다.

소멸자의 terminate를 발생시키지 않아 예외는 발생하지 않지만, 해당 콜러블 유닛에 특정 처리를 수행해야하는 파라미터 값을 넣어주어 어떤 변화를 기대할 경우 예측할 수 없는 결과를 야기할 수 있다.

 

 여기서 우리가 느낄 수 있는 것은.. 각 스레드의 라이프 사이클을 제어하는 것이 굉장히 골치 아프고 귀찮다는 것이다.

이에 관해 앤서니 윌리엄가 고안한 아이디어가 있다.

 

 스레드 객체를 wrapping하여 처리 하자는 것이다. 마치 mutex의 lock_guard 처럼...

앤서니 윌리엄은 이러한 객체를 scoped_thread라고 한다.

 

class scoped_thread
{
	thread t;
public:
	explicit scoped_thread(thread input_thread) : t(move(input_thread))
	{
		if (!t.joinable()) throw logic_error("Unable Thread\n");
	}
	~scoped_thread() 
	{ 
		t.join(); 
	}
	scoped_thread(scoped_thread& sc) = delete; // 복사생성 불가
	scoped_thread& operator=(scoped_thread& rhs) = delete; // 대입 불가
};

 

 구현 또한 간단하다.

 

 여기서 키 포인트는 생성자에서 이동 연산을 통해 가져올 스레드 객체에 관해, joinable 한 상태인지 확인하는 작업이다.

joinable하지 않은 상태라는 것은 이미 해당 스레드는 필요가 없는 스레드라는 것이다. (join()을 호출한 스레드 객체라면 이미 해당 스레드는 작업을 완료했을 것이다. detach()를 호출한 스레드라면 데몬 스레드로써 우리 제어를 떠나 독립적으로 수행되고 있을 것이다.) 

 

 이번 시간에는 C+11 thread를 생성하는 방법과, 라이프 사이클에 관해 정리해 보았다.

다음 게시글로는 콜러블 유닛에 넘겨줄 수 있는 파라미터 타입에 관해 다룰 것이다.