z 8. Designing concurrent code :: C++, 그래픽 기술 블로그

 기본적인 데이터 구조를 설계하고 사용하는 것보다 동시 코드를 설계하는 것이 더 중요합니다. 이제 더 넓은 문맥을 봐야 유용한 작업을 수행할 수 있는 더 큰 구조를 만들 수 있습니다. 일부 C++ 표준 라이브러리 알고리즘의 멀티 스레드 구현을 예로 들겠지만, 응용 프로그램의 모든 규모에서 동일한 원칙이 적용됩니다.
 다른 프로그래밍 프로젝트와 마찬가지로 동시 코드의 설계에 대해 신중하게 생각하는 것이 중요합니다. 그러나 멀티 스레드 코드의 경우 순차적 코드보다 고려해야 할 요소가 훨씬 더 많습니다. 캡슐화, 결합 및 응집(소프트웨어 설계에 관한 많은 책에 자세히 설명되어 있음)과 같은 일반적인 요인뿐만 아니라 공유할 데이터, 해당 데이터에 대한 접근을 동기화하는 방법, 특정 작업을 완료하기 위해 어떤 스레드가 대기해야 하는지 등을 고려해야 합니다.
 이 챕터에서는 사용할 스레드 수, 어떤 코드를 어떤 스레드에서 실행할 것인지, 그리고 이것이 코드의 명확성에 어떤 영향을 미칠 수 있는지에 대한 높은 수준의 고려 사항에서부터 최적의 성능을 위해 공유 데이터를 구성하는 방법에 대한 낮은 수준의 세부 사항까지 이러한 문제에 초점을 맞출 것입니다.

8.1. Techniques for dividing work between threads

사용할 스레드 수와 해당 스레드에서 수행할 작업을 결정해야 하며 스레드별로 작업을 어떻게 나눌지도 고민해야 합니다. 동시성을 사용하는 주요 이유가 무엇이든 간에 이러한 선택을 해야 하며, 이를 수행하는 방법은 코드의 성능과 선명도에 중요한 영향을 미칩니다. 따라서 응용프로그램 구조를 설계할 때 적절한 정보에 입각한 결정을 내릴 수 있도록 옵션을 이해하는 것이 중요합니다. 작업을 적절하게 나누는 방법부터 알아보겠습니다.

8.1.1. Dividing data between threads before processing begins

 병행처리하는 가장 쉬운 알고리즘은 데이터 집합의 각 원소에 대해 연산을 수행하는 std::for_each와 같은 단순한 알고리즘입니다. 이러한 알고리즘을 병행처리 하기 위해서는 각 원소들을 처리 스레드들중의 하나에 할당시켜 주면 됩니다. 최적의 성능을 위한 원소 분배는 데이터 구조 세부사항에 달려 있습니다.
 데이터를 분할하는 가장 간단한 방법은 첫 번째 N개의 요소를 한 스레드에 할당하고 다음 N개의 요소를 다른 스레드에 할당하는 것이지만 다른 패턴도 사용할 수 있습니다. 데이터가 어떻게 분할되든 간에 각 스레드는 처리를 완료할 때까지 다른 스레드와의 통신 없이 할당된 요소를 처리합니다. 작업은 병렬 작업 집합으로 분할되고 작업자 스레드는 이러한 작업을 독립적으로 실행하고 결과는 최종 감소 단계(reduction step)에서 결합됩니다. 이러한 마지막 감소 단계를 잘 짜는 것도 중요한데, 스레드에서 처리할 최소 항목 수보다 스레드 수가 많은 경우 자신을 재귀적으로 호출되도록 하거나 각 스레드가 작업을 완료할 때 감소 단계의 일부를 수행하도록 할 수 있습니다. 이 기술은 강력하지만, 모든 것에 적용될 수 있는 것은 아니다. 데이터가 처리될 때에만 필요한 구분이 나타나기 때문에 데이터를 사전에 깔끔하게 분할할 수 없는 경우가 있습니다. 

 

8.1.2. Dividing data recursively

퀵소트 알고리즘은 2개의 기본 단계를 가진다. 데이터를 피봇 이전과 이후 데이터로 나누는 것과 이 두개의 반쪽 데이터를 재귀적으로 정렬하는 것이다. 데이터를 먼저 분할하여 병렬화할 수는 없습니다. 항목을 처리해야만 항목이 반으로 입력되는지 알 수 있기 때문입니다. 이 알고리즘을 병렬화하려면 재귀적 특성을 활용해야 합니다. 이러한 재귀 호출은 개별 요소 집합에 접근하기 때문에 완전히 독립적이기에 동시적 실행으로 사용하기 좋습니다. 
 대규모 데이터 집합을 정렬하는 경우 각 재귀에 대해 새 스레드를 만들면 스레드가 빠르게 많아지므로 오히려 속도가 느려지고, 데이터가 크면 스레드가 부족해질 수 있습니다. 따라서 스레드를 엄격하게 관리해야 합니다. std::async은 이를 위한 유일한 방법은 아닙니다.
 한 가지 대안은 std::thread::hardware_concurrency() 함수를 사용하여 스레드 수를 선택하여, 재귀 호출할 때 마다 chunk로 스레드 세이프 스택을 정렬할 수 있습니다.

 

Listing 8.1 Parallel Quicksort using a stack of pending chunks to sort

template<typename T>
struct sorter
{
    struct chunk_to_sort
    {
        std::list<T> data;
        std::promise<std::list<T> > promise;
    };

    thread_safe_stack<chunk_to_sort> chunks;
    std::vector<std::thread> threads;
    unsigned const max_thread_count;
    std::atomic<bool> end_of_data;

    sorter():
        max_thread_count(std::thread::hardware_concurrency()-1),
        end_of_data(false)
    {}

    ~sorter()
    {
        end_of_data=true;
        for(unsigned i=0;i<threads.size();++i)
        {
            threads[i].join();
        }
    }

    void try_sort_chunk()
    {
        boost::shared_ptr<chunk_to_sort > chunk=chunks.pop();
        if(chunk)
        {
            sort_chunk(chunk);
        }
    }

    std::list<T> do_sort(std::list<T>& chunk_data)
    {
        if(chunk_data.empty())
        {
            return chunk_data;
        }

        std::list<T> result;
        result.splice(result.begin(),chunk_data,chunk_data.begin());
        T const& partition_val=*result.begin();

        typename std::list<T>::iterator divide_point=
            std::partition(chunk_data.begin(),chunk_data.end(),
                           [&](T const& val){return val<partition_val;});
        chunk_to_sort new_lower_chunk;
        new_lower_chunk.data.splice(new_lower_chunk.data.end(),
                                    chunk_data,chunk_data.begin(),
                                    divide_point);

        std::future<std::list<T> > new_lower=
            new_lower_chunk.promise.get_future();
        chunks.push(std::move(new_lower_chunk));
        if(threads.size()<max_thread_count)
        {
            threads.push_back(std::thread(&sorter<T>::sort_thread,this));
        }

        std::list<T> new_higher(do_sort(chunk_data));

        result.splice(result.end(),new_higher);
        while(new_lower.wait_for(std::chrono::seconds(0)) !=
              std::future_status::ready)
        {
            try_sort_chunk();
        }

        result.splice(result.begin(),new_lower.get());
        return result;
    }

