Search
Duplicate

전문가를 위한 C++/ C++ 멀티스레드 프로그래밍

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)
멀티스레드 프로그래밍(multithreaded programming)은 프로세서 유닛이 여러 개 장착된 컴퓨터 시스템에서 중요한 기법이다. 멀티스레드 프로그래밍을 이용하면 시스템에 있는 여러 프로세서 유닛을 병렬로 사용하는 프로그램을 작성할 수 있다.
독립적인 CPU를 담은 프로세서 칩이 여러 개 달려 있을 수 있고, 한 프로세스 칩 안에 코어라 부르는 독립적인 CPU가 여러 개 있을 수 있고, 또 어떤 시스템은 두 가지 방식을 혼합하기도 한다. 이렇게 프로세스 유닛이 여러 개 달린 프로세서를 흔히 멀티코어 프로세서라 부른다.
요즘 판매되는 CPU는 모두 멀티코어 프로세서다. 멀티코어 프로세서가 보편화 됐기 때문에 멀티스레드 애플리케이션을 작성할 줄 아는 것은 중요하다. 전문 C++ 프로그래머라면 프로세서의 기능을 최대한 활용할 수 있도록 멀티스레드 코드를 정확하게 작성할 줄 알아야 한다.
멀티스레드 애플리케이션은 플랫폼이나 OS에서 제공하는 API에 상당히 의존한다. 그래서 멀티스레드 코드를 플랫폼 독립적으로 작성하기는 힘들다. C++ 11부터 제공되는 표준 스레딩 라이브러리를 활용하면 이 문제를 어느 정도 해결할 수 있다.

멀티스레드 프로그래밍 개념

멀티스레드 프로그래밍을 사용하면 여러 연산을 병렬로 처리할 수 있다. 그래서 현재는 거의 모든 시스템에 장착된 멀티 프로세서를 최대한 활용할 수 있다. 20년 전만 해도 프로세서 제조사는 속도 경쟁에 열을 올렸지만 2005년 즈음 전력 소모량과 발열 문제가 발생하면서 속도 경쟁의 한계에 부딪혔다. 그래서 듀얼 코어, 쿼드 코어 프로세스가 보편화됐고, 12, 16, 18 코어나 심지어 그보다 더 많은 코어를 장착한 프로세서가 등장하게 됐다.
CPU 뿐만 아니라 GPU라 부르는 그래픽 카드용 프로세서도 자세히 들여다보면 상당히 병렬화돼 있다. 요즘 나오는 고성능 그래픽 카드는 코어를 무려 4,000개 이상 장착하고 있으며 그 수는 계속 증가하고 있다. 이렇게 제작된 그래픽 카드는 단순히 게임용으로만 사용하지 않고, 수학 연산의 비중이 높은 작업을 처리하는데도 활용된다. 예컨대 이미지나 비디오 처리, 단백질 분석, 외계지적생명체 탐사 프로젝트에서 신호를 처리하는 작업 등에 활용된다.
C++ 98/03 버전은 멀티스레드 프로그래밍을 지원하지 않아서 서드파티 라이브러리나 타깃 시스템의 OS에서 제공하는 멀티스레드 API를 활용하는 수밖에 없었다. C++ 11부터 표준 멀티스레드 라이브러리가 추가되면서 크로스 플랫폼 멀티스레드 프로그램을 작성하기 한결 쉬워졌다. 현재 C++ 표준은 GPU를 제외한 CPU만을 대상으로 API를 정의하고 있지만, 향후 GPU도 지원하도록 개선될 가능성이 있다.
멀티스레드 프로그래밍이 필요한 이유는 크게 두 가지다. 첫쨰, 주어진 연산 작업을 작은 문제들로 나눠서 각각을 멀티프로세서 시스템에서 병렬로 실행하면 전반적인 성능을 크게 높일 수 있다.
둘째, 연산을 다른 관점에서 모듈화 할 수 있다. 예컨대 연산을 UI 스레드에 종속적이지 않은 독립 스레드로 분리해서 구현하면 처리 시간이 긴 연산을 백그라운드로 실행시키는 방식으로 UI의 응답 속도를 높일 수 있다.
아래 그림은 병렬 처리가 절대적으로 유리한 상황을 보여준다. 예컨대 이미지의 픽셀을 처리할 때 주변 픽셀 정보를 참조하지 않는 방식으로 구현한다고 하자. 그러면 이미지를 크게 네 부분으로 나눠서 처리하도록 알고리즘을 구성할 수 있다. 이러면 성능이 코어 수에 정비례 하게 된다.
항상 독립 작업으로 나눠서 병렬화 할 수 있는 것은 아니지만 최소한 일부분만이라도 병렬화 할 수 있다면 조금이라도 성능을 높일 수 있다. 멀티스레드 프로그래밍을 하는데 어려운 부분은 병렬 알고리즘을 고안하는 것이다. 처리할 작업의 성격에 따라 구현 방식이 크게 달라지기 때문이다.
또한 경쟁 상태, 교착 상태(데드락), 테어링(tearing), 잘못된 공유(false-sharing) 등과 같은 문제가 발생하지 않게 만드는 것도 쉽지 않다. 이런 문제는 주로 아토믹과 명싲거인 동기화 메커니즘으로 해결하며 구체적인 방법은 뒤에 소개하겠다.
Note) 멀티스레드 관련 문제를 방지하려면 여러 스레드가 공유 메모리를 동시에 읽거나 쓰지 않도록 디자인해야 한다. 아니면 동기화 기법이나 아토믹 연산을 적용한다.

경쟁 상태

여러 스레드가 공유 리소스를 동시에 접근할 때 경쟁 상태가 발생할 수 있다. 그중에서도 공유 메모리에 대한 경쟁 상태를 흔히 데이터 경쟁이라 부른다. 데이터 경쟁은 여러 스레드가 공유 메모리에 동시에 접근할 수 있는 상태에서 최소 하나의 스레드가 그 메모리에 데이터를 쓸 때 발생한다.
예컨대 공유 변수가 하나 있는데 어떤 스레드는 이 값을 증가시키고, 또 어떤 스레드는 이 값을 감소시키는 경우를 생각해보자. 값을 증가하거나 감소하려면 현재 값을 메모리에서 읽어서 증가나 감소 연산을 수행해야 한다.
PDP-11이나 VAX와 같은 예전 아키텍쳐는 아토믹하게 실행되는(주어진 시점에 혼자만 실행되는) INC와 같은 인스트럭션을 제공했다. 하지만 최신 x86 프로세서에서 제공하는 INC는 더는 아토믹하지 않다. 다시 말해 INC를 처리하는 도중에 다른 인스트럭션이 실행될 수 있기 때문에 결과가 얼마든지 달라질 수 있다.
다음 표는 초깃값이 1일 때 감소 연산이 실행되기 전에 증가 연산을 마치는 경우를 보여준다.
스레드 1(증가 연산)
스레드 2(감소 연산)
값을 불러온다 (값 = 1)
값을 하나 증가시킨다 (값 = 2)
값을 저장한다 (값 = 2)
값을 불러온다 (값 = 2)
값을 하나 감소시킨다 (값 = 1)
값을 저장한다 (값 = 1)
메모리에 기록되는 최종 결과는 1이다. 이와 반대로 다음 표와 같이 증가 연산을 수행하는 스레드가 시작하기 전에 감소 연산을 수행하는 스레드가 작업을 모두 마쳐도 최종 결과는 1이 된다.
스레드 1(증가 연산)
스레드 2(감소 연산)
값을 불러온다 (값 = 1)
값을 하나 감소시킨다 (값 = 0)
값을 저장한다 (값 = 0)
값을 불러온다 (값 = 0)
값을 하나 증가시킨다 (값 = 1)
값을 저장한다 (값 = 1)
그런데 두 작업이 다음 표와 같이 서로 엇갈리면 결과가 달라진다.
스레드 1(증가 연산)
스레드 2(감소 연산)
값을 불러온다 (값 = 1)
값을 하나 증가시킨다 (값 = 2)
값을 불러온다 (값 = 1)
값을 하나 감소시킨다 (값 = 0)
값을 저장한다 (값 = 2)
값을 저장한다 (값 = 0)
이렇게 하면 최종 결과는 0이 된다. 다시 말해 증가 연산의 효과가 사라진다. 이를 데이터 경쟁 상태라 부른다.

테어링

테어링(tearing)이란 데이터 경쟁의 특수한 경우로서 크게 읽기 테어링(torn read), 쓰기 테어링(torn write)의 두 가지가 있다. 어떤 스레드가 메모리에 데이터의 일부만 쓰고 나머지 부분을 미처 쓰지 못한 상태에서 다른 스레드가 이 데이터를 읽으면 두 스레드가 보는 값이 달라진다. 이를 읽기 테어링리라 부른다.
또한 두 스레드가 이 데이터를 동시에 쓸 때 한 스레드는 그 데이터의 한쪽 부분을 쓰고, 다른 스레드는 그 데이터의 다른 부분을 썼다면 각자 수행한 결과가 달라진다. 이를 쓰기 테어링이라 부른다.

데드락

경쟁 상태를 막기 위해 상호 배제와 같은 동기화 기법을 적요하다 보면 멀티스레드 프로그래밍에서 흔히 발생하는 또 다른 문제인 데드락(교착 상태)에 부딪히기 쉽다. 데드락이란 여러 스레드가 서로 상대방 작업이 끝날 때까지 동시에 기다리는 상태를 말한다.
예컨대 두 스레드가 공유 리소스를 서로 접근하려면 그 리소스에 대한 접근 권한 요청부터 해야 한다. 현재 둘 중 한 스레드가 그 리소스에 대한 접근 권한을 확보한 상태로 계속 머물러 있으면 그 리소스에 대한 접근 권한을 요청하는 다른 스레드도 무한히 기다려야 한다. 이때 공유 리소스에 대한 접근 권한을 얻기 위한 방법 중에는 23.4절 ‘상호 배제’에서 설명할 상호 배제(뮤텍스)라는 것이 있다.
예컨대 스레드가 두 개 있고 리소스도 두 개 있을 때 이를 A와 B라는 뮤텍스 객체로 보호하고 있다고 하자. 이때 두 스레드가 각 리소스에 대한 접근 권한을 얻을 수 있지만 그 순서는 다음 표와 같이 서로 다른 경우를 생각해 보자.
스레드 1
스레드 2
A 확보
B 확보
B 확보
A 확보
// 작업을 수행한다
// 작업을 수행한다
B 해제
A 해제
A 해제
B 해제
이 스레드가 실행되면 다음과 같이 진행될 수 있다.
스레드 1: A 확보
스레드 2: B 확보
스레드 1: B 확보 (스레드 2가 B를 확보하고 있기 때문에 기다린다)
스레드 2: A 확보 (스레드 1이 A를 확보하고 있기 때문에 기다린다)
그러면 두 스레드 모두 상대방을 무한정 기다린느 데드락이 발생한다. 이러한 데드락 상황을 그림으로 표현하면 다음과 같다. 스레드 1은 A 리소스에 대한 접근 권한을 확보한 상태에서 B 리소스의 접근 권한을 얻을 때까지 기다린다. 스레드 2는 B 리소스의 접근 권한을 확보한 상태에서 A 리소스의 접근 권한을 얻을 때까지 기다린다. 그림을 보면 데드락 상황이 순환 관계를 이루고 있다. 결국 두 스레드는 서로 상대방을 무한정 기다리게 된다.
이러한 데드락이 발생하지 않게 하려면 모든 스레드가 일정한 순서로 리소스를 획득해야 한다. 또한 데드락이 발생해도 빠져나올 수 있는 메커니즘도 함께 구현하면 좋다.
한 가지 방법은 리소스 접근 권한을 요청하는 작업에 시간 제한을 걸어두는 것이다. 그래서 주어진 시간 안에 리소스를 확보할 수 없으면 더는 기다리지 않고 현재 확보한 권한을 해제한다. 그러고 나서 일정 시간 동안 기다렸다가 리소스를 확보하는 작업을 다시 시도한다. 이렇게 하면 다른 스레드가 리소스에 접근할 기회를 줄 수 있다. 물론 이 기법만으로 문제를 해결할 수 있는지는 주어진 데드락 상황에 따라 다르다.
방금 소개한 우회 기법보다는 데드락 상황 자체가 아예 발생하지 않게 만드는 것이 바람직하다. 여러 뮤텍스 객체로 보호받고 있는 리소스 집합에 대해 접근 권한을 얻을 떄는 리소스마다 접근 권한을 개별적으로 요청하지 않고 std::lock()이나 std::try_lock() 같은 함수를 활용하는 것이 좋다. 이 함수는 여러 리소스에 대한 권한을 한 번에 확보하거나 요청한다.

