Der Klimawandel sorgte Ende 2023 für Dauerregen und Überschwemmungen. Nach zwei zu trockenen Jahren haben wir jetzt in Deutschland ein viel zu nasses Jahr. Keine Frage, wir müssen etwas tun! Unter dem Stichwort Green IT gibt es zunehmend Initiativen und Bestrebungen, von Seiten der IT dem Klimawandel entgegen zu wirken. In diesem Artikel werde ich beleuchten, wie die Wahl der Programmiersprache einen signifikanten Einfluss auf den Ressourcenverbrauch von Anwendungen haben kann, ohne Kompromisse eingehen zu müssen, und zwar mit der Programmiersprache Rust.
Virtuelle Maschinen überall
Wir bei codecentric erstellen sehr häufig Geschäftsanwendungen für unsere Kunden. Diese Anwendungen sind in den allermeisten Fällen als Web-Anwendung aufgebaut, bestehend aus einem Frontend, meist in JavaScript oder TypeScript geschrieben, und einem Backend. In vielen Fällen läuft dieses Backend in einer Java Virtual Machine und ist in Java oder Kotlin, manchmal auch in Scala geschrieben. Als Framework kommt in vielen Fällen Spring Boot zum Einsatz.
Cloud-native Backends sind dagegen häufig in JavaScript oder TypeScript geschrieben und verrichten als Serverless Functions (z. B. AWS Lambdas auf Node.js) ihren Dienst.
Diese Backends sind dabei meistens nicht auf Performance und geringen Ressourcenverbrauch optimiert, sondern darauf, einfach, schnell und mit hoher Qualität entwickelt zu werden. Außerdem sollen sie so lesbar sein, dass man die Anwendungen auch nach Jahren noch leicht warten und verändern kann.
Die Programme sind mit Programmiersprachen geschrieben, die zur Laufzeit von eine virtuellen Maschine (JVM oder Node.js) ausgeführt werden. Diese virtuellen Maschinen haben einen Laufzeit-Overhead auf die Anwendung, da der Code zum Teil interpretiert und nicht von den Prozessoren der Infrastruktur direkt ausgeführt wird. Die Speicherverwaltung erfolgt außerdem zur Laufzeit per Garbage Collection.
Mit Rust (rust-lang.org) und Go gibt es seit ein paar Jahren zwei moderne Programmiersprachen in den Top 20 (des Tiobe Index), die nicht auf einer virtuellen Maschine basieren, sondern direkt in Maschinensprache kompiliert werden. Anders als C und C++ hingegen erlauben beide Sprachen dabei das plattformunabhängige Entwickeln (Write once, run anywhere), das kennzeichnend ist für moderne Softwareentwicklung. Rust kommt dabei sogar ohne eine Garbage Collection zur Speicherverwaltung aus und erlaubt außerdem einen modernen Programmierstil, wie man ihn sonst nur bei Kotlin oder Scala findet.
Gute Gründe, sich diese Sprache einmal anzusehen.
Ist Rust besser?
Wenn mich meine Kollegen fragen würden, was man mit Rust machen kann, das man nicht auch mit Java, Kotlin oder TypeScript machen kann – jeweils bezogen auf unseren Anwendungsfall bei codecentric –, dann muss ich ehrlicherweise antworten: Nichts.
Bei der reinen Softwareentwicklung bietet Rust keinen signifikanten Vorteil. Es gibt sogar einen (kleinen) Nachteil, denn die spezielle Form der Speicherverwaltung bei Rust, der (Borrow Checker), zwingt der Entwicklerin etwas mehr Arbeit und Nachdenken auf.
Wenn man aber die Frage umkehrt (Was kann man in Java, Kotlin oder TypeScript machen, das man nicht auch in Rust machen könnte?), dann kann kann man ebenfalls antworten: Nichts. Es gibt viele gute Frameworks, und auch das grundsätzliche Programmiermodell ist ebenso modern wie bei Kotlin oder TypeScript (Java hinkt hierbei tatsächlich etwas hinterher).
Daher habe ich ein Experiment angestellt und zwei funktionsgleiche Web-Backends in unterschiedlichen Sprachen und Frameworks geschrieben, um diese miteinander zu vergleichen.
Der Versuchsaufbau
Zuerst habe ich eine Postgres-Datenbank erstellt, dort eine Tabelle angelegt und insgesamt 10 Mio. Records hineingelegt. Warum so viele? Ich wollte in meinem Test zufällig auf die Einträge zugreifen und implizite Caching-Mechanismen des jeweiligen Frameworks ausschließen.
Dann habe ich zwei funktional identische Backends gebaut, mit diesen Fähigkeiten:
- über den Endpunkt
/read/<id>
lädt man einen Record aus der Datenbank und erhält diesen im JSON-Format. Hierbei wird der Path-Parameter<id>
(implizit) als Zahl validiert. - Zusätzlich muss man sich bei dem Aufruf mit einem API-Key authentifizieren. Dieser Key wird entweder als Header (
X-API-Key
) oder als Query-Parameter (?api_key=
) mitgegeben.
Im Kleinen entspricht das einem üblichen Web-Backend, wie wir es für unsere Kunden erstellen.
Als klassischen Kandidaten habe ich mich für eine Spring-Boot-Anwendung entschieden, mit Spring Data JDBC für den Zugriff auf die Postgres DB. Als Sprache nutze ich Kotlin. Exakt diesen Tech-Stack verwenden wir bei codecentric immer wieder in Kundenprojekten.
Als Vergleich mit Rust habe ich mich für das Framework Rocket entschieden. Mit Rocket kann man elegant und deklarativ Web-Backends schreiben. Ein SQL-Client gehört auch hier zum „Lieferumfang“.
Vergleich des Codes
Spring Boot arbeitet objektorientiert mit automatischer „Verdrahtung“ der einzelnen Objekte untereinander. Rocket hingegen ist funktional aufgebaut. So wundert es nicht, dass das jeweilige Grundgerüst der beiden Anwendung sehr unterschiedlich ist.
Wenn man sich aber die reinen fachlichen Funktionen ansieht, stellt man große Ähnlichkeiten fest. Hier ist der Rust-Zugriffscode auf die Datenbank:
1pub async fn read_dummy_with_id(
2 conn: &mut Connection<DummyDb>,
3 id: i32,
4) -> Result<DummyData, Error> {
5 sqlx::query_as::<_, DummyData>("SELECT * FROM dummy_data WHERE id = $1")
6 .bind(id)
7 .fetch_one(&mut ***conn)
8 .await
9}
Und hier das Pendant in Kotlin:
1fun readDummyWithId(id: Int): MutableList<DummyData> =
2 jdbcTemplate.query(
3 "SELECT * FROM dummy_data WHERE id = ?",
4 arrayOf(id),
5 intArrayOf(java.sql.Types.INTEGER),
6 ) { rs, _ ->
7 DummyData(
8 id = rs.getInt("id"),
9 value = rs.getString("value"),
10 odd = rs.getBoolean("odd"),
11 divisibleBy3 = rs.getBoolean("divisible_by_3"),
12 visited = rs.getBoolean("visited")
13 )
14 }
In beiden Fällen wird ein SQL-Statement als String mit einem Platzhalter erstellt, die id
übergeben und das Ergebnis in eine Datenstruktur gepackt. Bei Spring Boot musste das selbst machen, bei Rocket gab es dafür sogar einen Automatismus.
Auch der Controller-Code, um aufgrund einer HTTP-Anfrage das Ergebnis dieser Suche in eine HTTP-Antwort zu verwandeln, sieht im Prinzip sehr ähnlich aus. Hier der Code in Rust mit Rocket:
1#[get("/read/<id>")]
2async fn read(
3 mut db: Connection<DummyDb>,
4 id: i32,
5 _api_key: ApiKey,
6) -> Result<Json<DummyData>, Status> {
7 let res = read_dummy_with_id(&mut db, id).await.map_err(|e| match e {
8 RowNotFound => Status::NotFound,
9 _ => Status::InternalServerError,
10 })?;
11
12 Ok(Json(res))
13}
Und hier in Kotlin mit Spring Boot:
1@GetMapping("/read/{id}") 2 fun read(@PathVariable id: Int): ResponseEntity<DummyData> { 3 val res = dummyRepository.readDummyWithId(id) 4 5 return if (res.isNotEmpty()) 6 ResponseEntity.ok(res.first()) 7 else 8 ResponseEntity.notFound().build() 9 }
In beiden Fällen wird das Ergebnis der o. g. Funktion genommen und analysiert. Gibt es eines, wird es zurückgegeben, sonst kommt ein NotFound
Error (404). Bei Rust gibt es sogar noch einen InternalServer
Fehler (500), wenn es bei dem Zugriff auf die DB ein Problem gibt. Das Passiert bei Kotlin mit Spring Boot auch, ist dort aber nicht explizit zu sehen. Geschmacksache.
Als letztes schauen wir uns noch den Code an, der den Api Key prüft.
Hier in Rust als Rocket Request Guard:
1async fn from_request(request: &'r Request<'_>)
2 -> Outcome<Self, Self::Error> {
3 let api_key = match request.headers().get_one("X-API-Key") {
4 Some(api_key) => api_key.to_string(),
5 None => match request.query_value::<String>("api_key") {
6 Some(Ok(key)) => key,
7 _ => return request::Outcome::Error((Status::Unauthorized, ())),
8 },
9 };
10
11 if api_key == "valid_key" {
12 request::Outcome::Success(ApiKey(api_key))
13 } else {
14 request::Outcome::Error((Status::Unauthorized, ()))
15 }
16 }
vs Kotlin als Spring Boot Filter:
1override fun doFilterInternal(
2 request: HttpServletRequest,
3 response: HttpServletResponse,
4 filterChain: FilterChain
5 ) {
6 val apiKey = request.getHeader("X-API-Key")
7 ?: request.getParameter("api_key")
8
9 if (apiKey == "valid_key")
10 filterChain.doFilter(request, response)
11 else
12 response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
13 }
In beiden Fällen sieht man gut, dass zuerst in den Headers, dann in dem Query String nach dem API-Key gesucht wird.
Insgesamt ist der Rust-Code etwas umfangreicher und insbesondere das Fehlerhandling expliziter. Das gilt auch für die verwendeten Typen, insbesondere die Deklaration der Ergebnistypen einer Funktion. Rusts große Stärke, seine automatische Speicherverwaltung ohne Garbage Collection, erfordert leider etwas mehr Schreibaufwand.
Auch befinden sich hier die Frameworks sicher noch nicht in einem so ausgereiften Zustand wie Spring Boot, weswegen schwer lesbare Konstrukte wie .fetch_one(&mut ***conn)
notwendig sind.
Doch wenn man beide Sprachen kennt, so stellt man fest, dass der Code in jedem Fall leicht verständlich und ebenso leicht zu warten ist. Aus meiner Sicht gibt es hier keinen signifikanten Unterschied.
Und zur Laufzeit?
Nachdem beide Versionen liefen (was in beiden Fällen ungefähr gleich lange gedauert hat), war es für die Kontrahenten an der Zeit, in den Ring zu steigen. Hierzu habe ich von beiden Versionen eine lauffähige Anwendung gebaut und diese unter Last gesetzt. Dabei habe ich eine Minute lang insgesamt 600 Requests abgesetzt, 10 Requests pro Sekunde.
Größe der Anwendung als Datei
Mit ./gradlew bootJar
habe ich aus dem Spring-Boot-Projekt eine ausführbare Datei erstellt. Die Rust-Anwendung habe ich mit cargo build --release
gebaut.
Dateigröße:
- Kotlin: 30 MB *)
- Rust 7,7 MB
*) Zu dieser Dateigröße kommt noch der Platz für die JVM hinzu, die i. d. R. zwischen 80 und 120 MB groß ist. Das ist z. B. wichtig, wenn man die Anwendung in einem Docker-Image ausliefert.
Beide Dateien enthalten alles, was man zur Laufzeit braucht und können einfach gestartet werden (bei Kotlin natürlich mit der JVM). Beide Anwendung liefen nicht in einem Docker-Container, sondern in nativer Technologie auf meinem Laptop.
Speicherverbrauch zur Laufzeit
In den ersten Sekunden nach dem Start des Lasttests sieht man in beiden Fällen, dass der Speicherverbrauch ansteigt. Nach wenigen Sekunden hat er sich dann „eingependelt“ und steigt nicht mehr weiter.
Speicherverbrauch (Memory):
- Kotlin: 226 MB
- Rust: 5,8 MB
Antwortzeitverhalten
Bei der reinen Antwortzeit gibt es so gut wie keinen Unterschied. Die Messwerte schwanken zwischen 1 msek und im Maximum 100 msek, mit einem Durchschnitt von 5,8 msek und einem Median von 5 msek. Selbst das 99er Percentil liegt unter 20 msek.
Ich vermute, dass die Zeit im Wesentlichen in der Datenbank und nicht im Backend verbraucht wird.
CPU-Verbrauch zur Laufzeit
Anders sieht es allerdings beim CPU-Verbrauch aus. Die absoluten Zahlen sind sicher nicht aussagekräftig, vermutlich aber die relativen.
CPU-Auslastung:
- Kotlin: 4,5 % (stark schwankend von 3 % bis 7 %)
- Rust: 0,4 % (ziemlich gleich bleibend)
Summe CPU-Zeit über die Ausführung:
- Kotlin 9,43 sek
- Rust: 0,27 sek
Anzahl Threads:
- Kotlin: 47
- Rust: 11
Die Auswertung
In allen Messwerten hatte die in Rust geschriebene und nativ kompilierte Fassung deutlich die Nase vorn. Hier noch einmal alle Werte im Vergleich:
Kotlin | Rust | Prozentsatz | |
---|---|---|---|
Dateigröße (MB) | 30 | 7,7 | 26 % |
Memory (MB) | 226 | 5,8 | 3 % |
Antwortzeit (msek) | 5 | 5 | 100 % |
CPU Auslastung (%) | 4,5 | 0,4 | 9 % |
CPU Zeit (sek) | 9,43 | 0,27 | 3 % |
Threads | 47 | 11 | 23 % |
Auch wenn diese Messwerte sicher nicht absolut zu verstehen sind, so kann man doch daraus folgern, dass ein Backend, das in Rust mit Rocket geschrieben ist, deutlich kleiner ist, viel weniger Speicher und viel weniger CPU verbraucht als ein vergleichbares in JVM-Technik.
Fazit
Die entsprechende Erfahrung vorausgesetzt, schreibt man eine Web-Anwendung in Rust mit Rocket ähnlich leicht oder schwer wie in Kotlin mit Spring Boot. Auch in puncto Wartbarkeit erscheinen beide Ansätze vergleichbar. Beide Sprachen erzwingen Typsicherheit und automatische Speicherverwaltung. Automatisierte Tests (Unit- und Integrationtests) sind mit von der Partie. Beide Ansätze erlauben es, sichere und robuste Anwendungen zu schreiben.
Die Lernkurve bei Rust ist sicher etwas heftiger als bei Kotlin, aber wenn man die grundsätzlichen Konzepte verstanden hat, geht es auch hier schnell voran, das kann ich aus eigener Erfahrung sagen.
In puncto Laufzeitverhalten trennen beide Ansätze hingegen Welten. Wenn man mehrere Web-Anwendungen in einem Rechenzentrum oder in der Cloud betreibt, so kann man mit Rust vermutlich das drei- bis vierfache an Funktionalität aus derselben Infrastruktur herausholen, vielleicht sogar noch mehr. Das bedeutet, man kann Server sparen. Und Server sparen bedeutet CO2 sparen (neben dem reinen Kostenfaktor).
Mit Rust gibt es (endlich) eine moderne und hinreichend weit verbreitete Programmiersprache, die die Leichtigkeit von Java oder Kotlin mit der Ressourcenschonung von C oder C++ verheiratet. Ein guter Ansatz, um als Softwareentwickler etwas für das Klima zu tun.
Weitere Beiträge
von Goetz Markgraf
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Goetz Markgraf
Senior Agile Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.