    void sort_chunk(boost::shared_ptr<chunk_to_sort > const& chunk)
    {
        chunk->promise.set_value(do_sort(chunk->data));
    }

    void sort_thread()
    {
        while(!end_of_data)
        {
            try_sort_chunk();
            std::this_thread::yield();
        }
    }
};

template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)
{
    if(input.empty())
    {
        return input;
    }
    sorter<T> s;
    return s.do_sort(input);
}

여기서 parallel_quick_sort 함수는 정렬되지 않은 chunk와 스레드의 스택을 그룹화하는 쉬운 방법을 제공합니다. 주요 작업은 do_sort member 함수에서 수행되며, 이는 데이터의 일반적인 분할을 수행합니다. 이번에는 하나의 chunk를 스택 에 밀어넣고 프로세서가 아직 남아 있다면 새 스레드를 생성합니다. 하위 chunk가 다른 스레드에서 처리될 수 있으므로 준비될 때까지 기다려야 합니다. 유일한 스레드이거나 다른 모든 스레드가 이미 사용 중인 경우, 기다리는 동안 try_sort_chunk로 스택에서 chunk를 꺼내 정렬하고 이를 promise에 저장하여 가져갈 수 있도록 합니다.
새로 생성된 스레드는 end_of_data 플래그가 설정되지 않은 동안 스택에서 chunk를 정렬하고 있습니다. 체크 사이에 다른 스레드들이 스택에 추가 작업을 제공할 수도 있습니다. 이 코드는 sorter 클래스의 소멸자에 의존하여 스레드를 정리합니다. 모든 데이터가 정렬되면 do_sort가 반환되므로, 기본 스레드가 parallel_quick_sort에서 반환되고 sorter 개체가 삭제됩니다. 이렇게 하면 end_of_data 플래그를 설정하고 스레드가 마칠 때까지 기다립니다. 플래그를 설정하면 스레드 함수에서 루프가 종료됩니다.


 이러한 스레드의 관리와 스레드 간의 통신은 코드에 상당한 복잡성을 가중시킨다는 또 다른 잠재적 문제가 있습니다. 또한 스레드는 별도의 데이터 요소를 처리하지만, 모두 스택에 접근하여 새로운 chunk를 추가하고 chunk를 제거합니다. 이러한 심한 경쟁 상태는 lock-free 스택을 사용하는 경우에도 성능을 저하시킬 수 있습니다.
 이 접근 방식은 스레드 풀의 특수 버전에 해당합니다. 스레드 풀은 각 스레드 집합은 보류 중인 작업 목록에서 작업을 수행하고 다시 목록으로 이동하여 더 많은 작업을 수행합니다. 

 

8.1.3. Dividing work by task type

작업을 분배하는 다른 방법으로는 특별한 스레드를 만드는 방법이 존재합니다. 스레드들은 똑같은 데이터로 작업할 수도 있고 아닐 수도 있는데, 만약 똑같은 데이터로 작업하게 된다면, 스레드들의 목적은 서로 다르게 될 것입니다.

 

[Dividing Work by Task Type To Seperate Concerns]

단일 스레드 애플리케이션은 일정 기간 동안 지속적으로 실행되어야 하는 여러 작업이 있거나 다른 작업이 있는 동안에도 애플리케이션이 적시에 들어오는 이벤트(예: 사용자 키 누르기 또는 들어오는 네트워크 데이터)를 처리할 수 있어야 하는 단일 책임 원칙과의 충돌을 처리해야 합니다. 진행 중인 단일 스레드 환경에서는 작업 A의 비트, 작업 B의 비트, 키 누름 확인, 들어오는 네트워크 패킷 확인, 그리고 다시 루프백하여 작업 A의 다른 비트를 수행하는 코드를 수동으로 작성하게 됩니다. 이것은 작업 A의 코드가 그것의 상태를 저장하고 주기적으로 메인 루프로 제어를 되돌려야 하는 필요성에 의해 복잡해짐을 의미한다. 루프에 너무 많은 작업을 추가하면 작업이 너무 느려질 수 있으며 사용자가 키를 누르는 데 시간이 너무 오래 걸릴 수 있습니다. 여러분 모두 이런 극단적인 형태를 본 적이 있으실 겁니다. 어떤 애플리케이션이나 다른 애플리케이션에서는 어떤 작업을 수행하도록 설정하고, 작업을 완료할 때까지 인터페이스는 정지합니다.
여기서 실이 나옵니다. 각 태스크를 별도의 스레드에서 실행하는 경우 운영 체제에서 이를 처리합니다. 작업 A의 코드에서, 당신은 작업 수행에 집중할 수 있으며, 상태를 저장하고 메인 루프로 돌아가는 것 또는 작업하기 전에 걸리는 시간에 대해 걱정하지 않아도 된다. 운영 체제는 자동으로 상태를 저장하고 적절한 경우 태스크 B 또는 C로 전환하며, 대상 시스템에 여러 개의 코어 또는 프로세서가 있는 경우 태스크 A와 B를 동시에 실행할 수 있습니다. han- 주요 언론이나 네트워크 패킷 dling을 위한 코드 이제는 시의 적절하게 모두가:왜냐하면 각각의 실보다는 제어 흐름 및 사용자 interacti과 혼동하고 작전 직접적으로 responsibili- 관계에 관련을 다하는 데 집중할 수 있는 만큼 개발자, 더 간단한 코드를 가진 사용자, 그리고 당신, 시기 적절한 반응 이기운영될 것이다..
좋은, 장밋빛 전망처럼 들린다. 그것이 그렇게 될 수 있을까? 모든 것과 마찬가지로,은 세부 사항에 달려 있다. 만약 모든 것이 실을 필요가 없서 서로 의사 소통을 독립적인 것 이것은 쉬울 수 있다. 불행히도, 세상 거의 그와 같은 것이다. 이것이 좋아 배경 업무들은 보통,, 그들이 어떤 방법으로 사용자 인터페이스의 갱신에 따라 연락하고 사용자를 알 수 있게 하는 사용자가 요청한 무언가를 하고 있다. 대신에, 사용자 이것은 사용자 인터페이스 배경 작업을 중단시키기 위한 것까지 모든 메시지를 보내는 데 필요한 일을 취소할 수 있습니다. 둘 다 이러한 경우와 능력을 동기화 suit-지만, 대한 우려도 분리된 신중한 생각과 디자인이 필요하다. 사용자 인터페이스 스레드는 있지만, 이렇게 다른 스레드로 하는 질문을 받았는데를 갱신할 수 있는 사용자 인터페이스를 처리합니다. 마찬가지로,는 스레드가 배경 과제 달리기는 여전히 작전 그 직무에 필요한에서 단지 그들 중 한명이“과제 다른 스레드에 의해서 중단이 가능합니다.”해 있는 초점을 맞추고 있다. 어느 경우건만은 그들을 위해 의도하던 곳에서 요청에서 직접 그들의 책임과 관련된 것 왔다 하는 스레드를 챙겨 준다.
여러 스레드와 분리하는 것에 대한 우려에 두개의 큰 위험들이 있습니다. 스레드들 사이에 너무 많은 소통을 필요로 하게 됩니다. 이렇게 되면 소통의 이유를 살펴볼 필요가 있다. 모든 통신이 동일한 문제와 관련된 경우, 단일 스레드의 주요 책임이 되고 해당 스레드를 참조하는 모든 스레드에서 추출되어야 합니다. 또는 두 개의 스레드가 서로 많이 통신하지만 다른 스레드와는 훨씬 덜 통신하는 경우 단일 스레드로 결합해야 할 수도 있습니다.
작업 유형별로 스레드로 작업을 나눌 때 완전히 분리된 사례에 국한할 필요가 없습니다. 여러 입력 데이터 집합에서 동일한 작업 시퀀스를 적용해야 하는 경우 각 스레드가 전체 시퀀스에서 한 단계를 수행하도록 작업을 분할할 수 있습니다.

 

