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

CPU의 속도는 전력 소모량과 열에 의해 빠르기에 한계를 가지고 있다. 만약 CPU가 병행하게 작동한다면, 수행 기간은 이전보다 더 짧아질 것이다. 요즘은 듀얼과 쿼드라 코어 프로세스가 보편적으로 사용된다. 심지어, 12, 18 그리고 18 코어 프로세서도 도입되고 있다.

그래픽 카드 프로세서인 GPUS는 꽤 병렬화되어 있다. 고성능의 그래픽 카드는 4000 코어 이상을 가지고 있다. 그래픽 카드는 게임을 즐기는 데 사용될 뿐 아니라 수학 연산이나 과학 현상을 연구하는 데 널리 사용된다.

C++11 부터는 표준 멀티-스레드 라이브러리가 포함되어 있다. 현대 C++은 CPU를 위한 API를 정의하고 있으며, GPU는 정의하고 있지 않다. 하지만 GPU를 위한 API도 조만간 지원할 것이다.

멀티-스레드의 장점은 두 가지가 있다. 먼저 전체 퍼포먼스가 증가한다. 주어진 작업을 작은 문제들로 나누면, 문제들은 병렬적으로 처리된다. 두 번째로 UI를 모듈화할 수 있다. 긴 시간이 걸리는 동작의 경우 백그라운드에서 수행되어 UI의 응답 속도를 높일 수 있다.

모든 문제가 병렬적으로 해결되지는 않는다. 하지만 작업의 일부라도 병렬적으로 수행한다면 그건 명백한 이점이다. 하지만 멀티-스레드 프로그래밍을 할 때는 경쟁 조건(Race Condition), 테어링(Tearing), 데드락(Dead Lock), 잘못된 공유(False-Sharing)을 피해야 한다.

경쟁 조건(Race Condition)

경쟁 상태는 여러 스레드가 공유 리소스에 동시에 접근할 때 발생한다. 특히 메모리와 관련된 경쟁 상태를 데이터 경쟁이라고 부른다. PDP-11이나 VAX같은 이전 아카텍처들은 아토믹하게 운영되는 INC와 같은 인스트럭션을 제공했다. 하지만 최신 x86 아키텍처가 제공하는 INC는 더이상 아토믹하지 않다. INC를 처리하는 동안 실행되는 다른 인스트럭션 때문에 결과가 달라질 수도 있다. 예를들어, 스레드 1에서는 증가 연산을 스레드 2에서는 감소 연산을 수행한다고 해보자.

스레드 1 2 3 4 5 6
스레드 1 불러옴 (값=1) 증가 (값=2) 저장 (값=2)      
스레드 2       불러옴 (값=2) 감소 (값=1) 저장 (값=1)

만약 두 작업이 서로 엇갈리게 된다면 결과가 달라진다.

스레드 1 2 3 4 5 6
스레드 1 불러옴 (값=1) 증가 (값=2)     저장 (값=2)  
스레드 2     불러옴 (값=1) 감소 (값=0)   저장 (값=0)

이 경우 최종 결과는 0이다.

테어링(Tearing)

테어링은 데이터 경쟁의 특수한 경우이다. 테어링은 크게 읽기 테어링(Torn Read)와 쓰기 테어링(Torn Write)로 나눌 수 있다. 만약 스레드가 데이터의 부분만 쓰고 나머지 부분을 쓰지 못한 상태에서 다른 스레드가 이 데이터를 읽으면 두 스레드가 보는 값이 달라진다. 이러한 경우를 읽기 테어링이라고 한다. 그리고 두 스레드가 데이터에 동시에 쓸 때 한 스레드는 데이터의 한 쪽에, 다른 스레드는 데이터의 다른 쪽에 썼다면 수행한 결과가 다르다. 이를 쓰게 테어링이라고 한다.

데드락(Dead Lock)

데드락은 여러 스레드가 서로의 작업이 끝나길 기다리는 상태를 의마한다. 스레드 1이 스레드 2의 작업이 완전히 끝나길 기다리는 것과 스레드 2가 스레드 1의 작업이 끝나길 기다리는 것이 동시에 발생한다. 즉 두 스레드는 상대방을 무한정 기다리게 된다.

데드락을 피하려면, 모든 스레드들은 특정 순서에 따라 필요한 리소스를 취득해야 한다. 그리고 데드락 상태를 타계할 수 있는 메커니즘도 만들어두면 좋다. 또 다른 방법으로 리소스 접근 권한을 요청하는 작업에 시간 제한을 걸 수도 있다. 주어진 시간 동안 리소스를 확보할 수 없다면, 현재 확보 권한을 해제하고 일정 시간 이후 리소스를 확보하는 작업을 다시 시도한다. 이러한 기법들로 문제를 해결할 수 있는 지 여부는 주어진 상황에 따라 다르다.

하지만 가장 중요한 것은 데드락이 발생하는 상황을 피하는 것이다. 여러 뮤텍스 객체로 보호받는 리소스 집합에 대해 접근 권한을 얻을 때는 리소스마다 개별적으로 요청하기 보다 std::lock()이나 std::try_lock()같은 함수들을 이용하는 것이 좋다. 이 함수들은 여러 리소스에 대한 권한을 한 번에 확보하거나 요청한다.

잘못된 공유

캐시(Cache)는 캐시 라인(Cache Line) 단위로 처리되며, 최신 CPU는 64바이트 캐시 라인으로 구성된다. 캐시 라인에 데이터를 쓰려면 반드시 그 라인 전체에 락이 걸려야 한다. 이 과정에서 데이터 구조가 잘 짜여지지 않으면 성능이 크게 떨어질 수 있다. C++17부터는 <new> 헤더 파일에 hardware_destructive_interference_size라는 상수가 추가되어 코드를 이식하기 좋게 작성할 수 있도록 도와준다. 이 상수는 동시에 접근하는 두 객체가 캐시 라인을 공유하지 않도록 최소한의 오프셋을 제시한다. 이 상수와 alignas 키워드를 데이터를 적절히 정렬하는 데 사용한다.