Schrittweise statt Neubau

Rust einführen, ohne alles neu zu schreiben

Eine Komponente, eine klare Schnittstelle, ein messbarer Nutzen. Alles Weitere entscheidet sich danach an Zahlen und nicht mehr an der Überzeugungskraft im Team.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Der Neubau scheitert, die einzelne Komponente hat eine Chance

Die Neuschreibung einer laufenden Anwendung in einer anderen Sprache ist der Vorschlag, der in Besprechungen am schnellsten Zustimmung findet und in Projekten am zuverlässigsten scheitert. Der Grund ist selten die Sprache. Er liegt darin, dass die alte Fassung während der gesamten Umstellung weiterentwickelt werden muss, sodass ihr zwei Stände parallel pflegt und der Rückstand mit jedem Sprint wächst. Nach einem Jahr steht ein unvollständiges neues System neben einem gepflegten alten, und die Entscheidung fällt gegen das neue.

Der Gegenentwurf hat ein eigenes Problem: Wer die erste Komponente falsch wählt, bekommt ein schlechtes Ergebnis und daraus eine dauerhafte Meinung. Ausgewählt wird nämlich meist das Modul, das im Team am unbeliebtesten ist, und das ist fast immer dasjenige mit dem verworrensten Zustand und den meisten unsichtbaren Abhängigkeiten. Genau dort ist die Grenze zwischen alt und neu am schwersten zu ziehen.

Die dritte Falle sitzt außerhalb des Codes. Wer eine C-Bibliothek um einen Rust-Anteil ergänzt oder ein Python-Paket mit einem übersetzten Modul versieht, verändert damit den Bau und die Auslieferung. Aus einem Paket, das für jede Plattform gleich aussieht, wird eines mit einer Matrix aus Betriebssystemen, Prozessorarchitekturen und Sprachversionen. Diese Arbeit fällt an, egal wie klein der Rust-Anteil ist, und sie gehört in die Schätzung.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Schritt für Schritt

Der Ablauf, Schritt für Schritt

Sieben Stationen, die für die C-Richtung und die Python-Richtung im Muster gleich sind. Die Reihenfolge ist bewusst so gewählt, dass die teuren Fragen vor dem ersten Code stehen.

  1. 1

    Kandidaten auswählen und vermessen

    Such eine Komponente mit klarer Ein- und Ausgabe, wenig geteiltem Zustand und messbarem Nutzen: einen Parser für ein sperriges Format, einen Rechenkern, eine Kompression, eine Signaturprüfung. Miss vorher, wie lange sie in der bestehenden Sprache braucht und wie viel Speicher sie belegt, sonst gibt es später keinen Vergleich.

    Geschafft, wenn: Eine benannte Komponente mit einer Ausgangsmessung, auf die ihr euch später berufen könnt.

  2. 2

    Die Grenze festlegen, den alten Code stehen lassen

    Beschreib die Schnittstelle so, wie sie aussehen soll, und halte sie schmal. Entscheidend ist der zweite Teil: Die alte Umsetzung wird nicht gelöscht, sondern bleibt hinter einem Schalter erreichbar. Damit ist ein Rückzug jederzeit möglich, und genau das nimmt der Sache das Risiko.

    Geschafft, wenn: Eine Schnittstellenbeschreibung und ein Schalter, mit dem ihr zwischen alt und neu umlegen könnt.

  3. 3

    Richtung C: Bibliothek und Header erzeugen

    Setz crate-type auf staticlib oder cdylib, versieh die nach außen sichtbaren Funktionen mit der C-Aufrufkonvention und einem festen Symbolnamen und gib nur Typen weiter, die eine feste Speicheranordnung haben. Den Header schreibt niemand von Hand: cbindgen liest die Crate und erzeugt ihn, und dieser Schritt gehört in den Build und nicht in eine Anleitung.

    Geschafft, wenn: Ein erzeugter Header und eine Bibliothek, die euer bestehender Linker ohne Sonderbehandlung annimmt.

  4. 4

    Richtung Python: Modul und Wheel bauen

    Mit PyO3 markierst du Funktionen und Typen, die auf der Python-Seite sichtbar sein sollen, und fasst sie zu einem Modul zusammen. maturin develop baut das Ganze in die aktive virtuelle Umgebung, sodass ihr sofort testen könnt, maturin build erzeugt daraus ein Wheel. Prüf früh die abi3-Einstellung: Damit deckt ein einziges Wheel je Plattform mehrere Python-Versionen ab, statt für jede Version eines zu bauen.

    Geschafft, wenn: Ein import des Moduls funktioniert in der virtuellen Umgebung, und im Zielverzeichnis liegt ein installierbares Wheel.

  5. 5

    Fehler über die Grenze übersetzen

    Ein Result kennt die andere Seite nicht. Nach C wird daraus ein Rückgabewert mit Fehlercode, oft ergänzt um eine Funktion, die den Text zum letzten Fehler liefert. Nach Python wird daraus eine Ausnahme, indem du eine Umwandlung von deinem Fehlertyp in den Python-Fehlertyp bereitstellst. Und in beiden Richtungen gilt: Fang am Rand ab, damit kein Panic die Sprachgrenze erreicht.

    Geschafft, wenn: Ein absichtlich ausgelöster Fehler kommt auf der anderen Seite als sauberer Fehlercode oder als Ausnahme an, nicht als Absturz.

  6. 6

    Alt und neu nebeneinander laufen lassen

    Führ beide Umsetzungen eine Zeit lang mit denselben Eingaben aus und vergleich die Ergebnisse, im Test und wenn möglich auch im Betrieb auf einem Teil des Verkehrs. Abweichungen sind der eigentliche Ertrag dieser Phase: Sie zeigen entweder einen Fehler in der neuen Fassung oder ein Verhalten der alten, das niemand dokumentiert hatte.

    Geschafft, wenn: Über einen definierten Zeitraum stimmen beide Umsetzungen bei realen Eingaben überein.

  7. 7

    Den Bau für alle Zielsysteme absichern

    Erweiter die Pipeline um die Zielsysteme, die ihr tatsächlich ausliefert, und lass die Pakete dort bauen statt auf einzelnen Arbeitsplätzen. Das ist bei einer Python-Erweiterung die eigentliche Arbeit, nicht der Rust-Code, weil Linux, macOS und Windows jeweils eigene Anforderungen an das Ergebnis stellen.

    Geschafft, wenn: Ein Durchlauf der Pipeline erzeugt für jede unterstützte Plattform ein Paket, ohne dass jemand von Hand eingreift.