[Dividing A Sequence of Tasks Between Threads]

  태스크가 여러 독립 데이터 항목에 동일한 작업 시퀀스를 적용하는 것으로 구성된 경우, 파이프라인을 사용하여 시스템의 사용 가능한 동시성을 활용할 수 있습니다. 이러한 방식으로 작업을 나누려면 파이프라인의 각 단계에 대해 별도의 스레드를 만듭니다. 작업이 완료되면 다음 스레드에서 데이터 요소를 픽업할 대기열에 넣습니다. 이렇게 하면 파이프라인의 두 번째 스레드가 첫 번째 요소에서 작업하는 동안 시퀀스에서 첫 번째 작업을 수행하는 스레드가 다음 데이터 요소에서 시작할 수 있습니다.
 작업을 시작할 때 입력 데이터 자체를 모두 알 수 없는 상황에 적합합니다. 예를 들어, 데이터를 네트워크를 통해 수신하거나, 처리할 파일을 식별하기 위해 파일 시스템을 스캔하는 것이 순서일 수 있습니다.
 또한 시퀀스의 각 작업이 시간이 많이 걸리는 경우에도 파이프라인이 좋습니다. 즉, 데이터가 아닌 스레드 간에 태스크를 나누면 성능 프로필을 변경할 수 있습니다. 일괄적으로 한번에 결과가 나오던 것이 짧은 주기로 조금씩 결과가 나오게 됩니다. 이러한 보다 부드럽고 규칙적인 처리는 일부 상황에서 도움이 될 수 있습니다. 처음의 시간을 조금 감수하면 지속적으로 볼 수 있는 비디오가 이 예가 될 수 있습니다.

 

8.2.  Factors affecting the performance of concurrent code

 여러 프로세서가 있는 시스템에서 동시성을 사용하여 코드의 성능을 향상하는 경우 성능에 영향을 미칠 요인을 알아야 합니다. 여러 개의 스레드를 사용하여 문제를 분리하는 경우에도 성능에 부정적인 영향을 미치지 않도록 해야 합니다

8.2.1. How many processors?

 프로세서의 수와 구조는 멀티스레드 애플리케이션의 성능에 영향을 끼치는 가장 크고 중요한 요소입니다. 보통은 어떤 하드웨어에서 돌아가는 지 잘 알 수 없습니다. 실험 환경과 유사할 수도 있지만, 다를 수도 있습니다. 예를 들어 코어의 수가 듀얼일 수도 있지만, 실제로는 여러 개의 싱글 코어일 상황도 존재할 것입니다. 동시 프로그램의 동작 및 성능 특성은 이러한 서로 다른 상황에서 상당히 달라질 수 있으므로 어떤 영향이 있을 수 있는지 신중하게 생각하고 가능한 경우 테스트해야 합니다.
 하나의 16코어 프로세서는 4개의 쿼드코어 프로세서 또는 16개의 싱글코어 프로세서와 동일하며, 각각의 경우 시스템은 16개의 스레드를 동시에 실행할 수 있습니다. 이 기능을 사용하려면 프로그램에 최소 16개의 스레드가 있어야 합니다. 16개 미만이면 테이블 위에 프로세서 전원이 남아 있는 것입니다. 반면에, 실행 준비가 된 스레드가 16개 이상 있는 경우, 과다 구독이 발생합니다.
 std::thread::hardware_concurrency()를 통해 코어의 수를 알 수 있지만 이를 직접 사용하려면 주의가 필요합니다. 코드를 사용하려면 시스템에서 실행 중인 다른 스레드는 해당 정보를 명시적으로 공유해야 합니다. 최악의 경우 여러 스레드가 동시에 스케일링에 std::thread::hardware_concurrency()를 사용하는 함수를 호출하면 엄청난 과다 구독이 발생합니다. std::async의 경우는 라이브러리가 모든 호출을 인식하고 있으며 적절하게 스케쥴링할 수 있기 때문에 이 문제를 방지합니다. 또한, 스레드 풀을 신중하게 사용한다면 이 문제를 방지할 수 있습니다.
 그러나 응용프로그램에서 실행되는 모든 스레드를 고려하더라도 동시에 실행되는 다른 응용프로그램의 영향을 받을 수 있습니다. 한 가지 옵션은 std::async()와 같은 기능으로 스레드 수를 선택할 때 모든 응용 프로그램에서 실행되는 총 비동기 작업 수를 고려하는 것입니다. 다른 하나는 주어진 응용 프로그램에서 사용할 수 있는 처리 코어의 수를 제한하는 것입니다. 
 이 상황에 대한 추가적인 문제는 문제에 대한 이상적인 알고리즘이 처리 단위의 수와 비교하여 문제의 크기에 따라 달라질 수 있다는 것입니다. 프로세서의 수가 증가함에 따라 여러 프로세서가 동일한 데이터에 접근하려고 할 때 발생하는 또 다른 문제, 즉 여러 프로세서의 발생 가능성과 성능에 미치는 영향도 증가합니다.

 

8.2.2. Data contention and cache ping-pong

 두 개의 스레드가 서로 다른 프로세서에서 동시에 실행 중이고 둘 다 동일한 데이터를 읽는 경우 일반적으로 문제가 발생하지 않습니다. 데이터가 각각의 캐시에 복사되고 두 프로세서 모두 계속할 수 있습니다. 그러나 스레드 중 하나가 데이터를 수정하는 경우 이 변경사항은 다른 코어의 캐시로 전파되어야 하므로 시간이 걸립니다. 이 수정은 두 스레드의 동작의 특성과 동작에 사용되는 메모리 순서에 따라 두 번째 프로세서가 멈추고 변경 사항이 메모리 하드웨어를 통해 전달될 때까지 기다리게 될 수도 있습니다. CPU 명령어들의 측면에서, 정확한 타이밍은 주로 하드웨어의 물리적 구조에 달려있지만, 수백 개의 개별 명령어들과 맞먹는 놀랄 만큼 느린 동작일 수도 있습니다.

