Spaghetticode 2.0

von Hubert Schmid vom 2012-09-09

Ich habe meine ersten Programmiererfahrungen in den 80er-Jahren mit einem BASIC-Dialekt gesammelt, der noch mit Zeilennummern und Sprungbefehlen arbeitete. Selbst kleine Programme wurden aufgrund der verworrenen oder fehlenden Strukturen schnell unübersichtlich und fehleranfällig. Bereits früh hat sich dafür der Begriff Spaghetticode etabliert.

Zu diesem Zeitpunkt war das Paradigma der strukturierten Programmierung als Ausweg aus dieser Situation bereits weit verbreitet und verdrängte schließlich die Sprungbefehle fast vollständig. Der Zusammenhang zwischen Aufbau und Ablauf ist in der prozeduralen, objekt-orientierten Programmierung mittlerweile allgegenwärtig und wurde auch durch die Kontrollstrukturen für Fehlerbehandlung weiter gefestigt.

int CopyFile(String fromPathname, String toPathname) { using (File source = FileSystem.Open(fromPathname)) { bool removeTargetOnException = false; try { using (File target = FileSystem.Create(toPathname)) { removeTargetOnException = true; return Transfer(source, target); } } catch (Exception) { if (removeTargetOnException) { FileSystem.Remove(toPathname); } throw; } } } int Transfer(File source, File target) { int transferred = 0; Chunk chunk; while ((chunk = source.Read()) != null) { target.Write(chunk); transferred += chunk.Size(); } return transferred; }

Dieses C#‑Fragment zeigt die Verwendung solcher Kontrollstrukturen und den Zusammenhang zwischen der statischen Codestruktur und dem dynamischen Ablauf. Das Beispiel zeigt allerdings auch eine ganz andere Eigenschaft: Die beiden Funktionen arbeiten vermutlich nur einen Bruchteil der tatsächlich für die Ausführung benötigten Zeit, und warten größtenteils auf die Ergebnisse der IO‑Operationen.

Darin sehen einige Entwickler ein großes Problem, was zusammen mit der einfacheren Nutzbarkeit anonymer Funktionen in verbreiteten Programmiersprachen zu einer gestiegenen Beliebtheit der ereignisgesteuerten und durchgehend asynchronen Programmierung führt.

Eine wichtige Voraussetzung dafür ist, dass für alle potentiell blockierenden Operationen geeignete nicht-blockierende Schnittstellen bereitstehen. Charakteristisch dafür sind void-Methoden mit jeweils mindestens einem Callback-Parameter. Für das obige Beispiel könnte das wie folgt aussehen:

class FileSystem { public static void Open(String pathname, Action<File> success, Action<Exception> raise); public static void Create(String pathname, Action<File> success, Action<Exception> raise); public static void Remove(String pathname, Action success, Action<Exception> raise); } class File { public void Read(Action<Chunk> success, Action eof, Action<Exception> raise); public void Write(Chunk chunk, Action success, Action<Exception> raise); public void Close(Action success); }

Die Unterstützung anonymer Funktionen ist in C# bereits weit vorangeschritten – im Vergleich zu anderen verbreiteten und statisch typisierten Programmiersprachen. Das obige Beispiel lässt sich relativ einfach umsetzen, und ist auch nur unwesentlich länger. Nur beim Zeilenumbruch und der Einrückung habe ich mich ein wenig schwer getan.

void CopyFile( String fromPathname, String toPathname, Action<int> success, Action<Exception> raise) { FileSystem.Open( fromPathname, (source) => { FileSystem.Create( toPathname, (target) => { Action<Action> closeBoth = (action) => target.Close(() => source.Close(action)); Action<Action> cleanup = (action) => FileSystem.Remove(toPathname, action, raise); Transfer(source, target, (transferred) => closeBoth(() => success(transferred)), (ex) => closeBoth(() => cleanup(() => raise(ex)))); }, (ex) => source.Close(() => raise(ex))); }, raise); } void Transfer( File source, File target, Action<int> success, Action<Exception> raise) { int transferred = 0; Action transfer = () => source.Read( (chunk) => target.Write( chunk, () => { transferred += chunk.Size(); transfer(); }, raise), () => success(transferred), raise); transfer(); }

Beim genaueren Lesen dieses Codes sollte eine Sache auffallen: Der Code ist im Vergleich zur blockierenden Variante unverständlich und schlecht nachvollziehbar. Die Gründe sind vergleichbar zum Spaghetticode der 80er-Jahre: Obwohl keine Sprungbefehle verwendet werden, springt die Ausführung scheinbar willkürlich zwischen den verschiedenen Teilen des Codes umher.

Während also ein Problem gelöst wird, wird gleichzeitig ein Neues geschaffen. Das ist nicht ungewöhnlich, weil sich häufig nicht alle Probleme gleichzeitig lösen lassen. Man sollte sich nur genau überlegen, welches Problem größer ist. Doch diese Bewertung findet aus meiner Sicht zu selten statt. Erstens werden die Performance-Vorteile asynchroner Programme häufig maßlos überschätzt, obwohl beim Eintreffen eines Ereignisses faktisch die gleichen Dinge passieren und der Betriebssystemkern aufgrund seiner Möglichkeiten einen kleinen Vorteil besitzt. Und zweitens: Ich sehe zwar einen gewissen Vorteil der asynchronen Programmierung, wenn sehr viele parallele und lang blockierende Abläufe existieren. Die Frage ist nur, wie viel ist viel: Die Grenze liegt irgendwo deutlich im sechsstelligen Bereich pro eingesetztem Server. Denn ein aktuelles Betriebssystem kann ohne sichtbaren CPU-Overhead über 500.000 native Threads verwalten, und ich habe bisher selbst noch kein System gesehen, wo das nicht ausreichend war.