C++에서 join(혹은 detach) 없이 사용할 수 있는 thread는?
바로 jthread와 async가 떠오르지 않았다면 본 글에서 std::jthread를 알아보자. (async는 아래 글 참조)
2023.11.21 - [L C++] - [thread] std::async - 기존 함수 그대로 thread에 적용(2)
std::jthread 란?
C++ 20에 추가된 클래스로, <thread> 모듈에 포함되어 있고, 크게 두 가지 기능을 가지고 있는 스레드이다.
- joining thread : 클래스에 join이 포함되어 있어 따로 join을 하지 않아도 된다.
- cooperatively interruptible : 협력적으로 중단(취소)이 가능하다.
Joining thread(스레드 결합)
주 스레드가 종료하면 새로 생성된 스레드는 모두 강제 종료되기에, 새로 생성된 스레드가 안전하게 종료되기까지 주 스레드를 대기시키기 위해 join()을 사용해야 한다.(아니면 주 스레드와 분리시키는 detach를 해야 한다.)
함수명을 이런 의도를 가지고 지었는지 모르겠지만 join() 함수는 새로운 스레드와 주 스레드가 만나게 하는 역할을 하니 이름은 퍽 잘 지은 듯하다.
깜빡하고 join을 사용하지 않으면 아래와 같이 예외가 발생한다.
terminate called without an active exception // 예외 발생
기존 스레드와 사용방법 비교
사용예시를 통해 기존 스레드와의 사용방법 차이를 확인해 보자.
예제 1
std::thread와 std::jthread를 사용하여 두 스레드 간 사용방법 차이를 확인하는 프로그램 코드이다.
#include <iostream>
#include <thread>
void foo(int a, int b)
{
std::cout << "foo : " << a << ", " << b << std::endl;
}
int main()
{
// 기존 스레드
std::thread t1(&foo, 10, 20);
std::thread t2(&foo, 10, 20);
t1.join();
t2.join();
// C++20에 추가된 jthread
std::jthread jt1(&foo, 10, 20);
std::jthread jt2(&foo, 10, 20);
}
결과
foo : foo : 10, 2010, 20
foo : 10, 20
foo : 10, 20
참고 : 아래 옵션을 추가해서 빌드를 해야 컴파일이 된다.
g++ 의 경우: -std=c++20
cl 의 경우 : /std:c++20
join을 사용하지 않으면 예외가 발생하는데 다른 스레드와 달리 jthread는 join을 사용하지 않아도 예외가 발생하지 않는다.
jthread는 왜 join을 사용하지 않고도 예외가 발생하지 않을까?
std::jthread의 내부 구현부를 보면 ~jthread 소멸자 호출 시 자동으로 join이 불리게 설계되어 있다.
// jthread 클래스 구현부
~jthread() {
_Try_cancel_and_join();
}
void _Try_cancel_and_join() noexcept {
if (_Impl.joinable()) {
_Ssource.request_stop();
_Impl.join();
}
}
jthread 가 지역의 범위를 벗어나면서 jthread의 소멸자가 불리고, 소멸자에서 join이 실행되어 해당 스레드가 종료될 때까지 주 스레드가 대기하기에 안전하게 종료될 수 있다.
join에서 걸리는지 확인해 보기
std::jthread와 유사한 콘셉트의 로컬 jthread를 만들어서 실제 join에서 걸리는지 확인해 보자.
예제 2
전달받은 thread가 소멸되는 시점에 join() 함수가 호출되는 프래그램 코드이다.
#include <iostream>
#include <thread>
void foo(int a, int b)
{
std::cout << "foo : " << a << ", " << b << std::endl;
}
// 소멸자에서 자동으로 join 되는 클래스
class jthread
{
std::thread impl;
public:
jthread(std::thread&& t) : impl(std::move(t)) {}
~jthread()
{
if (impl.joinable())
impl.join();
}
};
int main()
{
// jthread 사용
// 1. thread 생성후 move로 전달
std::thread t2(&foo, 10, 20);
jthread sc1(std::move(t2));
// 2. 임시객체 형태로 전달
jthread sc2(std::thread(&foo, 10, 20));
}
중요코드 설명
jthread(std::thread&& t) : impl(std::move(t)) {}
std::thread는 복사가 허용되지 않기에 (아래 글 참조)
스레드를 전달받을 때 참조로 받아야 하는데 std::thread로 생성한 스레드는 jthread에 넘긴 이후에 사용하지 않기에 move 혹은 임시객체로 전달받을 수 있게 &&(rvalue)로 소유권 자체를 넘겨받게 설계되었다. (최종 내부 thread impl에게 소유권을 넘긴다.)
결과
지역 범위를 벗어날 때 jthread의 소멸자가 호출되어 join()이 실행되는 것을 확인할 수 있다.
C++20를 사용하지 못할 땐 std::jthread를 비슷하게 만들어보자.
위 예제 2는 std::jthread와 콘셉트는 같지만 일반 std::thread로 생성하여 전달하는 방법이 std::jthread와 달라 동일하게 사용할 수 있게 수정하면 예제 3과 같다.
예제 3
std::jthread와 마찬가지로 thread로 실행할 '함수'와 '인자'만 전달하여 std::jthread처럼 동작하게 하는 프로그램 코드이다.
#include <iostream>
#include <thread>
void foo(int a, int b)
{
std::cout << "foo : " << a << ", " << b << std::endl;
}
class jthread
{
std::thread impl;
public:
jthread(std::thread&& t) : impl(std::move(t)) {}
~jthread()
{
if (impl.joinable())
impl.join();
}
};
class jjthread
{
std::thread impl;
public:
// modern C++의 완벽한 전달 기술
template<typename F, typename ... ARGS>
jjthread(F&& f, ARGS&& ...args)
: impl( std::forward<F>(f),
std::forward<ARGS>(args)...){}
~jjthread()
{
if (impl.joinable())
impl.join();
}
};
int main()
{
//스레드를 임시객체로 생성하던 생성하여 move로 소유권을 넘기던 스레드를 별도로 생성해야 한다.
jthread sc(std::thread(&foo, 10, 20));
//upgrade
jjthread jt(&foo, 10, 20);
//std::jthread jt1(&foo, 10, 20);
}
이 예제는 '완벽한 전달' 기술을 사용하여 jthread와 똑같이 동작하게 하는 클래스를 만들었다.
jjthread jt(&foo, 10, 20); // 아래 std::jthread와 사용 방법이 같다.
//std::jthread jt1(&foo, 10, 20);
완벽한 전달
std::forward는 매개변수들을 각 템플릿 클래스의 생성자에 맞게 전달하는데 rvalue 참조를 통해 함수와 인자 각각을 적절한 생성자에 연결해 줍니다.(이것은 추후 별도로 정리하도록 하겠습니다.)
협력적 취소(cooperative cancellation)
새로 생성된 스레드에서 진행 중인 작업을 메인 스레드에서 종료시키는 것을 '협력적 취소'라고 한다.
예제 4
새로운 스레드에서 500ms마다 foo 로그를 찍고 있다가 주 스레드에서 2초 뒤 flag를 변경하여 새로운 스레드를 종료시키는 프로그램 코드이다.
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::literals;
void foo(bool& quitFlag)
{
for (int i = 0; i < 10; i++)
{
std::this_thread::sleep_for(500ms);
std::cout << "foo : " << i << std::endl;
// 스레드는 주기적으로 "flag" 를 조사하게 구현되어야 함
if (quitFlag == true)
break;
}
// 사용하던 자원은 안전하게 정리하고 종료
std::cout << "finish...foo\n";
}
int main()
{
bool quitFlag = false;
std::thread t(foo, std::ref(quitFlag) );
std::this_thread::sleep_for(2s);
quitFlag = true;
t.join();
}
결과
foo : 0
foo : 1
foo : 2
foo : 3
finish...foo
새로운 스레드 입장에서는 계획 수행 중 갑자기 종료요청을 받는 것이지만 실행 라인에서 강제로 종료되는 것이 아니라 안전하게 스레드가 종료하여 join이 풀릴 수 있게 동작된다.
예제 4에서는 별도의 flag를 이용하여 새로운 스레드를 종료시켰지만, std::jthread에서는 협력적 취소 멤버함수가 기본 내장되어 있기에 새로운 스레드에서 실행되는 함수의 인자로 std::stop_token을 받으면 협력적 취소 모델을 사용할 수 있다.
예제 5
새로운 스레드에서 실행되는 foo와 goo 함수는 함수명 출력을 500ms 마다 10번 하는 함수인데 이 중 foo 함수만 std::stop_token을 인자로 받아 주스레드에서 스레드 종료 멤버함수(request_stop()) 호출 시 '협력적 취소' 모델 동작을 확인할 수 있는 프로그램 코드이다.
#include <chrono>
#include <iostream>
#include <thread>
using namespace std::literals;
void foo( std::stop_token token)
{
for (int i = 0; i < 10; i++)
{
std::this_thread::sleep_for(500ms);
std::cout << "foo : " << i << std::endl;
if (token.stop_requested())
break;
}
std::cout << "finish... foo\n";
}
void goo()
{
for (int i = 0; i < 10; i++)
{
std::this_thread::sleep_for(500ms);
std::cout << "goo : " << i << std::endl;
}
std::cout << "finish... goo\n";
}
int main()
{
// 핵심 1. jthread 인자로 함수외에 어떤 다른 것도 전달하지 않습니다.
std::jthread j1(&foo);
std::jthread j2(&goo);
std::this_thread::sleep_for(2s);
j1.request_stop();
j2.request_stop();
}
결과
foo : goo : 00
foo : goo : 1
1
foo : goo : 2
2
foo : goo : 3
3
finish... foo
goo : 4
goo : 5
goo : 6
goo : 7
goo : 8
goo : 9
finish... goo
중요코드 설명
std::stop_token
std::jthread 취소 요청이 있는지 쿼리 하기 위한 인터페이스 (클래스)
jthread에서 취소요청을 받으면 아래 함수에 값이 true 가 된다.
token.stop_requested()
j1.request_stop()
스레드의 공유중지 상태를 통해 실행 중지를 요청한다.
foo, goo 함수 모두 request_stop을 호출하여 실행 중지를 요청했지만 실행 함수에서 stop_token을 인자로 받지 않으면 해당 정보를 foo, goo 함수에서 알 수가 없어서 요청받는 대로 종료한 foo 함수와 달리 goo 함수는 모든 출력을 다하고 종료된 것을 확인할 수 있다.
참고
* 본 글에 예제 코드는 코드누리 교육을 받으면서 제공된 샘플코드를 활용하였습니다.
'L C++ > Concurrency' 카테고리의 다른 글
[Concurrency] std::call_once - 중복 초기화 해소 기술 (0) | 2023.12.12 |
---|---|
[Concurrency] thread_local - thread 전용 변수 ✔ (0) | 2023.12.05 |
[Concurrency] std::async - 기존 함수 그대로 thread에 적용(2) (2) | 2023.11.21 |
[Concurrency] packaged_task - 기존 함수 그대로 thread에 적용(1) (0) | 2023.11.14 |
[Concurrency] Future✨에 대해 더 알아보자. (0) | 2023.11.07 |