Share via


병렬 패턴 라이브러리의 유용한 정보

업데이트: 2011년 3월

이 문서에서는 PPL(병렬 패턴 라이브러리)을 가장 효과적으로 사용하는 방법에 대해 설명합니다. PPL은 세분화된 병렬 처리를 수행하기 위해 일반적으로 사용되는 컨테이너, 개체 및 알고리즘을 제공합니다.

PPL에 대한 자세한 내용은 PPL(병렬 패턴 라이브러리)를 참조하십시오.

단원

이 문서에는 다음과 같은 단원이 포함되어 있습니다.

  • 작은 루프 본문은 병렬화하지 않음

  • 가능한 최고 수준에서 병렬화 표현

  • parallel_invoke를 사용하여 Solve Divide-and-Conquer 문제 해결

  • 취소 또는 예외 처리를 사용하여 병렬 루프에서 중단

  • 취소 및 예외 처리가 개체 소멸에 미치는 영향 이해

  • 병렬 루프에서 반복적으로 차단하지 않음

  • 병렬 작업을 취소할 때 차단 작업을 수행하지 않음

  • 병렬 루프에서 공유 데이터에 쓰지 않음

  • 가능한 경우 거짓 공유 기피

  • 작업 수명 전체적으로 변수가 유효한지 확인

작은 루프 본문은 병렬화하지 않음

비교적 작은 그룹 본문을 병렬화하면 관련된 예약 오버헤드가 병렬 처리의 이점보다 클 수 있습니다. 두 배열에 각 요소 쌍을 추가하는 다음 예를 살펴봅니다.

// small-loops.cpp
// compile with: /EHsc
#include <ppl.h>
#include <iostream>

using namespace Concurrency;
using namespace std;

int wmain()
{
   // Create three arrays that each have the same size.
   const size_t size = 100000;
   int a[size], b[size], c[size];

   // Initialize the arrays a and b.
   for (size_t i = 0; i < size; ++i)
   {
      a[i] = i;
      b[i] = i * 2;
   }

   // Add each pair of elements in arrays a and b in parallel 
   // and store the result in array c.
   parallel_for<size_t>(0, size, [&a,&b,&c](size_t i) {
      c[i] = a[i] + b[i];
   });

   // TODO: Do something with array c.
}

각 병렬 루프 반복의 작업 부하가 너무 작아서 병렬 처리에 대한 오버헤드에서 이점을 얻을 수 없습니다. 루프 본문에서 추가 작업을 수행하거나 루프를 연속으로 수행하여 이 루프의 성능을 향상시킬 수 있습니다.

[맨 위로 이동]

가능한 최고 수준에서 병렬화 표현

낮은 수준에서만 코드를 병렬화하면 프로세서 수가 증가할 경우 확장하지 않는 분기-조인 구문을 사용할 수 있습니다. 분기-조인 구문은 하나의 작업(task)이 해당 작업(work)을 작은 병렬 하위 작업(task)으로 나누고 해당 하위 작업(task)이 완료될 때까지 기다리는 구문입니다. 각 하위 작업은 재귀적으로 자체를 추가 하위 작업으로 나눌 수 있습니다.

분기-조인은 다양한 문제를 해결하는 데 유용하지만 동기화 오버헤드가 확장성을 감소시키는 경우도 있습니다. 예를 들어 이미지 데이터를 처리하는 다음과 같은 일련 코드를 가정해 봅니다.

// Calls the provided function for each pixel in a Bitmap object.
void ProcessImage(Bitmap* bmp, const function<void (DWORD&)>& f)
{
   int width = bmp->GetWidth();
   int height = bmp->GetHeight();

   // Lock the bitmap.
   BitmapData bitmapData;
   Rect rect(0, 0, bmp->GetWidth(), bmp->GetHeight());
   bmp->LockBits(&rect, ImageLockModeWrite, PixelFormat32bppRGB, &bitmapData);

   // Get a pointer to the bitmap data.
   DWORD* image_bits = (DWORD*)bitmapData.Scan0;

   // Call the function for each pixel in the image.
   for (int y = 0; y < height; ++y)
   {      
      for (int x = 0; x < width; ++x)
      {
         // Get the current pixel value.
         DWORD* curr_pixel = image_bits + (y * width) + x;

         // Call the function.
         f(*curr_pixel);
      }
   }

   // Unlock the bitmap.
   bmp->UnlockBits(&bitmapData);
}

각 루프 반복이 독립적이기 때문에 다음 예제에서와 같이 대부분의 작업을 병렬화할 수 있습니다. 이 예제에서는 Concurrency::parallel_for 알고리즘을 사용하여 외부 루프를 병렬화합니다.

