개발하는 리프터 꽃게맨입니다.

[멀티스레드 프로그래밍] 스레드 관리 본문

멀티스레드 프로그래밍/Concurrency in action C++

[멀티스레드 프로그래밍] 스레드 관리

파워꽃게맨 2024. 7. 5. 02:07

이전글

https://powerclabman.tistory.com/118

 

[멀티스레드 프로그래밍] C++ 동시성 프로그래밍 개론

참고사항C++ : Concurrency In Action (원서) 바탕으로 적은 글 입니다.해당 포스팅은 해당 저서의 번역보다는 번역 및 (제 기준으로) 이해하기 쉽도록 재구성한 부분이 다수 있습니다.주의해서 읽으시

powerclabman.tistory.com

 

애플리케이션에 동시성을 사용하기로 결다. 특히, 다수의 스레드를 사용하기로 했다. 이제 어떻게 해야할까? 이 스레드들을 어떻게 시작하고, 완료되었는지 어떻게 확인하며, 어떻게 관리해야할까? C++ 표준 라이브러리는 주어진 스레드와 관련된 std::thread 객체를 통해 대부분의 스레드 관리 작업을 쉽게 수행할 수 있도록 한다. 해결하기 어려운 작업에 대해서는 기본 빌딩 블록을 이용하여 유연하게 필요한 것을 구축할 수 있다.

 

이 포스팅에서는 기본적인 사항들을 다루면서 시작할 것이다. 스레드를 시작하는 방법, 스레드가 완료될 때까지 기다리는 방법 또는 백그라운드에서 실행하는 방법 등을 살펴볼 것이다. 그런 다음, 스레드 함수가 실행될 때 추가 매개변수를 전달하는 방법과 한 std::thread 객체에서 다른 객체로 스레드의 소유권을 이전하는 방법을 알아볼 것이다. 마지막으로, 사용할 스레드의 수를 선택하는 방법과 특정 스레드를 식별하는 방법을 살펴보겠다.


1. 기본 스레드 관리

모든 C++ 프로그램에는 최소한 하나의 스레드가 있으며, 이는 C++ 런타임에 의해 시작된다. 모든 프로그램에서 필수적으로 필요한 스레드는 메인 스레드라고 불리며 main() 함수를 실행하는 스레드이다. 프로그램은 다른 함수를 진입점으로 하는 추가 스레드를 실행할 수 있다. 프로세스 내의 스레드들은 모두 독립적, 병렬적으로 동시에 실행된다. 프로그램이 main()에서 반환될 때 종료되는 것처럼, 지정된 진입점 함수가 반환될 때 스레드도 종료된다. std::thread 객체가 있는 경우, 해당 스레드가 끝날 때까지 기다릴 수 있다. 하지만 먼저 스레드를 시작해야 하므로, 스레드를 실행하는 방법을 살펴보겠다.

 

[1] 스레드 실행

스레드는 해당 스레드에서 실행할 작업(initialize function)을 지정하는 std::thread 객체를 생성하여 시작할 수 있다.

가장 간단한 경우, 해당 작업은 매개변수를 받지 않는 단순한 void 반환 함수다. 이 함수는 자체 스레드에서 실행되며, 함수가 반환되면 스레드가 종료된다. 반면, 작업은 추가 매개변수를 받고 실행 중에 어떤 메시징 시스템을 통해 일련의 독립적인 작업을 수행하는 함수 객체일 수도 있으며, 이 경우 스레드는 또 다른 메시징 시스템을 통해 종료 신호를 받을 때까지 실행된다. (이 예제는 나중에 살펴보도록 한다.) 스레드가 무엇을 하든 어디서 시작되든 상관없이, C++ 스레드 라이브러리를 사용하여 스레드를 시작하는 방법은 항상 std::thread 객체를 생성하는 것으로 귀결된다.

 

이것이 가장 간단한 예다. 물론, 컴파일러가 std::thread 클래스의 정의를 볼 수 있도록 <thread> 헤더를 포함해야 한다. 많은 C++ 표준 라이브러리와 마찬가지로, std::thread는 호출 가능한 모든 타입(callable)과 함께 작동하므로, 함수 객체 인스턴스를 std::thread 생성자에 전달할 수도 있다:

 

이 경우, 제공된 함수 객체가 새로 생성된 실행 스레드의 저장소에 복사되고 그곳에서 호출된다. 조금만 더 자세하게 이야기 해보자. 함수 객체를 시작 함수로 설정하면, 스레드가 실행될 때 이 객체가 생성이되고 이것이 복사되어 생성된 스레드의 저장소에 저장되게 된다. 실질적인 호출은 스레드의 저장소에 있는 객체의 복사본이 호출되는 것이다.  따라서 복사본이 원본과 동일하게 작동해야 하며, 그렇지 않으면 예상치 못한 결과가 발생할 수 있다.

 

잘못된 예시를 하나 보도록하자.

 

이 코드를 보면 main 함수에 존재하는 Task 객체와 thread의 저장소에 존재하는 Task 객체가 전혀 다른 존재임을 알 수 있다. 이 문제를 해결하려면, 복사본과 원본이 동일해야함을 보장해야 하는데, 가장 적절한 해결책은 함수 객체를 참조로 넘기는 것이다.

 

