Parallele Software Übersicht

Neben Exceptions ist Threading ein weiteres Thema, welches viel Kopfweh bereiten kann. Bei Exceptions hat man oft gute Erfolgschancen zu verstehen, was schief gegangen ist. Bei e.g. Race-Conditions schauts relativ schlecht aus. Man kann es oft lokal nicht nachstellen oder sieht nur die Auswirkung – die Ursache zu finden ist dann meist unmöglich. Neben den Bugs gibt es aber auch positive Aspekte: die Applikation wird performanter, denn moderne CPUs haben viele Kerne.

Begriffe

Anbei die wichtigsten Begriffe, die mir im Laufe der Zeit untergekommen sind.

Core

Jeder Core hat (je nach Architektur) eigene

  • ALU (Arithmetisch-logische Einheit)
  • Register
  • L1 Cache
  • Fetch Decode Unit

Allerdings haben wir Softwareentwickler oft ein Problem. Bei Spielekonsolen sind die Anzahl der Kerne bekannt – bei „Standard-Software“ oft unbekannt. Wir kennen den Zielserver, auf dem die Applikation läuft oft nicht. Das macht die Sache herausfordernder.

Hyperthreading

Bei Hyperthreading werden zwei logische Prozessoren pro physikalischen Core angeboten. Jede logische Einheit hat ihren eigenen CPU State - aber die Ausführungseinheit und der Cache werden geteilt.

Prozess vs. Thread

Ein Prozess besitzt einen virtuellen Adressraum. Dieser wird in den physikalischen RAM gemapped und wird von mehreren Threads im Prozess genutzt. Ein Prozess besitzt einen oder mehrere Threads. Ein Prozess führt nichts aus – er beinhaltet Threads. Die Aufgabe des Threads ist die „Virtualisierung“ des Prozessors. Ein Thread enthält (unter Windows) u.a.

  • Thread Kernel Object: Eigenschaften + Thread Context (CPU Register)
  • Thread Environment Block (1 Page für u.a. Exception und GDI / OpenGL / …)
  • User Mode Stack (u.a lokale Variablen und Methodenargumente)
  • Kernel Mode Stack (Argumente an Funktionen im Kernel Mode übergibt)

Was man sich merken sollte: Ein Thread ist nicht gratis.

Concurrency vs. Paralellism

Diese Begriffe werden oft als Synonym verwendet – bedeuten aber was völlig unterschiedliches.

  • Concurrency: mehrere Threads welche Zeitscheiben (Times Slices) bekommen - geht auf Single Core Maschine.
  • Parallelism: Threads werden zur selben Zeit ausgeführt (e.g. auf einem Mehrprozessorsystem)

Racecondition

Wikipedia sagt:

bezeichnet in der Programmierung eine Konstellation, in der das Ergebnis einer Operation vom zeitlichen Verhalten bestimmter Einzeloperationen abhängt.

Man stelle sich das Programm X wie folgt vor: Read x x++ Write x Prozessor 1 liest für den Wert x = 3 – erhöht auf 4 und schreibt 4. Prozessor 2 tut dasselbe – und wir haben statt 5 den Wert 4 – obwohl wir 2 Mal erhöht haben. Diese Fehler sind extrem schwer zu entdecken und sollten unbedingt präventiv vermieden werden – mehr dazu später (u.a. Actor).

Deadlock

Wikipedia sagt:

bezeichnet in der Informatik einen Zustand, bei dem eine zyklische Wartesituation zwischen mehreren Prozessen auftritt, wobei jeder beteiligte Prozess auf die Freigabe von Betriebsmitteln wartet, die ein anderer beteiligter Prozess bereits exklusiv belegt hat.

Eines der besten Beispiele ist https://de.wikipedia.org/wiki/Philosophenproblem. Relativ gut zu erkennen – mit ein wenig Aufwand ist das Problem auch gut zu finden. Mit AOP kann man das im Hintergrund machen – hilft zu mindestens in den meisten Fällen (https://doc.postsharp.net/6.2/deadlock-detection). Arbeitet man e.g. mit SQL Server, so gibt es auch gute Hilfsmittel (https://docs.microsoft.com/en-us/sql/tools/sql-server-profiler/analyze-deadlocks-with-sql-server-profiler?view=sql-server-2017).

.NET

Die .NET Evolution der Asynchronous Programming Patterns hat schon 3 stolze Einträge:

  • Asynchronous Programming Model (APM)
    • IAsyncResult design pattern (“Callback”)
    • BeginOperationName and EndOperationName
  • Event-based Asynchronous Pattern (EAP)
    • EventArgs
  • Task-based Asynchronous Pattern (TAP)
    • Latest and Greatest

