Computer Science 기본 지식/운영체제

[C++ Thread] C++ Thread 관리하기

로파이 2021. 4. 24. 13:19

C++ Thread를 관리하는 방법

쓰레드도 엄연한 운영체제 자원으로 프로세스보다는 가볍지만 메모리를 할당받아 스택 메모리, 레지스터를 사용한다. 자동적으로 레지스터가 복구되고 스택이 팝 되는 일반 함수의 종료와 다르게 쓰레드 "객체"는 함수 종료 후 다른 쓰레드에서 계산 결과를 전달하는 등을 위해 자동적으로 자원을 반납하지 않는다. 즉 객체가 소멸되지 않는 이상 자원은 할당되어 있는 상태이다.  

 

쓰레드의 생성 fork

thread는 추가적으로 생성하는 자원이다. 왜냐하면, 모든 프로그램은 시작시 진입 프로시저를 가지고 보통 main 함수가 그러한 프로시저이다. 즉 추가적인 thread를 생성하지 않는다면 main 함수가 유일한 쓰레드가 된다. 여기서 추가적으로 생성하는 thread는 main 쓰레드의 자식 쓰레드가 되며 자식 쓰레드는 자신이 종료된 사실을 반드시 부모 쓰레드에 어떤 형태로든 전달해야한다.(보통의 경우 반환값을 전달한다.) 이는 프로세스를 새로 생성하는 데에도 적용된다.

 

합류가능한 쓰레드 joinable thread

이렇게 새로 생성된 쓰레드는 보통 부모 쓰레드에게 종료된 사실을 전달, 동기화 신호를 보내게된다. 부모 쓰레드가 아니라도 멀티쓰레드 프로그래밍에서 다른 쓰레드에게 동기화가 될 수도 있다. 어떻게되는 동기화 신호를 보낼 수 있는 쓰레드를 합류가능한 joinable thread라고 한다.

합류가능한 쓰레드

합류가능한 쓰레드의 종료

C++ thread에서는 합류가능한 쓰레드가 있음에도 메인 프로세스가 종료되거나 thread 객체의 life-cycle이 되는 Caller thread의 함수가 반환한다면 객체의 소멸자가 호출되면서 std::terminate()가 호출된다.

 

실제 std:thread 객체의 소멸자

  ~thread() noexcept {
        if (joinable()) {
            _STD terminate();
        }
    }

terminate()는 더이상 프로그램을 실행할 수 없다는 의미로 주로 비정상적인 상황이나 예외가 발생했을시 호출된다.

void callee_thread()
{
	printf("This is Callee\n");
}
void caller_thread()
{
	printf("This is Caller\n");
	thread callee(callee_thread);
}

int main()
{
	caller_thread();
}

위 코드를 실행하면 callee_thread는 합류가능상태임에도 caller_thread의 종료로 다음과 같은 예외가 발생한다.

terminate()의 호출은 terminate_handler라는 void(void)의 함수 포인터를 호출하는데, 정상적인 종료라면 set_teriminate()에 의해 terminate_handler가 대체되지 않았다면 기본 함수 abort()가 지정되어있으므로 abort()를 호출하게 된다.

en.cppreference.com/w/cpp/error/terminate_handler

 

std::terminate_handler - cppreference.com

typedef void (*terminate_handler)(); std::terminate_handler is the function pointer type (pointer to function that takes no arguments and returns void), which is installed and queried by the functions std::set_terminate and std::get_terminate and called by

en.cppreference.com

 

합류불가능하게 하기 join/detach

 

쓰레드의 종료로 할당받은 자원을 반납해야하는데 joinable 쓰레드는 자신이 어느 부분에서 합류 가능하다고 기대하기 때문에 (다른 쓰레드에서 계산 결과를 전달해주는 등) 실제로 그런 부분이 없음에도 자원을 반납하지 않고 기다리는 문제가 발생할 수 있다.

 

thread의 생성으로 실행 흐름이 발생했다면, 이에 대한 합류가능함을 다른 쓰레드에서 불가능하게 해야한다. 이에 대한 함수는 join과 detach가 있으며 각각 다음 역할을 수행한다.

join

join 함수는 해당 Callee Thread가 실행 종료가 될 때까지 Caller Thread를 차단(block)한다. 따라서 join는 두 쓰레드의 실행을 합류시키는 분기로 생각될 수 있다.

void func1()
{
	for (int i = 0; i < 10; i++)
	{
		printf("쓰레드 1 작동중 \n");
	}
}

void func2()
{
	for (int i = 0; i < 10; ++i)
	{
		printf("쓰레드 2 작동중 \n");
	}
}