std::atomic<unsigned long> counter(0);
void processing_loop()
{
    while(counter.fetch_add(1,std::memory_order_relaxed)<100000000)
    {
        do_something();
    }
}

카운터가 전역적이므로 processing_loop()을 호출하는 스레드는 동일한 변수를 수정합니다. 그러므로 프로세서는 각각의 증분에 대해 캐시에 카운터의 최신 복사본을 가지고 있는지 확인하고, 값을 수정하고, 다른 프로세서에 게시해야 한다. std::memory_order_relaxed를 사용하므로 컴파일러가 다른 데이터를 동기화할 필요는 없지만, fetch_add는 읽기-수정-쓰기 작업이기 때문에 변수의 가장 최근의 값을 검색하게 해야합니다. 만약 다른 프로세서의 다른 스레드가 동일한 코드를 실행 중이라면, 카운터의 데이터는 두 프로세서와 해당 캐시 사이에 앞뒤로 전달되어야만 각 프로세서가 카운터에 대한 최신 값을 가질 수 있습니다. do_something()이 짧거나 이 코드를 실행하는 프로세서가 너무 많으면 프로세서들은 서로 대기하고 있을 수 있습니다. 한 프로세서는 값을 업데이트할 준비가 되어 있지만 다른 프로세서는 현재 값을 업데이트하고 있기 때문에 두 번째 프로세서가 업데이트를 완료하고 변경 사항이 전파될 때까지 기다려야만 합니다. 이러한 상황을 높은 경쟁이라고 합니다. 프로세서가 서로 대기할 필요가 거의 없는 경우에는 낮은 경합이라 합니다.
 이와 같은 루프에서 counter 데이터는 캐시 간에 여러 번 앞뒤로 전달됩니다. 이를 캐시 핑퐁이라고 하며, 애플리케이션의 성능에 심각한 영향을 미칠 수 있습니다. 프로세서가 캐시 전송을 기다려야 하기 때문에 정지하는 경우, 유용한 작업을 수행할 수 있는 다른 스레드가 대기하고 있더라도 그 동안 아무 작업도 수행할 수 없습니다.
 루프에서 뮤텍스를 획득하는 경우 데이터 접근의 관점에서 코드가 이전 코드와 유사합니다. 뮤텍스를 잠그려면 다른 스레드가 뮤텍스를 구성하는 데이터를 프로세서로 전송하고 수정해야 합니다. 완료되면 뮤텍스를 다시 수정하여 잠금을 해제하고 뮤텍스를 획득하려면 다음 스레드로 뮤텍스 데이터를 전송해야 합니다. 이 전송 시간은 첫 번째 스레드가 뮤텍스를 해제하기 위해 두 번째 스레드가 대기해야 하는 시간에 추가됩니다.

std::mutex m;
my_data data;
void processing_loop_with_mutex()
{
    while(true)
    {
        std::lock_guard<std::mutex> lk(m);
        if(done_processing(data)) break;
    }
}

 

 최악은 데이터 및 뮤텍스에 두 개 이상의 스레드로 접근할 경우 시스템에 코어 및 프로세서를 추가할수록 높은 경합이 발생하고 한 프로세서가 다른 프로세서를 기다려야 할 가능성이 높아진다는 것 입니다. 동일한 데이터를 더 빠르게 처리하기 위해 여러 스레드를 사용하는 경우 스레드는 데이터를 위해 경쟁하므로 동일한 뮤텍스를 위해 경쟁합니다. 더 많을수록, 그들은 뮤텍스를 동시에 얻을려고 하거나, 동시에 원자 변수에 접근하는 등의 문제가 발생할 것입니다.
 뮤텍스를 사용한 경합의 영향은 프로세서의 레벨보다는 운영 체제 레벨에서 직렬화하기 때문에 원자적 연산의 경합과는 영향력이 차이가 납니다. 실행할 수 있는 스레드가 충분히 있는 경우 운영 체제는 한 스레드가 뮤텍스를 기다리는 동안 다른 스레드를 실행하도록 예약할 수 있습니다. 그러나 뮤텍스를 위해 경쟁하는 스레드의 성능에 여전히 영향을 미칩니다. 결국 한 번에 하나만 실행할 수 있습니다.
 캐시 핑퐁 효과는 데이터에 접근하는 모든 스레드가 여전히 뮤텍스 자체를 수정해야 하기 때문에 부하가 너무 많은 경우 이 뮤텍스의 이점을 무효화할 수 있습니다. 데이터에 접근하는 프로세서의 수가 증가함에 따라 뮤텍스 자체의 경합이 증가하며, 뮤텍스를 유지하는 캐시 라인은 코어 간에 전송되어야 하며, 이는 잠재적으로 잠금을 획득하고 해제하는 데 걸리는 시간을 바람직하지 않은 수준으로 증가시킬 수 있습니다. 
 캐시 핑퐁을 개선하기 위해서는 동시성 가능성을 개선하기 위한 일반적인 지침과 일치하여, 동일한 메모리 위치를 위해 경쟁하는 두 스레드의 가능성을 줄이기 위해 할 수 있는 일을 해야만 합니다.

 

8.2.3. False sharing

​프로세서 캐시는 보통 개개의 메모리 위치를 다루지 않습니다. 대신에 캐시라인(cache line)이라 불리는 메모리 덩어리를 다루는데, 이 메모리 덩어리는 보통 32바이트 혹은 64바이트 크기로 이는 프로세서 모델에 따라 다릅니다. 캐시는 오직 캐시 라인 크기의 메모리 덩어리만을 다루므로, 이웃한 메모리 위치에 있는 작은 데이터들은 같은 캐시라인에 존재하게 됩니다. 스레드에서 접근하는 데이터 집합이 동일한 캐시 라인에 있는 경우 동일한 데이터 집합이 여러 캐시 라인에 분산되어 있는 경우보다 애플리케이션 성능에 더 좋습니다. 그러나 캐시 라인의 데이터 항목이 관련이 없고 서로 다른 스레드로 접근해야 하는 경우 이는 성능 문제의 주요 원인이 될 수 있습니다. 만약 어떤 데이터가 두 프로세서 사이에서 실제로 공유되지는 않지만, 같은 캐시라인에 있기 때문에 캐시핑퐁이 발생한다면, 이를 거짓공유(false sharing)라 부릅니다.

 이 문제에 대한 해결방법은 같은 스레드에 의해 접근되는 데이터들은 이웃한 메모리에 위치시키고, 다른 스레드들에 의해 접근되는 데이터들은 분리시켜서 분리된 캐시라인에 위치하도록 하는 방법입니다.

 