Sechs Prüfpunkte für die erste Rust-Komponente

  1. 01 Die Schnittstelle passt auf eine Seite und ändert sich selten.
  2. 02 Es gibt wenig Zustand, den sie mit dem Rest der Anwendung teilt.
  3. 03 Der Nutzen lässt sich vorher und nachher in derselben Zahl messen.
  4. 04 Die alte Umsetzung kann während der Umstellung weiterlaufen.
  5. 05 Die Komponente hat Tests, oder ihr könnt welche mit vertretbarem Aufwand schreiben.
  6. 06 Der Bau für alle Zielsysteme ist geklärt, bevor die erste Zeile entsteht.
Was du mitnimmst

Danach führt ihr Rust an einer Stelle ein, an der es sich beweisen kann

Der Weg besteht aus einer Auswahlentscheidung und einer Handvoll technischer Handgriffe, die sich zwischen C und Python im Detail unterscheiden und im Muster gleichen. Wichtig ist, dass die alte Fassung während der ganzen Zeit lauffähig bleibt und dass vorher feststeht, woran ihr merkt, dass es nicht funktioniert.

Den richtigen ersten Kandidaten erkennen

Du wählst eine Komponente mit klarer Ein- und Ausgabe, wenig geteiltem Zustand und einem Nutzen, den du in einer Zahl ausdrücken kannst, statt des Moduls, über das sich alle beschweren.

Die C-Grenze sauber ziehen

Du weißt, welche Typen über eine C-Schnittstelle gehen dürfen, wer den Speicher freigibt und warum eine Bibliothek, die Speicher zurückgibt, auch eine Funktion zum Freigeben mitliefern muss.

Python-Erweiterungen bis zum Wheel bauen

Du erzeugst mit maturin ein installierbares Wheel und kennst den Schalter, mit dem ein einziges Wheel mehrere Python-Versionen abdeckt, statt für jede eines zu bauen.

Fehler übersetzen statt durchreichen

Du wandelst Rust-Ergebnisse am Rand in das um, was die andere Seite versteht, und sorgst dafür, dass kein Panic die Sprachgrenze erreicht.

Die Testabdeckung mitnehmen

Du lässt alte und neue Umsetzung eine Zeit lang mit denselben Eingaben nebeneinander laufen und vergleichst die Ergebnisse, statt der neuen von Anfang an zu vertrauen.