함수 객체를 스레드 생성자에 전달할 때 고려해야 할 사항 중 하나는 "C++의 가장 성가신 파싱"으로 불리는 문제를 피하는 것이다. 임시 객체가 아닌 이름이 지정된 변수를 전달하면, 구문이 함수 선언과 동일할 수 있으므로 컴파일러는 이를 객체 정의가 아닌 함수 선언으로 해석한다.

 

해당 문제가 성가신 파싱 문제이다.

현재 컴파일러는 스레드 생성 구문을 언뜻 보면, std::thread 객체를 반환하고, Task 객체를 반환하는 함수 포인터 타입의 매개변수를 받는 함수를 선언하는 것으로 해석할 수도 있다. 이 경우 새로운 스레드를 시작하지 않는다. 이를 피하려면 이전에 보여준 대로 함수 객체를 선언해여서 매개변수로 넣어거나, 오해의 소지가 없는 초기화 구문을 사용하면 된다.

모두 다 옳은 구문입니다^^

셋 다 성가신 파싱을 피하는 문제지만, 베스트 프랙티스는 객체 생성에 있어 중괄호를 사용하는 방식이다.

누가 정한 건 당연히 아니고 가장 가독성이 좋은 해결책이라고 생각한다. 

 

또 스레드의 시작 함수로 설정할 수 있는 callable한 객체는 람다이다. 이는 C++11의 새로운 기능으로, 지역 변수를 캡처하고 추가 매개변수를 전달할 필요없이 지역 함수를 작성할 수 있게 해준다. 이전 예는 다음과 같이 람다 표현식을 사용하여 작성할 수 있다.

 

이제 스레드의 생성은 끝마쳤다.

편의상 스레드 생성 함수를 호출한 스레드를 '부모 스레드', 생성된 스레드를 '자식 스레드' 라고 하겠다.

자식 스레드가 시작한 후에는 스레드 진행에 있어 2가지 옵션을 선택해야 한다.

 

1) 자식 스레드가 끝날 때까지 부모 스레드가 기다린다. (조인)

2) 자식 스레드를 독립적으로 실행하게 한다. (분리)

 

만약, 이를 선택하지 않고, std::thread 객체가 소멸해버리면 std::thread의 소멸자가 호출되어 std::terminate() 를 호출한다.

참고로 std::terminate()는 예외에 의해 프로그램을 강제로 종료시키는 함수이다. 따라서 std::thread의 객체가 소멸되기 전에 조인 혹은 분리를 결정해야만 한다. 스레드 자체는 조인 혹은 분리 훨씬 전에 종료될 수도 있지만, 이를 결정하지 않으면 소멸자에 의해 std::terminate가 호출된다. 만약, 분리를 하기로 결정했으면, std::thread 객체가 소멸되어도 스레드는 계속 실행될 수 있다.

 

스레드가 끝날 때까지 기다리지 않는 경우(즉, 스레드 분리) 스레드가 데이터를 처리하는 동안 그 데이터가 유효하도록 해야한다. 

 

이는 단일 스레드 코드에서도 객체가 소멸된 후 객체에 접근하면 정의되지 않은 동작을 초래하는 것과 마찬가지다. 스레드 사용 시에도 객체의 수명 문제에 주의해야 한다.

위 예제에서 join을 사용하면, delete 가 호출되기 전에 메인 스레드가 대기하기 때문에, 그나마 수명문제에서 자유롭다. 물론 그렇다 하더라도 완전히 수명 문제가 해결되는 것은 아니다.

 

이 예제는 스레드 함수가 지역 변수에 대한 포인터나 참조를 가지고 있고, 함수가 종료될 때 스레드가 완료되지 않은 경우 발생할 수 있는 문제를 보여준다.

 

이 경우, 스레드 t는 oops 함수가 종료될 때까지 실행중일 가능성이 있다. 'detach()' 를 호출하여 기다리지 않기로 결정했기 떄문이다. 스레드가 여전히 실행 중이라면, 스레드의 시작 함수 내부에서는 이미 소멸된 변수를 접근하게 된다. 이는 단일 스레드 코드에서도 지역 변수에 대한 포인터나 참조를 함수 종료 이후에도 지속시키는 것이 좋지 않은 것과 마찬가지로, 멀티스레드 코드에서도 이러한 실수를 저지르기 쉽다.

 

이런 시나리오를 처리하는 일반적인 방법은 스레드 함수를 자급자족하게 만들고 데이터를 스레드로 복사하는 것이다. 스레드 함수로 호출 가능한 객체를 사용하는 경우, 해당 객체 자체가 스레드로 복사되므로 원본 객체는 즉시 소멸될 수 있다. 하지만 여전히 포인터나 참조를 포함하는 객체에 대해서는 주의해야 한다. 특히 함수 내에서 지역 변수에 접근하는 스레드를 생성하는 것은 스레드가 함수 종료 전에 완료되는 것이 보장되지 않는 한 좋지 않은 생각이다.

 

또는, 스레드와 조인하여 함수가 종료되기 전에 스레드가 완료되었는지 확인할 수 있다.

 

[2] 스레드가 완료될 때까지 기다리기

스레드가 완료될 때까지 기다려야 하는 경우, 해당 std::thread 객체에서 join()을 호출하면 된다.

 