8.2.4. How close is your data?

 거짓 공유는 한 스레드가 다른 스레드가 접근하는 데이터에 너무 가깝게 데이터를 접근하여 발생하지만, 데이터 레이아웃과 관련된 또 다른 함정은 단일 스레드의 성능에 직접 영향을 미칩니다. 문제는 데이터 근접성로, 단일 스레드로 접근한 데이터가 메모리에 분산되어 있으면 별도의 캐시 라인에 있을 가능성이 높습니다. 반대로, 단일 스레드로 접근하는 데이터가 메모리에 가까이 있으면 동일한 캐시 라인에 있을 가능성이 높습니다. 따라서 데이터가 분산될 경우 더 많은 캐시 라인을 메모리에서 프로세서 캐시로 로드해야 하므로 서로 가까이 위치한 데이터에 비해 메모리 접근 지연 시간을 늘리고 성능을 저하시킬 수 있습니다.
 또한 데이터가 분산되면 현재 스레드에 대한 데이터를 포함하는 특정 캐시 라인에 현재 스레드에 대한 데이터가 아닌 데이터도 포함될 가능성이 높아집니다. 극단적으로 캐시에 있는 데이터보다 신경 쓰지 않는 데이터가 더 많을 것입니다. 이는 프로세서가 캐시에서 데이터 항목을 가져와야 하기 때문에 캐시 공간을 낭비하고 캐시 누락이 발생할 가능성을 높입니다. 이는 거짓 공유를 방지하기 위해서 서로 다른 캐시라인에 접근하게 짠다면, 캐시에 대한 부담이 증가하고, 분산된 데이터로 인해 캐시 라인 로드 수가 늘어날 것입니다.

 과다구독을 한 경우 스레드가 계속 옮겨 다녀야 하기에, 프로세스들 간에 캐시도 계속 이동하게 되는데 이는 성능에 큰 영향을 미칩니다.

8.2.5. Oversubscription and excessive task switching

 멀티 스레드 시스템에서는 대규모 병렬 하드웨어에서 실행하지 않는 한 프로세서보다 스레드가 더 많은 것이 일반적이지만, 기다리는 스레드들은 I/O와 같이 다른 작업에 시간을 소모하고 있어 효율적으로 작업이 되고 있습니다. 하지만, 추가 스레드가 너무 많으면 사용 가능한 프로세서보다 실행할 준비가 된 스레드가 더 많아져, 시간을 확보하기 위해 운영 체제에 부하가 생기게 됩니다. 이는 작업 전환의 오버헤드를 증가시킬 수 있을 뿐만 아니라 근접성 부족으로 인한 캐시 문제를 악화시킬 수 있습니다. 
 초과 구독이 자연스러운 업무 분담 때문이라면, 다른 부서를 선택하는 것 외에는 문제를 개선하기 위해 할 수 있는 일이 많지 않으며, 업무 분담 재편성이 효과적이라는 것을 증명한다면 바꿀 가치가 존재합니다.

.

8.3. Designing data structures for multithreaded

 앞서 말한 정보를 멀티 스레드 성능을 위한 데이터 구조를 설계할 때 사용할 수 있습니다. 멀티 스레드 성능을 위해 데이터 구조를 설계할 때 염두에 두어야 할 핵심 사항은 경쟁 상태, 거짓 공유 및 데이터 근접성입니다. 이 세 가지 모두 성능에 큰 영향을 미칠 수 있으며, 데이터 레이아웃을 변경하거나 어떤 데이터 요소가 어떤 스레드에 할당되는지 변경함으로써 성능을 개선할 수 있습니다.

 

8.3.1. Dividing array elements for complex operations

 

8.3.2.  Data access patterns in other data structures

기본적으로 배열에 대한 접근를 최적화할 때와 다른 데이터 구조의 데이터 접근 패턴을 최적화할 때 고려해야 할 사항은 동일합니다.

  •  같은 스레드에서 가까이 수행되는 데이터에 대해서는 스레드 간의 데이터 분포를 조정해야 합니다
  • 주어진 스레드에 요구되는 데이터를 줄여야 합니다
  • std::hardware_destructive_interference_size를 토대로 거짓 공유를 피하기 위해 여러 스레드에서 접근하는 데이터는 충분히 떨어뜨려놔야 합니다.

이는 데이터 구조에 적용하기가 쉽지 않습니다. 이진 트리의 경우 동적으로 할당될 뿐만아니라 기본 특성과 분할하는 섹션에 따라 구현하기 힘듭니다.

 데이터가 힙의 서로 다른 위치에 배치되는 것 자체가 특별한 문제는 아니지만 프로세서가 더 많은 데이터를 캐시에 보관해야 한다는 것을 의미합니다. 이는 거짓 공유를 데이터가 필요한 스레드에 의해 수정되는 경우 노드 데이터 자체와 트리 구조를 제공하는 데이터 간의 거짓 공유로 인한 성능 저하를 방지할 수 있습니다.
 뮤텍스로 보호되는 데이터에도 비슷한 문제가 있다. 몇 개의 데이터 항목과 여러 스레드의 접근을 보호하는 데 사용되는 뮤텍스가 포함된 단순 클래스가 있다고 가정합니다. 뮤텍스와 데이터 항목이 메모리에 가까이 있으면 뮤텍스를 획득하는 스레드에 이상적입니다. 뮤텍스를 수정하기 위해 로드되었기 때문에 필요한 데이터가 프로세서 캐시에 이미 있을 수 있습니다. 그러나 다른 스레드가 첫 번째 스레드에 의해 유지되는 동안 뮤텍스를 잠그려고 할 경우 해당 메모리에 접근해야 한다는 단점도 있습니다. 뮤텍스 잠금 장치는 일반적으로 뮤텍스를 얻기 위해 뮤텍스의 메모리 위치에서 읽기-수정-쓰기 원자 연산으로 구현되며, 뮤텍스가 이미 잠겨 있으면 운영 체제 커널에서 호출합니다. 이 읽기-수정-쓰기 작업으로 인해 뮤텍스를 소유한 스레드에 의해 캐시에 저장된 데이터가 무효화될 수 있습니다. 스레드가 뮤텍스의 잠금을 해제하기 전까지는 스레드가 뮤텍스에 닿지 않습니다. 그러나 mutex가 스레드에서 사용하는 데이터와 캐시 라인을 공유하는 경우 다른 스레드가 mutex를 잠그려고 했기 때문에 mutex를 소유한 스레드가 성능 타격을 입을 수 있습니다.
 이러한 종류의 거짓 공유가 문제인지 여부를 테스트하는 한 가지 방법은 서로 다른 스레드에서 동시에 접근할 수 있는 데이터 요소 사이에 거대한 패딩 블록을 추가하는 것입니다.

struct protected_data
{
    std::mutex m;
    char padding[std::hardware_destructive_interference_size];
    my_data data_to_protect;
};

