Vorwort
Ich bin begeistert von meinem Interesse an der Softwareentwicklung, insbesondere dem Puzzle, ergonomisch Softwaresysteme zu erstellen, die die breitesten Probleme lösen und gleichzeitig so wenige Kompromisse wie möglich machen. Ich betrachte mich auch gerne als Systementwickler, was nach Andrew Kelleys Definition einen Entwickler bedeutet, der daran interessiert ist, die Systeme, mit denen sie arbeiten, vollständig zu verstehen. In diesem Blog teile ich mit Ihnen meine Ideen zur Lösung des folgenden Problems: Erstellen einer zuverlässigen und Performanten-Unternehmensanwendung . Eine ziemliche Herausforderung, nicht wahr? In dem Blog konzentriere ich mich auf den Teil "Performanten Web Server" - hier kann ich eine neue Perspektive anbieten, da der Rest entweder gut gepflegt ist oder ich nichts hinzufügen kann.
Eine große Einschränkung - Es wird keine Code -Beispiele geben , ich habe das eigentlich nicht getestet. Ja, dies ist ein großer Fehler, aber das Umsetzen dieser Tatsache würde viel Zeit in Anspruch nehmen, was ich nicht habe, und zwischen der Veröffentlichung eines fehlerhaften Blogs und der Veröffentlichung überhaupt nicht mit dem ersteren. Du bist gewarnt.
und welche Teile würden wir unsere Bewerbung zusammenstellen?
- Ein Frontend, mit dem Sie sich wohl fühlen, aber wenn Sie minimale Abhängigkeiten wünschen - gibt es Zig in WASM -Form.
Ein Zig -Webserver, eng in den Linux -Kernel integriert. Dies ist der Performanten, auf den ich mich in diesem Blog konzentrieren werde. -
Ein Python -Backend, in Zig integriert. Dies ist der komplexe Teil. -
Integration in dauerhafte Ausführungssysteme wie zeitlich und fließbar. Dies unterstützt die Zuverlässigkeit und wird im Blog nicht besprochen. -
Mit unseren Tools entschieden sich, lass uns anfangen!
Sind Coroutinen überhaupt überbewertet?
Zig hat keine Sprachebene für Coroutines :( und Coroutines ist das, womit jeder Performanten -Webserver erstellt wird. Gibt es also keinen Sinn, es zu versuchen?
Halten Sie an, lasst uns zuerst unseren Systemprogrammiererhut aufnehmen. Coroutinen sind keine Silberkugel, nichts ist. Was sind die tatsächlichen Vorteile und Nachteile?
Es ist allgemein bekannt, dass Coroutines (Benutzerspace -Threads) lighgewichtig und schneller sind. Aber auf welche Weise genau? (Die Antworten hier sind weitgehend Spekulationen, nehmen Sie ein Körnchen Salz und testen Sie es selbst)
Sie beginnen standardmäßig mit weniger Stapelraum (2 KB statt 4 MB). Dies kann jedoch manuell angepasst werden. -
Sie kooperieren besser mit dem UserSpace -Scheduler. Da der Kernel -Scheduler präventiv ist, werden die von Threads ausgeführten Aufgaben Zeitscheiben zugeteilt. Wenn die tatsächlichen Aufgaben nicht in die Scheiben passen, wird eine CPU -Zeit verschwendet. Im Gegensatz zu Goroutines, die so viele Mikroaufgaben passen, die von verschiedenen Goroutinen in die gleiche Zeitscheibe des OS-Threads ausgeführt werden. -
Die GO -Laufzeit multiplexe Goroutines beispielsweise auf Betriebssystem -Threads. Themen teilen die Seitentabelle sowie andere Ressourcen, die einem Prozess gehören. Wenn wir die CPU -Isolation und Affinität in die Mischung einführen, werden die Threads kontinuierlich in ihren jeweiligen CPU -Kernen ausgeführt, und alle OS -Datenstrukturen bleiben im Speicher, ohne dass es ausgetauscht werden muss, ausgetauscht. Der UserSpace -Scheduler verteilt CPU -Zeit für Goroutinen mit Präzision, da das kooperative Multitasking -Modell verwendet wird. Ist der Wettbewerb überhaupt möglich?
Die Performance-Siege werden erzielt, indem die Abstraktion eines Threads auf OS-Ebene abgebrochen und durch die eines Goroutine ersetzt wird. Aber ist in der Übersetzung nichts verloren?
Können wir mit dem Kernel zusammenarbeiten?
Ich werde argumentieren, dass die "wahre" OS -Level -Abstraktion für eine unabhängige Ausführungseinheit nicht einmal ein Thread ist - es ist tatsächlich der Betriebssystemprozess. Tatsächlich ist die Unterscheidung hier nicht so offensichtlich - alles, was Fäden und Prozesse unterscheidet, sind die verschiedenen PID- und TID -Werte. In Bezug auf Dateideskriptoren, virtuelle Speicher, Signalhandler, verfolgte Ressourcen - ob diese für das Kind getrennt sind, werden in den Argumenten für das "Klon" -Symall angegeben. Daher werde ich den Begriff "Prozess" verwenden, um einen Ausführungsthread zu bedeuten, der seine eigenen Systemressourcen besitzt - in erster Linie CPU -Zeit, Speicher, Öffnen von Dateideskriptoren.
nun ist das wichtig? Jede Ausführungseinheit hat ihre eigenen Anforderungen an Systemressourcen. Jede komplexe Aufgabe kann in Einheiten unterteilt werden, in denen jede eigene, vorhersehbare, Ressourcenanforderung - Speicher und CPU -Zeit - erstellen kann. Und je weiter der Baum der Unteraufgaben Sie in Richtung einer allgemeineren Aufgabe gehen - die Systemressourcengrafik bildet eine Glockenkurve mit langen Schwänzen. Und es liegt in Ihrer Verantwortung sicherzustellen, dass die Schwänze die Systemressourcengrenze nicht überschreiten. Aber wie wird das getan und was passiert, wenn diese Grenze tatsächlich überrannt ist?
Wenn wir das Modell eines einzelnen Prozesses und viele Coroutinen für unabhängige Aufgaben verwenden, wird der gesamte Prozess getötet, wenn eine Coroutine die Speichergrenze überschreitet - da die Speicherverwendung auf der Prozessebene nachverfolgt wird. Dies ist im besten Fall - wenn Sie CGroups verwenden (was automatisch der Fall für Pods in Kubernetes ist, die eine CGroup pro Pod haben) -, wird die gesamte CGroup getötet. Ein zuverlässiger System muss dies berücksichtigt werden. Und was ist mit der CPU -Zeit? Wenn unser Service gleichzeitig mit vielen rechenintensiven Anfragen getroffen wird, wird dies nicht mehr reagieren. Dann Termine, Stornierungen, Wiederholungen, Neustarts folgen.
Die einzige realistische Möglichkeit, diese Szenarien für die meisten Mainstream -Software -Stapel zu befassen, besteht darin, das "Fett" im System - einige nicht verwendete Ressourcen für den Schwanz der Glockenkurve - und die Anzahl der gleichzeitigen Anfragen zu begrenzen - was wiederum zu unbenutzten Ressourcen führt. Und trotzdem werden wir Oom töten oder von Zeit zu Zeit nicht mehr reagieren - auch für "unschuldige" Anfragen, die sich im gleichen Prozess wie der Ausreißer befinden. Dieser Kompromiss ist für viele akzeptabel und serviert Softwaresysteme in der Praxis gut genug. Aber können wir es besser machen?
Ein Parallelitätsmodell
Da die Ressourcennutzung pro Prozess verfolgt wird, würden wir im Idealfall einen neuen Prozess für jede kleine, vorhersehbare Ausführungseinheit hervorbringen. Dann setzen wir die Ulimit für CPU -Zeit und -verdienste - und wir können loslegen! Ulimit hat weiche und harte Grenzen, die es dem Prozess ermöglichen, bei der weichen Grenze anmutig zu beenden. Leider ist das Laichen neuer Prozesse unter Linux langsam, der neue Prozess pro Anfrage wird für viele Web -Frameworks sowie andere Systeme wie Temporal nicht unterstützt. Zusätzlich ist das Prozessumschalten teurer - was durch Kuh- und CPU -Pinning gemindert wird, aber immer noch nicht ideal. Leider sind langlebige Prozesse eine unvermeidliche Realität.
Je weiter wir von der sauberen Abstraktion kurzlebiger Prozesse gehen, desto mehr Arbeiten auf OS-Ebene müssten wir auf uns selbst aufpassen. Es gibt aber auch Vorteile zu erzielen - z. B. IO_uring zum Batching -IO zwischen vielen Ausführungsfäden. Wenn eine große Aufgabe aus Unterbereitungen besteht, kümmern wir uns wirklich um ihre individuelle Ressourcenauslastung? Nur zum Profilerstellen. Aber wenn wir für die große Aufgabe die Schwänze der Ressourcenglockenkurve verwalten könnten, wäre das gut genug. Wir könnten also so viele Prozesse hervorbringen wie die Anfragen, die wir gleichzeitig bearbeiten möchten, sie langlebig sein und einfach die Ulimit für jede neue Anfrage neu einstellen. Wenn eine Anfrage ihre Ressourcenbeschränkungen überschreitet, wird ein Betriebssystemsignal angezeigt und in der Lage, andere Anfragen nicht zu beenden. Oder wenn die Verwendung der hohen Ressourcen beabsichtigt ist, können wir dem Kunden sagen, dass er für eine höhere Ressourcenquote bezahlen soll. Klingt ziemlich gut für mich.
Aber die Aufführung wird im Vergleich zu einem Coroutine-per-Request-Ansatz immer noch leiden. Erstens ist das Kopieren des Prozessspeichertabellens teuer. Da die Tabelle Verweise auf Speicherseiten enthält, könnten wir riesige Pages verwenden und so die zu kopierende Datengröße einschränken. Dies ist nur direkt mit Sprachen auf niedrigem Niveau wie Zig möglich. Darüber hinaus ist das Multitasking der Betriebssystemniveau präventiv und nicht kooperativ, was immer weniger effizient ist. Oder ist es?
Kooperatives Multitasking mit Linux
Es gibt die syScall ender_yield, mit der der Thread die CPU abgeben kann, wenn er seinen Arbeitsteil abgeschlossen hat. Scheint ziemlich kooperativ. Könnte es eine Möglichkeit geben, auch eine Zeitscheibe einer bestimmten Größe anzufordern? Tatsächlich gibt es - mit der Planungsrichtlinie ender_deadline. Dies ist eine Echtzeit -Richtlinie, was bedeutet, dass der Thread für die angeforderte CPU -Zeitscheibe ununterbrochen ausgeführt wird. Aber wenn die Scheibe überrannt ist - ist die Vorstellung ein und Ihr Faden wird ausgetauscht und entlarisiert. Und wenn das Schicht unterdrückt ist, kann der Thread Sched_yield aufrufen, um eine frühe Finish zu signalisieren, sodass andere Threads ausgeführt werden können. Das sieht aus wie das Beste aus beiden Welten - ein kooperatives und präemtives Modell.
Eine Einschränkung ist die Tatsache, dass ein Sched_deadline -Thread nicht gaben kann. Dies lässt uns zwei Modelle für die Parallelität zurück - entweder einen Prozess pro Anforderung, der die Frist für sich selbst festlegt und eine Ereignisschleife für ein effizientes IO ausführt, oder einen Prozess, der von Anfang an einen Thread für jede Mikroaufgabe hervorbringt, von denen jede ihre eigene Deadline festlegt und die Bekennungswarteschlangen miteinander verwendet. Ersteres ist straausweit, erfordert jedoch eine Ereignisschleife im Userspace. Letzteres nutzt den Kernel mehr.
Beide Strategien erreichen das gleiche Ende wie das Coroutine -Modell -
Durch die Zusammenarbeit mit dem Kernel können Bewerbungsaufgaben mit minimalen Unterbrechungen ausgeführt werden .
Python als eingebettete Skriptsprache
Dies ist alles für Hochleistungs-, niedrige und niedrige Seite der Dinge, in denen Zig glänzt. Aber wenn es um das tatsächliche Geschäft der Anwendung geht, ist Flexibilität viel wertvoller als Latenz. Wenn ein Prozess reale Personen beinhaltet, die sich für Dokumente anmelden, ist die Latenz eines Computers vernachlässigbar. Trotz der Leistung in Leistung geben objektorientierte Sprachen dem Entwickler bessere Primitive, um die Domäne des Geschäfts zu modellieren. Am weitesten entfernten Ende ermöglichen Systeme wie Flowable und Camunda Management- und Betriebspersonal, die Geschäftslogik mit mehr Flexibilität und einer geringeren Eintrittsbarriere zu programmieren. Sprachen wie Zig helfen nicht dabei und stehen Ihnen nur im Weg.
Python hingegen ist eine der dynamischsten Sprachen, die es gibt. Klassen, Objekte - Sie sind alle Wörterbücher unter der Motorhaube und können zur Laufzeit manipuliert werden, wie Sie möchten. Dies hat eine Leistungsstrafe, macht jedoch das Modellieren des Geschäfts mit Klassen und Objekten und vielen cleveren Tricks praktisch. Zig ist das Gegenteil davon - es gibt absichtlich nur wenige clevere Tricks im Zig, was eine maximale Kontrolle bietet. Können wir ihre Kräfte kombinieren, indem wir sie interoperieren lassen?
In der Tat können wir, weil beide die C ABI unterstützen. Wir können den Python -Dolmetscher innerhalb des Zig -Prozesses und nicht als separater Prozess ausführen lassen, wodurch der Overhead bei Laufzeitkosten und Klebercode reduziert wird. Dies ermöglicht es uns weiter, die benutzerdefinierten Allocatoren von Zig in Python zu verwenden - ein Arena für die Verarbeitung der einzelnen Anforderung festzulegen, wodurch der Aufwand eines Müllsammlers verringert wird und eine Speicherkappe festgelegt wird. Eine große Einschränkung wäre die CPython -Laufzeit -Laichen für die Müllsammlung und IO, aber ich fand keine Beweise dafür, dass dies der Fall ist. Wir könnten Python in eine benutzerdefinierte Ereignisschleife in Zick mit Perkoroutine-Speicherverfolgung einbinden, indem wir das Feld "Kontext" in AbstractMemoryloop verwenden. Die Möglichkeiten sind unbegrenzt.
Abschluss
Wir haben die Vorzüge von Parallelität, Parallelität und verschiedenen Formen der Integration in den OS -Kernel besprochen. Die Erkundung fehlt keine Benchmarks und Code, von denen ich hoffe, dass sie die Qualität der angebotenen Ideen ausgibt. Haben Sie etwas Ähnliches ausprobiert? Was denkst du? Feedback willkommen :)
Weitere Lesen
https://linux.die.net/man/2/clone -
https://man7.org/linux/man-pages/man7/Sched.7.html -
https://man7.org/linux/man-pages/man2/Sched_yield.2.html - ]
https://rigtorp.se/low-lazency-guide/ -
https://eli.thegreenplace.net/2018/measuring-context-schitching-and-memory-overheads-for-linux-threads/ -
https://hadar.gr/2017/lightweight-goroutines -