Vorher festlegen, wann Schluss ist

Du schreibst auf, welcher Gewinn bis wann eintreten muss und wie viel zusätzliche Bau- und Auslieferungskomplexität ihr akzeptiert. Danach ist ein Abbruch eine Entscheidung und kein Scheitern.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Welche Komponente die richtige erste ist

Drei Eigenschaften machen einen guten Kandidaten aus, und alle drei müssen zutreffen. Erstens eine klare Schnittstelle: Daten hinein, Daten heraus, keine Rückrufe in die Anwendung, keine Abhängigkeit vom Zustand des restlichen Systems. Zweitens wenig geteilter Zustand, denn jeder gemeinsam genutzte Puffer und jede globale Einstellung wird an der Sprachgrenze zu einer eigenen Konstruktion. Drittens ein Nutzen, den ihr in derselben Zahl vorher und nachher ausdrücken könnt, also Laufzeit, Speicherbedarf oder Durchsatz.

Typische Kandidaten sind deshalb Parser für eigene oder sperrige Formate, Rechenkerne mit engen Schleifen, Kompression und Dekompression, Kryptografie, Bild- und Signalverarbeitung sowie alles, was heute in einer interpretierten Sprache als Schleife über viele Millionen Elemente läuft. In der Python-Welt ist genau das der Grund, warum eine Reihe verbreiteter Pakete inzwischen einen Rust-Kern hat, darunter pydantic-core, polars und die Rust-Anteile von cryptography.

Was sich nicht eignet, ist die Komponente, über die sich alle beschweren. Sie ist unbeliebt, weil sie mit allem verflochten ist, und genau das macht die Grenzziehung teuer. Ebenso ungeeignet ist alles, was hauptsächlich Verbindungslogik ist: Wer nur Bibliotheken aneinanderreiht, gewinnt durch die Sprache nichts und bezahlt trotzdem den vollen Preis an der Grenze.

Die C-Richtung: Aufrufkonvention, Speicher und Build

Technisch ist der Kern schnell erzählt. Du setzt crate-type auf staticlib oder cdylib, versiehst die nach außen sichtbaren Funktionen mit der C-Aufrufkonvention und einem stabilen Symbolnamen und beschränkst die Typen an der Grenze auf solche mit fester, vorhersagbarer Speicheranordnung. Ein Rust-Enum mit Daten, ein String oder ein Vec haben an dieser Stelle nichts zu suchen, denn ihre innere Darstellung ist nicht zugesagt. Übergeben werden Zeiger, Längen und Zahlen, alles andere wird an der Grenze umgeformt.

Der Punkt, an dem die meisten Fehler entstehen, ist das Eigentum am Speicher. Die Regel ist einfach und wird trotzdem regelmäßig gebrochen: Wer Speicher anfordert, gibt ihn auch wieder frei. Gibt eure Rust-Bibliothek einen Puffer zurück, muss sie eine Funktion zum Freigeben mitliefern, und der C-Code ruft diese und nicht sein eigenes free. Umgekehrt darf Rust einen Puffer, den die C-Seite besitzt, nur lesen und schreiben, aber nicht freigeben. Bei Zeichenketten kommt die Nullterminierung dazu: Ein Rust-String kann Nullbytes enthalten und ist nicht terminiert, deshalb läuft die Übergabe über die dafür vorgesehenen Typen und nicht über einen rohen Zeiger auf die Bytes.

Der dritte Teil ist der Bau. Den Header erzeugt cbindgen aus der Crate, und dieser Aufruf gehört in den Build, damit Header und Bibliothek nicht auseinanderlaufen. Für die Einbindung in ein bestehendes Projekt hängt der Weg vom Bausystem ab: In einem Makefile ist es eine zusätzliche Regel und ein Verweis auf die erzeugte Bibliothek, für CMake gibt es fertige Bausteine, die cargo als Unterprojekt einhängen. Rechne damit, dass eine statisch gebundene Rust-Bibliothek nicht winzig ist, weil sie die Rust-Standardbibliothek mitbringt. Wer das drücken muss, setzt panic im Profil auf abort und entfernt damit die gesamte Abwicklungsmaschinerie.

Die Python-Richtung: PyO3, maturin und der Paketbau