struct my_data
{
    data_item1 d1;
    data_item2 d2;
    char padding[std::hardware_destructive_interference_size];
};
my_data some_array[256]

뮤텍스 경합 문제와 거짓 배열 데이터 공유를 테스트합니다. 이렇게 하면 성능이 향상되는 경우 거짓 공유가 문제였음을 알 수 있으며, 패딩을 그대로 두거나 데이터 접근을 재정렬하여 거짓 공유를 제거하는 작업을 수행할 수 있습니다.

8.4. Additional consideration when designing for concurrency

 

지금까지 이 장에서는 스레드 간에 작업을 나누는 방법, 성능에 영향을 미치는 요인 및 이러한 요소가 데이터 접근 패턴 및 데이터 구조에 미치는 영향에 대해 살펴보았습니다. 하지만 동시성을 위한 코드를 설계하는 데는 그 이상의 것이 있습니다. 또한 예외적인 안전성과 확장성과 같은 것도 고려해야 합니다. 코드는 시스템에 더 많은 프로세싱 코어가 추가됨에 따라 성능이 증가하면 확장(scalable) 가능하다고 합니다. 이상적으로 성능 증가는 선형적이므로 100개의 프로세서를 사용하는 시스템이 하나의 프로세서를 사용하는 시스템보다 100배 더 우수한 성능을 발휘합니다.
코드가 확장되지 않더라도 작동할 수 있지만 예외는 정확성의 문제입니다. 코드가 예외적으로 안전하지 않으면 불변량 깨짐 혹은 경쟁 상태, 작업 중 예외가 발생하여 응용 프로그램이 예기치 않게 종료될 수 있습니다. 이를 염두에 두고 우선 예외 안전성에 대해 알아보겠습니다.

8.4.1. Exception safety in parallel algorithms

예외 안전은 좋은 C++ 코드의 필수적인 측면이며 동시성을 사용하는 코드도 예외는 아닙니다. 실제로 병렬 알고리즘은 일반 순차 알고리즘보다 예외를 더 주의해야 하는 경우가 많습니다. 순차 알고리즘의 연산이 예외를 발생시키는 경우, 알고리즘은 자원 유출과 깨진 불변량을 피하기 위해 스스로 정리하는 것에 대해서만 걱정하면 되지만, 병렬 알고리즘에서 많은 연산들은 별도의 스레드에서 실행되기에 예외가 잘못된 호출 스택에 있으므로 전파할 수 없습니다. 새 스레드에서 생성된 함수가 예외로 종료되면 응용 프로그램이 종료됩니다.

 

Listing 8.2 A naive parallel version of std::accumulate

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);
}

 사용자 정의 유형에 대한 작업을 수행하거나 예외를 던지는 함수를 호출하여 예외가 발생할 수 있는 곳 부터 찾아보겠습니다.
먼저, 사용자 제공 반복자로 distance를 구하는데, 작업을 하지 않았거나 호출 스레드에 있다면 괜찮습니다. 다음으로 result 벡터의 할당과 thread 벡터의 할당은 호출하는 함수에 존재하며, 어떤 작업이나 스레드 생성을 수행하지 않았기에 괜찮습니다. threads의 생성에서 예외를 던진다면, 소멸자에서 처리를 해야지만 results에 할당된 메모리도 지워질 것입니다.
 block_start의 초기화도 비슷하게 안전합니다. 첫 번째 스레드의 생성을 지나고 나면, 예외를 던질 때 문제가 발생할 수 있습니다. std::thread의 새 객체가 파괴된다면 std::terminate를 호출하여 프로그램을 종료 시킵니다.
 accumulate_block에 대한 호출은 잠재적으로 유사한 결과를 초래할 수 있습니다. 스레드 개체가 삭제되고 std::terminate를 호출합니다. 반면에 std::accumulate에 대한 마지막 호출은 쉽게 예외를 던질 수 있는데, 이는 모든 스레드가 해당 지점에서 모이기 때문입니다.
 메인 스레드는 여기까지지만, 새로운 스레드가 accumulate_block을 호출할 떄 던지는 경우가 존재합니다. catch 블록이 없으므로 이 예외는 처리되지 않고 라이브러리에서 std::terminate()를 호출하여 응용 프로그램을 중단합니다. 확연히 분명하지 않은 경우, 이 코드는 예외적으로 안전하지 않습니다.

 

[Adding Exception Safety]

먼저 새 스레드에 적용된 예외 문제를 살펴보겠습니다. 새 스레드로 수행하려는 내용을 주의 깊게 살펴보면 코드가 예외를 발생을 고려하면서 반환할 결과를 계산하려는 것이 분명해집니다. 아래의 코드는 std::packaged_task와 std::future로 재구성한 결과 입니다.

 

Listing 8.3 A parallel version of std::accumulate using std::packaged_task

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

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<std::future<T> > futures(num_threads-1);
    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);
        std::packaged_task<T(Iterator,Iterator)> task(
            accumulate_block<Iterator,T>());
        futures[i]=task.get_future();
        threads[i]=std::thread(std::move(task),block_start,block_end);
        block_start=block_end;
    }
    T last_result=accumulate_block<Iterator,T>(block_start,last);

    std::for_each(threads.begin(),threads.end(),
                  std::mem_fn(&std::thread::join));

    T result=init;
    for(unsigned long i=0;i<(num_threads-1);++i)
    {
        result+=futures[i].get();
    }
    result += last_result;
    return result;
}

 첫 번째 변화는 accumulate_block이 결과를 직접 반환하는 것으로 예외 안전성을 위해 std::packaged_task 및 std::future를 사용하므로 결과를 전송하는 데도 사용할 수 있습니다. 이렇게 하려면 제공된 결과 값을 재사용하지 않고 std::accumulate c에 대한 호출에서 기본값으로 구성된 T를 명시적으로 전달해야 합니다.
 다음 변화는 결과의 벡터를 갖는 대신, std::future<T>로 저장한다는 점입니다. 생성된 각 스레드에 대해, 스레드 생성 루프에서 먼저 accumulate_block에 대한 작업을 생성합니다. std::packaged_task<T(Iterator, Iterator)>는 두 개의 Iterator를 사용하고 T를 반환하는 함수의 작업을 선언하여 작업에 대한 미래 객체를 통해 결과값이나 예외를 얻을 수 있습니다.
 미래 객체를 사용했기 때문에 결과 배열을 가지고 있지 않아 결과를 배열의 슬롯이 아닌 다른 변수에 저장해야 합니다. 또한 미래 객체에서의 값을 가져오기 위해 std::accumulate보다 루프의 기본을 사용하는 것이 더 간단합니다. 해당 태스크가 예외를 발생시킨 경우, 이는 미래 객체에 저장되며 get에서 다시 예외를 던지게 됩니다. 
 이러한 변화는 잠재적인 문제 중 하나인 작업자 스레드에서 던져진 예외가 기본 스레드에서 다시 던져지는 것을 방지합니다. 둘 이상의 작업자 스레드가 예외를 발생시킨 경우 하나만 전파되지만 std::nested_exception을 사용하여 모든 예외를 캡처하고 대신 해당 예외를 적용할 수 있습니다.
 나머지 문제는 첫 번째 스레드를 생성하여 모든 스레드들이 다시 합류하기 전까지 예외가 발생하는 경우의 스레드 노출입니다. 가장 간단한 해결책은 예외를 포착하고 여전히 합류 가능한 스레드는 합류 시키고 예외를 다시 던지는 것입니다.

try
{
    for(unsigned long i=0;i<(num_threads-1);++i)
    {
        // ... as before
    }
    T last_result=accumulate_block<Iterator,T>()(block_start,last);
    std::for_each(threads.begin(),threads.end(),
    std::mem_fn(&std::thread::join));
}
catch(...)
{
    for(unsigned long i=0;i<(num_thread-1);++i)
    {
        if(threads[i].joinable())
            thread[i].join();
    }
    throw;
}

 

 모든 스레드들이 블록을 떠나더라도 모든 스레드들은 합류하게 되기에 정상적으로 작동하지만, try-catch 블록은 보기 흉하고 코드가 중복됩니다. 정상 제어 흐름과 캐치 블록 모두에서 스레드를 결합하기에, 이를 객체의 소멸자에 넣어주어서 중복을 해결할 수 있습니다.

class join_threads
{
    std::vector<std::thread>& threads;
public:
    explicit join_threads(std::vector<std::thread>& threads_):
        threads(threads_)
    {}
    ~join_threads()
    {
        for(unsigned long i=0;i<threads.size();++i)
        {
            if(threads[i].joinable())
                threads[i].join();
        }
    }
};

 

 이를 활용하면 위의 코드를 다음과 같이 단순화할 수 있습니다.

 

Listing 8.4 An exception-safe parallel version of std::accumulate

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<std::future<T> > futures(num_threads-1);
    std::vector<std::thread> threads(num_threads-1);
    join_threads joiner(threads);

    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);
        std::packaged_task<T(Iterator,Iterator)> task(
            accumulate_block<Iterator,T>());
        futures[i]=task.get_future();
        threads[i]=std::thread(std::move(task),block_start,block_end);
        block_start=block_end;
    }
    T last_result=accumulate_block<Iterator,T>(block_start,last);
    T result=init;
    for(unsigned long i=0;i<(num_threads-1);++i)
    {
        result+=futures[i].get();
    }
    result += last_result;
    return result;
}

 스레드 컨테이너를 만든 후에는 종료 시 모든 스레드에 합류시킬 수 있는 join_threads를 통해 명시적 join 루프를 제거할 수 있게 되었고, futures[i].get()의 호출이 결과가 준비될 때까지 차단되므로 이 시점에서 스레드에 명시적으로 join할 필요가 없습니다.

 

[Exception Safety with std::async()]

 

이제 스레드를 명시적으로 관리할 때 예외 안전에 필요한 사항을 확인했으므로 std::async()으로 동일한 작업을 수행 해보겠습니다. 이 경우 라이브러리가 스레드를 관리하며, 이후가 준비되면 생성된 스레드가 완료됩니다. 예외 안전을 위해 주의해야 할 중요한 점은 미래 객체를 기다리지 않고 파괴하면 소멸자가 스레드가 완료되기를 기다린다는 것입니다. 이렇게 하면 데이터에 대한 참조를 여전히 실행하고 유지하는 누출된 스레드의 문제를 깔끔하게 방지할 수 있습니다.

 

Listing 8.5 An exception-safe parallel version of std::accumulate using std::async

template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
    unsigned long const length=std::distance(first,last);
    unsigned long const max_chunk_size=25;
    if(length<=max_chunk_size)
    {
        return std::accumulate(first,last,init);
    }
    else
    {
        Iterator mid_point=first;
        std::advance(mid_point,length/2);
        std::future<T> first_half_result=
            std::async(parallel_accumulate<Iterator,T>,
                       first,mid_point,init);
        T second_half_result=parallel_accumulate(mid_point,last,T());
        return first_half_result.get()+second_half_result;
    }
}

 

 이 버전은 데이터를 chunk로 미리 분할하는 대신 데이터의 재귀 분할을 사용하지만 이전 버전보다 훨씬 단순하고 여전히 예외적으로 안전합니다. 이전과 마찬가지로 시퀀스의 길이를 찾는 것으로 시작하고, 최대 chunk 크기보다 작으면 std::accumulate를 직접 호출하지만, 남아 있다면 중가점을 찾아 전반부는 chunk로, 후반부는 해당 스레드에서 직접 처리하여 두 결과를 더해서 반환합니다. 라이브러리는 std::async 호출이 초과되는 스레드를 생성하지 않고 사용 가능한 하드웨어 스레드를 사용할 수 있도록 합니다. 일부 비동기 호출은 get() 호출에서 동기적으로 실행됩니다.
 이 코드의 장점은 하드웨어 동시성을 활용할 수 있을 뿐만 아니라 예외적으로 안전하다는 점입니다. 만약 예외가 재귀 호출에 의해 던져진다면, std::async 호출로부터 만들어진 미래는 예외가 전파되면서 파괴되며, 이는 다시 비동기 작업이 완료될 때까지 대기하여 스레드가 늘어지는 것을 방지합니다. 반면에, 비동기 호출이 예외를 던지면 미래에 의해 캡처고, get()에 대한 호출은 예외를 다시 던집니다

 

8.4.2.  Scalability and Amdahl's law

확장성은 애플리케이션이 실행 중인 시스템의 추가 프로세서를 활용할 수 있도록 보장하는 것입니다. 주어진 멀티 스레드 프로그램에서 유용한 작업을 수행하는 스레드 수는 프로그램이 실행됨에 따라 달라집니다. 모든 스레드가 전체를 위해 유용한 작업을 하고 있더라도, 애플리케이션은 처음에는 오직 하나의 스레드를 가질 수 있으며, 그 후 다른 모든 스레드를 생성하는 작업을 가지게 되지만 이조차도 가능성이 매우 낮은 시나리오입니다. 스레드는 종종 서로 기다리거나 I/O 작업이 완료될 때까지 기다리게 됩니다. 하나의 스레드가 대기해야 할 때마다 프로세서에 다른 스레드가 준비되지 않는 한, 유용한 작업을 수행할 수 있는 프로세서를 유휴 상태로 둡니다.
 프로그램을 하나의 스레드만 유용한 작업을 수행하는 직렬 파트와 사용 가능한 모든 프로세서가 유용한 작업을 수행하는 병렬 파트로 나누면 단순하게 볼 수 있습니다. 더 많은 프로세서가 있다면 병렬 파트는 더 빨리 끝낼 수 있고 직렬 파트가 프로그램에서 차지하는 비중을 fs라고 할 때, N개의 프로세스를 사용한 경우의 성능 이득 P는 다음과 같이 계산 가능합니다.

 이것은 동시 코드의 성능에 대해 말할 때 자주 인용되는 암달의 법칙입니다. 이는 작업 분할에 신경 쓰지 않았고 CPU만이 작업에 참여하는 것을 가정하였기에 너무 단순하게 계산 되었지만, 확장성에 대해서 가늠할 수 있는 계산이기에 의미를 지닙니다. 이를 통해 환경에 따라 직렬 파트를 줄이고 병렬 파트를 늘려 성능을 올리는 판단을 할 수도 있습니다.
 스레드 간에 작업을 나누는 데 사용할 기술을 선택하기 전에 이러한 확장성 측면 중 어떤 부분이 중요한지를 파악하는 것이 중요합니다. 앞서 말한 것처럼 스레드에 항상 유용한 작업이 있는 것은 아니라고 언급했습니다. 때때로 다른 스레드나 I/O가 완료되기를 기다리거나 다른 것을 기다려야 합니다. 이 대기 중에 시스템에 유용한 작업을 제공하는 경우 대기 시간을 효과적으로 "숨길" 수 있습니다.

 

8.4.3. Hiding latency with multiple threads

멀티 스레드 코드의 성능에 대한 대부분의 논의에서 우리는 스레드가 전력으로 작업을 수행하며 프로세서에서 실행될 때 항상 유용한 작업이 있다고 가정하지만 실제로는 응용 프로그램 코드에서 스레드는 I/O, 뮤텍스 획득, 미래 객체등 무언가를 기다리는 동안 자주 차단됩니다. 
 대기하는 이유가 무엇이든 간에 시스템에 물리적 처리 장치가 있는 수의 스레드만 있는 경우 스레드가 차단되면 CPU 시간이 낭비됩니다. 그렇지 않으면 차단된 스레드를 실행하는 프로세서는 대신 아무것도 하지 않습니다. 따라서 스레드 중 하나가 대기하는 데 상당한 시간을 소비할 가능성이 있는 경우 하나 이상의 스레드를 추가로 실행하여 여유 CPU 시간을 활용할 수 있습니다.
 이는 최적화이므로 스레드 수가 변경되기 전과 후의 성능을 측정하는 것이 중요합니다. 최적의 스레드 수는 수행되는 작업의 특성과 스레드가 대기하는 시간의 비율에 따라 크게 좌우됩니다. 애플리케이션에 따라 추가 스레드를 실행하지 않고도 이 여유 CPU 시간을 모두 사용할 수 있습니다. 예를 들어 I/O 작업이 완료되기를 기다리고 있기 때문에 스레드가 차단되는 경우 비동기 I/O를 사용할 수 있으면 스레드가 백그라운드에서 I/O를 수행하는 동안 다른 유용한 작업을 수행할 수 있습니다. 다른 경우에는스레드가 다른 스레드가 작업을 수행하기를 기다리는 경우 차단 대신 대기 스레드가 해당 작업을 직접 수행할 수 있습니다. 극단적인 경우 스레드가 작업이 완료되기를 기다리고 있고 해당 작업이 아직 시작되지 않은 경우 대기 중인 스레드가 작업 자체를 수행하거나 완료되지 않은 다른 작업을 수행할 수 있습니다. 사용 가능한 모든 프로세서가 사용되고 있는지 확인하기 위해 스레드를 추가하는 것보다 외부 이벤트를 적시에 처리하여 시스템의 응답성을 높이는 데 도움이 되는 경우가 있습니다.

8.4.4. Improving responsiveness with concurrency

대부분의 최신 그래픽 사용자 인터페이스 프레임워크는 이벤트 기반이며, 사용자는 키를 누르거나 마우스를 움직여 사용자 인터페이스에서 작업을 수행하며, 이 작업은 응용 프로그램이 처리하는 일련의 이벤트나 메시지를 생성합니다. 이는 시스템 자체적으로도 메시지 또는 이벤트를 생성할 수 있는데, 모든 이벤트와 메시지가 올바르게 처리되도록 응용 프로그램에는 일반적으로 다음과 같은 이벤트 루프가 존재합니다.

while(true)
{
    event_data event=get_event();
    if(event.type==quit)
        break;
    process(event);
}

 

 분명히 API의 세부 사항은 다양하겠지만, 구조는 일반적으로 동일하다: 이벤트를 기다리고, 이벤트를 처리하기 위해 필요한 처리를 한 후 다음 이벤트를 기다립니다. 단일 스레드의 경우 이를 사용하기 어렵지만, 사용자 입력이 적시에 처리되도록 하려면 get_event()와 process()를 적절한 빈도로 호출해야 합니다. 즉, 작업이 주기적으로 자신을 일시 중단하고 이벤트 루프에 제어를 반환해야 하거나, 또는 편리한 지점에서 코드 내에서 get_event()/process() 코드를 호출해야 합니다. 두 가지 옵션 모두 작업 구현을 복잡하게 합니다.
 해당 문제는 분리하여 긴 작업을 완전히 새로운 스레드에 배치하고 전용 GUI 스레드를 남겨 동시적으로 이벤트를 처리할 수 있습니다. 그러면 스레드는 이벤트 처리 코드를 작업 코드와 혼합할 필요 없이 간단한 메커니즘을 통해 통신할 수 있습니다.

 

Listing 8.6 Seperating GUI thread from task thread

std::thread task_thread;
std::atomic<bool> task_cancelled(false);

void gui_thread()
{
    while(true)
    {
        event_data event=get_event();
        if(event.type==quit)
            break;
        process(event);
    }
}

void task()
{
    while(!task_complete() && !task_cancelled)
    {
        do_next_operation();
    }
    if(task_cancelled)
    {
        perform_cleanup();
    }
    else
    {
        post_gui_event(task_complete);
    }
}

void process(event_data const& event)
{
    switch(event.type)
    {
    case start_task:
        task_cancelled=false;
        task_thread=std::thread(task);
        break;
    case stop_task:
        task_cancelled=true;
        task_thread.join();
        break;
    case task_complete:
        task_thread.join();
        display_results();
        break;
    default:
        //...
    }
}

 이러한 방식으로 우려를 분리함으로써, 사용자 스레드는 작업이 오래 걸리더라도 항상 적시에 이벤트에 응답할 수 있다. 특정 작업이 수행될 때마다 완전히 차단하는 애플리케이션은 사용하기 불편합니다. 전용 이벤트 처리 스레드를 제공함으로써 GUI는 시간이 많이 걸리는 처리의 실행을 중단하지 않고 GUI 관련 메시지를 처리할 수 있으며, 이 메시지들이 장시간 실행되는 작업에 영향을 미치는 관련 메시지를 그대로 전달할 수 있게 됩니다.

8.5. Designing concurrent code in practice

이때까지 살펴본 내용을 실제로 어떻게 적용하는 지에 관한 것으로 읽어는 보았지만 정리는 나중에 하도록 하겠습니다.

+ Recent posts