Wer tiefer eintauchen will, dem sei das Buch „Concurrency in C# Cookbook“ ans Herzen gelegt. Tasks kann man sich als kleine und vor allem leichtgewichtige Ausführungseinheiten vorstellen, die auf einem Thread ausgeführt werden. Moderne Sprachen haben oft ähnliche Konzepte.

Primitive

Neben den Task Abstraktionen gibt es auch noch Primitive. Wie e.g. aus dem System.Threading Namespace: Barrier, CountdownEvent, ManualResetEventSlim, SemaphoreSlim, SpinLock. Dann gibt es noch Collections:

  • BlockingCollection: Für Producer – Consumer Szenarien
  • ConcurrentBag: Eine ungeordnete Liste
  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentQueue
  • ConcurrentStack

Ebenfalls gibt es auch Lockfreie Lösungen wie compare-and-swap (CAS) und Memory Barriers. Warum zähle ich diese Dinge auf? Ich hab inzwischen schon einigen Code gesehen, in dem diese Primitive integraler Bestandteil waren. Oft ist das ganze historisch gewachsen und man hat sich immer hier und da mit einem ManualResetEvent u.a. gerettet. Wenn ich Primitive in Standard-Code (Definition offen) sehe, schrillen bei mir alle Alarmglocken. Doch ab und zu braucht man sie. Microsoft hat da eine gute Empfehlung publiziert: https://docs.microsoft.com/en-us/previous-versions/msp-n-p/ff963554(v%3dpandp.10). Hier sind einige Empfehlungen, wie man mit OOP das in den Griff bekommt – ein Einzug:

  • Façade: The Façade pattern presents a simplified view of a larger system. You may want to use this pattern, or libraries that use this pattern, to hide the complexities of parallelism from other parts of an application
  • Decorators: The Decorator pattern overrides the behavior of an underlying class. Decorators use a “contains” relationship and inheritance.

Wenn man also Primitive mitten in Business-Logik sieht – it’s a smell – run.

Fazit

  • Diese „primitiven“ Klassen gezielt und sorgfältig einsetzen
    • E.g. Threads und Tasks sollten nicht „einfach so“ gestartet werden! Zuerst sollte man sich über Decomposition Gedanken machen und sich gut überlegen, was man asynchron ausführen will.
  • lock(…) – Bug-Fixes lösen nicht das Problem - nur das Symptom
  • Gründlich überdenken:
    • Habe ich die richtige „Decomposition“ getroffen?
    • Kann das ein anderer Entwickler mit wenig Aufwand 100%ig verstehen?
    • „Wenn“-Szenarien (Fehlerfälle): habe ich noch einen Überblick was alles passieren kann und wie ich damit umgehe?
    • Wenn ich auf eine Codestelle zeige … muss ich den ausführenden Thread sofort wissen. Ich habe schon so oft die Situation gehabt, dass man sich nicht sicher sein kann, von wieviel Threads und vor allem von welchem Thread eine Methode exekutiert wird. Hier kommt es oft zu Ratereien bis hinzu zu „auf Verdacht fixen – und auf ewig drin“ …
  • Parallele Ausführung von Code immer kritisch …
    • Fixed „ToList()“ das Problem? IEnumerable schreit meistens – schnell mal ein ToList und alles passt? Nein!
    • Habe ich eine Race-Condition? Endlose Diskussionen auf Blackboards …
    • Was hat sich der andere Entwickler vor 3 Jahren gedacht?
    • uvm.

Parallele Software Entwerfen & Literatur

Microsoft hat hier eine Empfehlung (https://docs.microsoft.com/en-us/previous-versions/msp-n-p/ff963542(v%3dpandp.10))

  • Decomposing: zerlegen der Arbeit in diskrete Einheiten (Tasks): So wählen, dass zwischen Tasks so wenig wie möglich Daten ausgetauscht werden müssen
  • Coordinate: Abhängigkeiten zwischen Tasks. Ergebnis Task X für Task Y
  • Sharing: Daten zwischen den Tasks zur Verfügung stellen

Ansonsten findet man auch vieles in Literatur:

Grundsätzlich gilt: Die Bausteine einer Software Architektur sind Components, Connectors, Configuration. .NET bietet mit AppDomains eine gute Isolation für Komponenten. Innerhalb der Komponenten sollte ich mir Gedanken machen, wie ich „Aufgaben“ abarbeite. Eine Möglichkeit sind sicher Actors – aber die Unterstützung in .NET (mit AKKA.NET) ist gut – man kann aber immer schnell aus dem Konstrukt ausbrechen, weil die Sprache nicht von Grund auf dafür gebaut wurde. Mit PostSharp kommt man der Sache schon näher (https://doc.postsharp.net/actor). Theorie findet man in diesen beiden Büchern: