개발하는 리프터 꽃게맨입니다.
[멀티스레드 프로그래밍] C++ 동시성 프로그래밍 개론 본문
참고사항
C++ : Concurrency In Action (원서) 바탕으로 적은 글 입니다.
해당 포스팅은 해당 저서의 번역보다는 번역 및 (제 기준으로) 이해하기 쉽도록 재구성한 부분이 다수 있습니다.
주의해서 읽으시길 바랍니다.
필수 사전지식
C++ 에 대한 전반적인 이해
권장 사전지식
운영체제에 대한 기본적인 이해
하드웨어, 프로세서 및 스레드에 대한 기본적인 이해
동기화 및 교착상태(Deadlock)에 대한 기본적인 이해
C++11 표준에서 가장 눈여겨봐야할 새로운 기능 중 하나는 멀티스레드 프로그래밍에 대한 지원이다.
C++ 표준이 처음으로 언어 내에서 멀티스레드 응용 작성을 위한 라이브러리의 구성 요소를 제공하게 되었는데,
이를 통해 운영체제나 플랫폼에 종속되지 않고 멀티스레드 C++ 프로그램을 작성할 수 있으며, 보장된 동작으로
이식 가능한 멀티스레드 코드를 작성할 수 있게 되었다.
먼저 동시성과 멀티스레딩이 무엇을 의미하는지, 왜 응용에서 동시성을 사용하는지 설명한다.
또, 응용에서 동시성을 사용하지 않으려는 이유에 대해 간단히 설명 후, C++의 동시성 지원 개요를 제공하고, C++ 동시성의 간단한 예제로 마무리할 것이다.
이제 동시성과 멀티스레딩이 무엇인지에 대해 설명해보겠다.
1. 동시성이란 무엇인가?
가장 간단하고 기본적인 수준에서 동시성(Concurrency) 는 2가지 이상의 독립적인 활동이 동시에 일어나는 것을 의미한다. 우리는 일상생활에서 자연스럽게 동시성을 접할 수 있다.
예를 들어, 우리는 걷는 것과 동시에 말할 수 있으며, 각각의 손으로 다른 동작을 수행할 수 있다.
그리고 각자가 독립적으로 자신의 삶을 살아간다. 예를 들어, 당신은 축구를 보면서 나는 수영을 하러 갈 수 있다.
이처럼 동시성은 일상생활에서 흔히 접할 수 있는 개념이다.
[1] 컴퓨터 시스템에서의 동시성
컴퓨터에서 동시성은 단일 시스템이 여러 개의 독립적인 활동을 병렬로 수행하는 것을 의미한다.
이는 새로운 패러다임이 아니다.
단일 컴퓨터가 작업 스위칭을 통해 여러 응용 프로그램을 동시에 실행할 수 있게 하는 multitasking 운영체제는 오랫동안 일반적이었으며, 진정한 동시성을 가능하게 해주는 다중 프로세서를 가진 고급 서버 머신도 오랫동안 존재해 왔다.
새로워진 점은 다수의 작업을 '실제로' 병렬로 실행할 수 있는 컴퓨터가 점점 더 보편화되고 있다는 것이다.
역사적으로 대부분의 컴퓨터는 단일 프로세서, 즉 하나의 처리 유닛이나 코어를 가지고 있었고
이는 오늘날의 많은 데스크탑 머신에서도 그렇다.
이러한 머신은 한 번에 한 가지 작업만을 수행할 수 있지만, 초당 여러 번 작업을 전환할 수 있다.
한 작업을 하다가 다른 작업으로 전환하는 식으로.. 이를 반복하면서 작업이 동시에 일어나는 것처럼 보이게 한다.
이를 작업 전환(task switching) 이라고 한다.
이러한 시스템에서도 동시성을 이야기한다. 작업 전환이 매우 빠르기 때문에 작업이 중단되고 다른 작업으로 전환되는 시점을 알 수 없다. 작업 전환은 사용자와 어플리케이션 모두에게 동시성을 제공한다.
이것은 '착각'이다. 동시성인양 착각하게 할 뿐 실제로는 동시성이 아니기 때문에 단일 프로세서 작업 전환 환경에서 응용 프로그램의 동작이 진정한 동시성이 있는 환경에서 실행될 때와 미묘하게 다를 수 있다.
다중 프로세서를 포함한 컴퓨터는 여러 해 동안 서버와 고성능 컴퓨팅 작업에 사용되어 왔으며, 이제는 단일 칩에 여러 코어가 있는 멀티코어 프로세서를 기반으로 한 컴퓨터가 데스크탑 컴퓨터로 점점 보급되고 있다.
다중 프로세서나 단일 프로세서 내 여러 코어를 가진 컴퓨터는 진정으로 여러 작업을 '병렬'로 실행할 수 있다.
이를 하드웨어 동시성이라고 한다.
위 그림은 두 가지 작업을 해야 하는 컴퓨터의 이상적인 시나리오를 보여준다.
각 작업을 10개의 동일한 크기로 나눴을 때
2개의 코어를 가진 프로세서에서는 각 작업이 독립된 코어에서 병렬로 실행될 수 있다.
그러나 단일 코어 머신에서는 작업 전환을 하여 동시성을 제공한다. 그림으로 이해할 수 있겠지만, 각 작업의 청크는 서로 얽혀 있다.
그러나 이러한 청크는 서로 약간의 간격이 있는 것을 볼 수 있을 것이다.
싱글 코어 부분 중간중간 회색 틈이 있는 것이 보이는가?
이는 시스템이 작업 전화을 할 때마다 Context Switch 를 수행해야 하므로 발생하는 오버헤드이다.
Context Switch 를 수행하기 위해 OS는 현재 실행 중인 작업의 CPU 상태와 명령 포인터를 저장하고, 전환할 작업을 결정하고, 전환할 작업의 CPU 상태를 다시 로드해야 한다. 그런 다음 CPU는 새 작업의 명령과 데이터를 캐시에 로드해야 하며, 이 시간동안 CPU는 명령어를 실행하지 못하기 때문에 지연 stall이 발생한다.
흔히 디스패치 지연시간이라고 말하는 것이다.
멀티프로세서 또는 멀티코어 시스템에서 하드웨어 동시성을 쉽게 확인할 수 있지만, 일부 프로세서는 단일 코어에서 여러 스레드를 실행할 수 있다. 여기서 중요한 요소는 하드웨어 스레드의 수인데 하드웨어 스레드의 수가 많을 수록 프로세서의 병렬성이 높아진다.
진정한 하드웨어 동시성이 있는 시스템에서도 병렬로 실행할 수 있는 작업보다 더 많은 작업이 할당되는 경우가 많으므로 작업 전환이 여전히 사용된다. 예를 들어, 일반적인 데스크탑 컴퓨터에서는 컴퓨터가 유휴 상태일 때에도 수백 개의 작업이 백그라운드에서 수행하고 있을 수 있다. 작업 전환 덕분에 이러한 백그라운드 작업을 실행하고 워드 프로세서, 컴파일러, 편집기 및 웹 브라우저를 한꺼번에 실행할 수 있다.
위 그림은 듀얼 코어 머신에서 4개의 작업 간의 전업 전환을 보여주며, 이상적인 시나리오에서 작업이 동일한 크기로 깔끔하게 나뉘어 있다. 실제로는 많은 문제로 인해 분할이 고르지 않고 스케줄링이 불규칙할 수 있다.
해당 시리즈에서 다루는 모든 기술, 함수 및 클래스는 응용 프로그램이 단일 코어 프로세서가 있는 머신에서 실행되든 다수의 멀티코어 프로세서가 있는 머신에서 실행되는 상관없이 사용할 수 있으며, 동시성이 작업 전환을 통해 이루어지든 진정한 하드웨어 동시성을 통해 이루어지든 영향을 받지 않는다. 그러나 동시성을 응용 프로그램에서 어떻게 사용하는지는 사용 가능한 하드웨어 동시성의 양(= 하드웨어 스레드의 양)에 따라 다를 수 있다.
기본적으로
동시성 Concurrency 는 시분할(time sharing) 을 통해 매번 컨텍스트 스위칭을 하며 다수의 프로세스들이 동시에 실행되는 것처럼 보이도록 하는 개념이다.
병렬성 Parallelism 은 서로 다른 프로세스들이 말 그대로 병렬로 동시에 실행되는 것을 의미한다.
그러나 해당 책을 읽어보니.. 병렬성을 동시성과 혼용해서 사용하고 있는 듯 하다.
이 책에서 말하는 동시성이란 사전적인 의미의 병렬성을 의미한다.
[2] 동시성 접근 방식
동시성 접근 방식에는 2가지가 존재한다.
단일 스레드 프로세스를 여러 개 사용하거나, 하나의 프로세스에서 다중 스레드를 사용하거나
소프트웨어 프로젝트에서 함께 작업하는 두 명의 프로그래머가 있다고 상상하라.
만약 개발자들이 각기 다른 사무실에 있다면, 서로 방해받지 않고 평화롭게 작업할 수 있으며, 각자 자신의 참조 매뉴얼을 가지고 있다. 그러나 의사소통은 간단하지 않다. 단순히 돌아서서 대화하는 대신 전화나 이메일을 사용하거나 직접 걸어가야 한다. 또한 두 개의 사무실을 관리하고 여러 참조 매뉴얼을 구입해야 하는 부담이 있다.
이제 개발자들을 같은 사무실로 옮겨 놓는다고 상상해보자. 이제 그들은 응용 프로그램 설계를 논의하기 위해 자유롭게 대화할 수 있고, 종이 또는 화이트보드에 다이어그램을 그리고 설계 아이디어나 설명을 도울 수 있다. 이제 한 사무실만 관리하면 되고, 하나의 자원 세트로 충분할 때가 많다. 반면, 집중하기가 더 어려워질 수 있고, 자원을 공유하는 문제가 발생할 수 있다.
이 2가지 조직 방식은 동시성에 대한 2가지 기본 접근 방식을 설명한다.
각 개발자를 스레드, 각 사무실을 프로세스라고 바꿔보자.
첫 번째 접근방식은 다수의 단일 스레드 프로세스를 갖는 것이고, 두 번째 접근방식은 단일 프로세스에서 다중 스레드를 갖는 것과 같다.
다음 그림은 2개의 코어에서 4개의 작업이 작업 전환되는 모습을 보여준다.
이 예제처럼 2가지 접근 방식을 응용 프로그램에 결합하여 멀티스레드 프로세스와 단일 스레드 프로세스를 혼합할 수 있지만, 앞서 말했던 원칙은 동일하다. 이제 응용 프로그램에서 이 2가지 동시성 접근 방식을 간단히 살펴보도록 하자.
[3] 다수의 단일 스레드 프로세스를 통한 동시성
응용 프로그램에서 동시서어을 활용하는 첫 번째 방법은 응용 프로그램을 여러 개의 독립적인 단일 스레드 프로세스로 나누어 동시에 실행하는 것이다. 이는 웹 브라우저와 워드 프로세서를 동시에 실행할 수 있는 것과 유사하다.
이런 독립적인 프로세스를 사용하는 방식은 프로세스간 통신 채널을 설계하여 서로 메시지를 주고받을 수 있다.
이런 통신채널로는 signal, 소켓, 공유 파일, 파이프 등.. 여러가지 방식으로 서로 통신을 할 수 있다.
그러나 이러한 프로세스 간 통신 (IPC)는 설정이 복잡하거나 느리거나 최악의 경우 둘 다일 수 있다.
운영체제가 일반적으로 프로세스 간에 많은 보호 장치를 제공하여 한 프로세스가 다른 프로세스의 데이터를 실수로 수정하는 것을 방지하기 때문이다. 또 다른 단점은 여러 프로세스를 실행하는 데 본질적인 오버헤드가 있다는 것이다. 프로세스를 시작하는 데 시간이 걸리고, 운영체제가 프로세스를 관리하기 위해 내부 자원을 할당해야 하는 등.. 여러 가지가 있다.
물론 단점만 있는 것은 아니다. 운영체제가 프로세스 간에 제공하는 추가적인 보호와 고급 통신 메커니즘 덕분에 스레드보다 프로세스로 안전한 동시성 코드를 작성하기가 더 쉬울 수 있다. 실제로 Erlang 프로그래밍 언어가 제공하는 환경과 같은 곳에서는 프로세스를 동시성의 기본 구성 요소로 사용하여 큰 효과를 보고 있다.
또한, 동시성을 위해 별도의 프로세스를 사용하는 또 다른 이점은 네트워크를 통해 연결된 별도의 머신에서 이러한 프로세스를 실행할 수 있다는 것이다. 이것은 통신 비용을 증가시키지만, 신중하게 설계된 시스템에서는 가용한 병렬성을 증가시키고 성능을 향상시키는 효율적인 방법이 될 수 있다.
[4] 동시성 접근 방식
동시성에 대한 또 다른 접근 방식은 단일 프로세스 내에서 여러 스레드를 실행하는 것이다.
스레드는 경량 프로세스와 비슷하다. 각 스레드는 다른 스레드와 독립적을 실행되며, 각 스레드는 서로 다른 명령 시퀀스를 실행할 수 있다. 하지만 프로세스 내 모든 스레드는 동일한 주소 공간을 공유하며, 대부분의 데이터는 모든 스레드에서 직접 접근할 수 있다. 전역 변수는 여전히 전역 변수로 남아 있고, 객체나 데이터에 대한 포인터나 참조는 스레드 간에 전달될 수 있다. 물론 프로세스 간에 메모리를 공유하는 것도 가능하지만, 이는 설정이 복잡하고 관리하기 어려운데, 이는 동일한 데이터의 메모리 주소가 서로 다른 프로세스에서 반드시 동일하지 않기 때문이다.
스레드 간 데이터 보호가 부족하고 공유 주소 공간이 존재하기 떄문에, 여러 스레드를 사용하는 데 따른 오버헤드는 여러 프로세스를 사용하는 것보다 훨씬 적다. 운영체제가 할 일이 줄어들기 때문이다. 하지만 공유 메모리의 유연성에는 대가가 따른다. 여러 스레드에서 데이터를 접근할 때, 각 스레드가 데이터를 Read할 때 일관성이 유지되도록 프로그래머가 보장해야만 한다. 이런 race condition을 피하기 위한 도구와 지침에 대해서 이 시리즈에서 다룰 것이다.
적절한 주의를 기울이면 이러한 문제는 극복할 수 있지만, 스레드 간 통신에 많은 고민이 필요하다.
단일 프로세스 내 여러 스레드를 실행하고 통신하는 데 따르는 낮은 오베헤드는, 다중 프로세스를 실행하고 통신하는 데 따르는 오버헤드보다 적기 때문에, C++을 포함한 주류 언어에서는 공유 메모리로 인한 잠재적 문제에도 불구하고 다중 스레드를 통해서 동시성을 구현하는 것을 선호한다.
또한 C++ 표준은 프로세스 간 통신에 대한 본질적인 지원을 제공하지 않기 땜누에, 여러 프로세스를 사용하는 으용 프로그램을 설계할 때는 플랫폼 종속적인 API를 사용해야 한다. 따라서 이 시리즈는 동시성을 위해 멀티스레딩을 사용하는 것에 중점을 두며, 이후 도잇성에 대한 언급은 모두 멀티스레드를 사용하는 것을 전제로 한다.
그러면 이제 응용 프로그램에서 동시성을 사용하는 이유를 살펴보도록 하자.
2. 왜 동시성을 사용하는가?
응용 프로그램에서 동시성을 사용하는 주된 이유는 2가지이다.
관심사의 분리와 성능이다.
사실, 이 2가지가 거의 동시성을 사용하는 유일한 이유라고해도 과언이 아니다. 다른 이유들도 자세히 들여다보면 결국 이 두 가지로 귀결된다.
[1] 관심사의 분리를 위한 동시성 사용
소프트웨어를 작성할 때 관심사의 분리를 적용하는 것은 거의 항상 좋은 아이디어이다. 관련 있는 코드들을 함께 그룹화하고 관련 없는 코드들을 분리함으로써, 프로그램을 이해하고 테스트하기 쉬워지며 버그가 발생할 가능성이 줄어든다. 동시성을 사용하면 명확하게 기능에 따라 코드를 분리할 수 있다. 만약 이런 설계에서 동시성을 사용하지 않으면 전업 전환 프레임워크를 작성하거나 작업 중에 관련 없는 코드 영역을 호출해야 할 것이다.
예를들어, 사용자 인터페이스가 있는 복잡한 프로그램을 예로 들어보다. 이 예제에서는 데스크탑 DVD 플레이어의 경우를 생각해볼것이다.
이러한 응용 프로그램은 두 가지 주요 작업을 한다.
1) 디스크에서 데이터를 읽고, 영상을 디코딩하고, 소리를 재생하여 DVD가 끊김 없이 재생되도록 한다.
2) 사용자가 '일시정지', '메뉴로 돌아가기', '종료' 버튼을 클릭할 때 이를 처리한다.
만약 이 프로그램이 단일 스레드로 동작한다면, 재생 중에 주기적으로 사용자 입력을 확인하는 흐름이 존재할 것이고, 전체적인 흐름을 고려했을 때 DVD 재생 코드와 사용자 인터페이스 코드가 섞이게 될 것이다. 하지만 멀티스레딩을 사용하면 이 두 작업을 분리할 수 있다. 한 스레드는 사용자 인터페이스를 처리하고, 다른 스레드는 DVD 재생을 처리한다. 이렇게 하면 서로의 작업이 독립적으로 실행된다.
이렇게 하면 사용자 인터페이스 스레드가 사용자 요청에 즉시 반응할 수 있다.
예를 들어, 사용자가 버튼을 클릭하면 즉시 사용자 요청에 대해서 '기다려 주세요' 라는 메시지를 표시할 수 있을 것이다. 그리고 실제 작업은 백그라운드 스레드에서 바쁘게 처리된다. 동시에 사용자 인터페이스는 멈추지 않고 계속 사용자의 요청에 응답할 수 있다.
이런 기법은 백그라운드에서 지속적으로 실행되어야 하는 작업을 실행하는 데 종종 사용된다. 이러한 방식으로 스레드를 사용하면 각 스레드의 논리가 훨씬 간단해지는데, 서로 다른 작업의 코드가 섞일 필요가 없고, 스레드 간의 상호 작용도 명확한 지점에서만 일어나기 때문이다.
이렇게 관심사를 분리하는 기법에서는 스레드의 수는 CPU 코어의 수와 관계없이, 프로그램의 구조를 더 명확하게 하기 위해 결정된다. CPU 코어 수는 스레드가 실행되는 방식을 결정하지만, 스레드를 어떻게 나눌지는 프로그램의 논리적 설계에 따라 결정된다.
[2] 성능을 위한 동시성 사용
멀티프로세서 시스템은 수십 년 동안 존재해왔지만, 과거에는 주로 슈퍼컴퓨터, 메인프레임, 대형 서버 시스템에서만 사용되었다. 그러나 최근에는 CPU 제조업체들이 단일 코어의 성능을 높이는 대신 하나의 CPU에 여러 개의 코어를 넣는 멀티코어 설계를 점점 더 선호하게 되었고, 결과적으로 멀티코어 장치가 점차 보편화되었다. 멀티코어 장치에서 컴퓨팅 파워를 증가시키기 위해서는 단일 작업을 더 빠르게 실행하는 것보다 여러 작업을 동시에 병렬로 실행하는 것이 더 효과적이다. 과거에는 새로운 세대의 프로세서가 나오면 기존 프로그램들은 별다른 노력 없이 빨라졌지만, 이제는 이러한 증가된 컴퓨팅 파워를 활용하려면 프로그램이 여러 작업을 동시에 실행하도록 설계해야 한다.
성능을 위한 동시성을 사용하는 방법에는 두 가지가 있다.
첫째로, 단일 작업을 여러 부분으로 나누어 각 부분을 병렬로 실행함으로써 전체 실행 시간을 줄이는 방법이다.
이를 작업 병렬성이라고 한다. 이는 간단해 보일 수 있지만, 여러 작업 간의 종속성 때문에 복잡해질 수 있다. 이러한 분할은 처리 측면에서 이루어질 수 있다. 예를 들어, 한 스레드가 알고리즘의 한 부분을 수행하는 동안 다른 스레드가 다른 부분을 수행하도록 설계할 수 있다. 이처럼 각 스레드가 동일한 작업을 다른 데이터 부분에서 수행하는 것을 데이터 병렬성이라고 한다.
이렇게 병렬화하기 쉬운 알고리즘은 흔히 '병렬화하기 부끄러울 정도로 쉬운 알고리즘'이라고 한다. 이러한 알고리즘은 좋은 확장성을 가지고 있으며, 사용할 수 있는 하드웨어 스레드의 수가 증가함에 따라 알고리즘의 병렬성도 증가시킬 수 있다.
둘째로, 사용 가능한 병렬성을 활용하여 더 큰 문제를 해결하는 방법이다. 예를 들어, 한 번에 하나의 파일을 처리하는 대신 동시에 2개, 10개, 20개의 파일을 처리하는 것이다.
이는 데이터 병렬성의 또 다른 형태로, 여러 데이터 집합에서 동일한 작업을 병렬로 수행하는 것이다. 하나의 데이터 청크를 처리하는 데 동일한 시간이 걸리지만, 동시에 더 많은 데이터를 처리할 수 있다. 물론 이 접근 방식에도 한계가 있으며 모든 경우에 유익한 것은 아니지만, 처리량 증가를 통해 새로운 가능성을 열어줄 수 있다. 예를 들어, 비디오 처리에서 각 프레임의 다른 영역을 병렬로 처리하면 해상도를 높일 수 있다.
[3] 동시성을 사용하지 말아야 할 때
동시성을 언제 사용하지 말아야 하는지 아는 것도 언제 사용해야 하는지 아는 것만큼 중요하다.
동시성을 사용하지 말아야 하는 이유는 그 이익이 비용을 상회하지 않을 때이다. 동시성을 사용하는 코드는 이해하기 어려워서 멀티스레드 코드를 작성하고 유지보수하는 데 시간이 많이 들고, 추가적인 복잡성은 더 많은 버그를 유발할 수 있다. 따라서 잠재적인 성능 향상이 충분히 크지 않거나 관심사의 분리가 명확하지 않다면, 동시성을 사용하는 것은 바람직하지 않다. 추가 개발 시간과 멀티스레드 코드를 유지보수하는 데 따른 비용을 정당화할 수 없다면 동시성을 사용하지 않는 것이 좋다.
또한, 예상되는 성능 향상이 크지 않을 수도 있다. 스레드를 시작하는 데는 고유의 오버헤드가 있으며, 운영체제가 관련 커널 자원과 스택 공간을 할당한 다음 새로운 스레드를 스케줄러에 추가해야 하기 때문에 시간이 걸린다. 만약 스레드에서 실행되는 작업이 빨리 완료된다면, 스레드를 시작하는 데 드는 오버헤드가 실제 작업 시간보다 더 클 수 있어서, 전체 응용 프로그램의 성능이 오히려 저하될 수 있다.
게다가 스레드는 제한된 자원이다. 너무 많은 스레드를 동시에 실행하면 운영체제 자원을 많이 사용하여 시스템 전체가 느려질 수 있다. 또한, 너무 많은 스레드를 사용하면 프로세스의 사용 가능한 메모리나 주소 공간이 소진될 수 있다. 각 스레드는 별도의 스택 공간을 필요로 하기 때문이다. 이는 평면 아키텍처를 가진 32비트 프로세스에서 특히 문제가 되며, 사용 가능한 주소 공간이 4GB로 제한된다. 각 스레드가 1MB의 스택을 가진다면, 4096개의 스레드로 주소 공간이 모두 사용되어 코드나 정적 데이터, 힙 데이터를 위한 공간이 남지 않게 된다. 비록 64비트 시스템은 이러한 직접적인 주소 공간 제한이 없지만, 여전히 자원이 한정되어 있어 너무 많은 스레드를 실행하면 문제가 발생할 수 있다.
스레드 풀을 사용하여 스레드 수를 제한할 수 있지만, 이 역시 만능 해결책은 아니며 자체적인 문제를 가지고 있다. 예를 들어, 클라이언트/서버 응용 프로그램의 서버 측에서 각 연결마다 별도의 스레드를 시작하는 경우, 적은 수의 연결에서는 잘 작동하지만, 많은 연결을 처리해야 하는 고수요 서버에서는 너무 많은 스레드를 시작하게 되어 시스템 자원이 고갈될 수 있다. 이 시나리오에서는 스레드 풀을 신중하게 사용하여 최적의 성능을 제공할 수 있다.
마지막으로, 실행 중인 스레드가 많을수록 운영체제가 컨텍스트 스위칭을 더 많이 해야 한다. 각 컨텍스트 스위칭은 유용한 작업을 수행하는 대신 시간을 소비하게 되므로, 어느 시점에서는 추가 스레드를 추가하는 것이 전체 응용 프로그램 성능을 증가시키기보다는 오히려 감소시킬 수 있다. 따라서 시스템의 최상의 성능을 달성하려면 실행 중인 스레드 수를 사용 가능한 하드웨어 동시성을 고려하여 조정해야 한다.
성능을 위한 동시성 사용은 다른 최적화 전략과 마찬가지로 응용 프로그램의 성능을 크게 향상시킬 잠재력을 가지고 있지만, 코드를 복잡하게 만들어 이해하기 어렵고 버그가 발생할 가능성을 높인다. 따라서 응용 프로그램에서 성능이 중요한 부분에 대해 측정 가능한 이득이 있을 때만 동시성을 사용하는 것이 가치가 있다. 물론, 성능 향상이 설계의 명확성이나 관심사의 분리에 비해 부차적인 경우에도 멀티스레드 설계를 사용하는 것은 여전히 가치가 있을 수 있다.
동시성을 사용하기로 결정했다면, 성능을 위해서든, 관심사의 분리를 위해서든, 혹은 그 밖에 이유가 있든, C++ 프로그래머에게 그것이 무엇을 의미하는지 알아보자.
3. C++에서의 동시성과 멀티스레딩
C++11 에서 멀티스레딩을 통한 동시성 지원이 시작되었고, 플랫폼별 확장을 사용하지 않고 멀티스레드 코드를 작성할 수 있게 되었다. 새로운 표준 C++ 스레드 라이브러리의 많은 결정들에 대한 이론적 배경을 이해하기위해 그 역사를 잠시 들여다보도록 하자.
[1] C++에서 멀티스레딩의 역사
1998년 C++ 표준은 스레드의 존재를 인정하지 않았으며, 메모리 모델도 공식적으로 정의되지 않았기 때문에 C++98 표준만으로는 컴파일러별 확장 없이 멀티스레드 애플리케이션을 작성할 수 없었다.
C++ 표준에는 멀티스레딩 지원이 없었지만, 컴파일러 공급업체들은 자신들만의 확장을 추가할 수 있었다. 예를 들어, POSIX C 표준이나 Microsoft Windows API와 같은 멀티스레딩을 지원하는 C API를 사용하여 컴파일러들이 멀티스레딩을 지원하도록 만들었다. 이러한 컴파일러들은 C API를 사용할 수 있게 하고, C++ 런타임 라이브러리가 멀티스레딩 환경에서 잘 작동하도록 보장했다.
아주 소수의 컴파일러만이 메모리 모델을 제공했기에, 대부분의 컴파일러에서는 메모리에 대한 접근이 명확히 정의되지 않았지만, 컴파일러와 프로세서가 멀티스레드 프로그램을 잘 실행할 수 있었기에 표준화된 지원이 없었음에도 불구하고 많은 프로그래머들이 멀티스레딩을 사용하여 프로그램을 만들 수 있었다.
C++ 프로그래머들은 멀티스레딩을 처리하기 위해 플랫폼별 C API를 사용하는 것에 만족하지 않고, 클래스 라이브러리를 통해 객체 지향 멀티스레딩 기능을 제공받고자 했다. MFC와 같은 애플리케이션 프레임워크, 그리고 Boost와 ACE 같은 범용 C++ 라이브러리는 기본 플랫폼별 API를 래핑하여 멀티스레딩을 더 쉽게 할 수 있는 고수준 도구를 제공했다. 다양한 클래스 라이브러리의 전체적인 클래스 구조는 많은 공통점을 가지고 있었다. 많은 C++ 클래스 라이브러리에 공통적으로 적용되며 프로그래머에게 상당한 이점을 제공하는 중요한 설계 중 하나는 Resource Acquisition Is Initialization (RAII) 설계를 사용하여 스코프를 벗어날 때 mutex가 자동으로 해제되도록 보장하는 것이었다.
많은 경우, 기존 C++ 컴파일러의 멀티스레딩 지원과 플랫폼별 API 및 Boost와 ACE 같은 플랫폼 독립적 클래스 라이브러리를 결합하면 멀티스레드 C++ 코드를 쉽게 작성할 수 있었다. 그러나 표준 지원의 부족은 하드웨어 차원에서 최적화를 하고자 할 때와 크로스 플랫폼 코드를 작성할 때 문제가 될 수 있었다. 이는 컴파일러의 실제 동작이 플랫폼마다 다를 수 있기 때문이다.
[2] 새로운 표준에서의 동시성 지원
그러나 C++11 표준에서 동시성 지원이 시작되면서, 새로운 스레드 인식 메모리 모델뿐만 아니라 C++ 표준 라이브러리가 확장되어 스레드를 관리하는 클래스, 공유 데이터를 보호하는 클래스, 스레드 간 작업을 동기화하는 클래스, 저수준의 원자적 연산 클래스가 포함되었다.
새로운 C++ 스레드 라이브러리는 이전에 언급된 C++ 클래스 라이브러리를 사용한 경험을 바탕으로 만들어졌다. 특히 Boost Thread Library가 주요 모델로 사용되었으며, 많은 클래스가 Boost와 이름과 구조를 공유하고 있다. 새로운 표준이 발전함에 따라 Boost Thread Library 자체도 C++ 표준에 맞추어 변경되었기에, Boost에서 전환하는 사용자에게도 많은 도움이 될 것이다.
C++에서 직접 원자적 연산을 지원하기에 프로그래머는 플랫폼별 어셈블리 언어 없이도 정의된 의미를 가진 효율적인 코드를 작성할 수 있게 되었다. 이는 효율적이고 이식 가능한 코드를 작성하려는 사람들에게 매우 큰 도움이 된다. 컴파일러가 플랫폼 특성을 처리할 뿐만 아니라, 컴파일러의 최적화기가 원자적 연산의 의미를 이해하고, 프로그램 전체를 최적화할 수 있다.
[3] C++ 스레드 라이브러리의 효율성
고성능 컴퓨팅에 관여하는 개발자들이 일반적으로 C++ 클래스에 대해 자주 제기하는 문제는 성능이다.
최상의 성능을 원한다면, 고수준 기능을 사용하는 것과 저수준 기능을 직접 사용하는 것 사이의 구현 비용을 이해하는 것이 중요하다. 이러한 비용을 추상화 비용이라고 한다.
C++ 표준 위원회는 C++ 표준 라이브러리 전반과 C++ 표준 스레드 라이브러리를 설계할 때 이 점을 매우 인식하고 있었다. 설계 목표 중 하나는 동일한 기능이 제공되는 경우 저수준 API를 사용하는 것과 라이브러리를 사용하는 것과 비교하여 성능상 차이가 거의 없도록 하는 것이었다. 따라서 라이브러리는 대부분의 주요 플랫폼에서 매우 낮은 추상화 비용으로 효율적으로 구현이 가능하도록 설계되었다.
또 다른 목표는 최상의 성능을 원하는 프로그래머를 위해 저수준 기능도 충분히 제공하는 것이었다. C++11 표준에는 이를 위한 새로운 메모리 모델과 원자적 연산이 포함되어 있으며, 이를 사용하면 개별 비트와 바이트를 직접 제어할 수 있고 스레드 간의 동기화와 변경 사항을 쉽게 파악할 수 있다. 이러한 원자적 타입과 관련 연산을 사용하면, 더 이상 저수준 구현을 위해 플랫폼별 어셈블리 언어를 사용할 필요가 없다. 새로운 표준 타입과 연산을 사용하면 다양한 플랫폼에서 이식성이 좋고 유지보수가 용이하다.
고수준의 동기화 도구들은 멀티스레딩을 쉽게 구현할 수 있도록 하지만, 대부분의 경우 필요 이상으로 추가 기능을 제공하기도 한다. 드물게 이러한 사용되지 않는 기능이 다른 코드의 성능에 영향을 미칠 수 있다. 성능을 목표로 하고 비용이 너무 높다면 저수준 기능에서 원하는 기능을 수작업으로 만드는 것이 더 나을 수 있다. 그러나 대부분의 경우 추가적인 복잡성과 오류 가능성이 작은 성능 이득보다 훨씬 더 크다. 프로파일링을 통해 병목 현상이 C++ 표준 라이브러리 기능에서 발생한다는 것이 입증되더라도, 이는 잘못된 라이브러리 구현보다는 잘못된 애플리케이션 설계 때문일 수 있다. 예를 들어, 너무 많은 스레드가 하나의 mutex를 두고 경쟁하면 성능에 큰 영향을 미친다. 이런 경우 mutex 자체의 문제보다는 경쟁을 줄이도록 응용 프로그램을 재구성하는 것이 더 유익하다.
아주 드물게 C++ 표준 라이브러리가 요구되는 성능이나 동작을 제공하지 못하는 경우가 발생하면, 플랫폼별 API를 사용하여 설계하는 것이 필요할 수도 있다.
[4] 플랫폼별 기능
C++ 스레드 라이브러리는 멀티스레딩과 동시성을 위해 상당히 포괄적인 기능을 제공하지만, 특정 플랫폼에서는 이 표준 라이브러리보다 더 강력한 기능을 제공할 수도 있다. 표준 C++ 스레드 라이브러리를 사용하면서도 플랫폼별 기능을 사용할 수 있는데, 이를 위해 C++ 스레드 라이브러리의 타입들은 'native_handle()' 이라는 멤버 함수를 제공한다. 이 함수는 기본 구현을 플랫폼별 API를 사용하여 직접 조작할 수 있게 한다. native_handle()을 사용하는 모든 연산은 본질적으로 플랫폼에 종속적이게 되며, 이는 이 포스팅의 범위를 벗어나는 내용일 뿐만 아니라 타 플랫폼으로의 이식성도 나빠질 수 있음을 이해해야 한다.
물론, 플랫폼별 기능을 사용하기 전에 표준 라이브러리가 제공하는 것을 충분히 이해하는 것이 중요하다.
이제 예제를 통해 동시성 프로그래밍을 다뤄보도록 하겠다.
4. 시작해봅시다.
이제 C++11을 지원하는 컴파일러에서 멀티스레드 라이브러리를 사용해보자. 멀티스레드 C++ 프로그램은 어떻게 생겼을까? 일반적인 C++ 프로그램과 거의 비슷하다. 유일한 차이점은 일부 함수가 동시에 실행될 수 있다는 것이다. 그러므로 앞서 설명한 것처럼 '공유 데이터'에 동시 접근함에 있어 안전한 접근을 보장해야 한다.
함수에 동시에 접근하기 위해서는 다양한 스레드 관리를 위한 함수와 객체들을 사용해야 한다.
(이는 나중에 추가적으로 다루도록 한다.)
[1] Hello, Concurrent World
뻔하지만 클래식한 예제로 시작해보자. "Hello World"를 출력해보자.
단일 스레드에서 실행되는 정말 간단한 Hello, World 프로그램은 다음과 같다.
그러면 다음 목록에서 메시지를 표시하기 위해 별도의 스레드를 시작하는 간단한 예제를 살펴보자.
위 두 개의 코드를 비교해보자.
첫 번째 차이점은 추가된 `#include <thread>`이다. 이는 멀티스레딩을 지원하는 새로운 C++ 표준 라이브러리 헤더이다. 스레드를 관리하기 위한 함수와 클래스는 <thread>에 선언되어 있으며, 공유 데이터를 보호하기 위한 것들은 다른 헤더에 선언되어 있다.
두 번째로, 메시지를 작성하는 코드가 별도의 함수로 이동하였다. 이는 모든 스레드가 초기 함수(initial function)를 가져야 하기 때문이다. 새 스레드의 실행은 이 함수에서 시작된다. 응용 프로그램의 초기 스레드의 경우 초기 함수는 `main` 함수이다. 하지만 다른 모든 스레드는 `std::thread` 객체의 생성자에서 지정된 함수에서 시작된다.
해당 예제에서는 스레드 `t`의 초기 함수는 `hello`이다.
새 스레드를 시작한 후, 초기 스레드는 실행을 계속한다. 만약 새로운 스레드가 끝날 때까지 기다리지 않으면, 초기 스레드는 `main()`의 끝까지 계속 실행될 것이고, 이어서 프로그램을 종료할 것이다. 이 경우 스레드 `t`가 실행을 완료한다는 보장이 없다. 그래서 `join()` 호출이 존재하는 것인데, 이는 `main()`의 호출 스레드가 `std::thread` 객체와 관련된 스레드를 기다리게 만든다. 이는 `std::thread`의 실행 완료를 보장한다는 의미이기도 하다.
해당 예시는 단순 예제일 뿐이고 단순히 메시지를 출력하기 위해 여러 스레드를 사용하는 것은 대부분 가치가 없다. 특히 초기 스레드가 그 동안 할 일이 없는 경우에는 더욱 그렇다. 나중에는 여러 스레드를 사용하는 것이 명확하게 이득이 되는 시나리오를 소개할 것이다.
예제에서 보았듯 C++ 표준 라이브러리의 클래스와 함수를 사용하는 것은 매우 간단하다. C++에서 여러 스레드를 사용하는 것 그 자체는 복잡하지 않다. 물론 코드가 의도한 대로 작동하도록 설계하는 것은 힘들 것이다.
다음 포스팅에서는 스레드 관리를 위해 사용할 수 있는 클래스와 함수들에 대해 살펴보도록 하겠다.
'멀티스레드 프로그래밍 > Concurrency in action C++' 카테고리의 다른 글
[멀티스레드 프로그래밍] 스레드 관리 (0) | 2024.07.05 |
---|