void func3()
{
	for (int i = 0; i < 10; ++i)
	{
		printf("쓰레드 3 작동중 \n");
	}
}
int main()
{
        // fork
	thread t1(func1);
	thread t2(func2);
	thread t3(func3);

	// 함수 실행이 완료가 되어도 쓰레드는 여전히 joinable하다. 
	std::this_thread::sleep_for(std::chrono::milliseconds(500));

	if (t1.joinable())
		printf("thread1 is joinable\n");
	if (t2.joinable())
		printf("thread2 is joinable\n");
	if (t3.joinable())
		printf("thread3 is joinable\n");

	// join는 해당 바탕 쓰레드가 종료할 때까지 반환하지 않는다.
	t1.join();
	t2.join();
	t3.join();
    
        // exit(0);
	return 0;
}

위 코드는 순서대로 t1,t2,t3 쓰레드 객체가 함수가 종료될 때까지 차례대로 기다리게 된다. join을 명시적으로 호출하지 않으면 함수가 이미 종료되었더라하도 합류가능(joinable)하다.

 

detach

detach는 Callee Thread를 Caller Thread로 분리시킨다. 메인 프로세스의 종료나 life-cycle의 종료로 소멸되어도 백그라운드, 데몬(daemon)상태로 남아있는 연산을 수행한다. detach 이후 해당 쓰레드는 합류가 불가능해진다.

 

대신 detach된 쓰레드는 메인 프로세스 종료이후 전역 변수를 사용하거나 다른 쓰레드의 thread_local 변수를 사용하면 미정의 행동이 발생할 수 있다.

int main()
{
        // fork
	thread t1(func1);
	thread t2(func2);
	thread t3(func3);

	// 함수 실행이 완료가 되어도 쓰레드는 여전히 joinable하다. 
	std::this_thread::sleep_for(std::chrono::milliseconds(500));

	if (t1.joinable())
		printf("thread1 is joinable\n");
	if (t2.joinable())
		printf("thread2 is joinable\n");
	if (t3.joinable())
		printf("thread3 is joinable\n");

	// join는 해당 바탕 쓰레드가 종료할 때까지 반환하지 않는다.
	//t1.join();
	//t2.join();
	//t3.join();

	// detach는 해당 바탕 쓰레드가 메인 프로그램으로 부터 분리되어 독립적으로 실행된다.
	t1.detach();
	t2.detach();
	t3.detach();

	return 0;
}

 

합류불가능한 쓰레드 unjoinable thread

 

이미 합류불가능한 쓰레드는 join과 detach를 호출하면 std::terminate() 예외가 발생하니 멀티쓰레드 프로그래밍에서 해당 쓰레드가 이미 합류불가능인지 상태인지 점검할 필요가 있을 수 있다. 합류불가능한 쓰레드의 상황은 다음과 같다.

 

1. 기본 생성자로 생성한 쓰레드

int main()
{
	// 빈 쓰레드
	thread t1;
    
	return 0;
}

2. 이동되어 바탕 쓰레드가 없는 빈 쓰레드

void func()
{
	// .. 연산
}

int main()
{
	thread t1(func);
    
    // 쓰레드는 이동 대입 연산자가 오버로딩 되어있다.
    thread t2 = std::move(t1);
    
    // 이제 t1은 빈 쓰레드이므로 unjoinable이다.
    return 0;
}

3. 다른 곳에서 이미 join된 쓰레드

4. detach된 쓰레드

 

Resource Acquisition is Initialization (RAII) 기반 쓰레드를 관리하는 방법

어떤 객체의 자원 관리는 생성될 때 할당되고 소멸될 때 모두 해제하는 규율을 적용하여 ThreadRAII 클래스를 설계할 수 있다. - 참고 : Effective C++, 동시성 API 편

#include <thread>

class ThreadRAII
{
public:
	enum class DtorAction { join, detach};

	// 소멸자 행동을 정의한다.
	ThreadRAII(std::thread&& t, DtorAction a)
		:
		action(a), t(std::move(t)) {}

	~ThreadRAII()
	{
		if (t.joinable())
		{
			if (action == DtorAction::join)
			{
				t.join();
			}
			else 
			{
				t.detach();
			}
		}
	}

	std::thread& get() { return t; }
private:
	DtorAction action;
	std::thread t;
};

 

추가 읽을거리

stackoverflow.com/questions/19744250/what-happens-to-a-detached-thread-when-main-exits

medium.com/@anubhavroh/c-threading-by-examples-part-2-ae6c9b87e7b5