잘못된 공유

캐시(cache)는 캐시 라인(cache line) 단위로 처리된다. 최신 CPU는 흔히 64바이트 캐시 라인으로 구성된다. 캐시 라인에 데이터를 쓰려면 반드시 그 라인 전체에 락을 걸어야 한다. 멀티스레드 코드를 실행할 때 데이터 구조를 잘 만들지 않으면 캐시 라인에 락을 거는 과정에서 성능이 크게 떨어질 수 있다.
예컨대 두 스레드가 두 가지 데이터 영역을 사용하는데, 데이터가 같은 캐시 라인에 걸쳐 있는 경우를 생각해 보자. 이때 한 스레드가 데이터를 업데이트하면 캐시 라인 전체에 락을 걸어버리기 때문에 다른 스레드는 기다려야 한다. 캐시 라인에 걸쳐 있지 않도록 데이터 구조가 저장될 메모리 영역을 명시적으로 정렬하면 여러 스레드가 접근할 때 대기하지 않게 만들 수 있다.
이러한 코드를 이식하기 좋게 작성할 수 있도록 C++ 17부터 <new> 헤더 파일에 hardware_destructive_interference_size란 상수가 추가됐다. 이 상수는 동시에 접근하는 두 객체가 캐시 라인을 고융하지 않도록 최소한의 오프셋을 제시해준다. 이 값과 alignas 키워드를 데이터를 적절히 정렬하는데 활용하면 된다.

스레드

<thread> 헤더 팡리에 정의된 C++ 스레드 라이브러리를 사용하면 스레드를 매우 간편하게 생성할 수 있다. 이때 새로 만든 스레드가 할 일을 지정하는 방식은 다양하다. 전역 함수로 표현하거나 함수 객체의 operator()로 표현하거나 람다 표현식으로 지정하거나 특정 클래스의 인스턴스에 있는 멤버 함수로 지정할 수 있다.

함수 포인터로 스레드 만들기

윈도우 시스템의 CreateThread(), _beginthread()와 같은 함수나 pthreads 라이브러리의 pthread_create()와 같은 스레드 함수는 매개변수를 하나만 받는다. 반면 C++ 표준에서 제공하는 std::thread 클래스에서 사용하는 함수는 매개변수를 원하는 만큼 받을 수 있다.
예컨대 다음과 같이 정수 두 개를 인수로 받는 counter() 함수를 살펴보자. 첫 번째 인수는 ID를 표현하고 두 번째 인수는 이 함수가 루프를 도는 횟수를 표현한다. 이 함수는 인수로 지정한 횟수만큼 표준 출력에 메시지를 보내는 문장을 반복한다.
void counter(int id, int numIterations) { for (int i = 0; i < numIteratrions; ++i) { cout << "Counter " << id << " has value " << i << endl; } }
C++
복사
std::thread를 이용하면 이 함수를 여러 스레드로 실행하게 만들 수 있다. 예컨대 인수로 1과 6을 지정해서 counter()를 수행하는 스레드인 t1을 다음과 같이 생성할 수 있다.
thread t1(counter, 1, 6);
C++
복사
thread 클래스 생성자는 가변 인수 템플릿이기 때문에 인수 개수를 원하는 만큼 지정할 수 있다. 첫 번째 인수는 새로 만들 스레드가 실행할 함수의 이름이다. 그 뒤에 나오는 인수는 스레드가 구동되면서 실행할 함수에 전달할 인수 개수다.
현재 시스템에서 thread 객체가 실행 가능한 상태에 있을 때 조인 가능(joinable) 하다고 표현한다. 이런 스레드는 실행을 마치고 나서도 조인 가능한 상태를 유지한다. 디폴트로 생성된 thread 객체는 조인 불가능(unjoinable)하다. 조인 가능한 thread 객체를 제거하려면 먼저 그 객체의 join()이나 detach()부터 호출해야 한다.
join()을 호출하면 그 스레드는 블록된다. 다시 말해 그 스레드가 작업을 끝낼 때까지 기다린다. detach()를 호출하면 thread 객체를 OS 내부의 스레드와 분리한다. 그래서 OS 스레드는 독립적으로 실행된다. 두 메서드 모두 스레드를 조인 불가능한 상태로 전환시킨다. 조인 가능 상태의 thread 객체를 제거하면 그 객체의 소멸자는 std::terminate()를 호출해서 모든 스레드뿐만 아니라 애플리케이션마저 종료시킨다.
다음 코드는 counter() 함수를 실행하는 스레드를 두 개 생성한다. main()에서 스레드를 생성하고 나서 곧바로 두 스레드에 대해 join()을 호출한다.
thread t1(counter, 1, 6); thread t2(counter, 2, 4); t1.join(); t2.join();
C++
복사
코드를 실행하는 시스템마다 결과가 달라질 수 있고, 같은 시스템에서도 실행할 때마다 결과가 달라질 수 있다. 두 스레드가 counter() 함수를 동시에 실행하므로 시스템에 장착된 코어 수와 OS의 스레드 스케쥴링 방식에 따라 출력 형태가 달라진다.
기본적으로 cout에 접근하는 작업은 스레드에 안전해서 여러 스레드 사이에서 데이터 경쟁이 발생하지 않는다. (출력이나 입력 연산 직전에 cout.sync_with_stdio(false)를 호출하지 않았을 경우) 그런데 데이터 경쟁이 발생하지 않더라도 스레드마다 출력한 결과는 여전히 겹쳐질 수 있다. 동기화 기법을 적용하면 뒤섞이지 않게 만들 수 있는데, 이에 대해서는 뒤에 소개하겠다.

함수 객체로 스레드 만들기

이번에는 함수 객체로 스레드를 실행시키는 방법을 알아보자. 앞서 소개한대로 함수 포인터로 스레드를 만들면 함수에 인수를 전달하는 방식으로만 스레드에 정보를 전달할 수 있다. 반면 함수 객체로 만들면 그 함수 객체의 클래스에 멤버 변수를 추가해서 원하는 방식으로 초기화해서 사용할 수 있다.
다음 예제는 먼저 Counter란 클래스를 정의한다. 이 클래스는 ID와 반복 횟수를 표현하는 멤버 변수를 가지고 있다. 두 변수 모두 생성자로 초기화한다. Counter 클래스를 함수 객체로 만들려면 18장에서 설명한대로 operator()를 구현해야 한다. operator()를 구현하는 코드는 앞에서 본 counter()와 같다.
class Counter { public: Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { } void operator()() const { for (int i = 0; i < mNumIterations; ++i) { cout << "Counter " << mId << " has value " << i << endl; } } private: int mId; int mNumIterations; };
C++
복사
다음 코드는 함수 객체로 만든 스레드를 초기화하는 세 가지 방법을 보여준다. 첫 번째 방법은 유니폼 초기화로 처리한다. Counter 생성자에 인수를 지정해서 인스턴스를 생성하면 그 값이 중괄호로 묶인 thread 생성자 인수로 전달된다.
두 번째 방법은 Counter 인스턴스를 일반 변수처럼 네임드 인스턴스로 정의하고, 이를 thread 클래스의 생성자로 전달한다.
세 번째 방법은 Counter 인스턴스를 생성해서 이를 thread 클래스 생성자로 전달하는 점에서 첫 번째와 비슷하지만, 중괄호가 아닌 소괄호로 묶는 점이 다르다.
// 유니폼 초기화를 사용하는 방법 thread t1{ Counter{ 1, 20 }}; // 일반 변수처럼 네임드 인스턴스로 초기화하는 방법 Counter c(2, 12); thread t2(c); // 임시 객체를 사용하는 방법 thread t3(Counter(3, 10)); // 세 스레드가 모두 마칠때까지 기다린다. t1.join(); t2.join(); t3.join();
C++
복사
t1과 t3의 생성 과정을 비교하면 전자는 중괄호를 사용하고, 후자는 소괄호를 사용한다는 점이 달라보인다. 하지만 함수 객체 생성자가 매개변수를 받지 않을 때 후자와 같이 코드를 작성하면 에러가 발생한다. 예컨대 다음과 같다.
class Counter { public: Counter() {} void operator()() const { /* 코드 생략 */ } }; int main() { thread t1(Counter()); // error t1.join(); }
C++
복사
이렇게 작성하면 컴파일 에러가 발생한다. C++ 인터프리터는 main()의 첫 줄을 t1 함수의 선언문으로 해석하기 때문이다. 즉 매개변수 없이 Counter 객체를 리턴하는 Counter 함수에 대한 포인터를 인수로 받아서 thread 객체를 리턶나느 t1 함수로 처리한다. 이때는 다음과 같이 유니폼 초기화를 사용하는 것이 좋다.
thread t1{ Counter{} }; // 정상 처리 된다.
C++
복사
Note) 함수 객체는 항상 스레드의 내부 저장소에 복제된다. 함수 객체의 인스턴스를 복제하지 않고 그 인스턴스에 대해 operator()를 호출하려면 <functional> 헤더에 정의된 std::ref()나 cref()를 사용해서 인스턴스를 레퍼런스로 전달해야 한다. 예컨대 다음과 같다.
Counter c(2, 12); thread t2(ref(c));
C++
복사

람다 표현식으로 스레드 만들기

람다 표현식은 표준 C++ 스레드 라이브러리와 궁합이 잘 맞는다. 예컨대 람다 표현식으로 정의한 작업을 실행하는 스레드를 생성하는 코드를 다음과 같이 작성할 수 있다.
int main() { int id = 1; int numIterations = 5; thread t1([id, numIterations] { for (int i = 0; i < numIterations; ++i) { cout << "Counter " << id << " has value " << i << endl; } }); t1.join(); }
C++
복사

멤버 함수로 스레드 만들기

스레드에서 실행할 내용을 클래스의 멤버 함수로 지정할 수도 있다. 다음 코드는 기본 Request 클래스에 process() 메서드를 정의하고 있다. 그러고 나서 main() 함수에서 Request 클래스의 인스턴스를 생성하고, Request 인스턴스인 req의 process() 메서드를 실행하는 스레드를 생성한다.
class Request { public: Request(int id) : mId(id) { } void process() { cout << "Processing request " << mId << endl; } private: int mId; }; int main() { Request req(100); thread t{ &Request::process, &req }; t.join(); }
C++
복사
이렇게 하면 특정한 객체에 있는 메서드를 스레드로 분리해서 실행할 수 있다. 똑같은 객체를 여러 스레드가 접근할 때 데이터 경쟁이 발생하지 않도록 스레드에 안전하게 작성해야 한다. 스레드에 안전하게 구현하는 방법 중 하나는 뒤에서 설명할 상호 배제(뮤텍스)라는 동기화 기법을 활용하는 것이다.