// Calls the provided function for each pixel in a Bitmap object.
void ProcessImage(Bitmap* bmp, const function<void (DWORD&)>& f)
{
   int width = bmp->GetWidth();
   int height = bmp->GetHeight();

   // Lock the bitmap.
   BitmapData bitmapData;
   Rect rect(0, 0, bmp->GetWidth(), bmp->GetHeight());
   bmp->LockBits(&rect, ImageLockModeWrite, PixelFormat32bppRGB, &bitmapData);

   // Get a pointer to the bitmap data.
   DWORD* image_bits = (DWORD*)bitmapData.Scan0;

   // Call the function for each pixel in the image.
   parallel_for (0, height, [&, width](int y)
   {      
      for (int x = 0; x < width; ++x)
      {
         // Get the current pixel value.
         DWORD* curr_pixel = image_bits + (y * width) + x;

         // Call the function.
         f(*curr_pixel);
      }
   });

   // Unlock the bitmap.
   bmp->UnlockBits(&bitmapData);
}

다음 예제에서는 루프에서 ProcessImage 함수를 호출하여 분기-조인 구문을 보여 줍니다. ProcessImage에 대한 각 호출은 각 하위 작업이 완료될 때까지 반환되지 않습니다.

// Processes each bitmap in the provided vector.
void ProcessImages(vector<Bitmap*> bitmaps, const function<void (DWORD&)>& f)
{
   for_each(bitmaps.begin(), bitmaps.end(), [&f](Bitmap* bmp) {
      ProcessImage(bmp, f);
   });
}

병렬 루프의 각 반복이 거의 작업을 수행하지 않거나 병렬 루프에 의해 수행되는 작업의 균형이 맞지 않을 경우, 즉 일부 루프 반복이 다른 루프 반복에 비해 시간이 오래 걸리는 경우 작업을 자주 분기 및 조인하는 데 필요한 예약 오버헤드가 병렬 실행으로 얻을 수 있는 이점보다 클 수 있습니다. 프로세서 수가 증가할수록 이 오버헤드가 증가합니다.

이 예제에서 예약 오버헤드 크기를 줄이기 위해 내부 루프를 병렬화하거나 파이프라인과 같은 다른 병렬 구문을 사용하기 전에 외부 루프를 병렬화할 수 있습니다. 다음 예제에서는 Concurrency::parallel_for_each 알고리즘을 사용하여 외부 루프를 병렬화하도록 ProcessImages 함수를 수정합니다.

// Processes each bitmap in the provided vector.
void ProcessImages(vector<Bitmap*> bitmaps, const function<void (DWORD&)>& f)
{
   parallel_for_each(bitmaps.begin(), bitmaps.end(), [&f](Bitmap* bmp) {
      ProcessImage(bmp, f);
   });
}

파이프라인을 사용하여 이미지 처리를 병렬로 수행하는 비슷한 예제를 보려면 연습: 이미지 처리 네트워크 만들기를 참조하십시오.

[맨 위로 이동]

parallel_invoke를 사용하여 Solve Divide-and-Conquer 문제 해결

divide-and-conquer 문제는 재귀를 사용하여 작업을 하위 작업으로 나누는 분기-조인 구문의 한 형태입니다. Concurrency::task_groupConcurrency::structured_task_group 클래스 외에도 Concurrency::parallel_invoke 알고리즘을 사용하여 divide-and-conquer 문제를 해결할 수 있습니다. parallel_invoke 알고리즘의 경우 작업 그룹 개체보다 구문이 더 간결하며 고정된 수의 병렬 작업이 있는 경우 이 알고리즘을 사용하면 유용합니다.

다음 예제에서는 parallel_invoke 알고리즘을 사용하여 바이토닉 정렬 알고리즘을 구현하는 방법을 보여 줍니다.

// Sorts the given sequence in the specified order.
template <class T>
void parallel_bitonic_sort(T* items, int lo, int n, bool dir)
{   
   if (n > 1)
   {
      // Divide the array into two partitions and then sort 
      // the partitions in different directions.
      int m = n / 2;

      parallel_invoke(
         [&] { parallel_bitonic_sort(items, lo, m, INCREASING); },
         [&] { parallel_bitonic_sort(items, lo + m, m, DECREASING); }
      );

      // Merge the results.
      parallel_bitonic_merge(items, lo, n, dir);
   }
}

parallel_invoke 알고리즘은 오버헤드를 줄이기 위해 호출 컨텍스트에서 일련의 작업 중 마지막 작업을 수행합니다.

이 예제의 전체 버전은 방법: parallel_invoke를 사용하여 병렬 정렬 루틴 작성을 참조하십시오. parallel_invoke 알고리즘에 대한 자세한 내용은 병렬 알고리즘을 참조하십시오.

[맨 위로 이동]

취소 또는 예외 처리를 사용하여 병렬 루프에서 중단

PPL은 작업 그룹 또는 병렬 알고리즘에 의해 수행되는 병렬 작업을 취소하는 두 가지 방법을 제공합니다. 이 방법 중 하나는 Concurrency::task_groupConcurrency::structured_task_group 클래스에서 제공하는 취소 메커니즘을 사용하는 것입니다. 다른 방법은 작업 함수 본문에서 예외를 throw하는 것입니다. 취소 메커니즘은 병렬 작업 트리를 취소할 때 예외 처리보다 더 효율적입니다. 병렬 작업 트리는 일부 작업 그룹에 다른 작업 그룹이 포함되어 있는 관련 작업 그룹의 그룹입니다. 취소 메커니즘은 작업 그룹 및 자식 작업 그룹을 위에서 아래로 취소합니다. 반대로 예외 처리는 아래에서 위로 동작하며, 예외가 위쪽으로 전파할 때 각 자식 작업 그룹을 독립적으로 취소해야 합니다.

작업 그룹 개체를 직접 사용하는 경우 Concurrency::task_group::cancel 또는 Concurrency::structured_task_group::cancel 메서드를 사용하여 해당 작업 그룹에 속한 작업을 취소합니다. parallel_for와 같은 병렬 알고리즘을 취소하려면 부모 작업 그룹을 만들고 해당 작업 그룹을 취소합니다. 예를 들어 배열에서 값을 병렬로 검색하는 parallel_find_any와 같은 함수를 가정해 봅니다.

// Returns the position in the provided array that contains the given value, 
// or -1 if the value is not in the array.
template<typename T>
int parallel_find_any(const T a[], size_t count, const T& what)
{
   // The position of the element in the array. 
   // The default value, -1, indicates that the element is not in the array.
   int position = -1;

   // Use parallel_for to search for the element. 
   // The task group enables a work function to cancel the overall 
   // operation when it finds the result.

   structured_task_group tasks;
   tasks.run_and_wait([&]
   {
      parallel_for(std::size_t(0), count, [&](int n) {
         if (a[n] == what)
         {
            // Set the return value and cancel the remaining tasks. 
            position = n;            
            tasks.cancel();
         }
      });
   });

   return position;
}

병렬 알고리즘은 작업 그룹을 사용하므로 병렬 반복 중 하나가 부모 작업 그룹을 취소하면 전체 작업이 취소됩니다. 이 예제의 전체 버전은 방법: 취소를 사용하여 병렬 루프 중단을 참조하십시오.

예외 처리는 병렬 작업을 취소할 때 취소 메커니즘보다 효율성이 떨어지는 방법이지만 예외 처리가 적합한 경우도 있습니다. 예를 들어 for_all 메서드는 tree 구조의 각 노드에서 작업 함수를 재귀적으로 수행합니다. 이 예제에서 _children 데이터 멤버는 tree 개체가 포함되어 있는 std::list입니다.

// Performs the given work function on the data element of the tree and
// on each child.
template<class Function>
void tree::for_all(Function& action)
{
   // Perform the action on each child.
   parallel_for_each(_children.begin(), _children.end(), [&](tree& child) {
      child.for_all(action);
   });

   // Perform the action on this node.
   action(*this);
}

tree::for_all 메서드의 호출자는 트리의 각 요소에서 작업 함수를 호출할 필요가 없는 경우 예외를 throw할 수 있습니다. 다음 예제에서는 제공된 tree 개체에서 값을 검색하는 search_for_value 함수를 보여 줍니다. search_for_value 함수는 트리의 현재 요소가 제공된 값과 일치하는 경우 예외를 throw하는 작업 함수를 사용합니다. search_for_value 함수는 try-catch 블록을 사용하여 예외를 캡처하고 결과를 콘솔에 출력합니다.

// Searches for a value in the provided tree object.
template <typename T>
void search_for_value(tree<T>& t, int value)
{
   try
   {
      // Call the for_all method to search for a value. The work function
      // throws an exception when it finds the value.
      t.for_all([value](const tree<T>& node) {
         if (node.get_data() == value)
         {
            throw &node;
         }
      });
   }
   catch (const tree<T>* node)
   {
      // A matching node was found. Print a message to the console.
      wstringstream ss;
      ss << L"Found a node with value " << value << L'.' << endl;
      wcout << ss.str();
      return;
   }

   // A matching node was not found. Print a message to the console.
   wstringstream ss;
   ss << L"Did not find node with value " << value << L'.' << endl;
   wcout << ss.str();   
}

이 예제의 전체 버전은 방법: 예외 처리를 사용하여 병렬 루프 중단을 참조하십시오.

PPL에서 제공하는 취소 및 예외 처리 메커니즘에 대한 자세한 내용은 PPL에서의 취소동시성 런타임에서 예외 처리를 참조하십시오.

[맨 위로 이동]

취소 및 예외 처리가 개체 소멸에 미치는 영향 이해

병렬 작업 트리에서 취소된 작업으로 인해 자식 작업이 실행되지 않게 됩니다. 자식 작업 중 하나가 사용자의 응용 프로그램에 중요한 작업(예: 리소스 해제)을 수행하는 경우 이로 인해 문제가 발생할 수 있습니다. 또한 작업을 취소하면 예외가 개체 소멸자를 통해 전파되고 응용 프로그램에서 정의되지 않은 동작이 발생할 수 있습니다.

다음 예제에서 Resource 클래스는 리소스를 설명하고 Container 클래스는 리소스가 포함된 컨테이너를 설명합니다. 해당 소멸자에서 Container 클래스가 Resource 멤버 중 두 멤버에서 cleanup 메서드를 병렬로 호출한 다음 세 번째 Resource 멤버에서 cleanup 메서드를 호출합니다.

// parallel-resource-destruction.h
#pragma once
#include <ppl.h>
#include <sstream>
#include <iostream>

// Represents a resource.
class Resource
{
public:
   Resource(const std::wstring& name)
      : _name(name)
   {
   }

   // Frees the resource.
   void cleanup()
   {
      // Print a message as a placeholder.
      std::wstringstream ss;
      ss << _name << L": Freeing..." << std::endl;
      std::wcout << ss.str();
   }
private:
   // The name of the resource.
   std::wstring _name;
};

// Represents a container that holds resources.
class Container
{
public:
   Container(const std::wstring& name)
      : _name(name)
      , _resource1(L"Resource 1")
      , _resource2(L"Resource 2")
      , _resource3(L"Resource 3")
   {
   }

   ~Container()
   {
      std::wstringstream ss;
      ss << _name << L": Freeing resources..." << std::endl;
      std::wcout << ss.str();

      // For illustration, assume that cleanup for _resource1
      // and _resource2 can happen concurrently, and that 
      // _resource3 must be freed after _resource1 and _resource2.

      Concurrency::parallel_invoke(
         [this]() { _resource1.cleanup(); },
         [this]() { _resource2.cleanup(); }
      );

      _resource3.cleanup();
   }

private:
   // The name of the container.
   std::wstring _name;

   // Resources.
   Resource _resource1;
   Resource _resource2;
   Resource _resource3;
};

이 패턴에는 고유 문제가 없지만 두 작업을 병렬로 실행하는 다음 코드를 살펴봅니다. 첫 번째 작업은 Container 개체를 만들고 두 번째 작업은 전체 작업을 취소합니다. 설명을 위해 이 예제에서는 두 Concurrency::event 개체를 사용하여, Container 개체가 만들어진 후 취소가 발생하는지와 취소 작업이 발생한 후 Container 개체가 삭제되는지 확인합니다.

// parallel-resource-destruction.cpp
// compile with: /EHsc
#include "parallel-resource-destruction.h"

using namespace Concurrency;
using namespace std;

static_assert(false, "This example illustrates a non-recommended practice.");

int main()
{  
   // Create a task_group that will run two tasks.
   task_group tasks;

   // Used to synchronize the tasks.
   event e1, e2;

   // Run two tasks. The first task creates a Container object. The second task
   // cancels the overall task group. To illustrate the scenario where a child 
   // task is not run because its parent task is cancelled, the event objects 
   // ensure that the Container object is created before the overall task is 
   // cancelled and that the Container object is destroyed after the overall 
   // task is cancelled.

   tasks.run([&tasks,&e1,&e2] {
      // Create a Container object.
      Container c(L"Container 1");

      // Allow the second task to continue.
      e2.set();

      // Wait for the task to be cancelled.
      e1.wait();
   });

   tasks.run([&tasks,&e1,&e2] {
      // Wait for the first task to create the Container object.
      e2.wait();

      // Cancel the overall task.
      tasks.cancel();      

      // Allow the first task to continue.
      e1.set();
   });

   // Wait for the tasks to complete.
   tasks.wait();

   wcout << L"Exiting program..." << endl;
}

이 예제의 결과는 다음과 같습니다.

Container 1: Freeing resources...
Exiting program...

이 코드 예제에는 예상과 다르게 동작하게 할 수 있는 다음 문제가 포함되어 있습니다.

  • 부모 작업을 취소하면 자식 작업인 Concurrency::parallel_invoke에 대한 호출도 취소됩니다. 따라서 이 두 리소스 모두 해제되지 않습니다.

  • 부모 작업을 취소하면 자식 작업에서 내부 예외를 throw합니다. Container 소멸자가 이 예외를 처리하지 않으므로 예외가 위쪽으로 전파되고 세 번째 리소스는 해제되지 않습니다.

  • 자식 작업에서 throw되는 예외는 Container 소멸자를 통해 전파됩니다. 소멸자에서 throw하면 응용 프로그램이 정의되지 않은 상태가 됩니다.

이러한 작업(task)이 취소되지 않는다고 확신할 수 없는 경우에는 작업(task)에서 리소스 해제와 같은 중요 작업(operation)을 수행하지 않는 것이 좋습니다. 그리고 사용자가 사용하는 형식의 소멸자에 throw될 수 있는 런타임 기능도 사용하지 않는 것이 좋습니다.

[맨 위로 이동]

병렬 루프에서 반복적으로 차단하지 않음

차단 작업을 통해 우위를 차지하는 Concurrency::parallel_for 또는 Concurrency::parallel_for_each와 같은 병렬 작업의 경우 짧은 시간 동안 런타임에서 많은 스레드를 만들 수 있습니다.

작업(task)이 완료되거나 협조적으로 차단 또는 양보하는 경우 동시성 런타임은 추가 작업(work)을 수행합니다. 하나의 병렬 루트 반복이 차단되면 런타임은 다른 반복을 시작할 수 있습니다. 사용 가능한 유휴 스레드가 없으면 런타임은 새 스레드를 만듭니다.

병렬 루프 본문이 가끔 차단되는 경우 이 메커니즘을 사용하여 전체 작업 처리량을 최대화할 수 있습니다. 그러나 많은 반복이 차단되는 경우에는 런타임에서 추가 작업을 실행하도록 많은 스레드를 만들 수 있습니다. 그러면 메모리가 부족한 상태가 되거나 하드웨어 리소스의 사용률이 저하될 수 있습니다.

parallel_for 루프의 각 반복에서 Concurrency::send 함수를 호출하는 다음 예제를 살펴봅니다. send는 협조적으로 차단하므로 send를 호출할 때마다 런타임에서 추가 작업을 실행하도록 새 스레드를 만듭니다.

// repeated-blocking.cpp
// compile with: /EHsc
#include <ppl.h>
#include <agents.h>

using namespace Concurrency;

static_assert(false, "This example illustrates a non-recommended practice.");

int main()
{
   // Create a message buffer.
   overwrite_buffer<int> buffer;

   // Repeatedly send data to the buffer in a parallel loop.
   parallel_for(0, 1000, [&buffer](int i) {

      // The send function blocks cooperatively. 
      // We discourage the use of repeated blocking in a parallel
      // loop because it can cause the runtime to create 
      // a large number of threads over a short period of time.
      send(buffer, i);
   });
}

이 패턴을 방지하도록 코드를 리팩터링하는 것이 좋습니다. 이 예제에서는 연속 for 루프에서 send를 호출하여 추가 스레드가 만들어지지 않게 할 수 있습니다.

[맨 위로 이동]

병렬 작업을 취소할 때 차단 작업을 수행하지 않음

가능한 경우 Concurrency::task_group::cancel 또는 Concurrency::structured_task_group::cancel 메서드를 호출하여 병렬 작업을 취소하기 전에 차단 작업을 수행하지 마십시오.

작업(task)이 차단 작업(operation)을 수행하면 첫 번째 작업(task)이 데이터를 기다리는 동안 런타임에서 다른 작업(work)을 수행할 수 있습니다. UMS(사용자 모드 예약)가 사용하도록 설정되어 있는 경우 작업(task)이 협조적 차단 작업(operation)이나 커널 전환이 포함된 차단 작업(operation)을 수행하면 런타임은 다른 작업(work)을 수행합니다. 기본값인 정상 스레드 예약이 사용하도록 설정되어 있으면 작업(task)이 협조적 차단 작업(operation)을 수행하는 경우에만 런타임이 다른 작업(work)을 수행합니다. 런타임은 대기 중인 작업이 차단 해제되면 해당 작업을 다시 예약합니다. 일반적으로 런타임은 최근에 차단 해제된 작업을 다시 예약하기 전에 가장 최근에 차단 해제된 작업을 다시 예약합니다. 따라서 차단 작업(operation)이 수행되는 동안 런타임에서 불필요한 작업(work)을 예약할 수 있으므로 성능이 저하됩니다. 따라서 병렬 작업(work)을 취소하기 전에 차단 작업(operation)을 수행하면 차단 작업(operation)이 cancel에 대한 호출을 지연시킬 수 있습니다. 그러면 다른 작업(task)이 불필요한 작업(work)을 수행합니다.