앞서 말한 예제에서 detach를 join으로 바꾼 코드이다. 이렇게 하면 스레드가 완료된 후에 함수가 종료되므로 로컬 변수가 소멸되기 전에 스레드가 종료되는 것을 보장할 수 있다.

 

이 경우, 메인 스레드는 스레드가 끝날 때까지 기다리므로 유용한 작업을 하지 않는다. 따라서 이 예제에서는 별도의 스레드에서 함수를 실행하는 것이 큰 의미가 없다. 하지만 실제 코드에서는 메인 스레드가 여러 스레드를 실행하여 유용한 작업을 빠르게 완료한 후, 모든 스레드가 완료되기를 기다리게 된다.

 

join()은 단순하고 강력한 방법이다. 스레드가 완료될 때까지 기다리거나 기다리지 않거나 둘 중 하나다. 스레드가 완료되었는지 확인하거나 일정 기간 동안만 기다리는 등 더 세밀한 제어가 필요한 경우에는 조건 변수와 미래 객체(futures) 같은 대체 메커니즘을 사용해야 한다. 이는 추후에 다루도록 한다.

 

join()을 호출하면 스레드와 관련된 모든 저장소가 정리되므로, std::thread 객체는 이제 완료된 스레드와 더 이상 연관되지 않는다. 이는 특정 스레드에 대해 join()을 한 번만 호출할 수 있음을 의미한다. 한 번 join()을 호출하면, std::thread 객체는 더 이상 조인할 수 없으며, joinable()은 false를 반환하게 된다.

 

[3] 예외 상황에서 대기하기

앞서 언급했듯이, std::thread 객체가 소멸되기 전에 반드시 join() 또는 detach()를 호출해야 한다. 스레드를 분리하는 경우에는 스레드를 시작한 직후 detach()를 호출해도 상관없기 때문에 문제가 발생하지 않는다. 하지만 스레드가 완료될 때까지 기다려야 하는 경우에는 join()을 호출할 위치를 신중하게 선택해야 한다. 만약 스레드를 시작한 후 join()을 호출하기 전에 예외가 발생하면 join() 호출이 생략될 가능성이 있다.

 

위 예제는 try/catch 블록을 사용하여 스레드가 로컬 상태에 접근할 수 있는 동안 함수가 종료되기 전에 스레드가 완료되도록 한다. 함수가 정상적으로 종료되든 예외로 종료되든 스레드가 완료되도록 한다. 하지만 try/catch 블록은 코드가 장황해지고, 범위를 잘못 설정하기 쉬워 이상적인 시나리오는 아니다. 함수가 종료되기 전에 스레드가 반드시 완료되도록 해야 한다면, 모든 종료 경로에 대해 이를 보장하는 간단하고 명확한 메커니즘을 제공하는 것이 중요하다.

 

이를 해결하는 한 가지 방법은 표준 RAII 설계를 사용하여, 소멸자에서 join을 호출하는 랩퍼 클래스를 제공하는 것이다. 다음 예제를 보자. 이 방법을 사용하면 thread 객체가 소멸되기 전에 join() 을 보장한다.

 

현재 스레드의 실행이 Oops의 끝에 도달하면 로컬 객체는 생성된 역순으로 소멸된다. 따라서 ThreadGuard 객체 gurad가 먼저 소멸되고, 소멸자에서 스레드가 join된다. 중간에 예외를 던져서 함수가 종료되든, return으로 함수를 종료하든 join() 호출을 보장한다.

 

ThreadGuard의 소멸자는 먼저 std::thread 객체가 joinable() 인지 확인한 후 join()을 호출하는데, 어떤 스레드에 대해 join() 을 한 번만 호출할 수 있기 때문이다. 따라서 스레드가 이미 join 된 경우에는 이를 호출하는 것은 잘못된 일이 된다.

 

복사 생성자와 복사 할당 연산자는 =delete로 표시하여 컴파일러가 자동으로 제공하지 않도록 한다. 이러한 객체를 복사하거나 할당하면, 스레드가 조인될 범위를 벗어날 수 있어 위험할 수 있기 때문이다. 

 

스레드를 분리할 경우에도 ThreadGuard는 매우 유용하다. ThreadGuard를 사용하여 스레드를 분리하면 std::thread 객체와 생성된 스레드의 연관성이 끊어지고, std::thread 객체의 값은 비워지게 된다. 이렇게 하면 스레드가 여전히 백그라운드에서 실행 중이더라도 std::thread 객체가 소멸될 때 std::terminate()가 호출되지 않아 예외 안전성 문제를 피할 수 있다.

 

[4] 백그라운드에서 스레드 실행하기

std::thread 객체에서 detach()를 호출하면, 스레드는 백그라운드에서 실행되며, 더 이상 그 스레드와 직접적으로 통신할 수 없다. 분리된 스레드를 기다릴 방법은 없으며, 분리된 스레드를 참조하는 std::thread 객체를 얻을 수 없기 때문에 더 이상 조인할 수 없다. 분리된 스레드는 진정한 백그라운드 실행이 되며, 소유권과 제어권은 C++ 런타임 라이브러리로 넘어가고, 스레드가 종료될 때 스레드와 관련된 자원이 올바르게 회수되도록 보장된다.

 

분리된 스레드는 종종 유닉스 개념의 데몬 프로세스에서 따온 '데몬 스레드'라고 불린다. 이러한 스레드는 일반적으로 장시간 실행되며, 파일 시스템 모니터링, 객체 캐시에서 사용되지 않는 항목 제거 또는 데이터 구조 최적화와 같은 백그라운드 작업을 수행하며 애플리케이션의 전체 수명 동안 실행될 수 있다.

장시간 실행되는 경우가 아니여도 스레드가 완료되었음을 확인하는 다른 메커니즘이 있거나 '파이어 앤 포겟(fire and forget)' 작업에 스레드를 사용하는 경우 분리된 스레드를 사용하는 것이 적절할 수 있다.

 

'파이어 앤 포겟' 작업이란, 시작만 해두고 결과나 완료 여부를 신경 쓰지 않는 작업을 말한다.

예를 들어, 로그를 기록하거나 간단한 백그라운드 작업을 수행하는 스레드는 시작한 후 신경 쓰지 않아도 되므로, detach()를 사용하여 스레드를 분리할 수 있다.

 

앞서 보았듯이, std::thread 객체의 detach() 멤버 함수를 호출하여 스레드를 분리할 수 있다. 호출이 완료되면 std::thread 객체는 더 이상 실제 실행 스레드와 연관되지 않으며, 따라서 더 이상 조인할 수 없다.

 

std::thread 객체에서 스레드를 분리하려면, 분리할 스레드가 있어야 한다. 즉, 실행 중인 스레드가 없는 std::thread 객체에서 detach()를 호출할 수 없다. 이는 join()의 요구 사항과 동일하며, 동일한 방식으로 확인할 수 있다. t.joinable()가 true를 반환하는 경우에만 std::thread 객체 t에서 t.detach()를 호출할 수 있다.

 

여러 문서를 동시에 편집할 수 있는 워드 프로세서와 같은 애플리케이션을 생각해보자. 이런 애플리케이션을 만들 때, UI와 내부 구현을 다양한 방법으로 처리할 수 있다. 요즘 점점 더 흔해지고 있는 방법 중 하나는 각 문서를 편집하는 독립된 최상위 창을 사용하는 것이다. 이러한 창들은 각각 독립적인 메뉴와 도구를 가지고 있는 것처럼 보이지만, 실제로는 동일한 애플리케이션 인스턴스 내에서 실행된다.

이를 내부적으로 처리하는 한 가지 방법은 각 문서 편집 창을 자체 스레드에서 실행하는 것이다. 각 스레드는 동일한 코드를 실행하지만, 편집 중인 문서와 해당 창 속성에 관련된 다른 데이터를 사용한다. 새 문서를 열려면 새로운 스레드를 시작해야 한다. 요청을 처리하는 스레드는 해당 스레드가 완료되기를 기다리지 않아도 되므로, 이는 분리된 스레드를 실행하기에 적합한 예이다.

 

다음 코드는 이 접근 방식을 위한 간단한 코드 예제를 보여준다.

 

사용자가 새 문서를 열기로 선택하면, 문서 이름을 묻고, 해당 문서를 열기 위해 새 스레드를 시작한 다음, 이를 분리한다. 새 스레드는 현재 스레드와 동일한 작업을 다른 파일에 대해 수행하므로, 동일한 함수 edit_document를 새로 선택한 파일 이름을 인수로 사용하여 재사용할 수 있다.

 

이 예제는 또한 스레드를 시작하는 함수에 매개변수를 전달하는 모습을 보여준다. 단순히 함수 이름을 std::thread 생성자에 전달하는 대신, 파일 이름 매개변수도 함께 전달한다. 매개변수를 가진 함수 객체를 사용할 수도 있지만, 스레드 라이브러리는 이를 쉽게 할 수 있는 방법을 제공한다.


2. 스레드 함수에 매개변수 넘겨주기

앞서 문서 편집 예제에서 보여준 것처럼, std::thread 생성자에 추가 인수를 입력하여 시작 함수(함수 객체나 함수 포인터 등..)에 매개변수를 전달할 수 있다. 기본적으로 인수는 쓰레드의 내부 저장소에 복사되며, 새로 생성된 실행 스레드에서 접근할 수 있게 한다. 이는 함수의 매개변수가 참조를 기대하는 경우에도 마찬가지다.

이 코드는 t와 연관된 새로운 실행 스레드를 생성하여 Foo(10, "Hello, Thread!")를 호출한다. Foo 함수는 두 번째 매개변수로 std::string을 받지만, 문자열 리터럴 "Hello, Thread!"는 char const*로 전달된다. 이 경우 스레드의 내부 저장소에는 int i와 const char* s (즉, 포인터 주소)가 복사되어 저장된다. 이 경우 생성 스레드의 생성 함수 내에서만 string으로 변환된다. 이는 특히 지역 변수에 대한 포인터나 참조를 매개변수로 넘길 때 주의해야 한다.

 

이 경우, 지역 변수 buffer에 대한 포인터가 새 스레드에 전달된다. 그러면 스레드 내부에는 int와 const char* 에 대한 복사본만 있기에, const cahr*의 스코프를 넘어가면 정의되지 않은 동작이 발생하게 된다. 해결 방법은 buffer를 std::string으로 변환한 후 std::thread 생성자에 전달하는 것이다.

 

이 경우, buffer는 std::string으로 변환된 후 새 스레드에 전달되므로, 스레드 내부 저장소에 std::string이 저장된다.

 

이제 참조를 전달해보자.

객체의 복사를 피하기 위해서 참조를 매개변수로 전달할 때에도 문제가 발생할 수 있다.

 

스레드가 참조로 전달된 데이터 구조를 업데이트하는 예시를 보도록 하자.

update_date_for_widget은 두 번째 매개변수를 참조로 받지만, std::thread 생성자는 이를 알지 못하고 제공된 값을 내부 복사본으로 전달한다. 따라서, oops_again 내부의 data 가 변경되는 것이 아니라 thread t 객체 내부에 있는 data의 복사본의 값이 참조되어 변경된다.

 

해결 방법은 참조가 필요한 인수를 std::ref 로 감싸는 것이다.

 

std::ref는 인수를 reference_wrapper 라는 랩퍼 클래스로 감싸주는 역할을 한다.

이러면 oops_again 함수 내부의 data가 올바르게 참조된다.

 

이번엔 멤버 함수를 호출하는 경우를 보자.

std::bind에 익숙하다면, 매개변수 전달 방식이 놀랍지 않을 것이다. std::thread 생성자와 std::bind의 동작은 동일한 메커니즘에 따라 정의되기 때문이다. 예를 들어, 멤버 함수 포인터를 함수로 전달하고 적절한 객체 포인터를 첫 번째 인수로 제공할 수 있다.

 

이 코드는 새 스레드에서 my_x의 멤버 함수를 호출한다. 첫 번째 인자에 원하는 멤버 함수, 두 번째 인자에 대상 객체를 전달한다. 비 static 함수가 아닌 이상은 대상 객체가 반드시 필요하다. 또한, 멤버 함수 호출에 매개변수를 전달할 수도 있는데, std::thread 생성자의 세 번째 인자는 멤버 함수의 첫 번째 인자가 된다.

 

인수를 복사할 수 없고 이동만 가능한 경우도 있다. 대표적으로 unique_ptr는 복사 및 대입이 delete 되어 있으니 반드시 이동을 사용해야만 한다. 다음은 객체의 소유권을 스레드로 이동시키는 예제이다.

 

std::thread 생성자에서 std::move(obj)를 지정하면 해당 소유권이 새로 생성된 스레드의 내부 저장소로 먼저 이동되고, 그 후 Dosomething 함수로 전달된다.

표준 스레드 라이브러리에는 std::unique_ptr과 같은 소유권 개념을 가진 여러 클래스가 있다. '소유권'이라는 개념을 가진 클래스는 일반적으로 대입과 복사가 금지되고 이동 연산만이 가능하다.

 

std::thread도 소유권 개념을 가지는 클래스이다. std::thread 인스턴스는 실행 중인 스레드를 관리하는 자원을 소유하며, 이동 연산을 통해 자신이 소유한 자원을 다른 스레드에게 넘길 수 있다. 이렇게 하면 하나의 실행 스레드가 특정 시점에 단 하나의 std::thread 객체와만 연관되도록 보장할 수 있다.


3. 스레드의 소유권 넘겨주기

특정 함수에서 새로운 스레드를 백그라운드에서 실행하도록 생성하고 생성한 스레드의 소유권을 반환하고 싶다고 가정해보자. 또는, 스레드를 생성하고 그 소유권을 어떤 함수의 매개변수로 넘겨서 해당 함수가 스레드가 완료 될떄까지 기다리도록 하고 싶을 수 있다.

 

 간단히 말하자면, 첫 번째는 어떤 함수의 반환값을 스레드의 소유권으로 설정하고 싶을 경우, 두 번째는 어떤 함수의 매개변수로 스레드의 소유권을 넘겨주고 싶을 경우

 

이때 'std::thread'의 이동 연산이 필요하다. 앞서 설명한 것처럼 C++ 표준 라이브러리에는 소유권을 주장하는 클래스가 다수 존재하며, 이러한 타입은 이동만 가능하고 복사를 할 수 없다. std::thread 도 마찬가지다. 다음 예제는 두 개의 실행 스레드를 생성하고, 그 스레드의 소유권을 서로 다른 std::thread 객체 간에 이동하는 것을 보여준다.

 

먼저, 새로운 스레드가 시작되고 t1에 연관된다. 그런 다음 t2가 생성되면서 소유권이 t1에서 t2로 이동된다. 이때 std::move()를 호출하여 명시적으로 소유권을 이동한다. 이 시점에서 t1은 더 이상 실행 스레드와 연관되지 않으며, DoSomthing을 실행하는 스레드는 이제 t2와 연관된다.

 

그런 다음, 새로운 스레드가 시작되고 임시 std::thread 객체와 연관된다. 이후 소유권이 t1으로 이동되는데, 이 경우 소유자가 임시 객체이므로 소유권 이동은 자동으로 이루어진다.

 

t3는 기본 생성되며, 이는 실행 스레드와 연관되지 않음을 의미한다. 현재 t2와 연관된 스레드의 소유권은 다시 명시적으로 std::move()를 호출하여 t3로 이동된다. 모든 이동이 끝나면, t1은 DoSomethingOther을 실행하는 스레드와 연관되고, t2는 실행 스레드와 연관되지 않으며, t3는 DoSomething을 실행하는 스레드와 연관된다.

 

마지막 이동에서, DoSomething을 실행하는 스레드의 소유권이 다시 t1으로 이동된다. 그러나 이 경우 t1은 이미 DoSomethingOther을 실행하는 스레드와 연관되어 있으므로, std::terminate()가 호출되어 프로그램이 종료된다. 이는 std::thread 소멸자와의 일관성을 위한 처리이다. 해당 스레드 객체에 새로운 스레드를 엮고 싶다면, 소유중인 스레드를 이동시키거나, 완료되기를 기다리거나 (join), 명시적으로 분리해야 한다. (detech)

 

std::thread의 이동 지원은 위와 같이 함수에서 소유권을 쉽게 이전할 수 있게 한다.

 

이러한 이동 연산을 활용하여 앞서 만들었던 ThreadGuard 클래스를 좀 더 안전하게 만들 수 있다.

 

앞서 만들었던 ThreadGuard 객체이다. ThreadGuard 객체의 수명이 참조하는 스레드의 수명보다 더 길경우 불쾌한 결과가 발생할 수 있다.

오른쪽 아래 콘솔창에 thread_id = 0 이 뜨는 것이 보이는가?

 

물론 억지로 만든 예제이긴 하지만, 어떤 멍청한 프로그래머가 위와 같이 잘못된 방법으로 코드를 작성하는 것을 막기위해서는 스레드 객체를 참조하는 클래스가 아닌 실제 스레드의 소유권을 가지고 있는 ThreadGuard 를 만들어 주는 것이 옳다. 소유권이 객체에 이전되면 다른 누구도 해당 스레드를 조인하거나 분리할 수 없다. 이러한 클래스는 주로 스코프가 종료되기 전에 스레드가 완료되도록 보장하기 위해 사용되므로 ScopedThread 라고 명명했다.

 

오른쪽 예제에서 볼 수 있듯 이동 연산을 통해, 새로운 스레드를 l-value 형태로 직접 ScopedThread에 전달할 수 있다. ScopedThread는 자신의 생명 주기가 끝나면 객체가 소멸하면서 스레드를 조인한다. 사실 이 경우 소유권이 유일하기 때문에 joinable 할 필요도 없다. 만약 멤버 변수 스레드 객체가 조인 가능하지 않다면 생성자에서 예외를 던질 수 있다.

 

이동 연산을 지원하는 컨테이너(ex: STL)가 있는 경우 std::thread 객체를 해당 컨테이너에 저장할 수 있다. 이는 다음 예제처럼 여러 개의 스레드를 생성한 후 이들이 완료되기를 기다리는 코드를 작성할 수 있음을 의미한다.

 

스레드를 사용하여 알고리즘의 작업을 나누는 경우, 모든 스레드가 작업을 완료한 후에 호출자에게 결과를 반환해야 한다. 이 구조에서는 각 스레드가 독립적으로 작업해야 하며, 공유 데이터에서의 작업 공간이 다른 스레드와 충돌하지 않아야 한다. 만약 f() 함수가 여러 스레드의 작업 결과를 기반으로 반환 값을 결정해야 한다면, 모든 스레드가 작업을 마친 후 공유 데이터를 조사하여 결과를 결정해야 한다.

 

std::thread 객체를 std::vector에 넣는 것은 이러한 스레드를 관리하는 더 나은 방법이다. 개별 스레드를 위한 변수를 생성하고 직접 조인하는 대신, 이를 그룹으로 취급할 수 있다. 이렇게 하면 런타임에 동적으로 필요한 수의 스레드를 생성할 수 있어서 고정된 수의 스레드를 생성하는 것보다 유연하게 작업을 처리할 수 있다.


4. 런타임에 스레드 수 선택하기

C++ 표준 라이브러리의 유용한 기능 중 하나는 std::thread::hardware_concurrency()다. 이 함수는 프로그램이 실행되는 동안 실제로 동시에 실행될 수 있는 스레드 수를 나타내는 값을 반환한다. 예를 들어, 멀티코어 시스템에서는 CPU 코어 수가 될 수 있다. 이 값은 단지 힌트일 뿐이며, 이 정보가 없는 경우 함수는 0을 반환할 수도 있지만, 작업을 스레드 간에 나누는 데 유용한 지침이 될 수 있다.

 

더보기

#include <iostream>
#include <thread>
#include <vector>
#include <algorithm>
#include <functional>
#include <numeric>

template<typename Iterator, typename T>
struct accumulate_block {
void operator()(Iterator first, Iterator last, T& result) {
result = std::accumulate(first, last, result);
}
};

template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init) {
unsigned long const length = std::distance(first, last);

if (!length)
return init;

unsigned long const min_per_thread = 25;
unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;
unsigned long const hardware_threads = std::thread::hardware_concurrency();
unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);
unsigned long const block_size = length / num_threads;

std::vector<T> results(num_threads);
std::vector<std::thread> threads(num_threads - 1);

Iterator block_start = first;
for (unsigned long i = 0; i < (num_threads - 1); ++i) {
Iterator block_end = block_start;
std::advance(block_end, block_size);
threads[i] = std::thread(accumulate_block<Iterator, T>(), block_start, block_end, std::ref(results[i]));
block_start = block_end;
}
accumulate_block<Iterator, T>()(block_start, last, results[num_threads - 1]);

std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));
return std::accumulate(results.begin(), results.end(), init);
}

void main()
{
std::vector<int> vec;

for (int i = 0; i < 10000; i++)
vec.push_back(rand());

std::cout << parallel_accumulate(vec.begin(), vec.end(), 0);
}

C++에서 std::accumulate의 병렬 버전을 구현하는 간단한 예제를 살펴보자. 이 코드는 작업을 여러 스레드 간에 나누어 수행하며, 너무 많은 스레드로 인한 오버헤드를 피하기 위해 스레드당 최소 요소 수를 유지한다. 이 구현은 작업 도중 예외가 발생하지 않는다고 가정하지만, 실제로는 예외가 발생할 수 있다. 예를 들어, 새로운 스레드를 시작할 수 없을 때 std::thread 생성자는 예외를 던질 수 있다.

 

이 함수는 비교적 길지만, 실제로는 간단하다. 입력 범위가 비어 있으면 초기값 init을 반환하고, 그렇지 않으면 최소 블록 크기로 나누어 최대 스레드 수를 계산한다. 이는 범위에 5개의 값만 있을 때 32개의 스레드를 생성하는 것을 피하기 위함이다.

 

실행할 스레드 수는 계산된 최대 값과 하드웨어 스레드 수 중 최소 값이다. 하드웨어가 지원할 수 있는 것보다 많은 스레드를 실행하면(이를 oversubscription 이라고 한다) 성능이 떨어질 수 있다. std::thread::hardware_concurrency()가 0을 반환하면, 기본값으로 2를 사용한다. 너무 많은 스레드를 실행하면 단일 코어 머신에서 느려질 수 있지만, 너무 적은 스레드를 실행하면 사용할 수 있는 동시성을 놓칠 수 있다.

 

각 스레드가 처리할 항목 수는 범위의 길이를 스레드 수로 나눈 값이다. 숫자가 고르게 나누어지지 않는 경우에 대해 걱정할 필요는 없다. 나중에 이를 처리할 것이다.

 

이제 스레드 수를 알았으므로 중간 결과를 위한 std::vector<T>와 스레드를 위한 std::vector<std::thread>를 생성할 수 있다. 스레드 수보다 하나 적게 스레드를 생성해야 한다는 점에 유의해야 한다. 이미 하나의 스레드가 있기 때문이다.

 

스레드를 생성하는 것은 단순한 루프를 통해 이루어진다. block_end 이터레이터를 현재 블록의 끝으로 이동시키고, 이 블록의 결과를 누적하기 위해 새 스레드를 생성한다. 다음 블록의 시작은 현재 블록의 끝이다.

 

모든 스레드를 생성한 후, 마지막 블록을 처리할 수 있다. 불균등한 나눔을 고려하여 마지막 블록의 끝은 last가 되며, 그 블록에 몇 개의 요소가 있는지는 중요하지 않다.

 

마지막 블록의 결과를 누적한 후, td::for_each를 사용하여 생성된 모든 스레드가 완료되기를 기다리고, 마지막으로 std::accumulate를 호출하여 결과를 합산한다.

 

만약 타입 T의 덧셈 연산자가 결합법칙을 따르지 않는다면(예: float 또는 double), std::accumulate와 parallel_accumulate의 결과가 다를 수 있다. 이는 병렬 처리를 위해 범위를 여러 블록으로 나누기 때문이다.

 

여기서 몇 가지 중요한 점을 짚고 넘어가자. 만약 타입 T의 덧셈 연산자가 결합법칙을 따르지 않는다면(예: float 또는 double), std::accumulate와 parallel_accumulate의 결과가 다를 수 있다. 이는 병렬 처리를 위해 범위를 여러 블록으로 나누기 때문이다

 

또한 이터레이터에 대한 요구사항이 약간 더 엄격하다. parallel_accumulate은 전진 이터레이터(forward iterator)여야 하지만, std::accumulate는 단일 패스 입력 이터레이터(single-pass input iterator)로도 작동할 수 있다.

 

그리고 결과 벡터를 생성하기 위해 T가 기본 생성 가능해야 한다. 이러한 요구사항 변화는 병렬 알고리즘에서 흔히 발생한다. 병렬화를 위해 어떤 방식으로든 다르게 처리되기 때문에 결과와 요구사항에 영향을 미친다. 병렬 알고리즘은 나중에 더 깊이 다룬다.

 

스레드에서 값을 직접 반환할 수 없기 때문에, 결과 벡터의 관련 항목에 대한 참조를 전달해야 한다. 스레드에서 결과를 반환하는 다른 방법은 나중에 다룰 미래 객체를 통해 해결할 수 있다.

 

이 예제에서는 각 스레드가 시작될 때 필요한 모든 정보(계산 결과를 저장할 위치 포함)를 전달받았다. 하지만 항상 그런 것은 아니다. 때로는 처리 중 일부를 위해 스레드를 식별할 필요가 있다. 임의로 지정한 식별 번호를 전달할 수 있지만, 식별자가 필요한 함수가 호출 스택에서 여러 수준 깊은 곳에 있고, 어느 스레드에서든 호출될 수 있는 경우 그렇게 하는 것은 불편하다. C++ 스레드 라이브러리를 설계할 때 이러한 필요성을 예상했기 때문에 각 스레드에는 고유한 식별자가 있다.