스레드 로컬 저장소

C++ 표준은 스레드 로컬 저장소(thread local storage)란 개념을 제공한다. 원하는 변수에 thread_local이란 키워드를 지정해서 스레드 로컬 저장소로 지원하면 각 스레드마다 이 변수를 복제해서 스레드가 없어질 때까지 유지한다. 이 변수는 각 스레드에서 한 번만 초기화된다.
예컨대 다음 코드에는 두 개의 전역 변수가 정의돼 있는데, 모든 스레드가 k의 복제본 하나를 공유하며, 각 스레드는 자신의 고유한 n의 복제본을 가진다.
int k; thread_local int n;
C++
복사
만일 thread_local 변수를 함수 스코프 안에서 선언하면 모든 스레드가 복제본을 따로 갖고 있고, 함수를 아무리 많이 호출하더라도 스레드마다 단 한번만 초기화된다는 점을 제외하면 static으로 선언할 때와 똑같이 작동한다.

스레드 취소하기

C++ 표준은 현재 실행 중인 스레드를 다른 스레드에서 중단시키는 메커니즘을 제공하지 않는다. 이렇게 다른 스레드를 종료시키기 위한 가장 좋은 방법은 여러 스레드가 공통으로 따르는 통신 메커니즘을 제공하는 것이다. 가장 간단한 방법은 공유 변수를 활용하는 것이다.
값을 전달 받은 스레드는 이 값을 주기적으로 확인하면서 중단 여부를 결정한다. 나머지 스레드는 이러한 공유 변수를 이용해 이 스레드를 간접적으로 종료시킬 수 있다.
하지만 이때 조심해야 할 점이 있다. 여러 스레드가 공유 변수에 접근하기 때문에 최소한 한 스레드는 그 변수에 값을 쓸 수 있다. 따라서 이 변수를 아토믹이나 조건 변수로 만드는 것이 좋다.

스레드로 실행한 결과 얻기

앞서 나온 예제에서 볼 수 있듯이 스레드를 새로 만들기는 쉽다. 하지만 정작 중요한 부분은 스레드로 처리한 결과다. 예컨대 스레드로 수학 연산을 수행하면 모든 스레드가 종료한 뒤에 나오는 최종 결과를 구해야 한다.
한 가지 방법은 결과를 담은 변수에 대한 포인터나 레퍼런스를 스레드로 전달해서 스레드마다 결과를 저장하게 만드는 것이다. 또 다른 방법은 함수 객체의 클래스 멤버 변수에 처리 결과를 저장했다가 나중에 스레드가 종료할 때 그 값을 가져오는 것이다. 이렇게 하려면 반드시 std::ref()를 이용해서 함수 객체의 레퍼런스를 thread 생성자에게 전달해야 한다.
그런데 이보다 더 쉬운 방법이 있다. 바로 future를 활용하는 것이다. 그러면 스레드 안에서 발생한 에러를 처리하기도 쉽다. 이에 대해서는 이후에 자세히 소개한다.

익셉션 복제와 다시 던지기