제공된 조건자 함수를 만족시키는 제공된 배열 요소를 검색하는 함수인 parallel_find_answer 함수를 정의하는 다음 예제를 살펴봅니다. 조건자 함수가 true를 반환하는 경우 병렬 작업(work) 함수는 Answer 개체를 만들고 전체 작업(task)을 취소합니다.

// blocking-cancel.cpp
// compile with: /c /EHsc
#include <windows.h>
#include <ppl.h>

using namespace Concurrency;

// Encapsulates the result of a search operation.
template<typename T>
class Answer
{
public:
   explicit Answer(const T& data)
      : _data(data)
   {
   }

   T get_data() const
   {
      return _data;
   }

   // TODO: Add other methods as needed.

private:
   T _data;

   // TODO: Add other data members as needed.
};

// Searches for an element of the provided array that satisfies the provided
// predicate function.
template<typename T, class Predicate>
Answer<T>* parallel_find_answer(const T a[], size_t count, const Predicate& pred)
{
   // The result of the search.
   Answer<T>* answer = nullptr;
   // Ensures that only one task produces an answer.
   volatile long first_result = 0;

   // Use parallel_for and a task group to search for the element.
   structured_task_group tasks;
   tasks.run_and_wait([&]
   {
      // Declare the type alias for use in the inner lambda function.
      typedef T T;

      parallel_for<size_t>(0, count, [&](const T& n) {
         if (pred(a[n]) && InterlockedExchange(&first_result, 1) == 0)
         {
            // Create an object that holds the answer.
            answer = new Answer<T>(a[n]);
            // Cancel the overall task.
            tasks.cancel();
         }
      });
   });

   return answer;
}

new 연산자는 차단될 수 있는 힙 할당을 수행합니다. UMS(사용자 모드 예약)가 사용하도록 설정되어 있으면 런타임은 차단 작업(operation)이 수행되는 동안 다른 작업(work)을 수행합니다. 정상 스레드 예약이 사용하도록 설정되어 있으면 작업(task)이 Concurrency::critical_section::lock에 대한 호출과 같은 협조적 차단 호출을 수행하는 경우에만 런타임이 다른 작업(work)을 수행합니다.

다음 예제에서는 불필요한 작업이 수행되지 않게 하여 성능을 향상시키는 방법을 보여 줍니다. 이 예제에서는 Answer 개체에 대한 저장소를 할당하기 전에 작업 그룹을 취소합니다.

// Searches for an element of the provided array that satisfies the provided
// predicate function.
template<typename T, class Predicate>
Answer<T>* parallel_find_answer(const T a[], size_t count, const Predicate& pred)
{
   // The result of the search.
   Answer<T>* answer = nullptr;
   // Ensures that only one task produces an answer.
   volatile long first_result = 0;

   // Use parallel_for and a task group to search for the element.
   structured_task_group tasks;
   tasks.run_and_wait([&]
   {
      // Declare the type alias for use in the inner lambda function.
      typedef T T;

      parallel_for<size_t>(0, count, [&](const T& n) {
         if (pred(a[n]) && InterlockedExchange(&first_result, 1) == 0)
         {
            // Cancel the overall task.
            tasks.cancel();
            // Create an object that holds the answer.
            answer = new Answer<T>(a[n]);            
         }
      });
   });

   return answer;
}

[맨 위로 이동]

병렬 루프에서 공유 데이터에 쓰지 않음

동시성 런타임에서는 공유 데이터에 대한 동시 액세스를 동기화하는 Concurrency::critical_section과 같은 몇 가지 데이터 구조를 제공합니다. 이러한 데이터 구조는 여러 작업에서 리소스에 대한 공유 액세스를 자주 필요로 하지 않는 경우를 포함한 여러 경우에 유용합니다.

Concurrency::parallel_for_each 알고리즘 및 critical_section 개체를 사용하여 std::array 개체에서 소수 개수를 계산하는 다음 예제를 살펴봅니다. 공유 변수 prime_sum에 액세스할 수 있을 때까지 각 스레드가 기다려야 하므로 이 예제를 확장하지 않습니다.

critical_section cs;
prime_sum = 0;
parallel_for_each(a.begin(), a.end(), [&](int i) {
   cs.lock();
   prime_sum += (is_prime(i) ? i : 0);
   cs.unlock();
});

