von Tim Varelmann
Dieser Bericht wird mit Genehmigung von Lucky Data geteilt.
Lucky Data ist ein deutsches IT-Unternehmen, das IT-Dienstleistungen anbietet. Darüber hinaus entwickelt Lucky Data Logistik-Dispositionssoftware, die modernisiert, wie Disponenten die Logistik in der Bauindustrie und in Binnenhäfen planen. Bluebird Optimization unterstützt diese Entwicklung.
Dieser Artikel beschreibt ein Refactoring von stark pointer-basiertem Legacy-C++ zu modernen Value Semantics: eine Änderung, die eine ganze Kategorie von Bugs eliminiert, den Umfang des fehleranfälligsten Codes fast halbiert und die Grundlage für eine schnellere, reaktivere Nutzererfahrung gelegt hat. Für ein Unternehmen, dessen Software täglich operative Entscheidungen mit hohem Einsatz trifft, verkürzt diese Kombination (weniger Fehler, leichter änderbar, schneller für den Nutzer) den Weg von neuen Kundenanforderungen zu ausgelieferten Features.
--
Über Monate hinweg lief die Entwicklung rund. Neue Features gingen in regelmäßigen Releases raus. Nichts Dramatisches, nichts brennt an. Ruhiges Fahrwasser, welches erlaubt, zu planen was als Nächstes kommt, statt sich den Kopf zu zerbrechen über das, was gerade kaputt ist.
Anfang März war das vorbei.
Er tauchte in Code auf, der sich noch in Entwicklung befand: ein hartnäckiger Bug mit reproduzierbarem Symptom und ohne offensichtliche Ursache. Ich habe einen ganzen Tag damit verbracht, ihn zu jagen, und hatte am Abend nichts als einen Patch: ein Stück Code, das das Symptom eliminierte, ohne es zu erklären. Mit einem Release am Horizont und anderen noch offenen Aufgaben habe ich entschieden, die Ursachensuche aufzuschieben.
Der Abend war unruhig. Ein ganzer Tag war weg, ohne etwas Vorzeigbares außer einem Fix, den ich nicht vollständig rechtfertigen konnte. Wenn man nicht erklären kann, warum ein Bug auftrat, kann man auch nicht wirklich sicher sein, dass er weg ist.
Eine Woche später, der nächste. Andere Oberfläche, gleiches Gefühl: schwer einzugrenzen, Symptome, die nicht sauber zu dem Code passten, der sie eigentlich hätte produzieren sollen. Wieder fast ein ganzer Tag, wieder ein Patch, wieder Symptome entfernt ohne sie zu verstehen. Der Release stand unmittelbar bevor. Der Patch ging rein, und der Release ging raus.
Nach dem Release war endlich Zeit zum Durchatmen: und zum Ursachen finden.
Ohne den Druck wurde schnell klar: Die beiden Bugs waren nicht unabhängig. Ihre Ähnlichkeit deutete auf die Architektur selbst: ein Design rund um Pointer, mit geteiltem Zugriff auf Daten, der sich über große Teile des Codes zog. Jeder Bug hatte seinen eigenen unmittelbaren Auslöser, aber alle hatten dieselbe tiefere Ursache.
Die unbequeme Schlussfolgerung: Diese Architektur hatte keine echte Rechtfertigung für das, was die Software tatsächlich tut. Sie wurde in der Vergangenheit ohne Überlegung implementiert und nie hinterfragt. Und sie produzierte Bugs, die unter anderen Namen immer wiederkommen würden, solange die darunter liegende Struktur unverändert blieb.
Um zu erklären, was sich geändert hat, ein kurzer Abstecher dazu, wie Programme Daten speichern:
Computerprogramme halten Daten im Speicher, und der Speicher hat zwei Hauptbereiche: den Stack und den Heap.
Daten auf dem Stack gehören einem bestimmten Stück Code der Funktion, die sie erzeugt hat. Nur diese Funktion kann sie lesen oder verändern.
Daten auf dem Heap leben für sich. Jedes Stück Code mit einem Pointer (im Wesentlichen eine Adresse, die dem Programm sagt, wo die Daten liegen) kann sie lesen oder verändern. Dasselbe Stück Heap-Daten kann viele Pointer haben, die von vielen Stellen im Programm aus darauf zeigen.
Was daran ist nun problematisch? Auf den ersten Blick klingt das doch in Ordnung: Wenn zwei Teile eines Programms dasselbe Datum verändern, wird jeder seinen guten Grund dafür haben. Und individuell betrachtet stimmt das meistens auch. Das Problem ist nicht die einzelne Änderung. Das Problem ist, dass Entwickler nicht mehr lokal denken können.
Ein Beispiel: Man stelle sich Code vor, der entscheidet, ob LKW 42 um 15 Uhr verfügbar ist. Er liest die Daten: "LKW 42, frei": und beginnt mit dem Zusammenstellen eines Dispositionsauftrags. Zwischen dem Moment des Lesens und dem Moment der echten Disposition markiert ein anderer Teil des Programms, ebenfalls aus einem völlig validen Grund, LKW 42 als in Wartung. Der dispositionierende Code hat keine Möglichkeit, davon zu erfahren. Der LKW wird trotzdem zugewiesen.
Für sich betrachtet sind beide Codeteile korrekt. Der Bug lebt im Raum zwischen ihnen. Und Debugging heißt, jeden Teil des Programms zu verfolgen, der möglicherweise einen Pointer auf LKW 42 hält: in einer größeren Codebasis können das Dutzende oder Hunderte von Stellen sein.
Das heißt nicht, dass Pointer schlecht sind. Jeder, der schon einmal eine Browser-Extension, ein Microsoft-Office-Add-in oder ein Spielemod installiert hat, hat von ihnen profitiert: Die ganze Kategorie "ein laufendes Programm um etwas erweitern, das es ursprünglich nicht kannte" beruht auf pointer-artigen Mechanismen. Pointer sind an der richtigen Stelle die richtige Wahl.
Für den zentralen Datenspeicher, um den es hier geht: das Stück, das die gerade angezeigten Daten und ihre zeitliche Umgebung hält: gab es aber keinen solchen Grund. Die Komplexität der Pointer war reiner Kostenfaktor, ohne Nutzen.
Drei Dinge stachen heraus.
Die Codebasis griff zu Pointern als Standardwerkzeug, in einem Teil des Codes, für den es keinen Anwendungsfall dafür gab. Das Ergebnis war genau der oben beschriebene Kostenfaktor: geteilter Datenzugriff ohne klaren Besitzer, Lebenszyklen, die Stück für Stück rekonstruiert werden mussten, und der Raum zwischen je zwei Zugriffen als potenzieller Bug.
Mutexes sind Koordinationsmechanismen für parallel laufenden Code: Sie verhindern, dass zwei Ausführungs-Threads sich gegenseitig in die Daten pfuschen. Aber zurzeit läuft dieser Teil der Software auf einem einzigen Thread. Jeder Mutex darin war irgendwann in der Vergangenheit als Patch zu einem Bug hinzugefügt worden, der wie eine Race Condition aussah. Sie waren Überbleibsel alter Feuerwehreinsätze und bremsten den Code bis heute aus.
Der Code nutzte eine abstrakte Oberklasse als einzigen Zugang zu den Daten für die zwei Arten von Dingen, die die Software verfolgt: Assignments (die geplanten Termine eines Dispositionsplans) und Entities (die Dinge, die an diesen Terminen beteiligt sind: LKWs, Betonmischer, Facharbeiter, Hilfsarbeiter, auszuliefernde Produkte). Vererbung ist ein Mechanismus, der unterschiedlichen Dingen eine übergeordnete Kategorie gibt. Sie ist nützlich, wenn man diese einheitliche Behandlung wirklich braucht, aber in C++ zwingt sie Entwickler praktisch zu Pointern: verschiedene Typen über eine gemeinsame Kategorie zu behandeln erfordert Pointer. Zusätzlich kostet Vererbung Laufzeit und macht den Code schwerer nachvollziehbar.
Zwei Entscheidungen haben den größten Teil der Arbeit gemacht.
Wenn ein Teil der Anwendung ein Assignment oder eine Entity braucht, fragt er über eine eindeutige ID an und bekommt eine Kopie. Die Kopie gehört dem Aufrufer (und liegt auf dessen Stack). Niemand sonst kann hineingreifen und etwas ändern. Wenn der Aufrufer fertig ist, verschwindet die Kopie einfach: keine Buchführung, keine Lecks, keine Überraschungen.
Es gibt einen alten Grundsatz im Software-Design: prefer composition over inheritance. Statt dass Assignments und Entities einen gemeinsamen Vorfahren teilen, enthalten sie jetzt jeweils ein kleines gemeinsames Stück (die Eigenschaften, die sie tatsächlich teilen) und sind ansonsten unabhängige Typen. Die übergeordnete Kategorie, die früher der einzige Zugang zu diesen Daten war, steht weiterhin zur Verfügung für Code, der beide Arten wirklich einheitlich behandeln muss: sie ist jetzt mit einem modernen C++17-Mechanismus namens Visitor-Pattern umgesetzt, von dem Entwickler nicht einmal wissen müssen, um ihn zu nutzen. Entscheidend aber: sie ist nicht mehr die einzige Tür. Teile der Anwendung, die wissen, dass sie nur mit Assignments oder nur mit Entities zu tun haben, können jetzt gezielt danach fragen. Weniger "über alles iterieren und anschließend filtern". Direkter, lesbarer.
Kurz zusammengefasst bedeutet das: Wir haben Fehler behoben und für die Zukunft vermieden, das Programm ist leichter, schneller und günstiger wart- und weiterentwicklungsbar, vorher undenkbare neue Features sind plötzlich kurz vor fertig. Und all das hat weniger als 2 Wochen fokussierter Arbeit gebraucht.
Rolf Ruß/Patrick Wolff, CEO von Lucky Data, bewertet diese Entwicklung wie folgt:
"Zitat von Patrick/Rolf"
Zwei verlorene Tage kurz vor einem Release waren teuer. Einen Bug zu patchen, ohne ihn zu verstehen, fühlt sich zunächst produktiv an: Das Symptom verschwindet, der Release geht raus, die Liste offener Punkte wird kürzer. Aber tatsächlich leiht man nur Zeit bis zum nächsten Bug. Der zweite Bug, eine Woche nach dem ersten, war die Quittung.
Die eigentliche Arbeit bestand darin, die Architektur ernst zu nehmen als Quelle von vielen möglichen (und zu leicht zu programmierenden) Bugs. Die unpassenden Aspekte einer Architektur zu identifizieren und ihnen die Zeit zu geben, die sie brauchen, um ersetzt zu werden: das war der Schlüssel.
Wenn dir etwas davon in deiner eigenen Codebasis bekannt vorkommt: Bugs, die unter verschiedenen Gestalten immer wiederkommen, Stellen im Code, vor denen alle ein bisschen Respekt haben: lass uns sprechen. Melde dich direkt, oder abonniere Bluebird Briefings, um zu Optimierungs- und Softwareentwicklungs-Themen wie diesem auf dem Laufenden zu bleiben.
Als enge Deadlines auf komplexe Unsicherheit in einem wegweisenden Lageroptimierungsprojekt trafen, wurde aus der Herausforderung durch Vertrauen und Innovation eine Chance. Das Ergebnis: eine siebenstellige Reduzierung der Lagerkosten bei gleichzeitiger Verbesserung der Materialverfügbarkeit.

Beim Gurobi Summit in Wien wurde eine Solver-Tuning-Challenge zu einem Wettlauf um Performance – und um eine Sachertorte. Wie gezieltes Parametertuning, mir Kuchen und wertvolle Optimierungserkenntnisse brachte.
