Share via


Windows mit C++

Visual C++ 2010 und die Parallel Patterns Library

Kenny Kerr

Dieser Artikel basiert auf einer Vorabversion von Visual Studio 2010. Änderungen an allen Informationen in diesem Artikel sind vorbehalten.

Inhalt

Sprachverbesserungen
Parallele Algorithmen
Aufgaben und Aufgabengruppen

Visual C++ wurde in der Version 2010 von Visual Studio umfassend aktualisiert. Viele der neuen Sprach- und Bibliotheksfeatures haben allein den Zweck, es Ihnen zu erleichtern, Ihre Wünsche in Code auszudrücken. Wie immer bei C++ ist es aber erst die Kombination dieser Features, die C++ zu einer derart leistungsfähigen und ausdrucksvollen Sprache macht.

Daher sollen in diesem Monat einige der Ergänzungen von C++ vorgestellt werden, die in Visual C++ als Bestandteil des in Kürze erscheinenden Standards C++0x hinzugefügt wurden. Anschließend werfen wir einen Blick auf die Parallel Patterns Library (PPL), die von Microsoft über den Standard C++0x hinaus entwickelt wurde. Damit können Sie Parallelität in Ihre Anwendungen in einer Weise einführen, die die C++-Standardbibliothek natürlich ergänzt.

Sprachverbesserungen

Im Artikel des Monats Mai 2008 C++ Plus: Erstellen besserer Windows-Anwendungen mit dem Visual C++ 2008 Feature Pack wurden die Ergänzungen zur C++-Standardbibliothek als Teil des TR1 (Technical Report 1) vorgestellt, der ursprünglich mit dem Visual C++ 2008 Feature Pack eingeführt wurde und jetzt in Visual Studio 2008 SP1 enthalten ist. In jenem Artikel habe ich die Unterstützung für Funktionsobjekte durch die Funktionsvorlagenklasse und die Bindungsvorlagenfunktion demonstriert. Die Fähigkeit, Funktionen polymorph zu behandeln, löste viele der prekären Probleme, denen C++-Entwickler häufig beim Schreiben oder Verwenden generischer Algorithmen begegneten.

An dieser Stelle soll kurz wiederholt werden, wie ein Funktionsobjekt mit dem standardmäßigen Plusalgorithmus initialisiert wird:

function<int (int, int)> f = plus<int>();
int result = f(4, 5);

Mithilfe der bind-Funktion können Sie eine Funktion transformieren, die die notwendige Funktionalität bietet, aber nicht über die richtige Signatur verfügt.

Im folgenden Beispiel verwende ich die bind-Funktion mit Platzhaltern, um das Funktionsobjekt mit einer Memberfunktion zu initialisieren:

struct Adder
{
   int Add(int x, int y, void* /*reserved*/)
   {
       return x + y;
   }
};

Adder adder;
function<int (int, int)> f = bind(&Adder::Add, &adder, _1, _2, 
    static_cast<void*>(0));
int result = f(4, 5);

Bei Verwendung dieser Bibliotheksergänzungen treten zwei Probleme auf, die sich ohne Sprachverbesserungen nur schwer überwinden lassen. Zunächst einmal ist es oft ineffizient, ein Funktionsobjekt explizit zu definieren, da so ein gewisser Mehraufwand entsteht, der vom Compiler andernfalls hätte vermieden werden können. Darüber hinaus kann es überflüssig und außerdem mühsam sein, den Funktionsprototyp erneut zu deklarieren, wenn der Compiler die Signatur, die dem Initialisierungsausdruck am besten entspricht, ganz eindeutig kennt.

In dieser Situation hilft Ihnen das neue auto-Schlüsselwort. Es kann anstelle der expliziten Definition des Typs einer Variablen verwendet werden. Dies ist nützlich bei der Vorlagenmetaprogrammierung, wenn die spezifischen Typen entweder schwierig zu definieren oder kompliziert auszudrücken sind. Das sähe dann wie folgt aus:

auto f = plus<int>();

Die Definitionen der Funktionen selbst würden ebenfalls von einigen Verbesserungen profitieren. Häufig können Sie nützliche Algorithmen, z. B. die in der C++-Standardbibliothek, wiederverwenden. Meistens müssen Sie jedoch eine domänenspezifische Funktion für einen ganz bestimmten Zweck schreiben, die im Allgemeinen nicht wiederverwendbar ist.

Da die Funktion jedoch an anderer Stelle definiert werden muss, müssen Sie sowohl den logischen als auch den physikalischen Entwurf berücksichtigen. Wäre es nicht schön, wenn die Definition genau dort platziert werden könnte, wo sie benötigt wird, wobei das Verständnis des Codes durch Verbesserung der Lokalität der Logik vereinfacht und die Kapselung des Gesamtentwurfs verbessert würde? Genau dies wird durch das Hinzufügen von Lambda-Ausdrücken ermöglicht:

auto f = [](int x, int y) { return x + y; };

Lambda-Ausdrücke definieren unbenannte Funktionsobjekte, die manchmal als Closures bezeichnet werden. Das Zeichen [] teilt dem Compiler mit, dass ein Lambda-Ausdruck beginnt. Dies ist die Lambda-Einführung, der eine Parameterliste folgt. Diese Parameterdeklaration kann auch einen Rückgabetyp enthalten, obwohl dieser oft entfällt, wenn der Compiler den Typ eindeutig ableiten kann, wie dies im vorherigen Codeausschnitt der Fall ist. Sogar die Parameterliste selbst kann weggelassen werden, wenn die Funktion keine Parameter akzeptiert. Der Lambda-Ausdruck wird mit beliebig vielen C++-Anweisungen in Klammern abgeschlossen.

Lambda-Ausdrücke können auch Variable für die Verwendung innerhalb der Funktion aus dem Bereich erfassen, in dem der Lambda-Ausdruck definiert ist. Im folgenden Beispiel wird ein Lambda-Ausdruck zur Berechnung der Summe der Werte in einem Container verwendet:

int sum = 0;
for_each(values.begin(), values.end(), [&sum](int value)
{
    sum += value;
});

Zugegeben, dies hätte mit der accumulate-Funktion eleganter durchgeführt werden können, aber darum geht es hier nicht. Dieses Beispiel zeigt, wie die Summenvariable durch Verweis erfasst und innerhalb der Funktion verwendet wird.

Parallele Algorithmen

Mit PPL werden außer einem Satz aufgabenorientierter Parallelitätskonstrukte einige parallele Algorithmen eingeführt, ähnlich denen, die heute in OpenMP verfügbar sind. Die PPL-Algorithmen werden jedoch mithilfe von C++-Vorlagen statt pragma-Direktiven geschrieben und sind daher wesentlich ausdrucksstärker und flexibler. Der grundlegende Unterschied zwischen PPL und OpenMP besteht allerdings darin, dass PPL einen Satz von Primitiven und Algorithmen unterstützt, die als Mustersatz besser zusammensetzbar und wiederverwendbar sind. OpenMP indessen ist von Natur aus in Bereichen wie der Planung deklarativer und expliziter und gehört letzten Endes nicht zum eigentlichen C++. Außerdem baut PPL auf der Concurrency Runtime auf und ermöglicht damit größere potenzielle Interoperabilität mit anderen Bibliotheken, die auf derselben Laufzeit basieren. Betrachten wir nun die PPL-Algorithmen und untersuchen dann, wie Sie die zugrunde liegende Funktionalität direkt für aufgabenorientierte Parallelität verwenden können.

