[Modern C++] 항목 35: 스레드 기반 프로그래밍보다 과제 기반 프로그래밍을 선호하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 7. 동시성 API
🧠 동시성 API 개요
- C++11의 큰 성과 중 하나는 동시성(concurrency)을 언어와 표준 라이브러리에 도입한 것이다.
- C++의 동시성 지원 상당 부분은 컴파일러 작성자에 대한 제약의 형태다. 이 언어 차원의 보장 덕분에, C++ 역사상 처음으로 모든 플랫폼에서 표준적인 행동을 보이는 다중 스레드 프로그램을 작성할 수 있게 되었다.
- 표준 라이브러리의 동시성 구성요소(과제, 미래, 스레드, 뮤텍스, 조건 변수, 원자적 객체 등)는 시작일 뿐이며, 점점 더 풍부해질 것이다.
- 미래(future) 객체를 위한 템플릿은
std::future와std::shared_future두 가지다. 구분이 중요하지 않은 경우가 많아 이 장에서는 둘을 그냥 미래 객체라고 통칭하는 경우가 많다.
👉🏻 항목 35: 스레드 기반 프로그래밍보다 과제 기반 프로그래밍을 선호하라
🔍 비동기 실행의 두 가지 방법
doAsyncWork라는 함수를 비동기적으로 실행하는 방법은 크게 두 가지다.
int doAsyncWork();
// 스레드 기반(thread-based) 프로그래밍
std::thread t(doAsyncWork);
// 과제 기반(task-based) 프로그래밍
auto fut = std::async(doAsyncWork); // fut은 future를 뜻함
std::async에 전달된 함수 객체(doAsyncWork)는 하나의 과제(task)로 간주된다.- 대체로 과제 기반 접근방식이 스레드 기반 접근방식보다 우월하다.
🔍 과제 기반의 장점 ①: 반환값과 예외 처리
doAsyncWork는 반환값을 돌려주며, 호출하는 코드는 그 반환값에 관심이 있을 것이라고 가정하는 것이 합리적이다.- 스레드 기반: 반환값에 접근할 방법이 없다.
- 과제 기반:
std::async가 돌려주는 미래 객체의get멤버 함수로 간단히 접근할 수 있다. doAsyncWork가 예외를 방출하는 경우,get을 통해 그 예외에도 접근할 수 있다.- 스레드 기반에서는
doAsyncWork가 예외를 던지면 프로그램이 죽는다(std::terminate호출).
🔍 과제 기반의 장점 ②: 더 높은 수준의 추상
과제 기반 접근방식은 더 높은 수준의 추상을 구현하므로, 프로그래머가 세부적인 스레드 관리에서 벗어날 수 있다. 동시적 C++ 소프트웨어에서 ‘스레드’라는 용어는 세 가지 의미로 쓰인다.
- 하드웨어 스레드: 실제 계산을 수행하는 스레드. 현세대 컴퓨터 아키텍처는 CPU 코어당 하나 이상의 하드웨어 스레드를 제공한다.
- 소프트웨어 스레드(OS 스레드, 시스템 스레드): 운영체제가 하드웨어 스레드들에서 실행되는 모든 프로세스와 일정을 관리하는 데 사용한다.
- 대체로 하드웨어 스레드보다 많은 소프트웨어 스레드를 생성할 수 있다.
- 한 소프트웨어 스레드가 차단(blocking)되어도(입출력 중이거나, 뮤텍스나 조건 변수를 기다리는 등) 다른 스레드를 실행함으로써 처리량을 향상할 수 있기 때문이다.
std::thread: C++ 프로세스 안에서 바탕 소프트웨어 스레드에 대한 핸들로 작용하는 객체.std::thread객체는 ‘널(null)’ 핸들을 나타내기도 한다. 즉 어떤 소프트웨어 스레드에도 대응되지 않을 수 있다.- 기본 생성자로 생성된 상태(실행할 함수가 없음)
- 다른
std::thread객체로 이동된 후 join을 통해 합류한 후(실행하던 함수가 종료된 후)detach를 통해 탈착된 후(std::thread객체와 소프트웨어 스레드 사이의 연결이 끊어진 후)
⚠️ 스레드 기반 프로그래밍의 문제점 ①: 스레드 고갈
- 소프트웨어 스레드는 제한된 자원이다. 시스템이 제공할 수 있는 것보다 많은 소프트웨어 스레드를 생성하려 하면
std::system_error예외가 발생한다. - 실행하려는 함수가 예외를 던질 수 없는 경우(
noexcept)에도 마찬가지다.
int doAsyncWork() noexcept; // noexcept는 항목 14를 보라
std::thread t(doAsyncWork); // 사용 가능한 스레드가 없으면
// 예외가 발생한다
- 이 상황을 처리하는 접근방식들과 각각의 문제점:
doAsyncWork를 현재 스레드에서 실행 → 현재 스레드에 부하(load)가 과중하게 걸릴 수 있다. 현재 스레드가 GUI 스레드라면 사용자 입력에 대한 반응성 문제가 발생할 수 있다.- 기존 소프트웨어 스레드가 완료되길 기다렸다가 새로 생성 → 기존 스레드들이
doAsyncWork가 수행해야 하는 어떤 동작(결과 생성, 조건 변수 통지 등)을 기다리고 있을 수도 있다.
⚠️ 스레드 기반 프로그래밍의 문제점 ②: 과다구독(oversubscription)
- 과다구독: 실행 준비가 된(차단되지 않은) 소프트웨어 스레드가 하드웨어 스레드보다 많은 상황.
- 과다구독이 발생하면 스레드 스케줄러(보통 OS의 일부)는 하드웨어상의 실행 시간을 여러 조각(time slice)으로 나누어 소프트웨어 스레드들에게 배분한다.
- 한 시간 조각이 끝나고 다른 스레드의 시간 조각이 시작될 때 문맥 전환(context switch)이 수행되며, 이는 시스템의 전반적인 스레드 관리 부담을 증가시킨다.
- 다음번에 실행될 하드웨어 스레드가 이전과 다른 코어에 있으면 비용이 더 커진다.
- CPU 캐시가 그 소프트웨어 스레드에 대해 차갑다(cold). 유용한 자료와 명령이 거의 없다.
- 그 코어에서 ‘새로운’ 스레드를 실행하면 기존 스레드들의 CPU 캐시가 “오염된다”.
- 과다구독을 피하기는 어렵다.
- 소프트웨어 대 하드웨어 스레드의 이상적인 비율은 실행 가능한 스레드 개수에 의존하는데, 그 개수가 동적으로 변할 수 있다.
- 최상의 비율은 문맥 전환 비용과 CPU 캐시 활용 효율에도 의존하며, CPU 아키텍처에도 의존한다.
- 따라서 한 컴퓨터에서 잘 조율해도 다른 종류의 컴퓨터에서 잘 작동한다는 보장이 없다.
✅ 해결책: std::async
이런 문제를 다른 누군가에게 떠넘기는 방법이 바로 std::async다.
auto fut = std::async(doAsyncWork); // 스레드 관리 부담을
// 표준 라이브러리
// 구현자들에게 떠넘긴다
- 스레드 관리의 책임을 호출자에서 C++ 표준 라이브러리 구현자로 옮긴다.
- 이 호출은 예외를 방출할 가능성이 거의 없다. 기본 시동 방침(항목 36)에서는
std::async가 새 소프트웨어 스레드를 생성하지 않을 수도 있기 때문이다. - 대신
std::async는 지정된 함수를 결과가 필요한 스레드(즉,fut에 대해get이나wait를 호출하는 스레드)에서 실행하라고 스케줄러에게 요청할 수 있다. - 합리적인 스케줄러는 시스템이 과다구독되었거나 스레드가 부족한 상황에서 이 자유의 장점을 취한다.
주의할 점
- “함수를 결과가 필요한 스레드에서 실행”하는 기법을 적용하면 앞서 말한 부하 불균형 문제가 발생할 수 있다.
- 다만 부하 균형화(load balancing)와 관련해서는, 컴퓨터 전반의 상황을 실행 스케줄러가 더 상세히 알고 있을 가능성이 크다(모든 프로세스의 스레드를 관리하므로).
std::async에서도 GUI 스레드의 반응성이 문제가 될 수 있다. 스케줄러는 어떤 스레드가 반응성이 좋아야 하는지 알 수 없기 때문이다.- 이 경우
std::launch::async시동 방침을std::async에 넘겨주면, 함수가 실제로 현재 스레드와는 다른 스레드에서 실행된다(항목 36 참고).
- 이 경우
// 반드시 다른 스레드에서 실행되도록 시동 방침을 명시한다
auto fut = std::async(std::launch::async, doAsyncWork);
최신 스레드 스케줄러
- 시스템 전역 스레드 풀을 이용해서 과다구독을 피한다.
- 작업 훔치기(work-stealing) 알고리즘으로 부하를 하드웨어 코어들에 균형 있게 분산한다.
- C++ 표준이 스레드 풀이나 작업 훔치기 사용을 요구하지는 않지만, 이런 기술을 활용하는 라이브러리 판매사들도 있다.
- 과제 기반으로 개발한 동시적 프로그램은 그런 기술이 널리 퍼짐에 따라 그 혜택을 저절로 받게 된다.
- 반면
std::thread를 직접 다루면 스레드 고갈, 과다구독, 부하 균형화를 처리하는 부담을 직접 짊어져야 하며, 같은 컴퓨터의 다른 프로세스들에 구현된 해법들과도 잘 맞물리게 해야 한다.
🔍 스레드를 직접 다루는 것이 적합한 경우
과제 기반 프로그래밍은 스레드를 일일이 관리하는 수고로움이 없고, 비동기 함수의 결과(반환값 또는 예외)를 자연스럽게 조회할 수 있다. 그래도 스레드를 직접 다루는 게 적합한 경우가 있다.
- 기반 스레드 라이브러리의 API에 접근해야 하는 경우
- C++ 동시성 API는 보통
pthreads나 Windows 스레드 라이브러리 같은 저수준 플랫폼 고유 API로 구현된다. - 이런 API는 C++보다 풍부한 기능을 제공한다(예: 스레드 우선순위, 친화도(affinity)).
- 이에 접근할 수 있도록
std::thread객체는 흔히native_handle멤버 함수를 제공한다. - 그러나
std::future(std::async가 돌려주는)에는 이에 해당하는 기능이 없다. - (옮긴이) ‘흔히’라는 단서는
native_handle과native_handle_type의 존재 여부와 개념을 구현이 정의하기 때문이다(implementation-defined). 따라서native_handle을 사용하는 코드는 자동으로 이식성이 없는 코드가 된다.
- C++ 동시성 API는 보통
- 응용 프로그램의 스레드 사용량을 최적화해야 하는, 그리고 할 수 있어야 하는 경우
- 예: 하드웨어 특성이 미리 정해진 컴퓨터에서 유일하게 의미 있는 프로세스로 실행될 서버 소프트웨어를 개발하는 경우
- C++ 동시성 API가 제공하는 것 이상의 스레드 적용 기술을 구현해야 하는 경우
- 예: C++ 구현이 스레드 풀을 제공하지 않는 플랫폼을 위해 스레드 풀을 직접 구현해야 하는 경우
이들은 흔치 않은 경우이며, 대부분은 스레드를 직접 다루는 대신 과제 기반 설계를 사용하는 것이 바람직하다.
🧐 정리
std::threadAPI에서는 비동기적으로 실행된 함수의 반환값을 직접 얻을 수 없으며, 그 함수가 예외를 던지면 프로그램이 종료된다.- 스레드 기반 프로그래밍에서는 스레드 고갈, 과다구독, 부하 균형화, 새 플랫폼으로의 적응을 직접 처리해야 한다.
std::async와 기본 시동 방침을 이용한 과제 기반 프로그래밍은 그런 대부분의 문제를 알아서 처리해준다.
댓글남기기