[Перевод] Envoy модeль потоков (Envoy threading model)

Übersetzung des Artikels: Envoy-Threading-Modell — https://blog.envoyproxy.io/envoy-threading-model-a8d44b922310

Dieser Artikel scheint mir recht interessant zu sein, und da Envoy meistens als Teil von „istio“ oder einfach als „Ingress-Controller“ in Kubernetes verwendet wird, haben die meisten Menschen nicht die gleiche direkte Interaktion wie beispielsweise mit typischen Nginx- oder Haproxy-Installationen. Wenn jedoch etwas kaputt geht, wäre es gut, zu verstehen, wie es intern funktioniert. Ich habe versucht, so viel Text wie möglich ins Russische zu übersetzen, inklusive spezieller Begriffe; für diejenigen, die es als schmerzhaft empfinden, diese zu sehen, habe ich die Originale in Klammern belassen. Willkommen unter dem Cut.

Die technische Dokumentation auf niedriger Ebene zur Codebasis von Envoy ist derzeit recht begrenzt. Um dies zu verbessern, plane ich eine Serie von Blogartikeln über verschiedene Subsysteme von Envoy. Da dies der erste Artikel ist, lassen Sie mich bitte wissen, was Sie denken und was Sie in den nächsten Artikeln interessieren könnte.

Eine der häufigsten technischen Fragen, die ich zu Envoy erhalte, ist die Anfrage nach einer niedrigen Beschreibung des verwendeten Thread-Modells. In diesem Beitrag werde ich beschreiben, wie Envoy Verbindungen mit Threads abgleicht und das System für den Thread-lokalen Speicher (Thread Local Storage), das intern verwendet wird, um den Code paralleler und leistungsfähiger zu gestalten.

Übersicht über Threads (Threading overview)

[Перевод] Envoy модeль потоков (Envoy threading model)

Envoy verwendet drei verschiedene Arten von Threads:

  • Haupt-Thread (Main): Dieser Thread verwaltet den Start und das Beenden des Prozesses, die gesamte Verarbeitung der XDS (xDiscovery Service) API, einschließlich DNS, Gesundheitsprüfung (health checking), allgemeines Cluster-Management und die Laufzeit des Dienstes, das Zurücksetzen von Statistiken, die Verwaltung und das allgemeine Management der Prozesse – Linux-Signale, Hot-Restart, usw. Alles, was in diesem Thread passiert, ist asynchron und „nicht blockierend“. Insgesamt koordiniert der Haupt-Thread alle kritischen Funktionalitätsprozesse, für deren Ausführung kein hoher CPU-Aufwand erforderlich ist. Dies ermöglicht es, den Großteil des Verwaltungs-Codes so zu schreiben, als ob er einthreadig wäre.
  • Arbeits-Thread (Worker): Standardmäßig erstellt Envoy einen Arbeits-Thread für jeden Hardware-Thread im System, dies kann über die Option --concurrency. Jeder Arbeits-Thread startet eine "nicht blockierende" Ereignisschleife (event loop), die dafür verantwortlich ist, jeden Listener (listener) zu überwachen. Zum Zeitpunkt des Schreibens des Artikels (29. Juli 2017) gibt es kein Sharding (segmentierung) des Listeners, das Annehmen neuer Verbindungen, das Erstellen einer Instanz des Filterstacks für die Verbindung und die Verarbeitung aller Ein-/Ausgaben (IO) während der Lebensdauer der Verbindung. Dies ermöglicht es, den Großteil des Verbindungsbehandlungs-Codes so zu schreiben, als wäre er ein Software-Thread.
  • Datei-Flush (File flusher): Jede Datei, die Envoy schreibt, hauptsächlich Zugriffsprotokolle (access logs), hat derzeit einen unabhängigen blockierenden Thread. Dies liegt daran, dass das Schreiben in Dateien, die vom Dateisystem zwischengespeichert werden, selbst mit O_NONBLOCK manchmal blockiert werden kann (seufz). Wenn Arbeits-Threads in eine Datei schreiben müssen, werden die Daten tatsächlich in einen Speicherpuffer verschoben, wo sie schließlich über den Thread file flushausgegeben werden. Dies ist einer der Bereiche des Codes, in dem technisch gesehen alle Arbeits-Threads (worker threads) das gleiche Lock (lock) blockieren können, während sie versuchen, den Speicherpuffer zu füllen.

Verbindungsbehandlung (Connection handling)

Wie oben kurz besprochen, hören alle Arbeits-Threads alle Listener (listeners) ohne jegliches Sharding. In diesem Sinne wird der Kernel verwendet, um die übernommenen Sockets effizient an die Arbeits-Threads zu senden. Moderne Kerne sind dabei im Allgemeinen sehr gut; sie nutzen Funktionen wie die Priorisierung von Ein-/Ausgaben (IO), um zu versuchen, einen Thread mit Arbeit zu füllen, bevor sie andere Threads verwenden, die ebenfalls denselben Socket abhören, und bringen es nicht dazu, zyklische Blockierungen (Spinlock) zur Verarbeitung jeder Anfrage zu verwenden.
Sobald eine Verbindung in einem Arbeits-Thread (worker thread) akzeptiert wird, verlässt sie diesen Thread niemals. Alle weiteren Verarbeitungen der Verbindung werden vollständig im Arbeits-Thread (worker thread) durchgeführt, einschließlich des Weiterleitungsverhaltens (forwarding behavior).

Das hat einige wichtige Konsequenzen:

  • Alle Verbindungs-Pools in Envoy gehören zu einem Arbeits-Thread. Das bedeutet, dass obwohl HTTP/2-Verbindungs-Pools nur eine Verbindung zu jedem übergeordneten Host gleichzeitig herstellen, es bei vier Arbeits-Threads vier HTTP/2-Verbindungen zu dem übergeordneten Host im stabilen Zustand gibt.
  • Der Grund, warum Envoy auf diese Weise arbeitet, liegt darin, dass durch die Konzentration auf einen einzigen Arbeits-Thread fast der gesamte Code ohne Sperrungen und so geschrieben werden kann, als ob er ein-Threadig ist. Dieses Design vereinfacht das Schreiben einer großen Menge an Code und skaliert unglaublich gut für nahezu unbegrenzte Arbeits-Threads.
  • Eine der wichtigsten Erkenntnisse ist jedoch, dass es in Bezug auf die Effizienz des Speicher- und Verbindungs-Pools tatsächlich entscheidend ist, die Einstellung --concurrencyrechtzeitig zu konfigurieren. Eine zu hohe Anzahl an Arbeits-Threads führt zu Speicherverschwendung, erhöhten inaktiven Verbindungen und verringert die Geschwindigkeit, mit der man in den Verbindungspool gelangt. Bei Lyft arbeiten unsere Envoy Sidecar-Container mit sehr niedrigem Parallelismus, sodass die Leistung ungefähr mit den Diensten vergleichbar ist, neben denen sie sitzen. Wir betreiben Envoy nur bei maximalem Parallelismus als Edge-Proxy-Server.

Was nicht-blockierend bedeutet

Der Begriff „nicht-blockierend“ wurde bereits mehrfach verwendet, um zu erläutern, wie Haupt- und Arbeits-Threads funktionieren. Der gesamte Code wurde unter der Annahme geschrieben, dass nichts jemals blockiert. Allerdings ist das nicht ganz korrekt.

Envoy verwendet mehrere langfristige Prozesssperren:

  • Wie bereits erwähnt, erhalten bei der Protokollierung aller Arbeits-Threads dieselbe Sperre, bevor der Log-Puffer im Speicher gefüllt wird. Die Haltezeit der Sperre sollte sehr niedrig sein, allerdings könnte diese Sperre bei hohem Parallelismus und hoher Bandbreite umstritten werden.
  • Envoy verwendet ein sehr komplexes System zur Verarbeitung von Statistiken, das lokal für den Stream ist. Dies wird Thema eines eigenen Beitrags sein. Dennoch möchte ich kurz erwähnen, dass im Rahmen der lokalen Verarbeitung der Statistik eines Streams manchmal eine Sperre für den zentralen ‚Statistikspeicher‘ erforderlich ist. Diese Sperre sollte niemals notwendig sein.
  • Der Haupt-Thread benötigt regelmäßig Koordination mit allen Arbeiter-Threads. Dies geschieht durch ‚Veröffentlichungen‘ aus dem Haupt-Thread in die Arbeiter-Threads und manchmal auch von den Arbeiter-Threads zurück in den Haupt-Thread. Für das Senden ist eine Sperre erforderlich, damit die veröffentlichte Nachricht in eine Warteschlange für die spätere Zustellung eingeordnet werden kann. Diese Sperren sollten niemals ernsthaft umstritten sein, können jedoch technisch gesperrt werden.
  • Wenn Envoy in den System-Fehler-Stream (standard error) protokolliert, erhält er eine Sperre für den gesamten Prozess. Im Allgemeinen wird das lokale Logging von Envoy hinsichtlich der Leistung als schrecklich angesehen, weshalb ihm nicht viel Aufmerksamkeit geschenkt wird.
  • Es gibt einige andere zufällige Sperren, aber keine von ihnen ist kritisch für die Leistung und sollte niemals angefochten werden.

Thread-Lokales Speichern

Aufgrund der Art und Weise, wie Envoy die Aufgaben des Haupt-Threads von den Aufgaben der Arbeiter-Threads trennt, besteht die Anforderung, dass komplexe Verarbeitungen im Haupt-Thread durchgeführt werden können und dann jedem Arbeiter-Thread mit hoher Parallelität bereitgestellt werden. In diesem Abschnitt wird das Envoy Thread Local Storage (TLS) System auf hoher Ebene beschrieben. Im nächsten Abschnitt werde ich erläutern, wie es zur Clusterverwaltung eingesetzt wird.
[Перевод] Envoy модeль потоков (Envoy threading model)

Wie bereits erwähnt, verarbeitet der Haupt-Thread praktisch alle Management-Funktionen und die Funktionalität der Steuerungsebene im Envoy-Prozess. Die Steuerungsebene ist hier ein wenig überladen, aber wenn man sie im Rahmen des Envoy-Prozesses betrachtet und mit der Weiterleitung vergleicht, die die Arbeiter-Threads übernehmen, erscheint dies sinnvoll. Im Allgemeinen führt der Haupt-Thread einige Arbeiten aus und muss dann jeden Arbeiter-Thread gemäß den Ergebnissen dieser Arbeit aktualisieren. In diesem Fall muss der Arbeitsthread keine Sperrung bei jedem Zugriff einrichten..

Das TLS-System (Thread Local Storage) von Envoy funktioniert wie folgt:

  • Der Code, der im Hauptthread ausgeführt wird, kann einen TLS-Slot für den gesamten Prozess reservieren. Obwohl dies abstrahiert ist, handelt es sich in der Praxis um einen Index in einem Vektor, der den Zugriff O(1) ermöglicht.
  • Der Hauptthread kann beliebige Daten in seinen Slot setzen. Wenn dies geschehen ist, werden die Daten in jedem Arbeitsthread als gewöhnliches Ereignis im Ereignisschleifen veröffentlicht.
  • Arbeitsthreads können aus ihrem TLS-Slot lesen und beliebige dort verfügbare lokale Thread-Daten abrufen.

Obwohl dies ein sehr einfaches und unglaublich mächtiges Paradigma ist, das sehr ähnlich der RCU (Read-Copy-Update)-Sperrkonzeption ist. Im Wesentlichen sehen Arbeitsthreads während der Ausführung ihrer Arbeiten nie irgendwelche Datenänderungen in den TLS-Slots. Änderungen erfolgen nur in der Ruhezeit zwischen den Arbeitsereignissen.

Envoy nutzt dies auf zwei verschiedene Arten:

  • Indem verschiedene Daten in jedem Arbeitsthread gespeichert werden, erfolgt der Zugriff auf diese Daten ohne jegliche Sperrung.
  • Indem ein gemeinsamer Zeiger auf globale Daten im „Nur-Lesen“-Modus in jedem Arbeitsthread gespeichert wird. So hat jeder Arbeitsthread einen Verweiszähler auf die Daten, der während der Ausführung der Arbeit nicht verringert werden kann. Erst wenn sich alle Arbeiter beruhigt haben und neue gemeinsame Daten laden, werden die alten Daten zerstört. Dies ist identisch mit RCU.

Thread zur Aktualisierung des Clusters (Cluster update threading)

In diesem Abschnitt beschreibe ich, wie TLS (Thread Local Storage) zur Clusterverwaltung verwendet wird. Die Clusterverwaltung umfasst die Bearbeitung der API xDS und/oder DNS sowie die Gesundheitsüberprüfung (health checking).
[Перевод] Envoy модeль потоков (Envoy threading model)

Die Verwaltung der Cluster-Threads umfasst die folgenden Komponenten und Phasen:

  1. Der Cluster-Manager ist eine Komponente innerhalb von Envoy, die alle bekannten Upstreams des Clusters, die APIs CDS (Cluster Discovery Service), SDS (Secret Discovery Service) und EDS (Endpoint Discovery Service), DNS und aktive externe Gesundheitsprüfungen (health checking) verwaltet. Er ist verantwortlich für die Erstellung einer „letztendlich konsistenten“ (eventually consistent) Sicht auf jeden Upstream des Clusters, die die entdeckten Hosts sowie den Gesundheitszustand (health status) umfasst.
  2. Das Überprüfungswerkzeug (Health Checker) führt eine aktive Überprüfung der Betriebsfähigkeit durch und informiert den Cluster-Manager über Änderungen des Betriebsstatus.
  3. CDS (Cluster Discovery Service) / SDS (Secret Discovery Service) / EDS (Endpoint Discovery Service) / DNS werden verwendet, um die Clusterzugehörigkeit zu bestimmen. Der Statuswechsel wird an den Cluster-Manager zurückgemeldet.
  4. Jeder Arbeits-Thread führt ständig eine Ereignisverarbeitungsschleife aus.
  5. Wenn der Cluster-Manager feststellt, dass sich der Status des Clusters geändert hat, erstellt er einen neuen, schreibgeschützten Statussnapshot des Clusters und sendet diesen an jeden Arbeits-Thread.
  6. Während des nächsten Ruhezeitraums aktualisiert der Arbeits-Thread den Snapshot im zugewiesenen TLS-Slot.
  7. Während eines E/A-Ereignisses, das den Host für die Lastverteilung bestimmen soll, fragt der Lastverteiler den TLS-Slot (Thread Local Storage) ab, um Informationen über den Host zu erhalten. Dafür sind keine Sperren erforderlich. Zudem kann TLS auch Ereignisse beim Update auslösen, sodass die Lastverteilungssysteme und andere Komponenten ihre Caches, Datenstrukturen usw. neu berechnen können. Dies geht über den Rahmen dieses Beitrags hinaus, wird jedoch an verschiedenen Stellen im Code verwendet.

Mit dem oben beschriebenen Verfahren kann Envoy jede Anfrage ohne Sperren (außer den zuvor beschriebenen) verarbeiten. Abgesehen von der Komplexität des eigentlichen TLS-Codes muss der Großteil des Codes nicht verstehen, wie Multithreading funktioniert, und kann im Einzelthread-Modus geschrieben werden. Dies erleichtert das Schreiben des Großteils des Codes und sorgt für hervorragende Leistung.

Andere Subsysteme, die TLS nutzen

TLS (Thread Local Storage) und RCU (Read Copy Update) werden in Envoy vielfach eingesetzt.

Anwendungsbeispiele:

  • Mechanismus zur Änderung der Funktionalität zur Laufzeit: Die aktuelle Liste der aktivierten Funktionen wird im Haupt-Thread berechnet. Dann erhält jeder Arbeits-Thread einen schreibgeschützten Snapshot unter Verwendung der RCU-Semantik.
  • Austausch von Routentabellen: Für die von RDS (Route Discovery Service) bereitgestellten Routentabellen werden Routentabellen im Hauptstream erstellt. Ein nur lesbarer Snapshot wird später jedem Arbeits-Thread mit der RCU (Read Copy Update)-Semantik zur Verfügung gestellt. Dies macht die Änderung von Routentabellen atomar effizient.
  • Caching von HTTP-Headern: Wie sich herausstellt, ist die Berechnung des HTTP-Headers für jede Anforderung (bei etwa 25K+ RPS pro Kern) ziemlich kostspielig. Envoy berechnet den Header zentral etwa alle halbe Sekunde und stellt ihn jedem Worker über TLS und RCU zur Verfügung.

Es gibt noch weitere Fälle, aber die vorherigen Beispiele sollten helfen, ein gutes Verständnis dafür zu vermitteln, wofür TLS verwendet wird.

Bekannte Leistungsprobleme

Obwohl Envoy insgesamt recht gut funktioniert, gibt es einige bekannte Bereiche, die Aufmerksamkeit erfordern, wenn er mit sehr hohem Parallelismus und Durchsatz verwendet wird:

  • Wie bereits in diesem Artikel beschrieben, erhalten derzeit alle Arbeits-Threads eine Sperre, wenn sie in den Speicherpuffer des Zugriffprotokolls schreiben. Bei hohem Parallelismus und hohem Durchsatz müssen Zugriffsprotokolle für jeden Arbeits-Thread gepuffert werden, was zu ungeordneter Zustellung beim Schreiben in die endgültige Datei führt. Als Alternative können separate Zugriffprotokolle für jeden Arbeits-Thread erstellt werden.
  • Obwohl die Statistiken stark optimiert sind, wird es bei sehr hohem Parallelismus und Durchsatz wahrscheinlich zu atomaren Wettbewerben auf einzelnen Statistiken kommen. Die Lösung dieses Problems besteht darin, Zähler pro Arbeits-Thread mit periodischem Zurücksetzen zentraler Zähler zu verwenden. Dies wird in einem späteren Beitrag behandelt.
  • Die vorhandene Architektur wird nicht gut funktionieren, wenn Envoy in einem Szenario bereitgestellt wird, in dem sehr wenige Verbindungen vorhanden sind, die erhebliche Ressourcen zur Verarbeitung benötigen. Es gibt keine Garantie, dass die Verbindungen gleichmäßig auf die Arbeits-Threads verteilt sind. Dies könnte durch die Implementierung einer Arbeitsverbindungsbalance gelöst werden, bei der die Möglichkeit des Verbindungswechsels zwischen den Arbeits-Threads realisiert wird.

Fazit

Das Envoy-Threading-Modell wurde entwickelt, um die Programmierung zu vereinfachen und massiven Parallelismus zu ermöglichen, was zu potenziell verschwenderischem Speicher- und Verbindungsgebrauch führen kann, wenn es nicht richtig konfiguriert ist. Dieses Modell erlaubt es, sehr gut mit einer sehr hohen Anzahl von Threads und Bandbreite zu arbeiten.
Wie ich kurz auf Twitter erwähnt habe, kann das Design auch über einen voll funktionsfähigen Netzwerk-Stack im Benutzermodus, wie DPDK (Data Plane Development Kit), arbeiten, was dazu führen kann, dass normale Server Millionen von Anfragen pro Sekunde bei voller L7-Verarbeitung verarbeiten. Es wird sehr interessant sein zu sehen, was in den nächsten Jahren entwickelt wird.
Ein letzter schneller Kommentar: Mir wurde oft die Frage gestellt, warum wir uns für C++ für Envoy entschieden haben. Der Grund liegt darin, dass dies nach wie vor die einzige weit verbreitete Sprache auf Industrieebene ist, mit der die in diesem Beitrag beschriebene Architektur aufgebaut werden kann. C++ ist definitiv nicht für alle oder sogar viele Projekte geeignet, aber für bestimmte Anwendungsfälle ist es nach wie vor das einzige Werkzeug, um die Aufgabe zu erledigen.

Links zum Code

Links zu den in diesem Beitrag besprochenen Header-Schnittstellen und deren Implementierungen:

Quelle: habr.com

60GB SSD 8Gb DDR4