Entwicklungstools Zuverlässigkeitsanalyse: Ganz nebenläufig

Bild 1: Entwickler erwarten laut einer Studie von VDC Research, dass sich die Anzahl von Einzelprozessor-Projekten innerhalb von zwei Jahren halbieren wird
Bild 1: Entwickler erwarten laut einer Studie von VDC Research, dass sich die Anzahl von Einzelprozessor-Projekten innerhalb von zwei Jahren halbieren wird

Um Wünsche nach größerem Funktionsumfang und Geschwindigkeit zu erfüllen, kommen Software-Entwicklerteams immer mehr von Einzelprozessor-Architekturen ab. Insbesondere Embedded Systeme, die früher mit einem einzigen Chip eine begrenzte Anzahl von Aufgaben ausführten, arbeiten heute in heterogenen Prozessorumgebungen.

 Die Folgen dieser zunehmenden Komplexität für Unternehmen sind erheblich: Mehrkern- und Mehrprozessor-Softwareprojekte sind 4,5-mal teurer, der benötigte Zeitaufwand erhöht sich um 25% und die Anzahl der benötigten Software-Entwickler verdreifacht sich.

Ein spezifisches Gebiet, auf dem sich dramatische Folgen bezüglich Kosten- und Terminplanüberschreitungen ergeben, ist der Bereich Software- und Codeprüfung. Eine Mehrkern-/Mehrprozessorumgebung kann dazu führen, dass sich das Vorgehen zur effizienten Identifikation von Softwarefehlern exponentiell verkompliziert. Insbesondere zwei Problembereiche haben dabei das Potenzial, die Produktivität eines Entwicklerteams zu beeinträchtigen: Nebenläufigkeitsfehler (Concurrency Errors) und Inkompatibilitäten der Byte-Reihenfolge (Endian Incompatibility).

Die Quellcodeanalyse ist ein Prozess, in dessen Rahmen das erwartete oder prognostizierte Verhalten eines Programms zur Laufzeit unter Einbezug jedes nur vorstellbaren Kontrollflusspfades geprüft wird. Dadurch lassen sich nicht regelkonforme Situationen erkennen, diagnostizieren und dem Programmierer auf eine Weise beschreiben, dass er sie auf einfachem Weg beheben kann.

Im Normalfall werden dabei keine anderen Timing-Informationen oder -Abläufe, außer die dem Kontrollflussgraph innewohnenden, interpretiert oder sind zur Durchführung der Analyse notwendig. Nebenläufigkeitsprobleme stellen die Analyse vor einige komplexe Herausforderungen, da sie es notwendig machen, dass Informationen, die sich auf Timing oder Ablauf beziehen, in den Kontrollflussgraphen befördert werden. Einige Probleme lassen sich dabei offenkundig einfacher identifizieren als andere. Dazu zählen beispielsweise Threads, die Locks reservieren und vor der Freigabe zeitaufwendige Aktivitäten ausführen.

Obwohl sie keine entscheidenden Fehlfunktionen wie Deadlocks zur Folge haben, können sie beim Endbenutzer zu Frustrationsgefühlen führen, beispielsweise wenn ein Gerät nicht reagiert. Die komplexeren Nebenläufigkeitsprobleme, wie beispielsweise Deadlocks, verlangen nach einer zusätzlichen Analyse, welche diejenige ergänzt, mit der nichtablaufbezogene Programmfehler wie Memory-Leaks oder Pufferüberläufe festgestellt wurden.

In diesem Fall stehen zwei verschiedene Analysen an: Die eine sammelt und übermittelt Informationen zum Laufzeitverhalten von Locks, die andere analysiert den gesamten Programmabschnitt und identifiziert Konflikte in diesem Verhalten. Die Quellcodeanalyse ermöglicht dies typischerweise mittels einer spezifischen Software für Nebenläufigkeitsanalysen (Bild 2).

In dieser Darstellung ist zu sehen, dass die normale Analysesoftware Daten im Zusammenhang mit den Lebenszyklen von Locks sammelt. Nachdem dies einmal für alle Systemmodule durchgeführt wurde, wird der gesamte Programm-abschnitt von der Nebenläufigkeits-Analysesoftware untersucht, sodass Programmschleifen im Laufzeitgraph gefunden werden können, was gleichbedeutend mit Deadlocks ist. Man betrachte nun folgende Funktion:

lock_t Lock1, Lock2;
void foo(int x) {
if( x & 1 ) {
lock(Lock1);
lock(Lock2);
}
else
lock(Lock1);
}

Bei einer näheren Betrachtung lässt sich einfach feststellen, dass diese Funktion eine Abhängigkeit von Lock2 zu Lock1 definiert, wenn eine ungerade Zahl als Parameter dient. Ohne ungeraden Parameter ist Lock1 immer noch reserviert. In diesem Fall besteht aber keine Abhängigkeit von Lock2 zu Lock1 im lokalen Anwendungsbereich, obwohl diese (oder eine andere) Abhängigkeit in einer interprozeduralen Sichtweise weiterhin vorhanden sein könnte. Zur Analyse sind also zwei unterschiedliche Typen von Fragen relevant:

Fragen zur mathematischen Logik:

  • Besteht ein gültiger Kontrollfluss, der uns function foo() mit einem ungeraden Parameter aufrufen lässt?
  • Besteht ein gültiger Kontrollfluss, der dazu führt, dass foo() mit einem geraden Parameter aufgerufen wird, gefolgt vom Aufruf für eine weitere Funktion, sodass ein weiterer Lock (z.B. Lock2) reserviert wird, bevor Lock1 freigegeben wird?

Fragen zur Abhängigkeit von Locks:

  • Wenn eines davon zutrifft: Gibt es eine andere Situation im natürlichen Kontrollfluss des Programms, in der eine Gegen-abhängigkeit von Lock1 zu Lock2 erreicht werden kann, was wiederum zu einem potenziellen Deadlock führt?

Der erste Fragentyp wird von der für die mathematische Logik zuständigen Software der Quellcodeanalyse im Rahmen der normalen Programmanalyse beantwortet. Den zweiten Fragentyp beantwortet anschließend die Nebenläufigkeitsanalyse, die mit allen möglichen Abhängigkeiten innerhalb des Programmabschnitts gefüttert wird. Als Resultat ergibt sich in der Regel eine kleine Anzahl Szenarien, die (manuell) unglaublich schwierig zu erkennen und (ohne entsprechendes Tool) schwierig zu analysieren sind. Entwickler können diese Szenarien im normalen Rahmen ihrer Implementierungsaufgaben sehr schnell sichten und die Fehler beheben.

Inkompatibilitäten der Byte-Reihenfolge

Zusammengefasst geht es bei der Byte-Reihenfolge (Endianness) darum, wie der Host-Prozessor die Datentypen, die ganzzahlige Werte speichern (Integer), im Speicher ablegt. Im Falle von 32-Bit-Integer-Werten, die in vier Bytes gespeichert werden, kann der Prozessor diese vier Bytes in einer Anzahl verschiedener Reihenfolgen lesen und schreiben, wobei herkömmlicherweise nur zwei zum Einsatz kommen:

  • die »Little Endian«-Reihenfolge, in der die Bytes in der Reihenfolge 0, 1, 2, 3 gespeichert werden, und
  • die »Big Endian«-Reihenfolge, in der die Bytes in der Reihenfolge 3, 2, 1, 0 gespeichert werden.

Diese Darstellung wird etwas undurchsichtiger, wenn der Prozessor mehrere Worte gleichzeitig schreibt (es handelt sich hier um eine größtenteils ziemlich historische Darstellung, die wir aber aus Gründen der Vollständigkeit erwähnen) und die Annahmen bezüglich der Byte-Reihenfolge auf jedes Wort anwendet:

  • Bei der Little-Endian-Reihenfolge werden die Bytes weiterhin in der Reihenfolge 0, 1, 2, 3 gespeichert,
  • bei der Big-Endian-Reihenfolge dagegen könnte er die Bytes auch in der Reihenfolge 1, 0, 3, 2 schreiben.