이 예제에서는 잦은 잠금 작업이 루프를 효과적으로 serialize하므로 성능이 저하될 수도 있습니다. 또한 동시성 런타임 개체가 차단 작업(operation)을 수행하면 첫 번째 스레드가 데이터를 기다리는 동안 다른 작업(work)을 수행하도록 스케줄러가 추가 스레드를 만들 수 있습니다. 여러 작업이 공유 데이터를 기다리기 때문에 런타임이 여러 스레드를 만드는 경우 응용 프로그램의 성능이 떨어지거나 리소스가 부족한 상태가 될 수 있습니다.

PPL은 잠금이 필요 없는 방식으로 공유 리소스에 대한 액세스를 제공하여 공유 상태를 제거할 수 있는 Concurrency::combinable 클래스를 정의합니다. combinable 클래스는 세분화된 계산을 수행한 후 이러한 계산을 최종 결과로 병합하는 데 사용할 수 있는 스레드 로컬 저장소를 제공합니다. combinable 개체를 환산(reduction) 변수로 생각할 수 있습니다.

다음 예제에서는 합계를 계산하는 데 critical_section 개체 대신 combinable 개체를 사용하여 이전 예제를 수정합니다. 각 스레드가 합계의 고유 로컬 복사본을 포함하므로 이 예제를 확장합니다. 이 예제에서는 Concurrency::combinable::combine 메서드를 사용하여 로컬 계산 결과를 최종 결과에 병합합니다.

combinable<int> sum;
parallel_for_each(a.begin(), a.end(), [&](int i) {
   sum.local() += (is_prime(i) ? i : 0);
});
prime_sum = sum.combine(plus<int>());

이 예제의 전체 버전은 방법: combinable을 사용하여 성능 개선을 참조하십시오. combinable 클래스에 대한 자세한 내용은 병렬 컨테이너 및 개체를 참조하십시오.

[맨 위로 이동]

가능한 경우 거짓 공유 기피

별도의 프로세서에서 실행 중인 여러 동시 작업이 동일한 캐시 라인에 있는 변수에 쓰는 경우 거짓 공유가 발생합니다. 하나의 작업이 변수 중 하나에 쓰면 두 변수의 캐시 라인이 무효화됩니다. 각 프로세서는 캐시 라인이 무효화될 때마다 캐시 줄을 다시 로드해야 합니다. 따라서 거짓 공유로 인해 응용 프로그램의 성능이 저하될 수 있습니다.

다음 기본 예제에서는 각 작업이 공유 카운터 변수를 늘리는 두 개의 동시 작업을 보여 줍니다.

volatile long count = 0L;
Concurrency::parallel_invoke(
   [&count] {
      for(int i = 0; i < 100000000; ++i)
         InterlockedIncrement(&count);
   },
   [&count] {
      for(int i = 0; i < 100000000; ++i)
         InterlockedIncrement(&count);
   }
);

두 작업 간에 데이터를 공유하지 않도록 하기 위해 두 개의 카운터 변수를 사용하도록 예제를 수정할 수 있습니다. 이 예제에서는 작업이 완료된 후 최종 카운터 값을 계산합니다. 그러나 이 예제에서는 count1count2 변수가 동일한 캐시 라인에 위치할 가능성이 높기 때문에 거짓 공유를 보여 줍니다.

long count1 = 0L;
long count2 = 0L;
Concurrency::parallel_invoke(
   [&count1] {
      for(int i = 0; i < 100000000; ++i)
         ++count1;
   },
   [&count2] {
      for(int i = 0; i < 100000000; ++i)
         ++count2;
   }
);
long count = count1 + count2;

거짓 공유를 제거하는 한 가지 방법은 카운터 변수를 별도의 캐시 라인에 두는 것입니다. 다음 예제에서는 count1count2 변수를 64바이트 경계에 정렬합니다.

__declspec(align(64)) long count1 = 0L;      
__declspec(align(64)) long count2 = 0L;      
Concurrency::parallel_invoke(
   [&count1] {
      for(int i = 0; i < 100000000; ++i)
         ++count1;
   },
   [&count2] {
      for(int i = 0; i < 100000000; ++i)
         ++count2;
   }
);
long count = count1 + count2;

이 예제에서는 메모리 캐시 크기가 64바이트 이하라고 가정합니다.

작업 간에 데이터를 공유해야 하는 경우 Concurrency::combinable 클래스를 사용하는 것이 좋습니다. combinable 클래스는 거짓 공유가 발생할 가능성이 적은 방식으로 스레드 로컬 변수를 만듭니다. combinable 클래스에 대한 자세한 내용은 병렬 컨테이너 및 개체를 참조하십시오.

