Freigeben über


Dieser Artikel wurde maschinell übersetzt.

Paralleles Debuggen

Debuggen task-basierter paralleler Anwendungen in Visual Studio 2010

Daniel Moth und Stephen Toub

Beispielcode herunterladen

Ganz gleich, wie viele CPU- oder GPU-Hardwarehersteller Sie fragen: Alle werden Ihnen bestätigen, dass die Zukunft Mehrkernprozessoren gehört. Die Prozessorkerngeschwindigkeiten erhöhen sich nicht mit dem exponentiellen Zuwachs der vergangenen vier Jahrzehnte; stattdessen werden neue Computer mit mehr Kernen erstellt. Folglich können sich Anwendungsentwickler nun nicht mehr auf die Jahr für Jahr auftretenden "freien" Leistungsverbesserungen verlassen. Um wieder in den Genuss eines kostenlosen Mittagessens zu kommen, das durch immer bessere Hardware ermöglicht wurde – und auch zur Verbesserung Ihrer Anwendungen mit neuen leistungsrelevanten Funktionen – müssen Sie mehrere Kerne mithilfe von Parallelität nutzen.

In Visual C++ 10 und Microsoft .NET Framework 4, beide mit Visual Studio 2010 verfügbar, führt Microsoft neue Bibliotheken und Laufzeiten ein, um den Prozess des Ausdrückens von Parallelität in Ihrer Codebasis erheblich zu vereinfachen. Außerdem gibt es Unterstützung für neue Tools für die Leistungsanalyse und das Debuggen von parallelen Anwendungen. In diesem Artikel erfahren Sie mehr über die Debugging-Unterstützung in Visual Studio 2010, wobei es hauptsächlich um task-basierte Programmiermodelle geht.

Die Notwendigkeit der aufgabenbasierten Programmierung

Der Grund für die Einführung von Parallelität in Ihrer Anwendung besteht darin, mehrere Kerne zu nutzen. Eine einzelne, sequenzielle Arbeitslast wird jeweils nur auf einem Kern ausgeführt. Damit eine Anwendung mehrere Kerne verwendet, sind mehrere Arbeitslasten erforderlich, sodass mehrere Threads diese Arbeitslast parallel verarbeiten können. Um daher eine parallele Beschleunigung über eine Mehrkernausführung durch eine einzelne Arbeitslast zu erreichen, muss die einzelne Arbeitslast in mehrere Einheiten partitioniert werden, die gleichzeitig ausgeführt werden können.

Die einfachsten Schemas umfassen eine statische Partitionierung: Teilen Sie die Arbeitslast in eine feste Zahl von Einheiten mit einer festen Größe. Natürlich möchten Sie nicht für jede Konfiguration der Hardware, auf der der Code ausgeführt werden soll, Code schreiben. Außerdem wird durch das Bestimmen einer festen Anzahl von Einheiten im Voraus die Skalierbarkeit Ihrer Anwendung eingeschränkt, wenn sie auf größeren und besseren Computern ausgeführt wird. Sie können die Anzahl von Einheiten stattdessen dynamisch zur Laufzeit basierend auf Details des Computers auswählen. Beispielsweise können Sie die Arbeit in eine Einheit pro Kern partitionieren. Auf diese Weise können Sie, wenn alle Einheiten hinsichtlich der erforderlichen Verarbeitungszeit gleich groß sind und wenn Sie einen Thread pro Einheit verwenden, den Computer vollständig auslasten.

Doch auch dieser Ansatz lässt viel zu wünschen übrig. Es kommt nur selten vor, dass eine reale Arbeitslast so aufgeteilt werden kann, dass jede Einheit garantiert den gleichen Zeitraum beansprucht, insbesondere, wenn Sie externe Faktoren wie andere Arbeitslasten berücksichtigen, die möglicherweise gleichzeitig auf dem Computer ausgeführt werden und einen Teil der Ressourcen des Computers beanspruchen. In solchen Fällen wird die Arbeitslast bei einer Partitionierung von einer Einheit pro Kern ungleichmäßig verteilt: Einige Threads werden die Verarbeitung ihrer Einheiten früher abschließen als andere Threads, was zu einer ungleichmäßigen Arbeitslast führt, und andere Kerne wiederum befinden sich im Leerlauf, während andere die Verarbeitung abschließen. Um dies zu beheben, müssen Sie die Arbeit "überpartitionieren" und die Arbeitslast in die kleinstmögliche Einheit aufteilen, sodass alle Ressourcen des Computers an der Verarbeitung der Arbeitslast beteiligt sind, bis diese abgeschlossen ist.

Wenn mit der Ausführung einer Arbeitseinheit keinerlei Arbeitsaufwand einhergeht, wäre die vorgeschlagene Lösung ideal, aber nur bei sehr wenigen realen Operationen entsteht keinerlei Arbeitsaufwand. In der Vergangenheit wurden Threads als Mechanismus zum Ausführen solcher Arbeitseinheiten verwendet: Sie erstellen einen Thread für jede Arbeitseinheit, lassen ihn ausführen und beenden dann den Thread. Leider sind Threads verhältnismäßig schwerfällig, und der mit der Verwendung von Threads einhergehende Arbeitsaufwand kann die Art der "Überpartitionierung", die oben beschrieben wurde, einschränken. Sie benötigen einen leichtgängigeren Mechanismus für die Ausführung der aufgeteilten Einheiten, um den Arbeitsaufwand zu minimieren – einen Mechanismus, der eine "Überpartitionierung" bedenkenlos zulässt. Bei diesem Ansatz könnten Sie, anstatt einen Thread pro Einheit zu erstellen, einen Planer verwenden, der die Ausführung einzelner Einheiten in Threads plant, die er verwaltet, sodass die Anzahl der Einheiten so klein wie möglich gehalten wird, während gleichzeitig ein maximaler Durchsatz gewährleistet wird.

Dabei handelt es sich um einen Threadpool, der die Kosten der Threadverwaltung über alle für ihn geplanten Arbeitsaufgaben hinweg amortisiert, wodurch der Arbeitsaufwand, der mit einer einzigen Arbeitsaufgabe einhergeht, minimiert wird. In Windows kann über die QueueUserWorkItem-Funktion, die aus "Kernel32.dll" exportiert wird, auf einen solchen Threadpool zugegriffen werden. (In Windows Vista wurde auch eine neue Threadpoolfunktionalität eingeführt.) In .NET Framework 4 kann über die System.Threading.ThreadPool-Klasse auf einen solchen Pool zugegriffen werden.

Die oben genannten APIs ermöglichen zwar eine Aufteilung mit relativ geringem Aufwand, richten sich aber hauptsächlich an so genannte Fire-and-Forget-Aufgaben. Die ThreadPool-Klasse von .NET Framework 4 stellt beispielsweise keinen konsistenten Mechanismus für die Ausnahmebehandlung, für den Abbruch von Arbeit, für das Warten auf den Abschluss von Arbeit, für das Empfangen von Benachrichtigungen, wenn die Arbeit abgeschlossen ist, usw. bereit. Diese Lücken werden durch neue APIs in .NET Framework 4 und Visual C++ 10 gefüllt, die für die "aufgabenbasierte" Programmierung sowohl in verwaltetem als auch systemeigenem Code entwickelt wurden. Aufgaben stellen Arbeitseinheiten dar, die effizient von einem zugrunde liegenden Planer ausgeführt werden können und gleichzeitig umfangreiche Funktionen für die Arbeit mit und das Steuern von Aspekten ihrer Ausführung zur Verfügung stellen. In Visual C++ 10 basieren diese APIs auf den Typen "Concurrency::task_group" und "Concurrency::task_handle". In .NET Framework 4 basieren sie auf der neuen System.Threading.Tasks.Task-Klasse.


Abbildung 1 Fenster für parallele Stapel


Abbildung 2 Fenster für parallele Aufgaben

Debuggen in Visual Studio heute

Die Geschichte der Entwicklung von Software hat immer wieder gezeigt, dass Programmiermodelle enorm von einer herausragenden Debuggingunterstützung profitieren; Visual Studio 2010 erfüllt genau diese Anforderung, denn es enthält zwei neue Debuggingprogrammfenster, die Sie bei aufgabenbasierten parallelen Programmierung unterstützen sollen. Doch bevor wir diese neuen Features näher betrachten, sehen wir uns den aktuellen Debugvorgang in Visual Studio an, um die entsprechenden Voraussetzungen zu schaffen.

(Für den Rest dieses Artikels werden wir die aufgabenbasierten .NET-Typen zur Veranschaulichung verwenden, die beschriebene Debuggingunterstützung gilt jedoch genauso für systemeigenen Code.)

Der Einstiegspunkt in einen Debugprozess in Visual Studio besteht natürlich darin, den Debugger anzufügen. Dies erfolgt standardmäßig durch Drücken der Taste [F5] (die Entsprechung des Befehls "Debuggen" > "Debuggen starten") in einem Projekt, das in Visual Studio geöffnet ist. Sie können den Debugger auch manuell an einen Prozess anfügen, indem Sie den Menübefehl "Debuggen" > "An den Prozess anhängen" auswählen. Nachdem der Debugger angefügt ist, besteht der nächste Schritt darin, den Debugger zu unterbrechen. Dies kann auf mehrere Arten erfolgen, z. B. durch einen benutzerdefinierten Haltepunkt, durch manuelles Unterbrechen (über den Befehl zum Unterbrechen im Debug-Menü), durch Anforderung durch den Prozess (z. B. in verwaltetem Code über einen Aufruf der System.Diagnostics.Debugger.Break-Methode) oder sogar, wenn eine Ausnahme ausgelöst wird.

Abbildung 3 Suchen von Primzahlen

static void Main(string[] args)
{
var primes =
from n in Enumerable.Range(1,10000000)
.AsParallel()
.AsOrdered()
.WithMergeOptions(ParallelMergeOptions.NotBuffered)
where IsPrime(n)
select n;
foreach (var prime in primes) Console.Write(prime + “, ”);
}
public static bool IsPrime(int numberToTest) // WARNING: Buggy!
{
// 2 is a weird prime: it’s even. Test for it explicitly.
if (numberToTest == 2) return true;
// Anything that’s less than 2 or that’s even is not prime
if (numberToTest < 2 || (numberToTest & 1) == 0) return false;
// Test all odd numbers less than the sqrt of the target number.
// If the target is divisible by any of them, it’s not prime.
// We don’t test evens, because if the target is divisible
// by an even, the target is also even, which we already checked for.
int upperBound = (int)Math.Sqrt(numberToTest);
for (int i = 3; i < upperBound; i += 2)
{
if ((numberToTest % i) == 0) return false;
}
// It’s prime!
return true;
}

Nachdem der Prozess den Debugger unterbrochen hat, werden alle Threads in der Anwendung angehalten: zu diesem Zeitpunkt und bis Sie die Ausführung fortsetzen, wird kein Code ausgeführt (ausgenommen Threads, die der Debugger selbst verwendet). Dieses Anhalten der Ausführung gibt Ihnen die Möglichkeit, den Zustand der Ihrer Anwendung zu diesem Zeitpunkt zu überprüfen. Wenn Sie den Anwendungsstatus überprüfen, haben Sie häufig ein Bild im Kopf, wie der Zustand sein sollte, und Sie können die verschiedenen Debuggerfenster verwenden, um einen Unterschied zwischen Ihrer Erwartung und der Realität festzustellen.

Die wichtigsten Debuggerfenster, die Entwickler in Visual Studio verwenden, sind das Threadfenster, das Aufruflistenfenster und das Variablenfenster (Locals, Autos, Watch). Das Threadfenster zeigt eine Liste aller Threads in Ihrem Prozess an, einschließlich Informationen wie die Thread-ID, Threadpriorität und eine Angabe (ein gelber Pfeil) des aktuellen Threads, was standardmäßig der Thread ist, der ausgeführt wurde, als der Debugger unterbrochen wurde. Die wichtigste Information über einen Thread ist sicherlich, wo er ausgeführt wurde, als der Debugger seine Ausführung anhielt, was durch das Aufruflistenframe in der Ortsspalte angezeigt wird. Indem der Cursor über diese Spalte bewegt wird, wird die gleichermaßen wichtige Aufrufliste angezeigt – die Datenreihen- oder Methodenaufrufe, die vom Thread ausgeführt werden sollten, bevor der aktuelle Ort erreicht wurde.

Das Aufruflistenfenster, das die Aufrufliste des aktuellen Threads anzeigt, enthält umfangreichere Informationen über die Aufrufliste, einschließlich Interaktionsmöglichkeiten.

Um die Aufrufliste eines anderen Threads im Aufruflistenfenster anzuzeigen, müssen Sie den anderen Thread aktuell machen, indem Sie im Threadfenster darauf doppelklicken. Die Methode, in der er derzeit ausgeführt wird (am oberen Rand der Aufrufliste), wird durch einen gelben Pfeil gekennzeichnet und wird als "oberster Frame", "Endframe" oder "aktiver Stapelrahmen" bezeichnet. Dies ist die Methode, aus der der Thread die Ausführung fortsetzt, wenn Sie den Debugger verlassen und die Ausführung der Anwendung fortsetzen. Der aktive Stapelrahmen ist standardmäßig auch der aktuelle Stapelrahmen – mit anderen Worten, die Methode, die die Variablenüberprüfung auslöst, die als Nächstes beschrieben wird.


Abbildung 4 Festlegen bedingter Haltepunkte

Die Variablenfenster werden verwendet, um die Werte der Variablen in Ihrer Anwendung zu überprüfen. Die Variablen der lokalen Methoden werden in der Regel im Locals- oder Autos-Fenster durchsucht; der globale Zustand (Variablen nicht in einer Methode deklariert) kann untersucht werden, indem Sie diese dem Überwachungsfenster hinzufügen. Beginnend mit Visual Studio 2005 überprüfen immer mehr Entwickler den Status, indem sie den Mauszeiger über eine Variable von Interesse bewegen und den resultierenden Popup-DataTip (der als eine Verknüpfung zu den Schnellansichtsfenstern betrachtet werden kann) überprüfen. Es ist wichtig zu beachten, dass Werte für Variablen nur angezeigt werden können, wenn die Variablen im Gültigkeitsbereich des aktuellen Stapelrahmens enthalten sind (der, wie wir zuvor festgestellt haben, standardmäßig der aktive Stapelrahmen des aktuellen Threads ist).

Um Variablen zu untersuchen, die zuvor im Gültigkeitsbereich in der Aufrufliste des Threads enthalten waren, müssen Sie den aktuellen Stapelrahmen durch Doppelklicken auf den Stapelrahmen im Aufruflistenfenster ändern. Zu diesem Zeitpunkt wird der aktuelle Stapelrahmen durch einen grünen Pfeil mit einem gebogenen Ende angegeben (der aktive Stapelrahmen wird weiterhin durch den gelben Pfeil angezeigt). Wenn Sie Variablen auf einen anderen Thread untersuchen möchten, müssen Sie den aktuellen Thread im Fenster Threads ändern und wechseln Sie dann den aktuellen Rahmen in der Aufrufliste im Fenster Aufrufliste.

Zusammenfassend können Sie beim Unterbrechen des Prozesses im Debugger ganz einfach die Variablen im Gültigkeitsbereich bei der ausgeführten Methode eines der Threads überprüfen. Um jedoch ein vollständiges Bild zu erstellen, in dem alle Threads ausgeführt werden, müssen Sie die Aufruflisten für jeden Thread einzeln untersuchen, indem Sie auf jeden Thread doppelklicken, um ihn aktuell zu machen, das Aufruflistenfenster betrachten und sich das ganzheitliche Bild mental vorstellen. Darüber hinaus werden wieder zwei Dereferenzierungsebenen benötigt, um Variablen auf verschiedenen Stapelrahmen von verschiedenen Threads zu untersuchen: Wechseln Sie Threads, und wechseln Sie dann Frames.

Parallele Stapel

Wenn Anwendungen mehrere Threads verwenden (was immer häufiger der Fall sein wird, wenn Personen Computern mit mehr Prozessorressourcen verwenden), müssen Sie in einer einzigen Ansicht sehen können, wo diese Threads zu einem bestimmten Zeitpunkt ausgeführt werden. Das ist, was das Fenster für parallele Stapel in Visual Studio 2010 liefert.

Um den Bildschirm für Immobilien beizubehalten, aber auch Methoden von besonderem Interesse für Parallelitätsszenarios anzugeben, wird das Fenster in dem gleichen Knoten wie die Aufruflistensegmente zusammengefügt, die Threads an ihrem Stamm gemeinsam haben. Beispielsweise können Sie in Abbildung 1 die Aufruflisten der drei Threads in einer einzigen Ansicht sehen. Die Abbildung unten zeigt einen Thread, der von Main nach A nach B ging, und zwei andere Threads, die bei dem gleichen externen Code angefangen haben und dann nach A gegangen sind. Einer der Threads fuhr mit B fort und dann mit externem Code, und der andere Thread fuhr mit C fort und dann mit einer AnonymousMethod. AnonymousMethod ist auch der aktive Stapelrahmen und gehört zu dem aktuellen Thread. Viele andere Funktionen werden in diesem Fenster unterstützt, z. B. Zoom, Vogelperspektive, Filtern von Threads über Kennzeichnungen, und ein Großteil der gleichen Funktionalität wie bereits im Aufruflistenfenster verfügbar.

Choosing the Freeze All Threads But This Command
Abbildung 5 Auswählen des Befehls zum Sperren aller Threads bis auf den aktuellen

Coalescing of Stack Frames
Abbildung 6 Zusammenfügen von Stapelrahmen

Wenn Ihre Anwendung Aufgaben anstelle von Threads erstellt, können Sie zu einer Aufgaben-zentrierten Ansicht wechseln. In dieser Ansicht werden die Aufruflisten von Threads, die keine Aufgaben ausführen, ausgelassen. Darüber hinaus werden Aufruflisten für Threads zum Darstellen von realen Aufruflisten von Aufgaben gekürzt, d. h. eine Aufrufliste mit einem einzigen Thread könnte zwei oder drei Aufgaben enthalten, die Sie teilen und separat anzeigen möchten. Eine spezielle Funktion des Fensters für parallele Stapel ermöglicht Ihnen, das Diagramm auf einer einzelnen Methode zu pivotieren und die Aufrufer und aufgerufenen Element dieses Methodenkontexts klar zu beobachten.

Abbildung 7 Aufgaben-basierter Code mit Abhängigkeiten

static void Main(string[] args) // WARNING: Buggy!
{
var task1a = Task.Factory.StartNew(Step1a);
var task1b = Task.Factory.StartNew(Step1b);
var task1c = Task.Factory.StartNew(Step1c);
Task.WaitAll(task1a, task1b, task1c);
var task2a = Task.Factory.StartNew(Step2a);
var task2b = Task.Factory.StartNew(Step2b);
var task2c = Task.Factory.StartNew(Step2c);
Task.WaitAll(task1a, task1b, task1c);
var task3a = Task.Factory.StartNew(Step3a);
var task3b = Task.Factory.StartNew(Step3b);
var task3c = Task.Factory.StartNew(Step3c);
Task.WaitAll(task3a, task3b, task3c);
}

Parallele Aufgaben

Zusätzlich zum Betrachten der echten Aufruflisten von Aufgaben im Fenster für parallele Stapel enthält ein neues Debuggerfenster zusätzliche Informationen über Aufgaben, einschließlich der Aufgaben-ID, des der Aufgabe zugewiesenen Threads, des aktuellen Speicherorts und des Einstiegspunkt (Delegat), der bei der Erstellung an die Aufgabe übergeben wird.Dieses Fenster, das als das Fenster für parallele Aufgaben bezeichnet wird, macht ähnliche Funktionen wie das Threadfenster verfügbar, z. B. die aktuelle Aufgabe (die oberste Aufgabe, die im aktuellen Thread ausgeführt wird), die Möglichkeit zum Wechseln der aktuellen Aufgabe, die Kennzeichnung von Aufgaben sowie das Sperren und Entsperren von Threads.


Abbildung 8 Verwendung paralleler Aufgaben zum Suchen von Abhängigkeitsproblemen

Die Statusspalte ist möglicherweise der größte Wert für Entwickler.Die Informationen in der Spalte "Status" ermöglichen die Unterscheidung zwischen ausgeführten Aufgaben und Aufgaben, die warten (auf eine andere Aufgabe oder auf eine Synchronisierungsprimitive) oder Aufgaben, die gesperrt sind (eine Spezialisierung der wartenden Aufgaben, für die das Tool eine zirkuläre Wartekette erkennt).Das Fenster für parallele Aufgaben zeigt auch geplante Aufgaben, Aufgaben, die noch nicht ausgeführt wurden, aber in einer Warteschlange sind, die auf die Ausführung durch einen Thread wartet.Ein Beispiel sehen Sie in Abbildung 2.Weitere Informationen zu parallelen Stapeln und dem Fenster für parallele Aufgaben finden Sie in den Blogbeiträgen unter danielmoth.com/Blog/labels/ParallelComputing.html und in der MSDN-Dokumentation unter msdn.microsoft.com/dd554943 (VS.100).aspx.

Abbildung 9 Deadlock-Code

static void Main(string[] args)
{
int transfersCompleted = 0;
Watchdog.BreakIfRepeats(() => transfersCompleted, 500);
BankAccount a = new BankAccount { Balance = 1000 };
BankAccount b = new BankAccount { Balance = 1000 };
while (true)
{
Parallel.Invoke(
() => Transfer(a, b, 100),
() => Transfer(b, a, 100));
transfersCompleted += 2;
}
}
class BankAccount { public int Balance; }
static void Transfer(BankAccount one, BankAccount two, int amount)
{
lock (one) // WARNING: Buggy!
{
lock (two)
{
one.Balance -= amount;
two.Balance += amount;
}
}
}