PyO3 ist die Anbindung an den Python-Interpreter, maturin das Werkzeug, das daraus ein installierbares Paket macht. Der Ablauf ist kurz: Funktionen und Typen bekommen ein Attribut, das sie auf der Python-Seite sichtbar macht, ein Modulattribut fasst sie zusammen, maturin develop baut das Ergebnis in die aktive virtuelle Umgebung, und danach ist es ein gewöhnlicher Import. Für die Auslieferung erzeugt maturin build ein Wheel, das sich mit pip installieren lässt wie jedes andere Paket.

Zwei Einstellungen entscheiden darüber, wie viel Arbeit danach übrig bleibt. Die erste ist abi3: Damit bindet ihr gegen eine stabile Schnittstelle des Interpreters, und ein einziges Wheel je Plattform deckt alle Python-Versionen ab einer Mindestversion ab. Ohne diesen Schalter braucht ihr für jede unterstützte Python-Version ein eigenes Wheel, und die Matrix wird schnell unübersichtlich. Die zweite betrifft die Sperre des Interpreters: Rechenintensive Arbeit sollte sie ausdrücklich abgeben, denn erst dann läuft euer Rust-Anteil tatsächlich parallel, während der Interpreter anderswo weiterarbeitet. Wer das vergisst, hat schnellen Code, der trotzdem alles blockiert.

Die eigentliche Arbeit steckt weder in PyO3 noch in Rust, sondern in der Auslieferung. Ein reines Python-Paket ist plattformunabhängig, ein Paket mit übersetztem Anteil ist es nicht. Ihr braucht Wheels für Linux nach dem manylinux-Standard, der sie an ausreichend alte Systembibliotheken bindet, dazu welche für macOS auf beiden Prozessorarchitekturen und für Windows, und das alles je nach Einstellung auch noch je Python-Version. Diese Matrix gehört in die Pipeline, bevor die erste Zeile Rust entsteht, sonst hängt die Auslieferung am Rechner einer einzigen Person.

Fehler und Panics an der Sprachgrenze

Result ist ein Rust-Begriff, und weder C noch Python kennen ihn. An der Grenze wird deshalb übersetzt, und die Frage ist nur, wie sorgfältig. Nach C hat sich ein Muster eingebürgert: Die Funktion gibt einen Fehlercode zurück, das eigentliche Ergebnis kommt über einen Ausgabezeiger, und für die Fehlermeldung gibt es eine zweite Funktion, die den Text zum letzten Fehler liefert. Der Speicher für diesen Text gehört dabei der Rust-Seite und wird über eine mitgelieferte Funktion freigegeben, sonst habt ihr das Eigentumsproblem an einer neuen Stelle.

Nach Python ist es weniger Arbeit, weil PyO3 die Umwandlung übernimmt, sobald du eine Umwandlung von deinem Fehlertyp in den Python-Fehlertyp bereitstellst. Danach gibt eine Funktion, die ein Result liefert, auf der Python-Seite entweder ein Ergebnis oder eine Ausnahme zurück, und die aufrufende Seite merkt nichts davon, dass darunter Rust liegt. Der Punkt, den man bewusst setzen muss, ist die Zuordnung: Welcher fachliche Fehler wird zu welcher Ausnahmeklasse, damit vorhandener Python-Code, der bisher auf einen bestimmten Fehler geprüft hat, weiter funktioniert.

Und dann gibt es noch den Panic. Ein Panic, der aus einer Funktion mit fremder Aufrufkonvention herauslaufen will, ist ein Fall, den ihr gar nicht erst eintreten lassen wollt. Zwei Wege führen dorthin: entweder das Abfangen am äußersten Rand jeder exportierten Funktion und die Übersetzung in einen Fehlercode, oder panic auf abort im Profil, womit der Prozess an dieser Stelle geordnet endet, statt einen halb abgewickelten Zustand über die Grenze zu tragen. Der erste Weg ist die freundlichere Variante für eine Bibliothek, die andere in ihre Anwendung einbinden.

Testabdeckung halten und rechtzeitig aufhören

Die verlässlichste Absicherung während einer Umstellung ist der Vergleich zweier Umsetzungen. Beide bekommen dieselben Eingaben, die Ergebnisse werden gegeneinander gehalten, und erst wenn sie über einen festgelegten Zeitraum und über echte Daten hinweg übereinstimmen, wird umgeschaltet. Das lässt sich im Test erledigen und, wo es der Aufbau erlaubt, für einen Teil des Verkehrs auch im Betrieb. Der Nebeneffekt ist regelmäßig der wertvollere Teil: Die Abweichungen zeigen Verhalten der alten Fassung, das niemand aufgeschrieben hatte und das trotzdem irgendwo vorausgesetzt wird.

Damit die alte Fassung diese Zeit übersteht, wird sie nicht gelöscht, sondern bleibt hinter einem Schalter erreichbar. Erst wenn die neue Umsetzung eine vereinbarte Zeit ohne Auffälligkeit gelaufen ist, wird der Schalter entfernt. Das kostet ein wenig Ordnung im Code und ersetzt eine Menge Bauchgefühl bei der Entscheidung, wann man umlegt.

Bleibt der unbequeme Teil: die Abbruchkriterien, und die gehören an den Anfang. Welcher Gewinn muss bis wann eingetreten sein, gemessen an der Ausgangszahl? Wie viel zusätzliche Bau- und Auslieferungskomplexität akzeptiert ihr, bevor der Aufwand den Nutzen aufzehrt? Und wer pflegt den Rust-Anteil, wenn die Person, die ihn geschrieben hat, das Team verlässt? Wenn diese drei Fragen vor dem Start beantwortet sind, ist ein Rückzug später eine sachliche Entscheidung. Sind sie es nicht, wird daraus eine Grundsatzdebatte über die Sprache, und die gewinnt niemand.

Dazu passende Kurse

Weil die Schwierigkeiten an der Grenze entstehen und nicht in der Sprache, lohnen sich für den Einstieg Rust-Kurse für Teams mit bestehendem Code .

Auf der anderen Seite der Grenze steht meist Python, und wer dort sauber arbeitet, spart sich die Hälfte der Umbauten, dabei helfen Python-Kurse für Teams mit Rechenlast .

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Sehr angenehmes, gut vorbereitetes Seminar, kann ich nur empfehlen.
Rust Fortgeschrittenenkurs

Häufige Fragen

Müssen wir für einen kleinen Rust-Anteil das ganze Team umschulen?
Nein, und das wäre auch der falsche Zuschnitt. Am Anfang reicht eine kleine Gruppe, die den Anteil schreibt und pflegt, während der Rest weiterhin in der bisherigen Sprache arbeitet und die Komponente nur über deren Schnittstelle benutzt. Wichtig ist nur, dass es von Beginn an mehr als eine Person ist, sonst hängt die Wartung an einem Namen und die Einführung endet mit dessen Wechsel.
Können wir Rust auch in die andere Richtung einsetzen und vorhandene C-Bibliotheken weiterbenutzen?
Ja, das ist sogar der häufigere Fall am Anfang. Mit bindgen erzeugst du aus den Headern die Rust-Deklarationen und rufst die Funktionen über die Fremdschnittstelle auf. Die Aufrufe selbst sind unsafe und gehören in eine kleine Hülle, die die Bedingungen der Bibliothek durchsetzt, damit der übrige Code sie sicher benutzen kann. Für C++ gibt es zusätzlich eine eigene Brücke, weil sich C++-Signaturen nicht direkt über eine C-Schnittstelle abbilden lassen.
Wie viel schneller wird es tatsächlich?
Das hängt vollständig davon ab, was ihr ersetzt. Gegen gut optimiertes C ist der Unterschied gering, dort geht es nicht um Tempo, sondern um die Klasse von Fehlern, die wegfällt. Gegen eine Schleife in einer interpretierten Sprache können es Größenordnungen sein, und genau deshalb sind Rechenkerne die üblichen ersten Kandidaten. Verlass dich nicht auf fremde Zahlen: Miss die betroffene Komponente vor der Umstellung, sonst habt ihr hinterher keine Grundlage für die Bewertung.
Was ändert sich für unsere Auslieferung?
Mehr, als der Umfang des Rust-Codes vermuten lässt. Aus einem Paket, das überall gleich aussieht, wird eines, das je Betriebssystem und Prozessorarchitektur gebaut werden muss, bei Python zusätzlich je nach Einstellung auch je Interpreterversion. Der Schalter für die stabile Interpreterschnittstelle reduziert das erheblich. Diese Matrix gehört vor dem Projektstart in die Pipeline und nicht auf den Rechner der Person, die zufällig Rust installiert hat.

Passt thematisch dazu

Weil maturin develop das Modul in die gerade aktive Umgebung baut, lohnt vorher ein Blick darauf, warum jedes Python-Projekt eigene Paketversionen bekommt .

Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Rust-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.