이번 시간에는 std::thread 생성 시, 호출하는 콜러블 유닛의 매개변수를 어떤 방식으로 전달할 수 있는지에 관해 알아볼 것이다.
일단, std::thread는 기본적으로 콜러블 유닛의 인자를 "복사"의 형태로 전달한다.
이 점은 매우 중요한 포인트이니 기억해두자.
그리고 콜러블 유닛의 매개 변수로는, 우/좌측값 참조 타입(&/&&)부터, 복사, 이동, 가변 인자까지 다양한 형태로
전달해줄 수 있다.
스레드 생성 시, 두 번째 인자부터 콜러블 유닛에서 사용할 매개변수 인자를 차례대로 넣어주면 된다.
일단 가장 간단한 복사 전달 방식부터 살펴보자.
//...
void TaskCopy(__int32 param)
{
std::cout << "copy param :" << param << "\n";
}
int main()
{
__int32 arg;
std::thread copy_thread(TaskCopy, arg);
copy_thread.join();
}
copy_thread는 TaskCopy라는 콜러블 유닛을 호출하며, 정수타입 arg를 매개변수 인자로 받는다.
다음으로 참조 타입이다.
// ...
void TaskRef(__int32& lparam, __int32&& rparam)
{
cout << "l-ref param: " << lparam++ << "\n";
cout << "r-ref param: " << rparam << "\n";
}
int main()
{
__int32 a = 0;
__int32 b = 0;
std::thread ref_thread(TaskRef, a, b);
ref_thread.join();
}
마찬가지로 콜러블 유닛의 매개변수 인자를 __int32& 나 __int32&&의 형태로 선언해주고 사용하면 될 것 같다.
하지만 이 코드를 직접 빌드해보면 컴파일 오류를 일으킨다.
왜 ?
앞서 언급했 듯, std::thread는 콜러블 유닛의 매개변수 인자를 "복사"의 형태로 전달하기 때문이다.
우리가 평상 시에 참조값을 전달 할 때는, 변수명을 전달해도 암묵적으로 형변환이 됐었지만 여기서는 복사가 되기 때문에 변수 a와 b를 전달할 때, 참조타입 형태로 바꿔 전달해줘야 한다. 우리는 여기서 std::ref 와 std::move를 사용할 수 있다.
다음은 수정된 코드이다.
// ...
void TaskRef(__int32& lparam, __int32&& rparam)
{
cout << "l-ref param: " << lparam++ << "\n";
cout << "r-ref param: " << rparam << "\n";
}
int main()
{
__int32 a = 0;
__int32 b = 0;
std::thread ref_thread(TaskRef, std::ref(a), std::move(b));
ref_thread.join();
std::cout << a << "\n" // a == 1
}
std::ref는 변수를 lvalue reference 형태로 바꿔준다.
std::move는 rvalue reference 형태로 바꿔준다.
이 두 함수에 관한 자세한 내용은 cppreference를 통해 확인하는 것이 좋다.
이렇게 전달하게 된다면, 불필요한 복사를 줄일 수 있기 때문에 효과적으로 매개변수 인자를 전달할 수 있을 것이다.
하지만 여기서 주의해야할 점은 스레드의 생명 주기를 잘 고려해야 한다는 점이다.
현재 코드에서는 join()을 호출했기 때문에 main 스레드와 ref_thread간의 동기화가 이루어져 크게 문제가 없다.
하지만 만약 스레드 A로부터 생성된 변수 a를 참조하던 스레드 B에 관해, 스레드 A가 먼저 콜러블 유닛을 종료함에 따라 B가 참조하던 a가 소멸될 경우, 스레드 B에서는 예측할 수 없는 문제가 발생할 수 있다.
그렇기 때문에 스레드의 콜러블 유닛의 매개변수 인자로 참조타입을 전달할 경우, 주의해야할 필요가 있다.
다음은 문제가 발생하는 코드를 예시로 보여주겠다.
// ...
void TaskRef(__int32& param, __int32 iterator_count)
{
for(__int32 i = 0; i < iterator_count; ++i)
{
std::this_thread::sleep_for(std::chrono::miliseconds(500));
param += 5;
}
}
int main()
{
int arg = 100;
std::thread t(TaskRef,std::ref(arg), 5);
t.detach();
std::cout << arg << "\n"; // 우리가 기대하는 값 arg == 125
}
TaskRef는 변수 arg를 참조 형태로 전달받으며, 0.5초의 주기로 5씩 더해져 나갈 것이다.
만약 이 코드가 정상적으로 수행됐다면, 우리가 기대하는 값은 arg 값이 125가 되는 것이다.
하지만 실제로 이 코드를 수행해보면 정상적으로 값이 처리되지 않음을 볼 수 있다.
이 현상은 t와 main 스레드가 경쟁 상태(Race Condition)를 유지하고 있으며, arg라는 변수를 공유하며 함께 읽고 쓰기 때문에 발생하는 문제점이다. 이런 현상을 Data Race라고 한다.
이를 정상적으로 처리하기 위해선 std::mutex와 같은 잠금 객체나 std::atomic같은 원자계 타입을 통해 공유 변수를 보호하는 방식을 수행해야한다. 혹은 detach가 아닌 join을 호출하는 것으로 스레드의 생명주기를 제어해 해결할 수 있다.
이 외에도 다음과 같은 문제점이 생길 수 있다.
t.detach() 이후, main 스레드와 t 스레드는 독립적으로 수행된다. 하지만 이 과정에서 main 스레드가 t 스레드보다 먼저
종료하게 된 것이다.
이런 경우 main 스레드에 속해있던 arg가 소멸했을 때, t 스레드에서 어떤 동작이 일어날 지, 예측할 수 없다.
(일단 해당 예시에선 detach()를 통해 t가 독립적으로 수행된다 한들, main 스레드의 종료와 동시에 이 또한 소멸되므로 그렇게 큰 문제를 일으키진 않을 것이다.)
마지막으로 가변 인자(탬플릿)의 형태로 전달하는 방식이다.
template<typename... Tys> // template-parameter pack
void TaskVArg(Tys&&... params)
{
cout << "varg param: ";
(cout << ... << params); // fold expression <== C++17부터 제공함.
cout << "\n";
}
int main()
{
thread varg_thread(TaskVArg<int32, int32, int32, int32, string>, 1, 2, 3, 4, "hello");
varg_thread.join();
TaskVArg(1, 2, 3, 4, "hello");
}
여기서 주의해야할 점은, 기본적으로 가변 인자 탬플릿 함수는 컴파일러가 컴파일 단계에서 들어온 인자값에 따라 자동으로 추론해주는 반면, 콜러블 유닛으로 전달하게 될 경우 이를 지원하지 않기 때문에 가변 인자의 타입을 일일히 전달해줘야 한다는 점이다.
이번 시간을 끝으로 C+11 std::thread에 관한 기본적인 설명을 마치도록 하겠다.
(그 외의 함수는 쉬운 내용이므로 금방 스스로 공부할 수 있다.)
참고문헌 : 시작하자 C++17 프로그래밍, Concurrency With Modern C++ by Rainer Grimm
'Modern C++' 카테고리의 다른 글
[C++] C++의 Exception Handling과 Exception Mechanism (0) | 2024.12.13 |
---|---|
[C++] chrono vs ctime (0) | 2024.11.26 |
[C++20] std::jthread 와 std::stop_source & std::stop_token (0) | 2024.08.07 |
[C++11] std::thread의 생성과 생명 주기 (0) | 2024.07.16 |