Allerdings liegt die Art, wie der Prozessor speichert und liest, ausschließlich in seinem eigenen Ermessen. Außer natürlich, der Entwickler weist den Prozessor an, derartige Daten statt in einem Speicher in einem Übertragungsmedium zu speichern. Übertragungs-medien wie Sockets, Dateien, Pipes und andere Interprozessor-Vektoren (beispielsweise Interrupts, die dazu führen, dass Daten auf der PCI-Express-Schnittstelle oder dem seriellen Bus landen) adressiert der Prozessor genau so wie Speicher, außer er ist spezifisch angewiesen, dies nicht zu tun.

Ein Big-Endian-Prozessor speichert deshalb 32-Bit-Integer-Werte in der Byte-Reihenfolge 3, 2, 1, 0 auf einen Socket. Falls die CPU am anderen Ende des Sockets mit einer Little-Endian-Reihenfolge arbeitet, wird der auf den Socket geschriebene Wert beim Lesen vollkommen anders interpretiert. Ein Wert 29 beispielsweise wird, geschrieben von einem Big-Endian-Prozessor und gelesen von einem Little-Endian-Prozessor, als 53  504 interpretiert - kein kleiner Unterschied.

Zur Entwicklung eines Programms, das in heterogenen Prozessorarchitekturen laufen soll, ist es deshalb notwendig, dass alle Datentypen identifiziert werden, die ganzzahlige Werte speichern, die jemals auf einen Übertragungsvektor treffen, der einen anderen Prozessor adressieren könnte. Zudem ist es notwendig, dass gewährleistet ist, dass der entsprechende Schreib-/Lesevorgang die Daten in eine neutrale Darstellung umwandelt, die mit beiden Seiten kompatibel ist, beziehungsweise sie von einer neutralen Darstellung her in eine andere umwandelt, die mit beiden Seiten kompatibel ist. In einem größeren Programm ist dies ganz offensichtlich keine triviale Aufgabe.

Datenüberwachung

Die Quellcodeanalyse kann Programmierern durch symmetrische Validierung der Typendarstellung helfen, wenn diese Datentypen die Grenzen von Übertragungsvektoren überschreiten. Das bedeutet, dass die Datenfluss-Software automatisch validieren kann, ob Datentypen, die ganzzahlige Werte speichern und direkt auf einen Übertragungsvektor geschrieben werden, vor der Speicheroperation einer Formatumwandlung vom Hostformat in ein neutrales Format unterzogen werden. Zugleich werden diese Datentypen überwacht, um sicherzustellen, dass sie vor der ersten Nutzung auf dem Host richtig umgewandelt werden. Die folgende einfache Funktion geht von der grundlegenden Annahme aus, dass der Leser am anderen Ende des Sockets dieselbe Prozessorarchitektur hat wie der Sender:

void foo(int sock)
{
int x;
for(x=0; x<256; x++)
if(send(sock, &x, sizeof int) < sizeofint)
return;
}

Dies kann zutreffen, oder besser: Dies kann zum jetzigen Zeitpunkt zutreffen. Aber welcher Programmierer kann weit genug in die Zukunft schauen, um zu wissen, dass dies immer so sein wird, ungeachtet aller Marktverschiebungen, großartigen Ideen von Marketingpraktikanten, etc.? Eine zur Analyse der Byte-Reihenfolge nützliche Software sollte, nach Überprüfung dieser Funktion, Folgendes ausweisen: »Value ‘x’ is sent in host byte order, but should be sent in neutral byte order«. Ein in interarchitektonischer Entwicklung bewanderter Programmierer kann diese Funktion anschließend schnell und einfach modifizieren, um den Wert der Variable ‘x’ vor der Übertragung umzuwandeln. Und - noch wichtiger - er kann dies während der Erstprogrammierung tun und nicht erst nach Auftreten eines Datenfeldfehlers oder ähnlichen Umstandes:

void foo(int sock)
{
int x, xt;
for(x=0; x<256; x++)
{
xt = htonl(x); // … or some other suitable form
if(send(sock, &xt,
sizeof int)<sizeof int)
return;
}
}

Gleichermaßen lässt sich der Datenfluss jedes empfangenen Datentyps, der ganzzahlige Werte speichert, validieren, wenn es darum geht, über einen Übertragungsvektor gesendete Informationen zu lesen. Um dadurch, auf die genau umgekehrte Weise wie beim Versand, sicherzustellen, dass solche Werte vor der ersten Verwendung in das Format des Hosts umgewandelt werden.