Skip to content

Chapter 08, Designing concurrent code

danny song edited this page Jan 25, 2016 · 17 revisions

Chapter 08, Designing concurrent code

도입

  • 이전 장에서는 C++11으로 동시성 코드를 구현하는 것에 집중했고 특히 6, 7장에서는 다수의 쓰레드에서 동시 접근 안전한 기본적인 데이타 구조 디자인을 살펴봤다. 이제 좀더 넓 문맥에서 동시성 디자인을 하는 방법을 알아보자. 동시성 코드는 다른 프로그래밍 처럼 디자인에 대해서 주의 깊은 관심이 필요하지만, 순차적 코드보다 더 많은 요소를 생각해 야 한다. 예들들어, 데이타 공유 그리고 다수 쓰레드에서 데이타 접근시 데이타 싱크로나이즈, 쓰레드간 싱크로나이즈 등등 이장에서는 다음을 알아본다.
    • 얼마나 많은 쓰레드를 사용하는지에 대한 high level관점(그리고 근본적인)에서의 고려
    • 어떤 코드가 어떤 쓰레드에서 실행되는지
    • 동시성 다지안이 어떻게 코드 명확화에 영향을 끼치는지
    • 최적의 성능을 위해 공유 데이타를 어떻게 구조화 하는지 low level관점에서의 고려

8.1 쓰레드 사이에 작업(Work)을 나누는 기술

  • 집을 짓는 다고 생각해보자
    • 무엇을 해야 할까?
      • 집 터 기초를 다져야 하고 벽을 쌓아야 하며 배관을 설치도 하고 선들도 연결하고 등등...
      • 방법
        1. 나 혼자 배워서 다 한다.
        • 시간도 오래 걸리고 이것 저것 할게 많아 문맥 전환이 빈번하다!
        1. 사람을 고용한다.
        2. 능숙하진 않지만 모든 기술을 조금씩 알고 있는 사람들 고용 - 여전히 문맥 전환은 존재하지만 혼자하는거보단 빠르다.
        3. 각 분야의 전문가를 고용한다. - 각 분야의 전문가가 모여 작업을 하기 때문에 집중도가 높아졌고 문맥 전환 비용이 낮다.
          그래서 효율 및 속도는 전체적으로 이전보다 높아졌다. 하지만 각 작업이 특정 전문가에만
          의존하기때문에 특정 작업이 없을 경우 그 전문가는 할수 있는게 없다.(go to idle)
          - 아마도 특정 분야에 따라 다른 수의 전문가를 고용할 수 있다. 예를들어, 집을 짓는데 전기기사보단 벽돌공이 더 많이 필요하다. 만약 한번에 많은 수의 집을 짓는다면 쉬고 있는 배관공에게 더 많은 일을 줄 수 있다. 만약 할 일이 없는 전문가에게 보수를 지불할 필요가 없다면 같은 숫자의 일할 수 있는 인력으로 더 큰 팀을 꾸릴 수 있다.
      • 결국엔 쓰레드를 다루는것도 같은 이치다!
        1. 얼마나 많은 쓰레드를 써야하고, 어떤 작업을 해야 하고, generalist써야 할지 specialist를 써야 할지..
        2. 이런 것을 생각하면서 동시성 프로그램 디자인을 디자인해야 하며 디자인에 따라 성능 뿐만아니라 코드 명료성에 지대한 영향을 끼친다.

8.1.1 처리를 시작하기 전에 쓰레드 사이에 데이터를 나누기

  • std::for_each로 간단한 병렬처리를 알아보자.
    • 처리 쓰레드에게 각 앨리먼트 연산을 맡길 수 있다. 최상의 성능을 위해 데이타 구조체를 어떻게 나누는가는 그 구조체 자체의 detail에 의존적이다.
    • 가장 쉽게는 첫번째 N개 앨리먼트를 첫번재 쓰레드에, 다음 N개를 두번째 이런식으로 처리 책임이 각각 쓰레드에서 맡겨지고 쓰레드 사이에 communcation없이 독립적으로 수행한다.
      • 이런 접근법은 Message Passing Interface ( MPI ), OpenMP와 비슷하다.
        • 작업이 작은 작업 단위로 나눠지고 각 worker 쓰레드에 assign된다. 결과는 최종 Reduction단계를 거쳐 완성된다.
          • secion 2.4에서 accumulate 예제의 접근법이며 for_each는 reduction 단계를 필요없다.
    • reduction 단계는 중요하다. listing 2.8같은 초보적인 구현은 reduction 단계를 마지막에 수행할 것이다. 그러나 이 단계 조차도 병렬화될 수 있다. 예들들어, 사실 accumulate 자체가 reduction 연산이여서, 쓰레드의 수가 하나의 쓰레드에서 처리 하는 최소 아이템 수보다 크면 재귀 호출로 구현할 수 있다.
    • 그리고 매번 새로운 쓰레드를 생성하는것 대신 각 쓰레드가 나눠진 작업을 완료할 때마다 worker 쓰레드가 reduction 작업을 수행할 수도 있다.
    • 하지만 이런 디자인도 데이타의 성격에 따라 적용될 수도 안될 수도 있음을 유의하자. 즉 데이타 그리고 작업의 성격에따라 다자인 되어야 한다.
      • 예를 들어 데이타가 작업 전에 독립적으로 나눠질 수 없고 데이타 처리 중에 또는 후에 가능하다면 어떻게 할 것인가?
      • Quick sort

8.1.2 데이터를 재귀적으로 나누기

  • 퀵소트는 두 가지 단계를 가진다
    1. 데이타를 앨리먼트중 하나를 기준(피봇)으로 전 후로 나누고 2)재귀적으로 반을 나눠서 정렬하는 알고리즘이다. 퀵소트 병렬화는 데이터를 먼저 나눈다고 해결될 순 없다. 이유는 반으로 나눠지는 아이템으로 처리되기 때문이다. 이런 알고리즘을 병렬화해야 한다면, 재귀 호출의 근본적인 속성을 활용해야 한다. 각 단계마다 피봇 전 후의 데이타를 정렬하고 나뉜 데이타 기준으로 재귀호출을 하기 때문에 재귀 호출의 단계가 깊어질수록 quick_sort 함수에 더 많은 호출이 생긴다. 분리된 앨리먼트 집합을 접근하기 때문에 이런 재귀 호출은 완전히 독립적이다. 그러므로 퀵소트는 동시성 실행에 아주 좋은 예제라 할 수 있다.

예제 4에서 관련 구현을 살펴봤다. 상, 하위 데이타 덩어리(chunk)를 두 개의 재귀 호출로 수행하는 것보다 각 단계 하위 덩어리를 위해 비동기 테스크(asynchronous tasks)를 만드는 std::async()로 처리했다. std::async()를 사용해서 테스크를 동기 처리할 건지 쓰레드로 할 건지를 c++ Thread Library가 결정하도록 맡길 수 있다.

많은 양의 데이트를 정렬해야 한다면, 각 재귀에 새로운 쓰레드를 생성하는 것은 빠르게 많은 쓰레드 생성을 야기할 수 있고 다음 문제를 야기할 수 있다.

  • 성능 저하
  • 데이타가 많을 경우 쓰레드 소진 하지만 생성된 쓰레드 숫자에 대해 주의만 한다면 재귀호출에서 전체 테스크를 나누는 아이디어 좋은 것이다.

std::async()를 이를 단순화해주지만 이것만 있는것은 아니다. accmulate()을 병렬 버젼으로 구현한 것 처럼 std::thread::hardward_concurrency() 함수를 사용해서 사용할 쓰레드 갯수를 정하는 방법이다. 재귀 호출마다 새로운 쓰레드를 생성하는거 보다 6,7장에서 설명한 것 중 하나로 덩어리가 정렬되도록 thread safe 스택에 푸쉬할 수 있다. 모든 덩어리 처리가 끝났거나 덩어리가 정렬되도록 기다리는 경우, 즉, 쓰레드가 어떤 것도 하지 않는다면 스택에서 덩어리를 가져와서 정렬한다. 다음 예제를 살펴보자.

"Listing 8.1"

1,19 : parallel_quick_sort 함수는 모든 기능을 sorted 클래스에 위임한다.
2 : 정렬 안된 덩어리의 스택을 그룹화하는 쉬운 방법을 제공
3 : 쓰레드 그룹도 제공.
9 : 주요 처리는 do_work에서 이루어 지고
10 : 데이터의 분할을 한다.
11 : 한 덩어리를 위해 쓰레드를 생성하지 않고 스택에 푸쉬한다.
12 : 프로세서가 여유가 있을때 새로운 쓰레드를 생성한다. 13 : 하위 덩어리(lower_chunk)가 다른 쓰레드로 처리될 수 있기 때문에 완료될때 까지 기다려야 한다.
14 : 원할한 실행을 위해(쓰레드가 하나 밖에 없거나 다른 모든 쓰레드가 바쁠 경우), 기다리는 동안 쓰레드에서 스택의 데이터 덩어리(저장된 하위 덩어리)를 꺼내 처리할 수 있다.
7 : try_sort_chunk는 스택에서 데이타를 꺼내서
8 : 정렬한다.
15 : 정렬된 결과를 promise에 저장하면 스택으로 덩어리를 넣었던 쓰레드가 꺼낼 준비된것이다.
16.,17 : 생성된 쓰레드는 스택에서 덩어리를 꺼내서 정렬하는 루프를 end_of_flag가 false일 동안 처리한다.
4, 18 : 스택에 더 많은 일을 넣을 수 있는 기회를 주기 위해, 루프 체크 사이에 yield를 해서 다른 쓰레드로
스캐쥴링될 수 있다. 이 코드는 쓰레드를 정리하는 위해서 sorter 클래스 소멸자에 의존한다.
20 : 모든 데이터가 정렬되면 do_sort는 리턴한다.(워커 쓰레드가 여전히 실행중이다.). 그리고 메인 쓰레드는 parallel_quick_sort으로부터 리턴한다.
5,6 : sorter 오브젝트 파괴되고 end_of_data 플래그는 셋된다. 다른 쓰레드가 종료되길 기다린다.
16 : 플래그 세팅은 쓰레드 함수 루프를 종료 시킨다.

spawn_thread와 같이 제한없이 새로운 쓰레드를 시작해서 야기되는 문제는 이 접근에선 가지질 않는다. 또 std::async처럼 쓰레드 갯수를 선택하기 위해 더 이상 C++ Thread Library에 의존하지 않는다. 대신 std::thread::hardward_concurrency 값을 기반으로 쓰레드의 갯수를 제한해서 과도한 문맥 전환을 피한다. 그러나 이런 접근은 쓰레드 관리 및 쓰레드 간 커뮤니케이션로 인한 상당한 코드 복잡성을 부가할 수 있다. 쓰레드가 분리된 데이터 앨리먼트를 처리한다고 해도 모든 쓰레드가 스택을 접근하고 처리를 위해 스택 데이터를 수정하기 때문에 높은 경쟁 상태를 야기한다. 이는 lock free를 사용한다고 해도(이유는 곧 설명할것이다.) 성능 저하는 야기할 수 밖에 없다.

이 접근은 쓰레드 풀의 특별한 버젼이고 - 미 처리 작업 리스트에서 작업을 가져와서 처리하고 다시 리스트로 가져가서 작업을 가져온다. 쓰레드 풀의 잠재적인 문제점과 해결하는 방법을 9장에서 알아본다. 멑티 프로세서에서 어플리케이션의 확장하는 문제는 8.2.1에서 알아보자

처리를 시작하기 전에 데이터를 나누고 재귀적으로 다시 나누는것은 데이터가 사전에 고정적이라는 가정이 있기 때문에 가능하다. 만약 데이터를 동적으로 생성되거나 외부 입력으로 만들어진다 이 접근은 동작하지 않는다. 데이터 나누는 것보다 작업 타입으로 작업을 나누는것이 더 이치에 맞다.

8.1.3 테스크 타입에 따라 작업을 나누기

각 쓰레드에 다른 덩어리의 데이터를 할당해서 쓰레드의 작업을 나누는 것(사전 또는 재귀적으로 )은 쓰레드가 데이터의 각 덩어리에 같은 작업을 한다는 가정에 기반한다. 작업을 나누는 다른 대안은 앞서 예로 언급한 집을 짓기 위해 배관공과 전기기사가 각각 자기 일을 전담해서 하는것 처럼, 각 쓰레드가 구별되는 작업을 수행하는 전문가 쓰레드를 만드는 것이다. 쓰레드는 같은 데이터로 수행될 수 있고 아닐수도 있다. 동시성을 가지는 관심의 분리를 결과로 하는 작업 분리이다. 각 쓰레드를 다른 작업을 가지고 다른 쓰레드와 독립적으로 수행된다. 때로는 다른 쓰레드가 데이터를 줄수도 있고 처리를 원하는 이벤트를 트리거할 수 있다. 그러나 일반적으로 각각 쓰레드는 하나의 작업만 수행하는것이 좋은 디자인이다.(single responsibility desgin)

싱글 쓰레드 어플리케이션의 다음 상황에서 single responsibility principle 디자인은 언제나 골칫거리다.

  1. 일정 시간동안 지속적으로 실행되야 하는 다수의 작업이 있거나
  2. 다른 작업을 해야 하는 동안 시시각각 들어오는 이벤트를 처리하는 (유저 키 press나 네트워크 패킷 데이타) 싱글 쓰레드에서 이러한 작업을 하려면 루프안에서 시리얼하게 A,B,C 작업을 처리해야 하고 상태 정보 또한 처리해야 한다.
    이는 잠재적으로 코드를 복잡하게 만드며 때로는 응답성에 문제가 생길수도 있다.

그래서 작업별로 다른 쓰레드를 생성해서 실행한다면 각 쓰레드는 주어진 책임만 수행하므로 코드 복잡성이 줄어들고 상태정보를 처리할 필요 없으며 독립적으로 실행되므로(물리 코어에 따라 완전한 독립이 아닐수 있지만) 응답성도 개선된다.
이는 운영체제가 상태 관리 및 쓰레드간 문맥 전환을 해주고 물리 코어가 생성된 쓰레드과 비교해 충분할 경우 물리적인 동시 실행 또한 가능한다. --> Everybody Win Win!!

이렇게 짜는게 이상적이지만 현실은 그렇게 녹록치않다! 전적으로 도메인 분야의 상세 시나리오에 따라 달라지며 이것에 따라 동시성 디자인도 달라진다. 즉, 모든 작업이 독립적으로 분리 실행할 수 있다면 커뮤니케이션 없이 동시 실행 가능하다. 하지만 백 그라운드 테스크의 유저 요청에 반응 해야 할 필요도 있고 어떤 작업이 끝났을 때 이를 유저 인터페이스에게 뭔가를 표시하도록 할수도 있다. 그리고 유저가 백그라운드 테스크를 중지하기위해 메시지를 보낼수도 있다. 이를 구현하기 위해선 조심스런 디자인과 동기화가 필요하다.
하지만 여전히 각각 쓰레드의 책임은 나눠진다. 이유는 유저 인터페이스는 여전히 유저 인터페이스 관련 작업을 처리한다. 때로는 다른 쓰레드로 부터 이벤트를 받아 작업을 처리할 수도 있다. 이는 백그라운드 테스크도 각자의 임무를 가지고 처리하면서 다른 쓰레드에게 메시지를 받고줄 수도 있다.

멀티 쓰레드의 책임 분리에서 두 가지 위험이 있다.

  1. 쓰레드 사이에 공유된 많은 데이터가 있을 경우
  2. 다른 쓰레드들이 서로를 기다릴 경우

두가지 경우 쓰레드 사이에 많은 커뮤니케이션을 야기한다. 이런 오버해드가 발생할 경우 커뮤케이션의 이유를 파악하는게 중요하다.
만약 모든 커뮤니케이션이 같은 이슈로 연관될 경우, 그 이슈는 하나의 쓰레드의 주요 책임이 되어야 하고 그것을 참조하는 모든 쓰레드에서 얻어져야 한다(??)
그리고 두 쓰레드가 다른 쓰레드보다 상당히 많은 커뮤니케이션을 하고 있다면 하나의 쓰레드로 만드는것이 좋다.

작업 타입에 의한 쓰레드 작업 분리 시, 완벽히 분리된 구현을 위해서라면 모든걸로 걸어도 좋다.(쉽진 않지만 그래야 좋은 디자인이니까!)

입력 데이터의 다수 집합이 동일한 순서의 연산을 요구한다면 각 쓰레드가 모든 순서를 하나의 단계에서 수행하는 할 수 있도록 작업을 나눌 수도 있다. (즉 파이프 라이닝!)

쓰레드 사이에 테스크 순서 나누기

테스크의 독립적인 데이터에 동일한 순서의 연산이 적용되야 한다면 시스템에서 가능한 동시성을 활용하기 위해 파이프라인을 사용할 수 있다. 물리적인 파이프라인과 동일한 의미며 한쪽에서 입력된 데이타는 일련의 연산을 거치면서 흐르면서 다른 한쪽으로 나온다.

  1. 파이프라인에서 각 단계를 위한 쓰레드를 생성해야 하고
  2. 연산이 완료되었을때 데이터 엘리먼트는 다음 쓰레드가 가져가도록 큐에 푸쉬한다.
  3. 일련의 연산 순서에서 첫번째 연산을 수행했던 쓰레드가 두번째 쓰레드가 첫번째 데이터를 수행하는 동안 다음 데이터를 처리하도록한다.

쓰레드간 데이터를 나누는 다른 대안이고 연산이 시작되었을때 입력 데이터가 완전하지 않을때 유용하다. 예를들어, 데이터가 네트워크를 통해서 오거나 순서에서 첫번째 연산이 처리할 파일을 확인하기위해 파일 시스템을 스캔하는것 일수도 있다. 연산 순서의 각 연산에 시간 소비가 되는 경우 파이프라인 방식이 적당하다.

  • 데이터보다 쓰레드사이에 테스크를 나누는 방법
    • 쓰레드로 데이터 나누기
      • 4개의 코어에 20개의 처리할 데이터가 있고
      • 각 데이터는 4개의 과정이 필요하며 각 과정은 3초가 걸린다.
      • 4개의 쓰레드를 가지고 있다면 240 / 4 = 60 초 안에 모든일을 끝낼 수 있다.
    • 파이프라인 사용하여 쓰레드로 나누기
      • 과정이 각 쓰레드로 할당된다.
      • 첫번째 데이터는 각 코어에 의해 처리되고 12 초가 걸릴것이다. 12 초 후에는 처리 완료된 하나의 데이타만 가질 수 있다.
      • 하지만 파이프라인이 채워졌다면 매 3 초마다 처리된 아이템을 가질 수 있다.
        • 9 + (3 * 20) = 69 초
      • 결과적으로 쓰레드로 나누는것 보다 좀더 길어졌지만(+9초) 자연스럽고 규칙적인 처리에 적합하다. 예를들어, 고 해상도 비디오 재생

8.2 동시성 코드 성능에 영향을 끼치는 요소.

  • 멀티 프로세서 시스템에서 코드 성능에 영향을 끼치는 요소는 무엇일까?
    • 앞서 본것 처럼 관심의 분리를 위해 멀티 쓰레드를 사용한다고 해도 성능에는 역행하지 않도록 주의해야 한다.

8.2.1 얼마나 많은 프로세서가 있어야 할까?

프로세서의 숫자는 멀티 쓰레드 프로그램 성능에 영향을 끼치는 가장 큰 요소다. 어떤 경우에는 타겟 하드웨어가 어떤 것인 알고 있으며 이에 따라 디자인 하고 측정한다. 하지만 실제 사용 S/W가 사용되는 머신은 다양할수 있고 프로세서의 다양한 수에 따라 S/W 성능이 좌지우지 될수 있다.

  • oversubscription 코어당 하나의 쓰레드가 동작되는것이 이상적이지만 총 코어 수보다 생성할 쓰레드 수가 많다면 쓰레드간 문맥 전환이 발생하는 overhead가 발생할 것이다. 이를 oversubscription이라 한다.

어플리케이션이 물리적 쓰레드 갯수(코어 갯수)에 따라 논리적 쓰레드를 확장하기 위해 C++ standard는 std::thread::hardward_concurrency()를 제공한다. (이전 장에서 사용법을 알아보았다) 이 api를 사용한다 해도 시스템상에서 실행되는 다른 쓰레드(혹은 프로세스)와 정보를 공유하지 않는다면 효과는 제한적이며 오히려 oversubscription를 야기할 수 있다. 즉 신중히 사용해야 한다.

  • std::async이 대안이 될수 있고 신중히 작성된 쓰레드 풀도 좋다. 어플리케이션에서 실행되는 모든 쓰레드를 고려한다고 해도 동시 실행되는 다른 어플리케이션의 영향도 생각해야 한다.
  • system에 각 어플리케이션이 쓰레드의 갯수를 선택할 수 있는 매카니즘을 제공하는것도 있다.
    • c++ std의 범위는 아니다. 그럼
  1. 쓰레드 갯수를 결정할때 모든 어플리케이션에서 실행되는 총 테스크의 크기를 고려하면서 디자인한다.
  2. 어플리케이션에서 사용하는 코어 갯수를 제한한다. (std::thread::hardward_concurrency()

그렇다고 프로세서가 많아지면 항상 좋은 것일까?

  • 공유 데이터를 생각해보자!

8.2.2 데이터 경쟁 및 캐쉬 핑퐁

두개의 코어에 각각의 쓰레드가 실행되고 공유 데이터를 접근한다고 가정하자

  1. 두 쓰레드가 동시에 공유 데이터를 읽는다면?
  • 데이터는 각각 코어의 캐시에 복사되지만 읽기만하므로 데이터 무결성을 보장된다.
  1. 하지만 쓰레드중 하나가 공유 데이터를 수정한다면?
  • 속한 코어 캐시에 반영이 될것이다. 그리고 다른 코어로 수정 내용이 전파가 되어야 캐시 무결성을 보장할 수 있다. 즉 전파로 인해 처리시간에 영향을 줄수 있다.
  • 두 쓰레드에서 일어나는 연산의 성격이나 연산에 쓰이는 메모리 오더링에 따라서 이러한 수정은 두번째 프로세서가 처리를 중단하고 메모리 하드웨어를 통해서 수정 전파를 기다리게할 수 있다.
    • CPU 명령어로 살펴보면 수백 명령어와 맞먹을 만큼 상당한 지연을 야기한다. (이것도 하드웨어 설계에 의존적이긴 한다.)
std::atomic<unsigned long> counter(0);
void processing_loop()
{
  while(counter.fetch_add(1,std::memory_order_relaxed) < 100000000)
  {
    do_something();
  }
}

counter는 전역이다. 그래서 processing_loop를 호출하는 어떤 쓰레드도 동일한 데이터를 수정할 수 있다. 매 수정마다 프로세서는 최신의 복사본이 캐시에 존재해야 하고 데이터를 수정하면 이를 자기 뿐만아니라 다른 프로세서 캐시에도 전파해야한다. std::memory_order_relaxed를 쓰므로 컴파일러가 어떤 다른 데이터도 동기화가 필요없다고 해도 fetch_add 는 read-modify-write 연산이고 가장 최신의 값을 가져와야 한다. 코드가 짧고 많은 프로세서가 있다면 각 프로세서의 빈번한 값 변경 그리고 이에따르는 값 전파등으로 high contention이 나타난다.

위 예제에선 업데이트된 counter 데이터가 캐시사이에 빈번하게 전달될 가능성이 있다. 성능에 많은 영향을 줄수 있는 cache ping-pong 문제이다. 즉, 프로세서가 캐시 전달때문에 stalling되었다면 유용한 일을 할수 있는 시간을 낭비하게 되는것이다.

아래 코드에서 한 쓰레드에서 mutex를 lock하면서 mutex context 수정하고 unlock하면서 context를 다시 수정한다. 이런 수정된 정보를 다음 쓰레드가 mutex를 lock할수 있도록 다른 코어로 전파가 되어야 한다.

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

많은 코어와 프로세서 그리고 쓰레드가 동일한 데이터(or mutex)에 접근하는것은 high contention을 야기해서 cache ping pong의 가능성을 높인다.

  1. mutex 사용으로 인한 경쟁
  • 운영체체 레벨에서 쓰레드 병렬화 (TLP)
  • 운영체제 스캐쥴러가 쓰레드가 mutex를 얻기위해 기다리고 있는동안 ready-to-run인 다른 쓰레드로 문맥전환한다.
  • 앞서 언급한것처럼 mutex lock, unlock도 결국엔 관련 데이터를 수정해야 되고 수정된 데이터가 있는 캐시라인을
    다른 코어에 전파되어야 하므로 high contention이다!
  1. atomic operation 사용으로 인한 경쟁
  • 프로세서 레벨의 병령화 (ILP)
  • 말 그대로 ILP이므로 processor stall이 발생가능하다. (하자민 라운도로빈에 의해 스캐쥴러 문맥전환할수 있지 않을까??? 아니다 스캐쥴러도 결국에 명령어니 cpu fetch가 되어야 실행 가능할텐데...)

8.2.3 False Sharing

캐시 데이터는 캐시라인라고 불리는 메모리 블록 기본단위로 존재한다. 32,64바이트 크기를 가지데 프로세서에따라 다양하다.

  • 캐시 fetch 정책은 시간적 또는 공간적 지역성으로 작동한다.
  • 하지만 캐시라인에 있는 데이터가 점유한 쓰레드와 관련이 없거나 다른 쓰레드에 의해 접근되야 할 경우 많은 캐시 미스를 야기하고 이는 곧 performance penalty다.
  • 예를들어 int shared[6] 공유 데이터가 존재하고 각 배열 인덱스를 다른 쓰레드에서 접근되고 수정된다면? (32비트 머신일 경우 24 바이트 배열 크기이고 일반적인 캐시라인 크기보다 작다.) 즉, cache ping pong 이고 같은 캐시 라인이 공유되는 상황이지만 실제로는 공유되지 않으므로 false sharing이다.
  • 세심한 데이터 구조 설계가 필요하다. 같은 쓰레드는 같은 캐시라인으로 다른 쓰레드는 다른 캐시라인으로.

8.2.4 How close is your data? 8.2.5 Oversubscription and excessive task switching

  • 하나의 쓰레드의 데이터가 메모리에 여기저기 존재한다면? 즉 공간적 지역성(data proximity)이 나쁘다면? 동일한 캐시라인에 있을 확률이 떨어진다. 많은 cache miss 야기 -> 더 많은 메인 메모리 접근 -> 캐쉬을 비효율적 사용
  • 반대로 메모리의 데이터가 오밀조밀하게 모여 있다면 동일한 캐시라인에 있을 확률이 높아진다.
  • 코어 수보다 생성된 프로세스 및 쓰레드의 숫가 클 경우? (특히 많은 쓰레드가 ready-to-run 인 경우, 즉 Oversubscription) 코어에 다수의 쓰레드가 할당될 수 있으므로 스캐쥴러에 의한 문맥 전환 발생 가능성 높아짐. 비용 : 쓰레드와 쓰레드로 문맥전환 비용 + 캐시라인 transferring인한 성능 저하
  • 어떻게 해야 할까? 데이타 분할을 위해 멀티쓰레드를 생성할 경우 쓰레드 수를 제한하자. 테스크를 나눌 경우 신중한 접근이 필요한데, Trade-off도 생각해야 되고 테스트를 나누는 디자인의 변경이 필요할수도 있다.

8.3 Designing data structures for multithreaded performance

멀티 쓰레드 프로그램 성능에 신경써야할 3 가지 영향
- contention
- false sharing
- data proximity

8.4 동시성 디자인시 추가 고려 사항

이제 예외 안정성 및 확장성에 대해 얘기해볼 차례다.
확장성
- 코어 수에 비례하여 성능도 향상된다.
예외 안정성
동작의 정확성에 영향을 미친다.
- end up with broken invariants
- end up with race conditions
- undefined termination

8.4.1 병렬 알고리즘에서 예외 안전성

한 시점에 하나의 프로그램(쓰레드)만 실행될 때 예외가 발생하면 그 쓰레드만 정리하면되고 예외를 전파할 수 있지만 여러 쓰레드가 실행되는 병렬 프로그램일 경우 쓰레드는 자신만의 스택을 가지고 있기 때문에 예외 전파가 제한적이다. 세밀한 제어를 못하면 std::terminate에 의해 전체 프로세스가 종료될 수 있다.

  1. listing 2.8 예제를 다시 한번 살펴보자 어떤 예외 안정성 문제가 있을까?
  • 7,9 : 쓰레드를 생성할때 그리고 쓰레드 생성 후 예외가 발생하면서 std::terminate()가 호출될것이다.
    원하지 않는 결과 및 구현에 따라서 자원 누수도 생길수 있다.
    (9번은 결국 std::accumulate를 호출하는데 간단한 구현이라 예외 안전할것이라 생각되지만 인자가 template라 함수 자체에서는 예외 안정성을 보장할수 없어서 그런것 같다.)
  • thread join도 예외를 던질수 있음. (예를들어 joinable하지 않을 경우)
  • 생성된 쓰레드도 std::accumulate에서 예외 가능 try catch가 없으므로 std::terminate로 프로세스 종료.
  • Exception safe한 코드는 아님
  1. 예외 안정성 추가
    <listing 8.3>
    이 예제는 std::packaged_task 그리고 std::future를 통해서 구현할 수 있다.
    - 결과를 전달할 수 있는 경로를 제공해주므로 코드가 좀더 단순해진다.
    - 예외에 안전하다.
    • 결과뿐만아니라 예외가 발생했을 경우 예외도 잡아서 전달해준다.
      쓰레드 테스크에서 예외가 발생하면 future에 의해 catch(?)되어서 get에의해 다시 throw 된다.
      즉 쓰레드의 예외 안정성을 보장해주고 get에 의해 caller 쓰레드에서 throw 될수 있도록 해준다.
      한가지, 여러 쓰레드에서 각각의 예외가 발생하는 경우라면 default로 하나의 예외만 전파된다. 모든 예외를 잡고 싶다면 std::nested_exception을 쓰면 된다.
      - 메인에서 첫 쓰레드를 spawn하고 join하는 사이에 예외가 발생했다면 모든 쓰레드가 join하기전에 종료될 수 있다.
      이 문제는 join_thread와 같은 RAII guard를 구현해서 해결할수 있다.
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>

  1. std::async()를 사용한 예외 안전성 - std::async가 알아서 쓰레드 관리를 해준다. - listing 8.5의 예외 안정성
    • 재귀 함수 호출에서 예외가 발생하면 std::async에서 생성된 future가 예외가 전파되면서 삭제될것이다. 그리고 비동기 테스크가 종료될때까지 기다린다.?? (no dangling thread)
    • 비동기에서 예외를 던지면 future에서 잡힐거고 get()을 통해 rethrow될 것이다.??

8.4.2 확장성과 암달의 법칙

- 병렬화 가능한 부분과 가능하지 않는 부분(직렬)의 비율 그리고 N개 프로세서를 사용해서,
 성능 이득을 공식화할 수 있다.  
 <<암달의 법칙>>  
 - 아무리 프로세서가 많다고 해도 직렬화되는 부분 비율의 역수 만큼만 성능 이득을 볼수 있다.  
 - 하지만 암달의 법칙이 실 프로그램 환경에 그대로 적용는건 아니다.  
  - 테스크 의존성으로 인한 지연  
  - IO Bound Task  
 - 역으로 생각하면 직렬 처리 비율을 줄이고 테스크 의존으로 인한 지연을 줄이면 성능 개선을 이룰수 있다.    
 scalability is about 1)reducing the time it takes to perform an action or 2)increasing the amount of data that can be processed in a given time as more processors are added
 : 1)2)를 확장성 있는 디자인을 위해 고려하자.  

8.4.3 멀티플 쓰레드에서 지연 감추기

8.4.4 동시성으로 응답성 개선

쓰레드로 작업을 나눔으로써 응답성을 개선할 수 있다.  
예를들어,  
  GUI 프레임웍은 event driven이고 보통 event loop를 다음과 같이 가진다.  

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

  하지만 싱글쓰레드에서 롱런 테스크를 처리하면 이벤트 응답성에 문제가 생길수 있다.  
  그래서 이벤트에 대한 테스크 실행을 새로운 쓰레드에서 실행함으로써 응답성을 개선시킬 수 있다.  
  Concern의 분리!  
  <<Listing 8.6>>  

8.5 실용적인 동시성 코드 디자인

Clone this wiki locally