Suchen des Fehlers

Eine der besten Möglichkeiten, neue Funktionen von Tools zu verstehen, besteht darin, sie in Aktion zu sehen. Dazu haben wir ein paar fehlerhafte Codeausschnitte erstellt, und wir werden die neuen Toolfenster verwenden, um die zugrunde liegenden Fehler im Code zu finden.

Stepping

Sehen Sie sich zuerst den Code in Abbildung 3 an. Ziel dieses Codes ist, die Primzahlen zwischen 1 und 10.000.000 parallel auszugeben. (Die Parallelisierungsunterstützung wird von Parallel LINQ bereitgestellt; siehe blogs.msdn.com/pfxteam und msdn.microsoft.com/dd460688(VS.100).aspx für weitere Informationen.) Die Implementierung von IsPrime ist fehlerhaft, wie Sie sehen können, indem Sie den Code ausführen und die Ausgabe der ersten Zahlen anzeigen:

2, 3, 5, 7, 9, 11, 13, 15, 17, 19, 23, 25, ...

Die meisten dieser Zahlen sind Primzahlen, aber 9, 15 und 25 sind keine Primzahlen. Wenn dies eine Singlethreadanwendung wäre, könnten Sie problemlos den Code durchgehen, um den Grund für die ungenauen Ergebnisse zu suchen. Wenn Sie in einem Multithreadprogramm jedoch Stepping ausführen (z. B. über den Einzelschrittbefehl im Debug-Menü) kann für jeden Thread in dem Programm Stepping ausgeführt werden. Dies bedeutet, dass Sie bei den Schritten möglicherweise zwischen Threads springen, wodurch es schwieriger wird, die Ablaufsteuerung und die Diagnoseinformationen über den aktuellen Speicherort in dem Programm zu verstehen. Um dies zu unterstützen, können Sie verschiedene Funktionen des Debuggers nutzen. Zuerst müssen Sie bedingte Haltepunkte festlegen.

Wie in Abbildung 4 dargestellt, können Sie einen Haltepunkt (in diesem Fall in der ersten Zeile der IsPrime-Methode) festlegen und angeben, dass der Debugger nur unterbrochen werden soll, wenn eine bestimmte Bedingung erfüllt ist -- in diesem Fall, wenn eine der falschen Primzahlen ausgewertet wird.

Wir hätten den Debugger so festlegen können, dass er unterbrochen wird, wenn ein bestimmter dieser Werte erreicht wird (anstatt wenn ein beliebiger dieser Werte erreicht wurde) festgelegt haben, aber wir können keine Annahmen über die Reihenfolge machen, in der PLINQ die Werte im Hintergrund auswertet. Stattdessen haben wir den Debugger angewiesen, nach einem dieser Werte zu suchen, um die Wartezeit vor der Unterbrechung zu minimieren.

Nachdem der Debugger unterbricht, möchten wir ihn anweisen, Stepping nur für den aktuellen Thread auszuführen. Um dies zu tun, können wir die Möglichkeit des Debuggers nutzen, Threads zu sperren und zu entsperren und angeben, dass gesperrte Threads erst ausgeführt werden, nachdem sie entsperrt wurden. Das neue Fenster für parallele Aufgaben erleichtert das Suchen des Threads, der zum Fortsetzen (suchen Sie das Symbol mit dem gelben Pfeil) und zum Fixieren aller anderen Threads (über das ContextMenu) zugelassen ist, wie in Abbildung 5 dargestellt.

Mit den irrelevanten Thread gesperrt können wir jetzt Stepping über die fehlerhafte IsPrime ausführen. Durch Debuggen von numberToTest== 25 können wir problemlos sehen, was falsch gegangen ist: die Schleife sollte den upperBound-Wert in den Test einschließen, wobei dieser Wert derzeit ausgeschlossen wird, da die Schleife, den Less-Than-Operator anstelle von less-than-or-equals verwendet. Die Quadratwurzel von 25 ist hier 5, und 25 kann durch 5 geteilt werden, 5 wird aber nicht getestet, deswegen wird 25 fälschlicherweise als Primzahl kategorisiert.