[맨 위로 이동]

작업 수명 전체적으로 변수가 유효한지 확인

작업 그룹 또는 병렬 알고리즘에 람다 식을 제공하는 경우 캡처 절은 람다 식의 본문이 바깥쪽 범위에 있는 변수에 값을 통해 액세스하는지 아니면 참조를 통해 액세스하는지 지정합니다. 참조를 통해 변수를 람다 식에 전달하는 경우 해당 변수의 수명이 작업 완료 시까지 유지되도록 해야 합니다.

object 클래스 및 perform_action 함수를 정의하는 다음 예제를 살펴봅니다. perform_action 함수는 object 변수를 만들고 해당 변수에 대해 비동기적으로 일부 작업을 수행합니다. perform_action 함수에서 반환하기 전에 작업이 완료된다는 보장이 없으므로 작업이 실행 중일 때 object 변수를 삭제하면 프로그램 작동이 중단되거나 지정되지 않은 동작이 발생합니다.

// lambda-lifetime.cpp
// compile with: /c /EHsc
#include <ppl.h>

using namespace Concurrency;

// A type that performs an action.
class object
{
public:
   void action() const
   {
      // TODO: Details omitted for brevity.
   }
};

// Performs an action asynchronously.
void perform_action(task_group& tasks)
{
   // Create an object variable and perform some action on 
   // that variable asynchronously.
   object obj;
   tasks.run([&obj] {
      obj.action();
   });

   // NOTE: The object variable is destroyed here. The program
   // will crash or exhibit unspecified behavior if the task
   // is still running when this function returns.
}

응용 프로그램의 요구 사항에 따라 다음 기술 중 하나를 사용하여 모든 작업의 수명 동안 변수가 유효한 상태로 유지되도록 할 수 있습니다.

다음 예제에서는 값을 통해 object 변수를 작업에 전달합니다. 따라서 작업은 변수의 고유 복사본에 대해 작동합니다.

// Performs an action asynchronously.
void perform_action(task_group& tasks)
{
   // Create an object variable and perform some action on 
   // that variable asynchronously.
   object obj;
   tasks.run([obj] {
      obj.action();
   });
}

값을 통해 object 변수가 전달되므로 이 변수에 대해 발생하는 모든 상태 변경 내용이 원래 복사본에는 나타나지 않습니다.

다음 예제에서는 Concurrency::task_group::wait 메서드를 사용하여 perform_action 함수에서 반환하기 전에 작업이 완료되도록 합니다.

// Performs an action.
void perform_action(task_group& tasks)
{
   // Create an object variable and perform some action on 
   // that variable.
   object obj;
   tasks.run([&obj] {
      obj.action();
   });

   // Wait for the task to finish. 
   tasks.wait();
}

이제 함수에서 반환하기 전에 작업이 완료되므로 perform_action 함수는 더 이상 비동기적으로 동작하지 않습니다.

다음 예제에서는 object 변수를 참조하도록 perform_action 함수를 수정합니다. 호출자는 작업이 완료될 때까지 object 변수의 수명이 유효하도록 해야 합니다.

// Performs an action asynchronously.
void perform_action(object& obj, task_group& tasks)
{
   // Perform some action on the object variable.
   tasks.run([&obj] {
      obj.action();
   });
}

포인터를 사용하여, 작업 그룹 또는 병렬 알고리즘에 전달하는 개체의 수명을 제어할 수도 있습니다.

람다 식에 대한 자세한 내용은 Lambda Expressions in C++을 참조하십시오.

[맨 위로 이동]

참고 항목

작업

방법: parallel_invoke를 사용하여 병렬 정렬 루틴 작성

방법: 취소를 사용하여 병렬 루프 중단

방법: combinable을 사용하여 성능 개선

개념

동시성 런타임 유용한 정보

PPL(병렬 패턴 라이브러리)

병렬 컨테이너 및 개체

병렬 알고리즘

PPL에서의 취소

동시성 런타임에서 예외 처리

기타 리소스

연습: 이미지 처리 네트워크 만들기

비동기 에이전트 라이브러리의 유용한 정보

동시성 런타임의 유용한 일반 정보

변경 기록

날짜

변경 내용

이유

2011년 3월

병렬 루프에서 반복적으로 차단되지 않도록 하는 방법과 취소 및 예외 처리가 개체 소멸에 주는 영향에 대한 정보를 추가했습니다.

향상된 기능 관련 정보

2010년 5월

지침을 확장했습니다.

향상된 기능 관련 정보