Ein Kunde meldete sich mit einer einfachen Beobachtung, die sich als genau richtig herausstellte: Das Ausliefern von Dateien über den HTTP-Server verbrauchte viel Speicher. Wenn TsgcWebSocketHTTPServer eine große Datei aus DocumentRoot auslieferte, wurde die gesamte Datei für jede Verbindung in den RAM eingelesen. Einhundert Clients, die dieselbe 1 GB große Datei herunterladen, bedeuteten rund 100 GB RAM. Dieses Release behebt das. Dateien werden jetzt von der Festplatte gestreamt und der Speicherverbrauch des Servers bleibt konstant, unabhängig von der Dateigröße und der Anzahl der verbundenen Clients. Es ist in sgcWebSockets 2026.6.0 enthalten.
Das Problem: eine vollständige Kopie pro Verbindung
Beide Pfade für statische Dateien, DoResponseHTTP für HTTP/1.x und DoHTTP2DocumentRoot für HTTP/2, erzeugten die Antwort auf dieselbe Weise. Sie erstellten einen TMemoryStream, riefen LoadFromFile auf und übergaben diesen Stream an die Antwort. LoadFromFile liest die gesamte Datei auf einmal in den Speicher, sodass die Kosten pro Anfrage der vollen Dateigröße entsprachen und jeder gleichzeitige Download seine eigene Kopie hielt.
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
Damit liegt der Spitzenspeicherbedarf etwa bei Dateigröße × gleichzeitige Verbindungen. Eine Handvoll Clients, die ein Video, einen Installer, ein Datenträgerabbild oder ein Datenbank-Backup abrufen, reicht aus, um einen Server ins Auslagern oder in einen Out-of-Memory-Absturz zu treiben.
Der Slow-Read-Multiplikator
Derselbe Kunde beschrieb ein zweites Szenario, und es ist ein echter Angriff: viele Verbindungen, die jeweils die Antwort sehr langsam lesen, in der Größenordnung von einem Byte pro Sekunde. Das ist ein Slow-Read-Angriff, eine Variante von Slowloris. Ein Client, der seine Lesevorgänge tröpfchenweise vornimmt, hält die Verbindung sehr lange offen, und mit dem alten Code band jede dieser Verbindungen zusätzlich eine vollständige In-Memory-Kopie der Datei. Ein paar tausend langsame Leser einer mäßig großen Datei könnten Gigabyte als Geisel halten, während sie fast nichts übertragen.
Wie wir es gemessen haben
Wir haben ein kleines Testgerüst gebaut: einen Server mit einem DocumentRoot und einer großen Testdatei sowie einen Client mit zwei Modi. Ein „schneller“ Modus öffnet N gleichzeitige normale Downloads, und ein „langsamer“ Modus öffnet M rohe Verbindungen, die eine gültige Anfrage senden und dann den Body mit einem Byte pro Sekunde lesen. Wir haben das Working Set des Servers mit einer 100 MB großen Datei abgetastet, vor dem Fix und danach.
| Szenario (100 MB-Datei) | Vor dem Fix | Nach dem Fix |
|---|---|---|
| 5 gleichzeitige Downloads | ~511 MB | ~11 MB |
| 10 gleichzeitige Downloads | ~1.012 MB | ~11 MB |
| 20 gleichzeitige Downloads | ~1,7 GB | ~12 MB |
| 100 Slow-Read-Verbindungen (1 byte/sec) | ~1,67 GB konstant gehalten | ~19 MB |
| Dateiintegrität | n. z. | SHA-256 byte-genau, korrekte Content-Length |
Vor dem Fix folgte der Speicher fast genau der Dateigröße mal der Verbindungsanzahl. Nach dem Fix bleibt er im Bereich von einigen zehn Megabyte, egal wie groß die Datei ist oder wie viele Clients sich verbinden.
Der Fix: von der Festplatte streamen
Die Datei wird jetzt mit einem schreibgeschützten, gemeinsam genutzten TFileStream statt mit einem TMemoryStream ausgeliefert. Nichts anderes in der Pipeline musste geändert werden, denn die Schreibseite streamt bereits: Der HTTP/1.x-Server sendet den Content-Stream in 32 KB-Blöcken, und der HTTP/2-Server gibt ihn in etwa 64 KB großen DATA-Frames aus. Der Server liest also einen kleinen Block von der Festplatte, sendet ihn und macht weiter, wodurch die Speicherkosten pro Verbindung von der gesamten Datei auf einige zehn Kilobyte sinken.
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length ist weiterhin korrekt, da es aus TFileStream.Size stammt, und der Stream wird nach der Antwort genau wie zuvor freigegeben. Wir haben mit einem SHA-256-Vergleich überprüft, dass Downloads byte-identisch zur Quelldatei sind und dass der Header die richtige Länge meldet. Es gibt keine API-Änderung und nichts zu konfigurieren: Das Setzen von DocumentRoot funktioniert genauso wie immer, es puffert die Datei nur nicht mehr.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
Die .NET-Edition erbt den Fix automatisch, da ihr DocumentRoot-Datei-Serving im selben kompilierten Kern läuft, den die Delphi-Komponenten verwenden.
Langsame Leser ausbremsen
Das Streamen von der Festplatte beseitigt die Speicherverstärkung, sodass ein langsamer Leser keine ganze Datei mehr im RAM festhalten kann. Was übrig bleibt, ist die Verbindung selbst, und dafür gibt es ein Sende-Timeout. Options.WriteTimeOut setzt eine Frist für Socket-Schreibvorgänge, sodass ein Client, der keine Daten mehr annimmt, getrennt wird, statt einen Server-Thread für immer zu blockieren. In diesem Release funktioniert dieses Timeout auch unter Linux und anderen POSIX-Plattformen, nicht nur unter Windows. Die POSIX-Socket-API erwartet einen timeval statt einer Millisekunden-Ganzzahl, und das wird jetzt korrekt behandelt.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000; // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;
Für einen öffentlichen Server empfehlen wir, drei Hebel zu kombinieren: Setzen Sie Options.WriteTimeOut, damit ins Stocken geratene Schreibvorgänge abgebrochen werden, nutzen Sie das RateLimit.MaxConnectionsPerIP der Firewall, um zu begrenzen, wie viele Verbindungen eine einzelne Adresse öffnen kann, und ziehen Sie einen Thread-Pool-Scheduler oder die IOCP- und EPOLL-Handler in Betracht, damit eine Flut langsamer Verbindungen nicht jeweils einen Thread erschöpfen kann.
Aktualisieren
Der Streaming-Fix ist ein direkter Ersatz. Sobald Sie aktualisieren, liefert DocumentRoot Dateien mit konstantem Speicherverbrauch von der Festplatte aus, ohne Codeänderung. Die Änderung wurde über Delphi 7 bis 13 hinweg verifiziert, auf den HTTP/1.x- und HTTP/2-Pfaden, und der .NET-Build übernimmt sie über den gemeinsamen Kern. Wenn Sie große statische Dateien ausliefern, ist dies eine spürbare Speicherreduktion und ein nützlicher Schritt gegen Slow-Read-Missbrauch.
Aktualisieren Sie über die sgcWebSockets-Download-Seite oder beziehen Sie es über GetIt oder Ihr registriertes Konto.
Fragen, Feedback oder Hilfe bei der Migration? Nehmen Sie Kontakt auf, Sie erhalten eine Antwort von den Leuten, die den Code geschrieben haben.