Das Fenster für parallele Stapel bietet auch eine praktische, konsolidierte Ansicht davon, was in unserer Anwendung beim Unterbrechen geschieht. Abbildung 6 zeigt den aktuellen Zustand der Anwendung, nachdem wir sie noch einmal ausgeführt haben, und dieses Mal wird explizit mithilfe der Break All-Funktion des Debuggers unterbrochen.

PLINQ führt IsPrime in mehreren Aufgaben aus, und der Wert von numberToTest für alle diese Vorgänge ist im Popupfenster angezeigt; hier wird angezeigt, dass Aufgabe 1 numberToTest==8431901 ausführt, während Aufgabe 2 numberToTest==8431607 ausführt.

Abhängigkeitsprobleme

Der Code in Abbildung 7 zeigt eine Instanz eines häufigen Musters in parallelen Anwendungen. Dieser Code verzweigt mehrere Operationen (step1a, step1b, step1c, die alle Methoden des Formulars "void StepXx()"). sind, die möglicherweise parallel ausgeführt werden, und stellt dann eine Verknüpfung dazu her. Anschließend verzweigt sich die Anwendung erneut mit Code, für den die vorherigen Operationen aufgrund einer Abhängigkeit von den Nebeneffekten der Vorgänge (z. B. das Schreiben von Daten in einige gemeinsam genutzte Arrays) bereits abgeschlossen sein müssen.

Leider enthält dieser Code einen Fehler, und der Entwickler, der ihn geschrieben hat, erhält einige ungenaue Ergebnisse von der dritten Gruppe von Aufgaben. Die Folge ist, dass, obwohl der Entwickler auf den Abschluss aller vorherigen Aufgaben wartet, etwas fehlt und nicht alle vorherigen Berechnungen ihre Ergebnisse tatsächlich abgeschlossen haben. Um den Code zu debuggen, legt der Entwickler einen Haltepunkt für den letzten WaitAll-Aufruf fest und verwendet das Fenster für parallele Aufgaben, um den aktuellen Status des Programms anzuzeigen, der in Abbildung 8 dargestellt ist.

Im Fenster für parallele Aufgaben wird angezeigt, dass die Aufgabe für Step2c weiterhin ausgeführt wird, obwohl die Aufgaben für Schritt 3 geplant wurden. Eine Überprüfung des zweiten Task.WaitAll-Anrufs veranschaulicht, warum: aufgrund von Tippfehlern sind task1a, task1b und task1c anstelle ihrer Entsprechungen von task2 in der Warteschlange.

Deadlocks

Abbildung 9 stellt das Prototypbeispiel eines Deadlock-Szenarios dar, das entsteht, wenn die Sperrsortierung nicht beachtet wird. Der Hauptcode überweist ständig Geld zwischen Bankkonten. Die Transfer-Methode sollte threadsicher sein, damit Sie von mehreren Threads gleichzeitig aufgerufen werden kann. Deshalb sperrt sie die damit verbundenen BankAccount-Objekte intern, indem einfach das erste und dann das zweite Objekt gesperrt wird. Dieses Verhalten kann leider als aktiv zu Deadlocks führen, wie das Ausführend des Codes veranschaulichen wird. Der Debugger unterbricht, wenn er feststellt, dass keine Übertragungen stattfinden. (Die Unterbrechung erfolgt mit Code, der eine Debugger.Break auslöst, wenn festgestellt wird, dass keine neuen Übertragungen nach einer bestimmten Zeitdauer abgeschlossen wurden. Dieser Code ist im Download enthalten, der diesen Artikel begleitet.)

Information on Deadlocks in Parallel Tasks
Abbildung 10 Informationen zu Deadlocks in parallelen Aufgaben

 

Parallel Stacks Showing Deadlocks
Abbildung 11 Parallele Stapel mit Deadlocks

 

Method View in Parallel Stacks
Abbildung 12 Methodenansicht in parallelen Stapeln

Wenn Sie im Debugger arbeiten, sehen Sie sofort eine grafische Darstellung, die zeigt, dass ein Deadlock vorhanden ist, wie in Abbildung 10 dargestellt. Die Abbildung veranschaulicht auch, dass durch Bewegen des Mauszeigers über den Waiting-Deadlocked-Status weitere Details dazu bereitgestellt werden, worauf genau gewartet wird und welcher Thread die geschützte Ressource hält. In der Threadzuweisungsspalte können Sie sehen, dass Aufgabe 2 auf eine von Aufgabe 1 gehaltene Ressource wartet; wenn Sie den Mauszeiger über die Aufgabe 1 bewegen würden, würden Sie die Umkehrung sehen.