5. 특정 스레드 식별하기

스레드 식별자는 std::thread::id 타입이며, 두 가지 방법으로 가져올 수 있다.

 

첫째, 스레드와 연관된 std::thread 객체에서 get_id() 멤버 함수를 호출하여 식별자를 얻을 수 있다.

만약 std::thread 객체가 실행 스레드와 연관되어 있지 않다면, get_id() 호출은 기본 생성된 std::thread::id 객체를 반환하며, 이는 "어떤 스레드도 아님"을 나타낸다.

 

둘째, 현재 스레드의 식별자는 <thread> 헤더에 정의된 std::this_thread::get_id()를 호출하여 얻을 수 있다.

 

std::thread::id 타입의 객체는 자유롭게 복사 및 비교할 수 있다. 그렇지 않으면 식별자로서의 유용성이 떨어질 것이다. 두 std::thread::id 객체가 같다면, 같은 스레드를 나타내거나 둘 다 "어떤 스레드도 아님" 값을 가지고 있는 것이다. 두 객체가 다르다면, 서로 다른 스레드를 나타내거나 하나는 스레드를 나타내고 다른 하나는 "어떤 스레드도 아님" 값을 가지고 있는 것이다.

 

"어떤 스레드도 아님" 값의 경우 일반적으로 0을 갖는다.

그래서 간단하게 두 thread_id 객체가 같다면, 같은 스레드를 나타내는 것이고, 아니라면 다른 스레드를 다타내는 것, id 가 0이면 "어떤 스레드도 아님"을 나타낸다고 볼 수 있다.

 

스레드 라이브러리는 스레드 식별자가 같은지 여부만 확인하는 것을 넘어 '비교 연산자 오버로딩'을 지원한다. 이를 통해 식별자를 기준으로 정렬하거나 비교할 수 있다. 비교 연산자는 직관적으로 동작한다. 또한 std::hash<std::thread::id> 를 제공하므로, std::thread::id 타입의 값을 이용하여 해싱을 사용할 수 있다.

 

std::thread::id 인스턴스는 종종 해당 스레드가 어떤 작업을 수행해야 하는지 확인하는 데 사용된다. 예를 들어, 앞서 살펴본 std::parallel_accumulate와 같이 스레드가 작업을 분할하는 경우, 초기 스레드는 알고리즘 중간에 약간 다른 작업을 수행해야 할 수 있다. 이 경우 초기 스레드의 std::this_thread::get_id() 결과를 미리 저장해 두고, 알고리즘의 중간에서 if 문을 통해 초기 스레드인지 판단하여 분기처리할 수 있다.

 

스레드의 std::thread::id를 데이터 구조에 저장하여 권한을 확인하거나 특정 작업을 허용할 수 있다. 여러 스레드가 동일한 시작 함수를 사용하는 경우, 이 데이터 구조에서 this_thread::get_id()를 키 값으로 검색하여 해당 작업이 허용되는지 여부나 특정한 분기 처리 등을 수행할 수 있다.

 

마찬가지로, 스레드 ID는 특정 데이터를 스레드와 연관시켜야 하는 경우 연관 컨테이너의 키로 사용할 수 있다. TLS 같은 대체 메커니즘이 적절하지 않은 경우에 유용하다. 예를 들어, 제어 스레드는 관리하는 각 스레드에 대한 정보를 저장하거나 스레드 간에 정보를 전달하기 위해 이러한 컨테이너를 사용할 수 있다.

 

std::thread::id는 대부분의 상황에서 스레드의 일반 식별자로 충분하다. 식별자에 특정 의미가 부여되지 않는 한, 대안이 필요하지 않다. std::thread::id 인스턴스를 std::cout과 같은 출력 스트림에 쓸 수도 있다.

 

정확한 출력은 구현에 따라 다르다. 표준에서 보장하는 것은 동일한 스레드 ID를 비교하면 항상 같은 출력이 나오고, 다른 스레드 ID는 다른 출력을 생성해야 한다는 것이다. 따라서 스레드 ID는 주로 디버깅이나 로그 기록에 유용하다. 값 자체에는 특별한 의미가 없기 때문에 더 이상 설명할 내용은 없다.


 

이 포스팅에서는 C++ 표준 라이브러리를 사용한 스레드 관리의 기본 사항을 다루었다. 스레드를 시작하고, 완료될 때까지 기다리거나 백그라운드에서 실행하도록 설정하는 방법을 살펴보았다. 또한 스레드 시작 시 스레드 함수에 인수를 전달하는 방법, 코드의 한 부분에서 다른 부분으로 스레드의 소유권을 이전하는 방법, 그리고 작업을 분할하기 위해 스레드 그룹을 사용하는 방법에 대해 배웠다. 마지막으로, 데이터를 특정 스레드와 연관시키거나 특정 동작을 수행하기 위해 스레드를 식별하는 방법에 대해 논의했다. 독립적인 스레드가 각각 별도의 데이터를 작업하는 경우 많은 작업을 수행할 수 있지만, 때로는 스레드가 실행되는 동안 데이터를 공유하는 것이 바람직하다.

 

다음 포스팅에서는 스레드 간에 데이터를 직접 공유하는 문제를 다루고, 그 다음에는 공유 데이터 없이 작업을 동기화하는 문제를 다룰 것이다.