In der Ausgabe dieser Rubrik vom Oktober 2008 (Hochleistungsalgorithmen) wurden die Vorteile effizienter Algorithmen und die Auswirkungen von Lokalität und cachebewussten Entwürfen auf die Leistung erläutert. In jenem Artikel habe ich gezeigt, dass ein ineffizienter Singlethreadalgorithmus für die Konvertierung eines großen Bilds in ein Graustufenformat 46 Sekunden benötigte. Eine effiziente Implementierung, nach wie vor mit nur einem Thread, benötigte hingegen nur 2 Sekunden. Mit ein wenig OpenMP konnte ich den Algorithmus über der Y-Achse parallelisieren und den Zeitaufwand sogar noch weiter verringern. Abbildung 1 zeigt den Code für den OpenMP-Algorithmus.

Abbildung 1 Graustufenalgorithmus mithilfe von OpenMP

struct Pixel
{
    BYTE Blue;
    BYTE Green;
    BYTE Red;
    BYTE Alpha;
};

void MakeGrayscale(Pixel& pixel)
{
    const BYTE scale = static_cast<BYTE>(0.30 * pixel.Red +
                                         0.59 * pixel.Green +
                                         0.11 * pixel.Blue);

    pixel.Red = scale;
    pixel.Green = scale;
    pixel.Blue = scale;
}

void MakeGrayscale(BYTE* bitmap,
                   const int width,
                   const int height,
                   const int stride)
{
    #pragma omp parallel for
    for (int y = 0; y < height; ++y)
    {
        for (int x = 0; x < width; ++x)
        {
            const int offset = x * sizeof(Pixel) + y * stride;

            Pixel& pixel = *reinterpret_cast<Pixel*>(bitmap + offset);

            MakeGrayscale(pixel);
        }
    }
}

PPL enthält eine Parallele für Algorithmen, die (mit ein wenig Unterstützung durch Lambda-Ausdrücke) OpenMP in der MakeGrayscale-Funktion in Abbildung 1 auf natürliche Weise ablösen kann:

parallel_for(0, height, [&] (int y)
{
    for (int x = 0; x < width; ++x)
    {
        // omitted for brevity
    }
});

Wie Sie sehen, wurden die for-Schleife und das OpenMP-Pragma durch die Funktion „parallel_for“ ersetzt. Die ersten beiden Parameter der Funktion definieren den Bereich der Iteration, genau wie für die frühere for-Schleife. Im Unterschied zu OpenMP, das erhebliche Einschränkungen für die for-Direktive auferlegt, ist parallel_for eine Vorlagenfunktion. Daher können Sie beispielsweise unsignierte Typen oder sogar komplexe Iteratoren aus den Standardcontainern durchlaufen. Der letzte Parameter ist ein Funktionsobjekt, das ich als Lambda-Ausdruck definiere.

Sie werden feststellen, dass die Lambda-Einführung nur ein kaufmännisches Und-Zeichen enthält, ohne explizit Variable zu deklarieren, die erfasst werden sollen. Dadurch wird der Compiler angewiesen, alle möglichen Variablen durch Verweis zu erfassen. Da die Anweisungen innerhalb des Lambda-Ausdrucks einige Variable verwenden, habe ich dies als Kurzschrift verwendet. Lassen Sie hier jedoch Vorsicht walten, da der Compiler nicht alle ungenutzten Variablen „wegoptimieren“ kann. Dies kann zu einer mangelhaften Laufzeitleistung führen. Die benötigten Variablen hätte ich explizit mithilfe der folgenden Erfassungsliste erfassen können:

 [&bitmap, width, stride]

Die Funktion „parallel_for“ ist eine parallele Alternative zur for-Schleife, die parallele Iteration über einen Bereich von Indizes ermöglicht. In ähnlicher Weise bietet PPL auch die Vorlagenfunktion „parallel_for_each“ als parallele Alternative zur Standardfunktion „for_each“. Sie ermöglicht parallele Iteration über einen Bereich von Elementen, die durch ein Paar von Iteratoren definiert werden, z. B. jenen, die von den Standardcontainern bereitgestellt werden. Obwohl es für das vorige Beispiel sinnvoller war, die Funktion „parallel_for“ mit expliziten Indizes zu verwenden, ist die Verwendung von Iteratoren zur Definition eines Bereichs von Elementen oft natürlicher. Die Werte für ein Array von Zahlen könnten Sie mithilfe der Funktion „parallel_for“ auf folgende Weise quadrieren:

array<int, 5> values = { 1, 2, 3, 4, 5 };

parallel_for(0U, values.size(), [&values] (size_t i)
{
    values[i] *= 2;
});

Dieser Ansatz ist allerdings übermäßig ausführlich, erfordert die Erfassung des Arrays selbst durch den Lambda-Ausdruck und könnte (je nach Art des Containers) ineffizient sein. Mit der Funktion „parallel_for_each“ werden diese Probleme gelöst:

parallel_for_each(values.begin(), values.end(), [] (int& value)
{
    value *= 2;
});

Wenn Sie nur einige Funktionen parallel oder so parallel wie möglich (je nach Anzahl der verfügbaren Hardwarethreads) ausführen möchten, können Sie die Vorlagenfunktion „parallel_invoke“ verwenden. Es sind Überladungen verfügbar, die zwischen 2 und 10 Funktionsobjekte akzeptieren. Hier ein Beispiel für die parallele Ausführung von 3 Lambda-Funktionen:

combinable<int> sum;

parallel_invoke([&] { sum.local() += 1; },
                [&] { sum.local() += 2; },
                [&] { sum.local() += 3; });

int result = sum.combine([] (int left, int right)
{
    return left + right;
});

ASSERT(6 == result);

Dieses Beispiel zeigt eine weitere Hilfsklasse, die von PPL bereitgestellt wird. Die combinable-Klasse erleichtert es ungemein, die Ergebnisse einiger paralleler Aufgaben zu kombinieren – mit minimaler Sperrung. Durch Bereitstellung einer lokalen Kopie des Werts für jeden Thread und anschließendes Kombinieren der Ergebnisse der einzelnen Threads erst nach Abschluss der parallelen Vorgänge vermeidet die combinable-Klasse einen Großteil der Sperrungen, die in solchen Fällen normalerweise angewendet werden.

Aufgaben und Aufgabengruppen

Die eigentliche Parallelität in den diskutierten Algorithmen wird mittels einer einfachen aufgabenorientierten API erreicht, die Sie direkt verwenden können. Aufgaben werden mit der Klasse „task_handle“ definiert, die mit einem Funktionsobjekt initialisiert wird. Die Gruppierung der Aufgaben erfolgt mithilfe der Klasse „task_group“, die die Aufgaben ausführt und auf ihren Abschluss wartet. Selbstverständlich bietet task_group nützliche Überladungen, sodass Sie in vielen Fällen die task_handle-Objekte nicht einmal selbst definieren müssen, sondern dem task_group-Objekt die Zuordnung und die Verwaltung der Lebensdauer überlassen können. Hier ist ein Beispiel dafür, wie Sie die Funktion „parallel_invoke“ aus dem vorigen Beispiel mithilfe von task_group ersetzen können:

task_group group;
group.run([&] { sum.local() += 1; });
group.run([&] { sum.local() += 2; });
group.run([&] { sum.local() += 3; });
group.wait();

Zusätzlich zu den hier besprochenen Algorithmen und APIs könnten weitere parallele Algorithmen und Hilfsklassen enthalten sein, wenn die Parallel Patterns Library schließlich mit Visual C++ 2010 veröffentlicht wird. Die aktuellsten Informationen zum Thema Parallelität finden Sie unter Parallele Programmierung in systemeigenem Code.

Senden Sie Fragen und Kommentare (in englischer Sprache) an mmwincpp@microsoft.com.

Kenny Kerr ist Softwarespezialist, der auf die Softwareentwicklung für Windows spezialisiert ist. Er schreibt leidenschaftlich gern und unterrichtet Entwickler in den Bereichen Programmierung und Softwareentwurf. Sie erreichen Kenny Kerr unter weblogs.asp.net/kennykerr.