Diese Informationen können auch aus dem Fenster für parallele Stapel abgeleitet werden. Abbildung 11 zeigt die Aufgabenansicht im Fenster für parallele Stapel, wodurch hervorgehoben wird, dass es zwei Aufgaben gibt, die beide in einem Aufruf von Monitor.Enter blockiert sind (aufgrund der Lock-Anweisungen aus Abbildung 9). Und Abbildung 12 veranschaulicht die im Fenster für parallele Stapel verfügbare Methodenansicht (über die entsprechende Symbolleisten-Schaltfläche). Indem wir uns auf die Transfer-Methode konzentrieren, wird leicht ersichtlich, dass zwei Aufgaben derzeit übertragen werden, die beide in einen Aufruf an Monitor.Enter übergegangen sind. Durch Bewegen des Mauszeigers über dieses Feld erhalten Sie weitere Informationen zum Deadlock-Status der beiden Aufgaben.

Abbildung 13 Erstellen eines Lock Convoy

static void Main(string[] args) // WARNING: Buggy!
{
object obj = new object();
Enumerable.Range(1, 10).Select(i =>
{
var t = new Thread(() =>
{
while (true)
{
DoWork();
lock (obj) DoProtectedWork();
}
}) { Name = “Demo “ + i };
t.Start();
return t;
}).ToList().ForEach(t => t.Join());
}

Lock Convoys

Lock Convoys können auftreten, wenn mehrere Threads wiederholt für den gleichen geschützten Bereich konkurrieren.(Wikipedia liefert eine gute Zusammenfassung für Lock Convoys unter en.wikipedia.org/wiki/Lock_convoy).Der Code in Abbildung 13 bietet ein ultimatives Beispiel für einen Lock Convoy: mehrere Threads führen wiederholt eine Arbeitsmenge außerhalb einen geschützten Bereichs auf, verwenden jedoch dann eine Sperre, um weitere Arbeit in dem geschützten Bereich auszuführen.Je nach dem Verhältnis zwischen der Arbeit innerhalb und außerhalb des Bereichs kann es zu Leistungsproblemen kommen.Diese Art von Problemen wird nach Ausführung des Programms mithilfe eines Tools wie dem Parallelitäts-Profiler ersichtlich, der in Visual Studio 2010 verfügbar ist, aber sie können auch durch Verwendung eines Debugging-Tools wie dem Fenster für parallele Stapel abgefangen werden.

Lock Convoys with Parallel Stacks

Abbildung 14 Lock Convoys mit parallelem Stapeln

Abbildung 14 zeigt eine Ausführung des Codes in Abbildung 13.Der Code wurde ein paar Sekunden nach Beginn seiner Ausführung unterbrochen.Sie können am oberen Rand des Bilds sehen, dass neun Threads derzeit blockiert sind und auf einen Monitor warten -- alle Threads warten auf die Beendigung eines Threads, um DoProtectedWork zu beenden, sodass einer der Threads die Arbeit im geschützten Bereich fortsetzen kann.

Zusammenfassung

In diesem Artikel wurden Beispiele dafür gezeigt, wie mit den Debugger-Toolfenstern von Visual Studio 2010 der Vorgang des Suchens von Fehlern in aufgabenbasiertem Code vereinfacht werden kann.Die aufgabenbasierten APIs für verwalteten und systemeigenen Code sind zu umfangreich als dass sie in den kurzen Beispielen in diesem Artikel veranschaulicht werden können. Sehen Sie sich diese in NET Framework 4 und Visual C++ 10 näher an.Im Hinblick auf die Tools wurde neben den beiden neuen Debuggerfenstern, die besprochen wurden, ein neuer paralleler Leistungs-Analyzer in den vorhandenen Profiler in Visual Studio integriert.

Um die behandelten Informationen in die Praxis umzusetzen, laden Sie die Betaversion von Visual Studio 2010 von msdn.microsoft.com/dd582936.aspx herunter.

Daniel Moth* arbeitet für das Parallel Computing Platform Team bei Microsoft:Er kann über einen Blog unter danielmoth.com/ erreicht werden.
Stephen Toub arbeitet für das Parallel Computing Platform Team bei Microsoft: Er schreibt außerdem redaktionelle Beiträge für das* MSDN Magazine.