스레드가 하나만 있을 때는 C++의 익셉션 메커니즘 관련 문제가 발생할 일이 없다. 그런데 스레드에서 던진 익셉션은 그 스레드 안에서 처리해야 한다. 던진 익셉션을 스레드 안에서 잡지 못하면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션 전체를 종료시킨다. 한 스레드에서 던진 익셉션을 다른 스레드에서 잡을 수는 없다. 그래서 멀티스레드 환경에서 익셉션을 처리하는 과정에 여러 가지 문제가 발생한다.
표준 스레드 라이브러리를 사용하지 않고도 스레드 사이에 발생한 익셉션을 처리할 수 있지만 굉장히 힘들다. 이를 위해 표준 스레드 라이브러리는 다음과 같은 익셉션 관련 함수를 제공한다. 이 함수는 std::exception 뿐만 아니라 int, string, 커스텀 익셉션 등에도 적용된다.
exception_ptr current_exception() noexcept;
이 함수는 catch 블록에서 호출하며, 현재 처리할 익셉션을 가리키는 exception_ptr 객체나 그 복사본을 리턴한다. 현재 처리하는 익셉션이 없으면 널 exception_ptr 객체를 리턴한다. 이때 참조하는 익셉션 객체는 exception_ptr 타입의 객체가 존재하는 한 유효하다. exception_ptr의 타입은 NullablePointer이기 때문에 간단히 if 문을 작성해서 테스트하기 쉽다. 이에 대한 예제는 뒤에서 소개한다.
[[noreturn]] void rethrow_exception(exception_ptr p);
이 함수는 exception_ptr 매개변수가 참조하는 익셉션을 다시 던진다. 참조한 익셉션을 반드시 그 익셉션이 처음 발생한 스레드 안에서만 다시 던져야 한다는 법은 없다. 그래서 여러 스레드에서 발생한 익셉션을 처리하는 용도로 딱 맞다. [[noreturn]] 속성은 이 함수가 정상적으로 리턴하지 않는다는 것을 선언한다.
template<class E> exception_ptr make_exception_ptr(E e) noexcept;
이 함수는 주어진 익셉션 객체의 복사본을 참조하는 exception_ptr 객체를 생성한다. 실질적으로 다음 코드의 축약이다. try { tyrow e; } catch(…) { return current_exception(); }
이러한 함수로 스레드에서 발생한 익셉션을 처리하는 방법을 살펴보자. 다음 코드는 일정한 작업을 수행한 뒤 익셉션을 던지는 함수를 정의한다. 이 함수는 별도 스레드로 실행한다.
void doSomeWork() { for (int i = 0; i < 5; ++i) { cout << i << endl; } cout << "Thread throwing a runtime_error exception..." << endl; throw runtime_error("Exception from thread"); }
C++
복사
다음 threadFunc() 함수는 doSomeWork()가 던진 익셉션을 모두 받도록 try/catch 블록으로 묶는다. threadFunc()는 exception_ptr& 타입 인수 하나만 받는다. 익셉션을 잡았다면 current_exception() 함수를 이용하여 처리할 익셉션에 대한 레퍼런스를 받아서 exception_ptr 매개변수에 대입한다. 그런 다음 스레드는 정상적으로 종료한다.
void threadFunc(exception_ptr& err) { try { doSomeWork(); } catch (...) { cout << "Thread caught exception, returning exception..." << endl; err = current_exception(); } }
C++
복사
다음 doWorkInThread() 함수는 메인 스레드에서 호출된다. 이 함수는 스레드를 생성해서 그 안에 담긴 threadFunc()의 실행을 시작하는 역할을 담당한다. threadFunc()의 인수로 exception_ptr 타입 객체에 대한 레퍼런스를 지정한다. 일단 스레드가 생성되면 doWorkInThread() 함수는 join() 메서드를 이용하여 이 스레드가 종료될 때까지 기다리고 그 후 에러 객체가 발생하는지 검샇나다.
exception_ptr은 NullablePointer 타입이기 때문에 if 문으로 간단히 검사할 수 있다. 이 값이 널이 아니라면 현재 스레드에 그 익셉션을 다시 던진다. 이 예제에서는 메인 스레드가 현재 스레드다. 이 익셉션을 메인 스레드에서 다시 던지기 때문에 한 스레드에서 다른 스레드로 전달된다.
void doWorkInThread() { exception_ptr error; // 스레드를 구동한다. thread t{ threadFunc, ref(error) }; // 스레드가 종료할 때까지 기다린다. t.join(); // 스레드에 익셉션이 발생했는지 검사한다. if (error) { cout << "Main thread received exception, rethrowing it..." << endl; rethrow_exception(error); } else { cout << "Main thread did not receive any exception" << endl; } }
C++
복사
여기서 구현한 main() 함수는 간단하다. doWorkInThread()를 호출하고, doWorkInThread()에서 생성한 스레드가 던진 익셉션을 받도록 try/catch 블록을 작성한다.
int main() { try { doWorkInThread(); } catch (const exception& e) { cout << "Main function caught: '" << e.what() << "'" << endl; } }
C++
복사
이 장에서는 예제를 최대한 간결하게 구성하기 위해 main() 함수에서 join()을 호출해서 메인 스레드를 블록 시키고 스레드가 모두 마칠 때까지 기다린다. 물론 실전에서는 이렇게 메인 스레드를 블록 시키면 안 된다. 예컨대 GUI 애플리케이션에서 메인 스레드를 블록시키면 UI가 반응하지 않게 된다.
이럴 때는 스레드끼리 메시지로 통신하는 기법을 사용한다. 예컨대 앞서 본 threadFunc() 함수는 current_exception() 결과의 복제본을 인자로 하여 UI 스레드로 메시지를 보낼 수 있다. 하지만 앞서 설명했듯이 이렇게 하더라도 생성된 스레드에 대해 join()이나 detach()를 호출해야 한다.

아토믹 연산 라이브러리

아토믹 타입(atomic type)을 사용하면 동기화 기법을 적용하지 않고 읽기와 쓰기를 동시에 처리하는 아토믹 접근(atomic access)이 가능하다. 아토믹 연산을 사용하지 않고 변수의 값을 증가시키면 스레드에 안전하지 않다. 컴파일러는 먼저 메모리에서 이 값을 읽고, 레스스터로 불러와서 값을 증가시킨 다음, 그 결과를 메모리에 다시 저장한다.
그런데 이 과정에서 같은 메모리 영역을 다른 스레드가 건드리면 데이터 경쟁이 발생한다. 예컨대 다음 코드는 스레드에 안전하지 않게 작성돼 데이터 경쟁이 발생하는 상황을 보여준다.
int counter = 0; // 전역 변수 ++counter; // 여러 스레드에서 실행한다.
C++
복사
이 변수에 std::atomic 타입을 적용하면 뮤텍스 객체와 같은 동기화 기법을 따로 사용하지 않고도 스레드에 안전하게 만들 수 있다. 앞서 나온 코드를 이렇게 고치면 다음과 같다.
atomic<int> counter(0); // 전역 변수 ++counter; // 여러 스레드에서 실행한다.
C++
복사
아토믹 타입을 사용하려면 <atomic> 헤더 파일을 인클루드 해야 한다. C++ 표준은 언어에서 제공하는 모든 기본 타입마다. 네임드(이름이 지정된) 정수형 아토믹 타입을 정의하고 있다. 그 중 몇가지만 소개하면 다음과 같다.
네임드 아토믹 타입
동등 std::atomic 타입
atomic_bool
atomic<bool>
atomic_char
atomic<char>
atomic_uchar
atomic<unsigned char>
atomic_int
atomic<int>
atomic_uint
atomic<unsigned int>
atomic_long
atomic<long>
atomic_ulong
atomic<unsigned long>
atomic_llong
atomic<long long>
atomic_ullong
atomic<unsigned long long>
atomic_wchar_t
atomic<wchar_t>
아토믹 타입을 사용할 때는 동기화 메커니즘으로 명싲거으로 사용하지 않아도 된다. 하지만 특정 타입에 대해 아토믹 연산으로 처리할 때는 뮤텍스와 같은 동기화 메커니즘을 내부적으로 사용하기도 한다.
예컨대 연산을 아토믹 방식으로 처리하는 인스트럭션을 타깃 하드웨어에서 제공하지 않을 수 있다. 이럴 때는 아토믹 타입에 대해 is_lock_free() 메서드를 호출해서 잠그지 않아도 되는지(lock-free 인지) 즉, 명시적으로 동기화 메커니즘을 사용하지 않고도 수행할 수 있는지 확인한다.
std::atomic 클래스 템플릿은 정수 타입 뿐만 아니라 다른 모든 종류의 타입에 대해서도 적용할 수 있다. 예컨대 atomic<double>이나 atomic<MyType>과 같이 쓸 수 있다. 단, MyType을 쉽게 복제할 수 있어야 한다. 지정한 타입의 크기에 따라 내부적으로 동기화 메커니즘을 사용해야 할 수도 있다.
다음 예제를 보면 Foo와 Bar를 쉽게 복제할 수 있다. 다시 말해 이 둘에 대해 std::is_trivially_copyable_v가 true다. 하지만 atomic<Foo>는 lock-free가 아니고, atomic<Bar>는 lock-free다.
class Foo { private: int mArray[123]; }; class Bar { private: int mInt; }; int main() { atomic<Foo> f; cout << is_trivially_copyable_v<Foo> << " " << f.is_lock_free() << endl; atomic<Bar> b; cout << is_trivially_copyable_v<Bar> << " " << b.is_lock_free() << endl; } // 실행 결과 // 1 0 // 1 1
C++
복사
일정한 데이터를 여러 스레드가 동시에 접근할 때 아토믹을 사용하면 메모리 순서, 컴파일러 최적화 등과 같은 문제를 방지할 수 있다. 기본적으로 아토믹이나 동기화 메커니즘을 사용하지 않고서 동일한 데이터를 여러 스레드가 동시에 읽고 쓰는 것은 위험하다.

아토믹 타입 사용 예

(예시 코드 생략)
이처럼 코드에 동기화 메커니즘을 따로 추가하지 않고도 스레드에 안전하고 데이터 경쟁이 발생하지 않게 만들 수 있다. ++counter 연산을 수행하는데 필요한 불러오기(load), 증가, 저장 작업을 하나의 아토믹 트랜잭션으로 처리해서 중간에 다른 스레드가 개입할 수 없기 때문이다.
그런데 이렇게 하면 성능 문제가 발생한다. 아토믹이나 동기화 메커니즘을 사용할 때 동기화 작업으로 인해 성능이 떨어지기 때문에 이 부분을 처리하는데 걸리는 시간을 최소화 하도록 구성해야 한다.
앞서 본 예제처럼 간단한 코드라면 increment()가 로컬 변수에 대해 결과를 계산하도록 만들고, 루프를 마친 후 그 결과를 counter 레퍼런스로 추가하도록 작성하는 것이 가장 바람직하다. 이렇게 할 때도 여전히 아토믹 타입을 사용해야 한다. 여러 스레드가 counter 변수에 쓰는 작업을 수행한다는 점은 변하지 ㅇ낳기 때문이다.

아토믹 연산

C++ 표준에서는 여러 가지 아토믹 연산을 정의하고 있다. 이 절에서는 그중 몇 가지만 소개한다.
아토믹 연산에 대한 첫 번째 예제는 다음과 같다.
bool atomic<T>::compare_exchage_strong(T& expected, T desired);
C++
복사
이 연산을 아토믹하게 수행하도록 구현하면 다음과 같다. 여기서는 의사 코드로 표현했다.
if (*this == expected) { *this = desired; return true; } else { expected = *this; return false; }
C++
복사
얼핏 보면 좀 이상하지만 데이터 구조를 락을 사용하지 않고 동시에 접근하게 만드는데 핵심적인 연산이다. 이렇게 락-프리 동시성 데이터 구조(lock-free concurrent data structure)를 이용하면 이 데이터 구조에 대해 연산을 수행할 때 동기화 메커니즘을 따로 사용하지 않아도 된다. 하지만 이렇게 데이터 구조를 구현하는 기법은 고급 주제에 해당하며 이 책에서는 소개하지 ㅇ낳는다.
두 번째 예제는 정수 아토믹 타입에 적용되는 atomic<T>::fetch_add()를 사용하는 것이다. fetch_add()는 주어진 아토믹 타입의 현재 값을 가져와서 지정한 값만큼 증가시킨 다음, 증가시키기 전의 값을 리턴한다. 예컨대 다음과 같다.
atomic<int> value(10); cout << "Value = " << value << endl; int fetched = value.fetched_add(4); cout << "Fetched = " << fetched << endl; cout << "Value = " << value << endl; // 실행 결과 // Value = 10 // Fetched = 10 // Value = 14
C++
복사
정수형 아토믹 타입은 fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor(), ++, –, +=, -=, &=, ^=, |=과 같은 아토믹 연산을 지원한다. 아토믹 포인터 타입은 fetch_add(), fetch_sub(), ++, –, +=, -=을 지원한다.
아토믹 연산은 대부분 원하는 메모리 순서를 지정하는 매개변수를 추가로 받는다. 예컨대 다음과 같다.
T atomic<T>::fetch_add(T value, memory_order = memory_order_seq_cst);
C++
복사
그러면 디폴트로 설정된 memory_order 값을 다른 값으로 변경할 수 있다. C++ 표준은 이를 위해 memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst를 제공한다. 모두 std 네임스페이스 아래에 정의돼 있다.
그런데 디폴트 값이 아닌 다른 값을 지정할 일은 별로 없다. 디폴트보다 나은 성능을 보여주는 메모리 순서가 있긴 하지만 자칫 잘못하면 데이터 경쟁이 발생하거나 디버깅하기 힘든 스레드 관련 문제가 발생할 수 있다.

상호 배제

멀티스레드 프로그램을 작성할 때는 반드시 연산의 순서를 신중하게 결정해야 한다. 스레드에서 공유 데이터를 읽거나 쓰면 문제가 발생할 수 있기 때문이다. 이러한 문제를 방지하기 위한 방법은 다양하다. 극단적으로 스레드끼리 데이터를 아예 공유하지 않게 만들 수도 있다. 하지만 공유를 막을 수 없다면 한 번에 한 스레드만 접근할 수 있도록 동기화 메커니즘을 제공해야 한다.
부울값이나 정숫값을 비롯한 스칼라 값은 앞서 소개한 아토믹 연산만으로도 충분히 동기화할 수 있다. 하지만 복잡하게 구성된 데이터를 여러 스레드가 동시에 접근할 때는 동기화 메커니즘을 사용해야 한다.
표준 라이브러리는 mutex와 lock 클래스를 통해 상호 배제 메커니즘을 제공한다. 이를 활용하면 여러 스레드를 동기화하도록 구현할 수 있다.

mutex

mutex(뮤텍스)는 상호 배제를 뜻하는 mutual exclusion의 줄임말이다. mutex의 기본 사용법은 다음과 같다.
다른 스레드와 공유하는 (읽기/쓰기용) 메모리를 사용하려면 먼저 mutex 객체에 락을 걸어야(잠근 요청을 해야) 한다. 다른 스레드가 먼저 락을 걸어뒀다면 그 락이 해제되거나 타임아웃으로 지정된 시간이 경과해야 쓸 수 있다.
스레드가 락을 걸었다면 공유 메모리를 마음껏 쓸 수 있다. 물론 공유 데이터를 사용하려는 스레드마다 뮤텍스에 대한 락을 걸고 해제하는 동작을 정확히 구현해야 한다.
공유 메모리에 대한 읽기/쓰기 작업이 끝나면 다른 스레드가 공유 메모리에 대한 락을 걸 수 있도록 락을 해제한다. 두 개 이상의 스레드가 락을 기다리고 있다면 어느 스레드가 먼저 락을 걸어 작업을 진행할지 알 수 없다.
C++ 표준은 시간 제약이 없는 뮤텍스(non-timed mutex)와 시간 제약이 있는 뮤텍스(timed mutex) 클래스를 제공한다.

시간 제약이 없는 뮤텍스 클래스

표준 라이브러리는 std::mutex, recursive_mutex, shared_mutex라는 세 종류의 시간 제약이 없는 뮤텍스 클래스(non-timed mutex class)를 제공한다. 그중 첫 번째와 두 번째 클래스는 <mutex> 헤더 파일에 정의돼 있고, 세 번째 클래스는 C++ 17부터 추가된 것으로 <shared_mutex> 헤더 팡리에 정의돼 있다. 각 뮤텍스 마다 다음과 같은 메서드를 제공한다.
lock()
호출하는 스레드가 락을 완전히 걸 때까지 대기한다(블록된다). 이때 대기 시간에는 제한이 없다. 스레드가 블록되는 시간을 정하려면 다음 절에서 설명하는 시간 제약이 있는 뮤텍스를 사용한다.
try_lock()
호출하는 측의 스레드가 락을 걸도록 시도한다. 현재 다른 스레드가 락을 걸었다면 호출이 즉시 리턴된다. 락을 걸었다면 try_lock()은 true를 리턴하고, 그렇지 않으면 false를 리턴한다.
unlock()
호출하는 측의 스레드가 현재 걸어둔 락을 해제한다. 그러면 다른 스레드가 락을 걸 수 있게 된다.
std::mutex는 소유권을 독점하는 기능을 제공하는 표준 뮤텍스다. 이 뮤텍스는 한 스레드만 가질 수 있다. 다른 스레드가 이 뮤텍스를 소유하려면 lock()을 호출하고 대기한다. try_lock()을 호출하면 락 걸기에 실패해 곧바로 리턴된다. 뮤텍스를 이미 확보한 스레드가 같은 뮤텍스에 대해 lock()이나 try_lock()을 또 호출하면 데드락이 발생하므로 조심해야 한다.
std::recursive_mutex는 mutex와 거의 같지만 이미 recursive_mutex를 확보한 스레드가 동일한 recursive_mutex에 대해 lock()이나 try_lock()을 또 다시 호출할 수 있다. recursive_mutex에 대한 락을 해제하려면 lock()이나 try_lock()을 호출한 횟수만큼 unlock() 메서드를 호출해야 한다.
shared_mutex는 공유 락 소유권(shared lock ownership) 또는 읽기-쓰기 락(reader-writer lock)이란 개념을 구현한 것이다. 스레드는 락에 대한 독점 소유권(exclusive ownership)이나 공유 소유권(shared ownership)을 얻는다. 독점 소유권 또는 쓰기 락(write lock)은 다른 스레드가 독점 소유권이나 공유 소유권을 가지고 있지 않을 때만 얻을 수 있다. 공유 소유권 또는 읽기 락(read lock)은 다른 스레드가 독점 소유권을 가지고 있지 않거나 공유 소유권만 가지고 있을 때 얻을 수 있다.
shared_mutex 클래스는 lock(), try_lock(), unlock() 메서드를 제공한다. 이 메서드는 독점 락을 얻거나 해제한다. 또한 lock_shared(), try_lock_shared(), unlock_shared()와 같은 공유 소유권 관련 메서드도 제공한다. 공유 소유권 버전의 메서드도 기존 메서드와 비슷하지만 획득하고 해제하는 대상이 공유 소유권이라는 점이 다르다.
shated_mutex에 이미 락을 건 스레드는 같은 뮤텍스에 대해 한 번 더 락을 걸 수 없다. 그러면 데드락이 발생하기 때문이다.

시간 제약이 있는 뮤텍스 클래스

표준 라이브러리는 std::time_mutex, recursive_timed_mutex, shared_timed_mutex라는 세 종류의 시간 제약이 있는 뮤텍스 클래스를 제공한다. 그중 첫 번째와 두 번째 클래스는 <mutex>에 세 번째 클래스는 <shared_mutex>에 정의돼 있다. 세 가지 클래스 모두 lock(), try_lock(), unlock() 메서드를 제공하고, shared_timed_mutex는 lock_shared(), try_lock_shared(), unlock_shared()도 제공한다. 이러한 메서드의 동작은 모두 앞 절에서 설명한 방식과 같다. 여기에 추가로 다음 메서드도 제공한다.
try_lock_for(rel_time)
호출하는 측의 스레드는 주어진 상대 시간 동안 락을 획득하려 시도한다. 주어진 타임아웃 시간 안에 락을 걸 수 없으면 호출은 실패하고 false를 리턴한다. 주어진 타임아웃 시간 안에 락을 걸었다면 호출은 성공하고 true를 리턴한다.
try_lock_untin(abs_time)
호출하는 측의 스레드는 인수로 지정한 절대 시간이 시스템 시간과 길거나 초과하기 전까지 락을 걸도록 시도한다. 그 시간 내에 락을 걸수 있다면 true를 리턴한다. 지정된 시간이 경과하면 이 함수는 더는 락을 걸려는 시도를 멈추고 false를 리턴한다.
shared_time_mutex는 try_lock_shared_for()와 try_lock_shared_until()도 제공한다.
time_mutex나 shared_time_mutex의 소유권을 이미 확보한 스레드는 같은 뮤텍스에 대해 락을 중복해서 걸지 못한다. 그러면 데드락이 발생하기 때문이다.
recursive_timed_mutex를 이용하면 스레드가 락을 중복해서 걸 수 있다. 사용법은 recursive_mutex와 같다.
Caution) 앞서 소개한 뮤텍스 클래스에 대한 락/언락 메서드를 직접 호출하면 안 된다. 뮤텍스 락은 일종의 리소스라서 거의 대부분 RAII 원칙에 따라 독점적으로 획득한다. RAII는 28장에서 자세히 소개한다. C++ 표준은 RAII 락 클래스를 다양하게 제공한다. 이에 대해서는 다음 절에서 소개한다. 데드락을 방지하려면 반드시 락 클래스를 사용하는 것이 좋다. 락 객체가 스코프를 벗어나면 자동으로 뮤텍스를 언락해주기 때문에 unlock() 메서드를 일일이 정확한 시점에 호출하지 않아도 된다.

lock

lock 클래스는 RAII 원칙이 적용되는 클래스로서 뮤텍스에 락을 정확히 걸거나 해제하는 작업을 쉽게 처리하게 해준다. lock 클래스의 소멸자는 확보했던 뮤텍스를 자동으로 해제시킨다. C++ 표준에서는 std::lock_guard, unique_lock, shared_lock, scoped_lock이라는 네 가지 타입의 락을 제공한다. 그중 scoped_lock은 C++ 17부터 추가 됐다.

lock_guard

std::lock_guard는 다음 두 가지 생성자를 제공한다.
explicit lock_guard(mutex_type& m);
뮤텍스에 대한 레퍼런스를 인수로 받는 생성자다. 이 생성자는 전달된 뮤텍스에 락을 걸려 시도하고 완전히 락이 걸릴 때까지 블록된다.
lock_guard(mutex_type& m, adopt_lock_t);
뮤텍스에 대한 레퍼런스와 std::adopt_lock_t의 인스턴스를 인수로 받는 생성자다. std::adopt_lock이라는 이름으로 미리 정의된 adopt_lock_t 인스턴스가 제공된다. 이때 호출하는 측의 스레드는 인수로 지정한 뮤텍스에 대한 락을 이미 건 상태에서 추가로 락을 건다. 락이 제거되면 뮤텍스도 자동으로 해제된다.

unique_lock

std::unique_lock은 <mutex> 헤더에 정의된 락으로서 락을 선언하고 한참 뒤 실행될 때 락을 걸도록 지연시키는 고급 기능을 제공한다. 락이 제대로 걸렸는지 확인하려면 owns_lock() 메서드나 unique_lock에서 제공하는 bool 타입 변환 연산자를 사용한다. 이러한 변환 연산자를 사용하는 방법은 ‘시간 제약이 있는 락 사용하기’에서 자세히 소개한다.
unique_lock은 다음과 같은 버전의 생성자를 제공한다.
explicit unique_lock(mytex_type& m);
이 생성자는 뮤텍스에 대한 레퍼런스를 인수로 받아서 그 뮤텍스에 락을 걸려 시도하고 완전히 락이 걸릴 때까지 블록시킨다.
unique_lock(mutex_type& m, defer_lock_t) noexcept;
이 생성자는 뮤텍스에 대한 레퍼런스와 std::defer_lock_t의 인스턴스를 인수로 받는다. std::defer_lock 이라는 이름으로 미리 정의된 defer_lock_t 인스턴스도 제공한다. unique_lock은 인수로 전달된 뮤텍스에 대한 레퍼런스를 저장하지만 곧바로 락을 걸지 않고 나중에 다시 걸도록 시도한다.
unique_lock(mutex_type& m, try_to_lock_t);
이 생성자는 뮤텍스에 대한 레퍼런스와 std::try_to_lock_t의 인스턴스를 인수로 받는다. std::try_to_lock 이라는 이름으로 미리 정의된 try_to_lock_t 인스턴스도 제공한다. 이 버전의 락은 레퍼런스가 가리키는 뮤텍스에 대해 락을 걸려 시도한다. 실패할 경우 블록하지 않고 나중에 다시 시도한다.
unique_lock(mutex_type& m, adopt_lock_t);
이 생성자는 뮤텍스에 대한 레퍼런스와 std::adopt_lock_t의 인스턴스를 인수로 받는다. 이 락은 호출하는 츠그이 스레드가 레퍼런스로 지정된 뮤텍스에 대해 이미 락을 건 상태라고 가정하고, 그 락에 여기서 지정된 뮤텍스를 추가한다. 락이 제거되면 뮤텍스도 자동으로 해제된다.
unique_lock(mutex_type& m, const chrono::time_point<Clock, Duration>& abs_time);
이 생성자는 뮤텍스에 대한 레퍼런스와 절대 시간에 대한 값을 인수로 받는다 그래서 지정된 절대 시간 안에 락을 걸려 시도한다.
unique_lock(mutex_type& m, const chrono::duration<Rep, Period>^ rel_time);
이 생성자는 뮤텍스에 대한 레퍼런스와 상대 시간을 인수로 받아서 주어진 시간 안에 인수로 지정한 뮤텍스에 락을 걸려 시도한다.
unique_lock 클래스는 lock(), try_lock(), try_lock_for(), try_lock_until(), unlock() 메서드를 제공한다.

shared_lock

shared_lock 클래스는 <shared_mutex> 헤더 파일에 정의돼 있으며, unique_lock과 똑같은 타입의 생성자와 메서드를 제공하고, 내부 공유 뮤텍스에 대해 공유 소유권에 관련된 메서드를 호출한다는 점이 다르다. 따라서 shared_lock 메서드는 lock(), try_lock()을 호출할 때 내부적으로 lock_shared(), try_lock_shared() 등과 같은 공유 뮤텍스에 대한 메서드를 호출한다.
이렇게 하는 이유는 shared_lock과 unique_lock의 인터페이스를 통일시키기 위해서다. 따라서 unique_lock을 사용하던 자리에 그대로 넣을 수 있다. 그러면 독점 락 대신 공유 락을 건다.

한 번에 여러 개의 락을 동시에 걸기

C++은 두 가지 제네릭 락 함수를 제공한다. 이 함수는 데드락이 발생할 걱정 없이 여러 개의 뮤텍스 객체를 한 번에 거는데 사용된다. 두 함수 모두 std 네임스페이스에 정의돼 있으며 22장에서 소개한 가변 인수 템플릿 함수로 정의했다.
첫 번째 함수인 lock()은 인수로 지정된 뮤텍스 객체를 데드락 발생 걱정 없이 한꺼번에 락을 건다. 이때 락을 거는 순서는 알 수 없다. 그중에서 어느 하나의 뮤텍스 락에 대해 익셉션이 발생하면 이미 확보한 락에 대해 unlock()을 호출한다. 이 함수의 프로토타입은 다음과 같다.
template<class L1, class L2, class... L3> void lock(L1&, L2&, L3&...);
C++
복사
try_lock()의 프로토타입도 이와 비슷하지만 주어진 모든 뮤텍스 객체에 대해 락을 걸 때 try_lock()을 순차적으로 호출한다. 모든 뮤텍스에 대해 try_lock()이 성공하면 -1을 리턴하고 어느 하나라도 실패하면 이미 확보된 락에 대해 unlock()을 호출한다. 그러면 뮤텍스 매개변수의 위치를 가리키는 0을 기준으로 매긴 인덱스 값을 리턴한다.
다음 예제는 이러한 제네릭 lock() 함수를 사용하는 방법을 보여준다. process() 함수는 먼저 두 뮤텍스에 대한 락을 하나씩 생성하고, std::defer_lock_t 인스턴스를 unique_lock의 두 번째 인수로 지정해서 그 시간 안에 락을 걸지 않게 한다. 그런 다음 std::lock()을 호출해서 데드락이 발생할 걱정 없이 두 락을 모두 건다.
mutex mut1; mutex mut2; void process() { unique_lock lock1(mut1, defer_lock); // C++ 17 unique_lock lock2(mut2, defer_lock); // C++ 17 //unique_lock<mutex> lock1(mut1, defer_lock); //unique_lock<mutex> lock2(mut2, defer_lock); lock(lock1, lock2); // 락을 걸었다. } // 락을 자동으로 해제한다.
C++
복사

scoped_lock

std::scoped_lock은 <mutex> 헤더파일에 정의돼 있으며, lock_guard와 비슷하지만 뮤텍스를 지정하는 인수 개수에 제한이 없다. scoped_lock을 사용하면 여러 락을 한 번에 거는 코드를 훨씬 간결하게 작성할 수 있다. 예컨대 scoped_lock을 사용하면 앞 절에서 본 process() 함수를 다음과 같이 구현할 수 있다.
mutex mut1; mutex mut2; void process() { scoped_lock locks(mut1, mut2); // 락을 걸었다. } // 락을 자동으로 해제한다.
C++
복사
여기서는 C++ 17에 추가된 생성자에 대한 템플릿 인수 추론 기능을 적용했는데, 현재 사용하는 컴파일러가 이 기능을 지원하지 않는다면 다음과 같이 작성해야 한다.
scoped_lock<mutex, mutex> locks(mut1, mut2);
C++
복사

std::call_once

std::call_once()와 std::one_flag를 함꼐 사용하면 같은 once_flag에 대해 여러 스레드가 call_once()를 호출하더라도 call_once의 인수로 지정한 함수나 메서드가 단 한 번만 호출되게 할 수 있다. 인수로 지정한 함수나 메서드에 대해 call_once가 단 한 번만 호출된다. 지정한 함수가 익셉션을 던지지 않을 때 이렇게 호출하는 것을 이펙티브(effective) call_once() 호출이라 부른다.
지정한 함수가 익셉션을 던지면 그 익셉션은 호출한 측으로 전달되며, 다른 호출자를 골라서 함수를 실행시킨다. 특정한 once_flag 인스턴스에 대한 이펙티브 호출은 동일한 once_flag에 대한 다른 call_once() 호출보다 먼저 끝난다.
아래 그림은 스레드 세 개로 이를 실행한 예를 보여준다. 스레드 1은 이펙티브 call_once() 호출을 수행하고, 스레드 2는 이러한 이펙티브 호출이 끝날 때까지 블록되고, 스레드 3은 스레드 1의 이펙티브 호출이 이미 끝났기 때문에 블록되지 않는다.
다음 예제는 call_once()를 사용하는 방법을 보여준다. 이 예제에서는 공유 자원을 사용하는 processFunction()을 실행하는 스레드 세 개를 구동한다. 여기서 공유하는 자원은 반드시 initializeSharedResources()로 단 한 번만 호출해서 초기화해야 한다. 이렇게 하려면 각 스레드마다 once_flag라는 전역 플래그에 대해 call_once()를 호출한다. 코드를 실행하면 단 한 스레드만 initializeSharedResources()를 정확히 한 번 실행한다. call_once() 호출이 진행되는 동안 다른 스레드는 initializeSharedResources()가 리턴할 때까지 블록된다.
once_flag gOnceFlag; void initializeSharedResources() { // 여러 스레드가 사용할 공유 리소스를 초기화한다. cout << "Shared resources initailized" << endl; } void processingFunction() { // 공유 리소스를 반드시 초기화한다. call_once(gOnceFlag, initializeSharedResources); // 원하는 작업을 수행한다. 이때 공유 리소스를 사용한다. cout << "Processing" << endl; } int main() { // 스레드 세 개를 구동시킨다. vector<thread> threads(3); for (auto& t : threads) { t = thread { processingFunction }; } // 각 스레드에 대해 join()을 호출한다. for (auto& t : threads) { t.join(); } }
C++
복사
이 코드를 실행한 결과는 다음과 같다.
Shared resources initialized Processing Processing Processing
C++
복사

뮤텍스 객체 사용 방법

스레드에 안전한 스트림 출력 기능 구현하기

23.2절 ‘스레드’에서 Counter 클래스 예제를 살펴보면서 C++ 스트림에 대해서는 기본적으로 데이터 경쟁이 발생하지 않지만 여러 스레드로 출력한 결과가 뒤섞일 수 있다고 설명했다. 이렇게 결과가 뒤섞이지 않게 하려면 뮤텍스 객체를 이용하여 스트림 객체에 읽거나 쓰는 작업을 한 번에 한 스레드만 수행하도록 만들면 된다.
다음 코드는 모든 스레드가 Counter 클래스의 cout에 대한 접근을 동기화시키는 예를 보여주고 있다. 이를 위해 static mutex 객체를 추가했다. 여기서 반드시 static을 지정해야 이 클래스의 모든 인스턴스가 동일한 mutex 인스턴스를 사용할 수 있다. 그러고 나서 cout에 쓰기 전에 lock_guard로 이 mutex 객체에 락을 건다.
class Counter { public: Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { } void operator()() const { for (int i = 0; i < mNumIterations; ++i) { lock_guard lock(sMutex); cout << "Counter " << mId << " has value " << i << endl; } } private: int mId; int mNumIterations; static mutex sMutex; }; mutex Counter::sMutex;
C++
복사
여기서는 for 문을 한 번 돌 때마다 lock_guard 인스턴스를 생성한다. 이때 락이 걸린 시간을 최소화하도록 지정해야 한다. 그렇지 않으면 다른 스레드를 너무 오래 블록시키게 된다. 예컨대 lock_guard 인스턴스를 for 문 앞에서 한 번만 생성하면 여기서 구현한 멀티스레드 효과가 사라지게 된다. for 문 전체에 대해 하나의 스레드가 락을 걸기 때문에 다른 스레드는 이 락이 해제되기 전까지 기다려야 하기 때문이다.

시간 제약이 있는 락 사용하기

다음 예제는 시간 제약이 있는 뮤텍스를 사용하는 방법을 보여준다. 앞서 본 Counter 클래스와 대체로 비슷하지만 이번에는 unique_lock과 timed_mutex를 조합해서 사용한다.
unique_lock 생성자에 200ms란 상대 시간을 인수로 지정해서 그 시간 동안 락 걸기를 시도한다. 이 시간 안에 락을 걸지 못해도 unique_lock 생성자는 그냥 리턴한다. 실제로 락이 걸렸는지는 나중에 lock 변수에 대한 if 문으로 확인한다. unique_lock은 bool 타입 변환 연산자를 제공하기 때문에 이렇게 할 수 있다.
class Counter { public: Counter(int id, int numIterations) : mId(id), mNumIterations(numIterations) { } void operator()() const { for (int i = 0; i < mNumIterations; ++i) { unique_lock lock(sTimedMutex, 200ms); if (lock) { cout << "Counter " << mId << " has value " << i << endl; } else { // 200 ms 안에 락을 걸지 못하면 아무것도 출력하지 않는다. } } } private: int mId; int mNumIterations; static timed_mutex sTimedMutex; }; timed_mutex Counter::sTimedMutex;
C++
복사

이중 검사 락

이중 검사 락 패턴(double-checked lock pattern)은 사실 안티패턴(anti-pattern)이라서 사용하지 않는 것이 좋다. 하지만 이렇게 작성된 코드가 많기 때문에 소개한다.
이중 검사 락 패턴은 기본적으로 상호 배제 객체를 최대한 사용하지 않는다. 이는 상호 배제 객체를 사용할 때보다 코드를 효율적으로 작성하려는 어설픈 시도로 나온 것이다. 예컨대 뒤에서 소개할 예제보다 더 빠르게 실행되도록 atomic<bool> 대신 부울 타입을 곧바로 쓰는 것처럼, 아토믹 연산의 제약을 줄일 때 문제가 발생하기 쉽다. 이 패턴에 따라 구현하면 데이터 경쟁이 발생하기 쉽고 이를 해결하기 어렵다. 아리어니하게도 call_once()를 사용하면 실제로 실행 속도가 빨라진다. 게다가 매직 스태틱(magic static)을 사용하면 더 빠르게 만들 수 있다.
Caution) 이중 검사 락 패턴은 사용하지 마라! 대신 기본 락, 아토믹 변수, call_once(), 매직 스태틱 등을 사용하라.
이중 검사 락 패턴을 사용하면 리소스가 단 한 번만 초기화되도록 보장할 수 있다. 다음 예제는 이렇게 구현하는 방법을 보여준다. 이 코드에서 보는 것처럼 gInitialized 변수를 락을 걸기 전과 뒤에 두 번 검사하기 때문에 이중 검사 락 패턴이라 부른다. 첫 번째 검사는 필요 없을 때 락을 걸지 않도록 막아주고, 두 번째 검사는 이 변수를 한 번 검사한 뒤 락을 걸기 전까지 다른 스레드가 초기화를 수행하지 않도록 막아준다.
void initializeSharedResources() { // 여러 스레드가 사용할 공유 리소스를 초기화한다. cout << "Shared resources initialized" << endl; } atomic<bool> gInitialized(false); mutex gMutex; void processingFunction() { if (!gInitialized) { unique_lock(gMutex); if (!gInitialized) { initializedSharedResources(); gInitialized = true; } } cout << "OK" << endl; } int main() { vector<thread> threads; for (int i = 0; i < 5; ++i) { threads.push_back(thread{processingFunction}); } for (autu& t : threads) { t.join(); } }
C++
복사
이 코드를 실행해 보면 다음과 같이 한 스레드만 공유 리소스를 초기화한다는 것을 알 수 있다.
Shared resources initialized OK OK OK OK OK
C++
복사
Note) 이 예제에서는 이중 검사 락 대신 앞서 설명한 call_once()로 구현하는 것이 바람직 하다.

조건 변수

조건 변수(condition variable)를 이용하면 다른 스레드가 조건을 설정하기 전이나 따로 지정한 시간이 경과하기 전까지 스레드의 실행을 멈추고 기다리게 할 수 있다. 그래서 스레드 통신을 구현할 수 있다. Win32 API로 멀티스레드 프로그래밍을 해본 경험이 있다면 윈도우의 이벤트 객체(event object)와 비슷하다고 보면 된다.
C++는 두 가지 조건 변수를 제공한다. 둘 다 <condition_variable> 헤더 파일에 정의돼 있다.
std::condition_variable: unique_lock<mutex>만 기다리는 조건 변수로서 C++ 표준에 따르면 특정한 플랫폼에서 효율을 최대로 이끌어낼 수 있다.
std::condition_variable_any: 커스텀 락 타입을 비롯한 모든 종류의 객체를 기다릴 수 있는 조건 변수다.
condition_variable은 다음과 같은 메서드를 제공한다.
notify_one()
조건 변수를 기다리는 스레드 중 하나를 깨운다. 윈도우의 자동 리셋 이벤트(auto_reset event)와 비슷하다.
notify_all()
조건 변수를 기다리는 스레드를 모두 깨운다.
wait(unique_lock<mutex>& lk)
wait()를 호출하는 스레드는 반드시 lk에 대한 락을 걸고 있어야 한다. wait()를 호출하면 lk.unlock()을 아토믹하게 호출해서 그 스레드를 블록시키고, 알림(notification)이 오길 기다린다. 다른 스레드에서 호출한 nofity_one()이나 notify_all()로 인해 블록된 스레드가 해제되면 lk.lock()을 다시 호출해서 완전히 걸 때까지 블록시킨 뒤 리턴한다.
wait_for(unique_lock<mutex>& lk, const chrono::duration<Rep, Period>& rel_time)
wait()와 비슷하지만 notify_one()이나 notify_all()이 호출되거나 지정된 시간이 만료하면 현재 스레드의 블록 상태를 해제한다.
wait_until(unique_lock<mutex>& lk, const chrono::time_point<Clock, Duration>& abs_time)
wait()와 비슷하지만 notify_one()이나 notify_all()이 호출되거나 시스템 시간이 절대 시간으로 지정한 시간을 경과하면 블록된 스레드가 해제된다.
프레디케이트 매개변수를 추가로 받는 버전의 wait(), wait_for(), wait_until()도 있다. 에컨대 wait() 버전 중에는 다음과 같이 프레디케이트를 추가로 받는 것도 있다.
while (!predicate()) { wait(lk); }
C++
복사
condition_variable_any 클래스에서 제공하는 메서드는 condition_variable과 비슷하지만 unique_lock<mutex> 뿐만 아니라 다른 모든 종류의 락 클래스도 인수로 받는다는 점이 다르다. 이렇게 받은 락 클래스는 반드시 lock()과 unlock() 메서드를 제공해야 한다.

비정상적으로 깨어나기

조건 변수를 기다리는 스레드는 다른 스레드가 notify_one()이나 notify_all()을 호출할 때까지 기다린다. 기다리는 시간은 상대 시간이나 절대 시간(특정한 시스템 시각)으로 지정한다.
그런데 이렇게 미리 지정된 시점에 다다르지 않았는데 비정상적으로 깨언라 수도 있다. 다시 말해 notify_one()이나 notify_all()을 호출한 스레드도 없고, 타임아웃도 발생하지 않았는데 스레드가 깨어나는 것이다. 그러므로 조건 변수를 기다리도록 설정했던 스레드가 그 이전에 깨어나면 그 이유를 검사해야 한다. 한 가지 방법은 프레디케이트를 인수로 받는 wait()를 사용하는 것이다. 다음 절에서 구체적인 예를 소개한다.

조건 변수 사용하기

예컨대 큐ㅔ에 담긴 원소를 백그라운드로 처리할 때 조건 변수를 활용한다고 하자. 먼저 처리할 원소를 추가할 큐를 정의한다. 백그라운드 스레드는 큐에 원소가 들어올 때까지 기다렸다가 원소가 추가되면 스레드를 깨워서 그 원소를 처리하고 다음 원소가 들어올 때까지 다시 잠든 상태로 기다린다. 큐는 다음과 같이 선언한다.
queue<string> mQueue;
C++
복사
주어진 시점에 한 스레드만 이 큐를 수정해야 한다. 이는 뮤텍스로 구현할 수 있다.
mutex mMutex;
C++
복사
원소가 추가된 사실을 백그라운드 스레드에 알려주어 다음과 같이 조건 변수를 선언한다.
condition_variable mCondVar;
C++
복사
큐에 원소를 추가하는 스레드는 먼저 앞에서 선언한 뮤텍스에 락부터 걸어야 한다. 그러고 나서 큐에 원소를 추가하고 백그라운드 스레드에 알려준다. 이때 실제로 락을 걸었는지에 관계 없이 notify_one()이나 notify_all()을 호출한다. 둘 다 정상적으로 처리된다.
// 뮤텍스에 락을 걸고 큐에 원소를 추가한다. unique_lock lock(mMutex); mQueue.push(entry); // 스레드를 깨우도록 알림을 보낸다. mCondVar.notify_all();
C++
복사
여기서 백그라운드 스레드는 무한 루프를 돌면서 알림이 오기를 기다린다. 구현 코드는 다음과 같다. 이때 프레디케이트를 인수로 받는 wait()를 이용하여 비정상적으로 깨어나지 않게 만든다. 이 프레디케이트로 큐에 실제로 원소가 추가됐는지 확인한다. wait()를 호출한 결과가 리턴되면 실제로 큐에 뭔가 추가됐다고 보장할 수 있다.
unique_lock lock(mMutex); while (true) { // 알림을 기다린다. mCondVar.wait(lock, [this]{ return !mQueue.empty(); }); // 조건 변수를 통한 알림이 도착했다. 따라서 큐에 뭔가 추가됐다는 것을 알 수 있다. // 추가된 항목을 처리한다. }
C++
복사
23.7절 ‘멀티스레드 Logger 클래스 예제’에서는 조건 변수로 다른 스레드에 알림을 보내는 구체적인 구현 방법을 소개한다.
C++ 표준은 std::notify_all_at_thread_exit(cond, lk)라는 헬퍼 함수도 제공한다. 여기서 cond는 조건 변수고, lk는 unique_lock<mutex> 인스턴스다. 이 함수를 호출하는 스레드는 lk라는 락을 이미 확보한 상태여야 한다. 이 스레드가 종료하면 다음 코드가 자동으로 실행된다.
lk.unlock(); cond.notify_all();
C++
복사
Note) lk 락은 스레드가 종료될 때까지 잠긴 상태를 유지한다. 따라서 데드락이 발생하지 않도록 각별히 주의한다. 예컨대 락 걸기 순서가 잘못되면 데르락이 발생할 수 있다.

promise와 future

앞서 설명했듯이 어떤 값을 계산하는 스레드를 std::thread로 만들어서 실행하면 그 스레드가 종료된 후에는 최종 결과를 받기 힘들다. 예외나 여러 가지 에러를 처리하는데도 문제가 발생한다. 스레드가 던진 익셉션을 그 스레드가 받지 않으면 C++ 런타임은 std::terminate()를 호출해서 애플리케이션 전체를 종료시킨다.
이때 future를 사용하면 스레드의 실행 결과를 쉽게 받아올 수 있을 뿐만 아니라 익셉션을 다른 스레드로 전달해서 원하는 방식으로 처리할 수 있다. 물론 익셉션이 발생한 스레드에서 벗어나지 않도록 항상 같은 스레드 안에서 익셉션을 처리하는 것이 바람직하다.
스레드의 실행 결과를 promise에 담으면 future로 그 값을 가져올 수 있다. 채널에 비유하면 promise는 입력 포트고 future는 출력 포트인 셈이다. 같은 스레드나 다른 스레드에서 실행하는 함수가 계산해서 리턴하는 값을 promise에 담으면 나중에 그 값을 future에서 가져갈 수 있다. 이 메커니즘을 결과에 대한 스레드 통신 채널로 볼 수 있다.
C++ std::future라는 표준 future를 제공한다. std::future에 있는 결과를 가져오는 방법은 다음과 같다. 여기서 T는 계산된 결과에 대한 타입이다.
future<T> myFuture = ...; // 이에 대한 설명은 뒤에 한다. T result = myFuture.get();
C++
복사
여기서 get(0을 호출해서 가져온 결과를 result 변수에 저장한다. 이때 get(0을 호출한 부분은 계산이 끝날 때까지 멈추고 기다린다(블록된다). future 하나에 대해 get()을 한 번만 호출할 수 있다. 두 번 호출하는 경우는 표준에 따로 정해져 있지 않다.
코드가 블록되지 않게 하려면 다음과 같이 future를 검사해서 결과가 준비됐는지 확인부터 한다.
// 계산이 끝난 경우 if (myFuture.wait_for(0)) { T result = myFuture.get(); } // 계산이 안 끝난 경우 else { ... }
C++
복사

std::promise와 std::future

C++은 promise를 구현하는 std::promise 클래스를 제공한다. promise에 대해 set_value()를 호출해서 결과를 저장하거나 set_exception()을 호출해서 익셉션을 promise에 저장할 수 있다. 참고로 특정 promise에 대해 set_value()나 set_exception()을 단 한 번만 호출할 수 있다. 여러 번 호출하면 std::future_error 익셉션이 발생한다.
A 스레드와 B 스레드가 있을 때 A 스레드가 어떤 계산을 B 스레드로 처리하기 위해 std::promise를 생성해서 B 스레드를 구동할 때 이 promise를 인수로 전달한다. 이때 promise는 복제될 수 없고 이동만 가능하다. B 스레드는 이 promise에 값을 저장한다.
A 스레드는 promise를 B 스레드로 이동시키기 전에 생성된 promise에 get_future()를 호출한다. 그러면 B가 실행을 마친 후 나온 결과에 접근할 수 있다. 이를 코드로 구현하면 다음과 같다.
void DoWork(promise<int> thePromise) { // 원하는 작업을 수행한다. // 최종 결과를 promise에 저장한다. thePromise.set_value(42); } int main() { // 스레드에 전달할 promise를 생성한다. promise<int> myPromise; // 이 promise에 대한 future를 가져온다. auto theFuture = myPromise.get_future(); // 스레드를 생성하고 앞서 만든 promise를 인수로 전달한다. thread theThread{ DoWork, std::move(myPromise) }; // 원하는 작업을 수행한다. // 최종 결과를 가져온다. int result = theFuture.get(); cout << "Result: " << result << endl; // 스레드를 join한다. theThread.join(); }
C++
복사
Note) 이 코드는 단지 promise와 future의 사용법을 보여주기 위한 것이다. 먼저 스레드를 생성해서 계산을 수행한 뒤 future에 대해 get()을 호출한다. 그러면 최종 결과가 나올 때까지 블록된다. 하지만 이렇게 작성하면 성능이 크게 떨어진다. 실전에서는 future에 최종 결과가 나왔는지 주기적으로 검사하도록 구현하거나(앞서 소개한 wait_for()로) 조건 변수와 같은 동기화 기법을 사용하도록 구현한다. 그러면 결과가 나오기 전에 무조건 멈춘 뒤 기다리지 않고 다른 작업을 수행할 수 있다.

std::packaged_task

std::packaged_task를 이용하면 앞서 소개한 std::promise를 명싲거으로 사용하지 않고도 promise를 구현할 수 있다. 다음 코드는 이를 위한 구체적인 방법을 보여준다.
여기서는 먼저 packaged_task를 생성해서 CalculateSum()을 실행한다. 이 packaged_task에 대해 get_future()를 호출해서 future를 가져온다. 스레드를 구동해서 이 packaged_task를 그곳으로 이동 시킨다. 이때 packaged_task는 복제되지 않는다는 점에 주의한다. 스레드가 구동되고 나면 받아온 future에 대해 get()을 호출해서 결과를 가져온다. 이때 결과가 나오기 전까지 블록된다.
여기서 CalculateSum()은 promise에 저장하는 작업을 하지 않아도 된다. packaged_task가 promise를 자동으로 생성하고, 호출한 함수(여기서는 CalculateSum())의 결과를 promise에 알아서 저장해준다. 이때 발생한 익셉션도 promise에 함께 저장된다.
int CalculateSum(int a, int b) { return a + b; } int main() { // packaged_task를 생성해서 CalculateSum을 실행한다. packaged_task<int(int, int)> task(CalculateSum); // 새로 생성한 packaged_task로부터 CalculateSum의 결과를 담을 future를 받는다. auto theFuture = task.get_future(); // 스레드를 생성한 뒤 앞에서 만든 packaged_task를 이동시키고 인수를 적절히 전달해서 작업을 수행한다. thread theThread{ std::move(task), 39, 3 }; // 다른 작업을 수행한다. // 결과를 가져온다. int result = theFuture.get(); cout << result << endl; // 스레드를 조인한다. theThread.join(); }
C++
복사

std::async

스레드로 계산하는 작업을 C++ 런타임으로 좀 더 제어하고 싶다면 std::async()를 사용한다. std::async()는 실행할 함수를 인수로 받아서 그 결과를 담은 future를 리턴한다. 지정한 함수를 async()로 구동하는 방법은 두 가지다.
함수를 스레드로 만들어 비동기식으로 구동한다.
스레드를 따로 만들지 않고, 리턴된 future에 대해 get()을 호출할 때 동기식으로 함수를 실행한다.
async()에 인수를 주지 않고 호출하면 런타임이 앞에 나온 두 가지 방법 중 하나를 적절히 고른다. 이때 시스템에 장착된 CPU의 코어 수나 동시에 수행되는 작업의 양에 따라 방법이 결정된다. 다음과 같이 정책을 나타내는 인수를 지정하면 이러한 선택 과정에 가이드라인을 제시할 수 있다.
launch::async: 주어진 함수를 다른 스레드에서 실행시킨다.
launch::deferred: get()을 호출할 때 주어진 함수를 현재 스레드와 동기식으로 실행시킨다.
launch::async | launch::deferred: C++ 런타임이 결정한다. (디폴트 동작)
async()를 사용하는 예는 다음과 같다.
int calculate() { return 123; } int main() { auto myFuture = async(calculate); // auto myFuture = async(launch::async, calculate); // auto myFuture = async(launch::deferred, calculate); // 다른 작업을 수행한다. // 결과를 가져온다. int result = myFuture.get(); cout << result << endl; }
C++
복사
이 예제에서 볼 수 있듯이 std::async()는 원하는 계산을 비동기식으로 처리하거나(다른 스레드에서) 동기식으로 처리해서(현재 스레드에서) 나중에 결과를 가져오도록 구현하는 가장 쉬운 방법이다.
Caution) async()를 호출해서 리턴된 future는 실제 결과가 담길 때까지 소멸자에서 블록된다. 다시 말해 async()를 호출한 뒤 리턴된 future를 가져가지(캡쳐하지) 않으면 async()가 블록되는 효과가 발생한다. 예컨대 다음 코드는 calculate()를 동기식으로 호출한다. async(calculate);  이 문장에서 async()는 future를 생성해서 리턴한다. 이렇게 리턴된 future를 캡쳐하지 않으면 임시 future 객체가 생성된다. 그래서 이 문장이 끝나기 전에 소멸자가 호출되면서 결과가 나올 때까지 블록된다.

익셉션 처리

future의 가장 큰 장점은 스레드끼리 익셉션을 주고 받는데 활용할 수 있다는 것이다. future에 대해 get()을 호출해서 계산된 결과를 리턴하거나, 이 future에 연결된 promise에 저장된 익셉션을 다시 던질 수 있다. packaged_task나 async()를 사용하면 구동된 함수에서 던진 익셉션이 자동으로 promise에 저장된다. 이때 promise를 std::promise로 구현하면 set_exception()을 호출해서 거기에 익셉션을 저장한다. async()를 사용하는 예는 다음과 같다.
int calculate() { throw runtime_error("Exception thrown from calculate()"); } int main() { // 강제로 비동기식으로 실행하도록 launch::async 정책을 지정한다. auto myFuture = async(launch::async, calculate); // 다른 작업을 실행한다. // 결과를 가져온다. try { int result = myFuture.get(); cout << result << endl; } catch (const exception& ex) { cout << "Caught exception: " << ex.what() << endl; } }
C++
복사

std::shared_future

std::future<T>의 인수 T는 이동 생성할 수 있어야 한다. future<T>에 대해 get()을 호출하면 future로부터 결과가 이동돼 리턴된다. 그러므로 future<T>에 대해 get()을 한 번만 호출 할 수 있다.
get()을 여러 스레드에 대해 여러 번 호출하고 싶다면 std::shared_future<T>를 사용한다. 이때 T는 복제 생성할 수 있어야 한다. shared_future는 std::future::share()로 생성허가나 shared_future 생성자에 future를 전달하는 방식으로 생성한다. 이때 future는 복제될 수 없다. 따라서 shared_future 생성자에 이동시켜야 한다.
shared_future는 여러 스레드를 동시에 꺠울 때 사용한다. 예컨대 다음 코드는 람다 표현식 두 개를 서로 다른 스레드에서 비동기식으로 실행한다. 각 람다 표현식은 가장 먼저 promise에 값을 설정해서 스레드가 구동됐다는 사실을 알리는 일부터 한다. 그런 다음 signalFuture에 대해 get()을 호출해서 블록시켰다가 future를 통해 매개변수가 설정되면 각 스레드를 실행한다. 각 람다 표현식은 promise를 레퍼런스로 캡쳐한다. signalFuture는 값으로 캡쳐한다. 따라서 두 표현식 모두 signalFuture의 복제본을 갖고 있다. 메인 스레드는 async()를 이용해 두 람다 표현식을 서로 다른 스레드에서 비동기식으로 실행시킨다. 그러고 나서 두 스레드가 구동될 때까지 기다리다가 두 스레드 모두 깨우도록 signalPromise에 매개변수를 지정한다.
promise<void> thread1Started, thread2Started; promise<int> signalPromise; auto signalFuture = signalPromise.get_future().share(); // shared_future<int> signalFuture(signalPromise.get_future()); auto function1 = [&thread1Started, signalFuture] { thread1Started.set_value(); // 매개변수가 설정될 때까지 기다린다. int parameter = signalFuture.get(); // ... }; auto function2 = [&thread2Started, signalFuture] { thread2Started.set_value(); // 매개변수가 설정될 때까지 기다린다. int parameter = signalFuture.get(); // ... }; // 두 람다 표현식을 비동기식으로 구동한다 // async()에서 리턴한 future를 까먹지 말고 캡쳐한다. auto result1 = async(launch::async, function1); auto result2 = async(launch::async, function2); // 두 스레드 모두 구동될 때까지 기다린다. thread1Stated.get_future().wait(); thread2Stated.get_future().wait(); // 이제 두 스레드 모두 매개변수가 설정되기를 기다린다. // 두 스레드를 깨우는 매개변수를 설정한다. signalPromise.set_value(42);
C++
복사

멀티스레드 Logger 클래스 예제

(생략)

스레드 풀

프로그램을 구동할 때부터 종료할 때까지 스레드를 필요할 때마다 생성했다 삭제하는 식으로 구현하지 않고 필요한 수만큼 스레드 풀(thread pool)을 구성해도 된다. 주로 스레드에서 특정한 종류의 이벤트를 처리할 때 이 기법을 적용한다.
일반적으로 프로세스 코어 수만큼 스레드를 생성하는 것이 적절하다. 스레드 수가 코어 수보다 많으면 다른 스레드가 실행되는 동안 기다려야 하는 스레드가 생겨서 오버헤드가 증가할 수 있다. 이상적인 스레드 수는 코어 수와 일치하는 경우지만, 어디까지나 I/O 연산처럼 중간에 블록되지 않고 계산 작업만 수행하는 경우에만 적용되는 기준이다. 스레드가 블록되면 코어 수보다 많은 스레드를 수용하게 된다. 최적의 스레드 수는 작업의 성격에 따라 다르며, 처리량을 정확히 측정해봐야 알 수 있다.
처리할 작업이 서로 다를 수도 있기 때문에 스레드 풀에서 가져온 스레드가 입력값으로 수행할 작업을 표현하는 함수 객체나 람다 표현식을 입력받게 만드는 경우가 흔하다.
스레드 풀에서 가져온 스레드는 이미 생성된 상태기 때문에 입력된 내용을 바탕으로 스레드를 새로 만들어서 구동할 때보다 OS 입장에서 훨씬 효율적으로 스케쥴링할 수 있다. 게다가 스레드 풀을 사용하면 생성될 스레드 수를 플랫폼 상황에 따라 한 개부터 수천 개까지 유연하게 관리할 수 있다.
스레드 풀을 구현하는 라이브러리가 다양하게 나와 있다. 예컨대 인텔의 스레드 빌딩 블록(Threading Building Block, TBB)과 마이크로소프트의 패러렐 패턴즈 라이브러리(Parallel Patterns Library, PPL)가 있다. 스레드 풀을 직접 구현하지 말고 이런 라이브러리를 활용하는 것이 바람직하다. 스레드 풀을 직접 구현하고 싶다면 객체 풀(object pool)과 비슷한 방식으로 만들면 된다.

바람직한 스레드 디자인과 구현을 위한 가이드라인

표준 라이브러리에서 제공하는 병렬 알고리즘을 활용한다.
표준 라이브러리는 방대한 종류의 라이브러리를 제공한다. C++ 17부터는 그중 60개 이상이 병렬 실행을 지원한다. 멀티스레드 코드를 직접 구현하기보다는 가능하면 표준 라이브러리의 병렬 알고리즘을 사용하는 것이 좋다.
애플리케이션을 종료하기 전에 반드시 조인해야 할 thread 객체가 하나도 남지 않게 한다.
모든 thread 객체에 대해 join()이나 detach()를 호출했는지 확인한다. 조인할 예정인 thread 소멸자는 std::terminate()를 호출하게 된다. 그러면 모든 스레드와 애플리케이션이 갑자기 종료된다.
동기화 메커니즘이 없는 동기화 방식이 최고다.
멀티스레드 프로그래밍을 할 때 공유 데이터를 다루는 스레드가 그 데이터를 읽기만 하고 쓰지 않게 또는 다른 스레드가 읽지 않은 부분만 쓰도록 구성하면 코드를 훨씬 쉽게 구현할 수 있다. 그러면 동기화 메커니즘을 따로 구현할 필요 없으며, 데이터 경쟁이나 데드락도 발생하지 않는다.
가능하면 싱글 스레드 소유권 패턴을 적용한다.
다시 말해 데이터 블록을 한 번에 한 스레드만 소유하게 만든다. 데이터를 소유한다(데이터의 소유권을 갖는다)는 말은 다른 스레드가 그 데이터를 읽거나 쓸 수 없다는 뜻이다. 스레드가 데이터에 대한 작업을 마치면 그 데이터에 대한 소유권을 다른 스레드로 넘길 수 있다. 그러면 그 스레드만 데이터 소유권을 갖게 돼 동기화 메커니즘이 필요 없다.
아토믹 타입과 아토믹 연산을 최대한 많이 사용한다.
아토믹 타입과 아토믹 연산을 사용하면 데이터 경쟁과 데드락이 발생하지 않게 만들기 쉽다. 동기화 작업을 알아서 처리해주기 때문이다. 아토믹 타입과 연산으 ㄹ제공하지 않는 환경에서 데이터를 공유해야 한다면 상호 배제와 같은 동기화 메커니즘을 반드시 제공해서 적절히 동기화시켜야 한다.
변경될 수 있는 공유 데이터는 락으로 보호한다.
변경될 수 있는 공유 데이터를 여러 스레드가 동시에 쓸 수 있는데 아토믹 타입과 아토믹 연산을 사용할 수 없다면 반드시 락 메커니즘을 이용하여 여러 스레드의 읽기 및 쓰기 연산을 동기화 시켜야 한다.
락을 거는 기간은 짧을수록 좋다.
공유 데이터를 락으로 보호할 때는 최대한 빨리 해제한다. 한 스레드가 락을 걸고 있으면 그 락을 기다리는 다른 스레드가 블록돼 전체 성능이 떨어질 수 있다.
여러 개의 락을 걸 때는 직접 구현하지 말고 std::lock()이나 std::try_lock()을 사용한다.
여러 스레드가 락을 여러 개 걸어야 한다면 반드시 모든 스레드를 똑같은 순서로 걸어야 한다. 그렇지 않으면 데드락이 발생할 수 있다. 이렇게 여러 개의 락을 걸 때는 제네릭 함수인 std::lock()이나 std::try_lock()을 사용한다.
RAII 락 객체를 사용한다.
락이 제때 자동으로 해제되도록 lock_guard, unique_lock, shared_lock, scoped_lock과 같은 RAII 클래스를 사용한다.
멀티스레드를 지원하는 프로파일러를 활용한다.
그러면 멀티스레드로 구현한 애플리케이션에서 발생하는 성능 저하 지점뿐만 아니라 현재 생성된 스레드가 시스템의 처리량을 최대로 활용하고 있는지 쉽게 알아낼 수 있다. 멀티스레드를 지원하는 프로파일러의 예로 마이크로소프트웨어 비주얼 스튜디오에서 제공하는 프로파일러가 있다.
멀티스레드를 지원하는 디버거를 활용한다.
대부분의 디버거는 멀티스레드 애플리케이션을 디버깅하는데 필요한 최소 기능을 제공한다. 적어도 애플리케이션에서 현재 구동하고 있는 스레드 목록을 조회하거나 그 중 원하는 스레드의 콜 스택을 조회하는 기능은 갖추고 있어야 한다. 그러면 각각의 스레드가 현재 실행되는 현황을 정확히 볼 수 있기 때문에 데드락 검사와 같은 작업을 수행할 수 있다.
멀티스레드를 지원하는 디버거를 활용한다.
대부분의 디버거는 멀티스레드 애플리케이션으 디버깅하는데 필요한 최소 기능을 제공한다. 적어도 애플리케이션에서 현재 구동하고 있는 스레드 목록을 조회하거나 그중 원하는 스레드 콜 스택을 조회하는 기능은 갖추고 있어야 한다. 그러면 각각의 스레드가 현재 실행되는 현황을 정확히 볼 수 있기 때문에 데드락 검사와 같은 작업을 수행할 수 있다.
스레드가 많을 때는 필요 때마다 생성했다가 삭제하지 말고 스레드 풀을 이용한다.
동적으로 생성했다 삭제하는 스레드 수가 많을수록 성능 저하 폭이 크다. 이럴 때는 스레드 풀을 이용해서 기존에 생성된 스레드를 최대한 재사용하는 것이 좋다.
하이레벨 멀티스레딩 라이브러리를 사용한다.
현재 시점에서 C++ 표준은 멀티스레드 코드를 작성하는데 아주 기본적인 기능만 제공한다. 이런 기능을 제대로 활용하기란 쉽지 않다. 따라서 스레드 관련 기능을 직접 구현하지 말고, 인텔의 스레딩 빌딩 블록(TBB)나 마이크로소프트의 패러렐 패턴즈 라이브러리(PPL)과 같은 하이레벨 관점으로 멀티스레딩을 지원하는 라이브러리를 활용하는 것이 좋다. 멀티 스레드 프로그램을 에러 없이 정확히 동작하게 만들기란 쉽지 않다. 또한 직접 구현한 것이 생각보다 유용하지 않을 수도 있다.