Non-blocking I/O – Der Schlüssel zur Verbesserung der OpenResty-Leistung

API7.ai

December 2, 2022

OpenResty (NGINX + Lua)

Im Kapitel zur Leistungsoptimierung werde ich Sie durch alle Aspekte der Leistungsoptimierung in OpenResty führen und die in den vorherigen Kapiteln erwähnten Einzelheiten in einem umfassenden OpenResty-Codierungsleitfaden zusammenfassen, damit Sie qualitativ hochwertigeren OpenResty-Code schreiben können.

Die Verbesserung der Leistung ist nicht einfach. Wir müssen die Optimierung der Systemarchitektur, die Datenbankoptimierung, die Codeoptimierung, Leistungstests, Flammendiagrammanalyse und andere Schritte berücksichtigen. Aber es ist einfach, die Leistung zu reduzieren, und wie der Titel des heutigen Artikels andeutet, können Sie die Leistung um das Zehnfache oder mehr reduzieren, indem Sie nur ein paar Codezeilen hinzufügen. Wenn Sie OpenResty verwenden, um Ihren Code zu schreiben, aber die Leistung nicht verbessert wurde, liegt es wahrscheinlich an blockierenden I/O-Operationen.

Bevor wir uns also mit den Details der Leistungsoptimierung befassen, schauen wir uns ein wichtiges Prinzip in der OpenResty-Programmierung an: Non-blocking I/O first.

Seit unserer Kindheit wurden wir von unseren Eltern und Lehrern gelehrt, nicht mit Feuer zu spielen und den Stecker nicht zu berühren, da dies gefährliche Verhaltensweisen sind. Die gleiche Art von gefährlichem Verhalten gibt es auch in OpenResty. Wenn Sie in Ihrem Code blockierende I/O-Operationen durchführen müssen, führt dies zu einem dramatischen Leistungsabfall, und der ursprüngliche Zweck, OpenResty zur Erstellung eines Hochleistungsservers zu verwenden, wird vereitelt.

Warum können wir keine blockierenden I/O-Operationen verwenden?

Zu verstehen, welche Verhaltensweisen gefährlich sind und sie zu vermeiden, ist der erste Schritt zur Leistungsoptimierung. Lassen Sie uns zunächst überprüfen, warum blockierende I/O-Operationen die Leistung von OpenResty beeinträchtigen können.

OpenResty kann eine hohe Leistung aufrechterhalten, einfach weil es die Ereignisbehandlung von NGINX und die Coroutine von Lua nutzt, daher:

  • Wenn Sie auf eine Operation wie Netzwerk-I/O stoßen, die erfordert, dass Sie auf eine Rückkehr warten, bevor Sie fortfahren, rufen Sie die Lua-Coroutine yield auf, um sich selbst zu hängen, und registrieren dann einen Callback in NGINX.
  • Nachdem die I/O-Operation abgeschlossen ist (oder ein Timeout oder ein Fehler auftritt), ruft NGINX resume auf, um die Lua-Coroutine zu wecken.

Ein solcher Prozess stellt sicher, dass OpenResty immer CPU-Ressourcen effizient nutzen kann, um alle Anfragen zu verarbeiten.

In diesem Verarbeitungsfluss gibt LuaJIT die Kontrolle nicht an die Ereignisschleife von NGINX ab, wenn es keine nicht-blockierende I/O-Methode wie cosocket verwendet, sondern stattdessen eine blockierende I/O-Funktion zur Handhabung von I/O verwendet. Dies führt dazu, dass andere Anfragen in der Warteschlange stehen, bis die blockierende I/O-Operation abgeschlossen ist, bevor sie eine Antwort erhalten.

Zusammenfassend sollten wir in der OpenResty-Programmierung besonders vorsichtig mit Funktionsaufrufen sein, die I/O blockieren könnten; andernfalls kann eine einzige Zeile blockierenden I/O-Codes die Leistung des gesamten Dienstes beeinträchtigen.

Im Folgenden werde ich einige häufige Probleme vorstellen, einige oft falsch verwendete blockierende I/O-Funktionen; lassen Sie uns auch erleben, wie Sie auf die einfachste Weise "durcheinanderbringen" und schnell die Leistung Ihres Dienstes um das Zehnfache reduzieren können.

Externe Befehle ausführen

In vielen Szenarien verwenden Entwickler OpenResty nicht nur als Webserver, sondern statten es mit mehr Geschäftslogik aus. In diesem Fall kann es notwendig sein, externe Befehle und Tools aufzurufen, um einige Operationen abzuschließen.

Zum Beispiel, um einen Prozess zu beenden.

os.execute("kill -HUP " .. pid)

Oder für zeitaufwändigere Operationen wie das Kopieren von Dateien, die Verwendung von OpenSSL zur Generierung von Schlüsseln usw.

os.execute(" cp test.exe /tmp ")

os.execute(" openssl genrsa -des3 -out private.pem 2048 ")

Oberflächlich betrachtet ist os.execute eine eingebaute Funktion in Lua, und in der Lua-Welt ist es tatsächlich die Methode, externe Befehle aufzurufen. Es ist jedoch wichtig, sich daran zu erinnern, dass Lua eine eingebettete Programmiersprache ist und in anderen Kontexten unterschiedliche empfohlene Verwendungsweisen hat.

In der Umgebung von OpenResty blockiert os.execute die aktuelle Anfrage. Wenn die Ausführungszeit dieses Befehls also besonders kurz ist, ist die Auswirkung nicht sehr groß. Aber wenn der Befehl Hunderte von Millisekunden oder sogar Sekunden zur Ausführung benötigt, wird die Leistung drastisch sinken.

Wir verstehen das Problem, also wie sollten wir es lösen? Im Allgemeinen gibt es zwei Lösungen.

1. Wenn eine FFI-Bibliothek verfügbar ist, bevorzugen wir die FFI-Methode

Zum Beispiel, wenn wir oben den OpenSSL-Befehl zur Generierung des Schlüssels verwendet haben, können wir dies ändern, um FFI zu verwenden, um die OpenSSL-C-Funktion aufzurufen und so das Problem zu umgehen.

Um einen Prozess zu beenden, können Sie lua-resty-signal, eine mit OpenResty gelieferte Bibliothek, verwenden, um dies nicht-blockierend zu lösen. Die Code-Implementierung ist wie folgt. Natürlich wird hier lua-resty-signal ebenfalls durch FFI gelöst, um Systemfunktionen aufzurufen.

local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")

Darüber hinaus hat die offizielle Website von LuaJIT eine besondere Seite, die verschiedene FFI-Bindungsbibliotheken in verschiedenen Kategorien vorstellt. Zum Beispiel, wenn Sie sich mit Bildern, Verschlüsselung und Entschlüsselung von CPU-intensiven Operationen befassen, können Sie dort zunächst nachsehen, ob es Bibliotheken gibt, die bereits gekapselt sind und direkt verwendet werden können.

2. Verwenden Sie die lua-resty-shell-Bibliothek basierend auf ngx.pipe

Wie zuvor beschrieben, können Sie Ihre Befehle in shell.run ausführen, eine nicht-blockierende I/O-Operation.

$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
    shell.run([[echo "hello, world"]])
    ngx.say(stdout) '

Disk-I/O

Schauen wir uns das Szenario der Handhabung von Disk-I/O an. In einer Server-Anwendung ist es eine häufige Operation, eine lokale Konfigurationsdatei zu lesen, wie der folgende Code zeigt.

local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()

Dieser Code verwendet io.open, um den Inhalt einer bestimmten Datei zu erhalten. Obwohl es sich um eine blockierende I/O-Operation handelt, vergessen Sie nicht, dass Dinge in einem realen Szenario betrachtet werden müssen. Wenn Sie es also init und init worker nennen, ist es eine einmalige Aktion, die keine Client-Anfragen beeinflusst und vollkommen akzeptabel ist.

Natürlich wird es inakzeptabel, wenn jede Benutzeranfrage ein Lesen oder Schreiben auf die Festplatte auslöst. Zu diesem Zeitpunkt müssen Sie ernsthaft über die Lösung nachdenken.

Zunächst können wir das lua-io-nginx-module, ein Drittanbieter-C-Modul, verwenden. Es bietet eine nicht-blockierende I/O-Lua-API für OpenResty, aber Sie können es nicht einfach wie cosocket verwenden. Denn der Verbrauch von Disk-I/O verschwindet nicht ohne Grund, es ist nur eine andere Art, Dinge zu tun.

Dieser Ansatz funktioniert, weil das lua-io-nginx-module den NGINX-Thread-Pool nutzt, um Disk-I/O-Operationen vom Hauptthread in einen anderen Thread zu verschieben, um sie zu verarbeiten, sodass der Hauptthread nicht durch Disk-I/O-Operationen blockiert wird.

Sie müssen NGINX neu kompilieren, wenn Sie diese Bibliothek verwenden, da es sich um ein C-Modul handelt. Es wird auf die gleiche Weise wie die Lua-I/O-Bibliothek verwendet.

local ngx_io = require "ngx.io"
local path = "/conf/apisix.conf"
local file, err = ngx_io.open(path, "rb")
local data, err = file: read("*a")
file:close()

Zweitens, versuchen Sie eine architektonische Anpassung. Können wir unsere Methode für diese Art von Disk-I/O ändern und aufhören, auf lokale Festplatten zu lesen und zu schreiben?

Lassen Sie mich Ihnen ein Beispiel geben, damit Sie durch Analogie lernen können. Vor Jahren arbeitete ich an einem Projekt, das das Protokollieren auf einer lokalen Festplatte für statistische und Fehlerbehebungszwecke erforderte.

Zu dieser Zeit verwendeten Entwickler ngx.log, um diese Protokolle zu schreiben, wie folgt.

ngx.log(ngx.WARN, "info")

Diese Codezeile ruft die von OpenResty bereitgestellte Lua-API auf, und es scheint keine Probleme zu geben. Der Nachteil ist jedoch, dass Sie es nicht sehr oft aufrufen können. Erstens ist ngx.log selbst ein kostspieliger Funktionsaufruf; zweitens können große und häufige Schreibvorgänge auf die Festplatte die Leistung ernsthaft beeinträchtigen, selbst mit einem Puffer.

Wie lösen wir das also? Gehen wir zurück zum ursprünglichen Bedarf - Statistiken, Fehlerbehebung und das Schreiben von Protokollen auf die lokale Festplatte wären nur eines der Mittel gewesen, um das Ziel zu erreichen.

Sie können also auch Protokolle an einen Remote-Protokollierungsserver senden, um cosocket für nicht-blockierende Netzwerkkommunikation zu verwenden; das heißt, die blockierende Disk-I/O an den Protokollierungsdienst zu delegieren, um die Blockierung des externen Dienstes zu vermeiden. Sie können lua-resty-logger-socket verwenden, um dies zu tun.

local logger = require "resty.logger.socket"
if not logger.initted() then
    local ok, err = logger.init{
        host = 'xxx',
        port = 1234,
        flush_limit = 1234,
        drop_limit = 5678,
    }
local msg = "foo"
local bytes, err = logger.log(msg)

Wie Sie bemerkt haben sollten, sind beide oben genannten Methoden gleich: Wenn blockierende I/O unvermeidbar ist, blockieren Sie nicht den Haupt-Worker-Thread; delegieren Sie es an andere Threads oder Dienste außerhalb.

luasocket

Schließlich sprechen wir über luasocket, eine in Lua eingebaute Bibliothek, die leicht von Entwicklern verwendet wird und oft zwischen luasocket und dem von OpenResty bereitgestellten cosocket verwechselt wird. luasocket kann auch Netzwerkkommunikationsfunktionen ausführen, hat aber nicht den Vorteil der Nicht-Blockierung. Wenn Sie also luasocket verwenden, sinkt die Leistung dramatisch.

Allerdings hat luasocket auch seine einzigartigen Verwendungsszenarien. Zum Beispiel, ich weiß nicht, ob Sie sich erinnern, dass cosocket in mehreren Phasen nicht verfügbar ist, und wir können es normalerweise durch die Verwendung von ngx.timer umgehen. Außerdem können Sie luasocket für cosocket-Funktionen in einmaligen Phasen wie init_by_lua* und init_worker_by_lua* verwenden. Je vertrauter Sie mit den Gemeinsamkeiten und Unterschieden zwischen OpenResty und Lua sind, desto mehr interessante Lösungen wie diese werden Sie finden.

Darüber hinaus ist lua-resty-socket eine sekundäre Wrapper-Bibliothek für eine Open-Source-Bibliothek, die luasocket und cosocket kompatibel macht. Dieser Inhalt ist ebenfalls einer weiteren Untersuchung wert. Wenn Sie noch interessiert sind, habe ich Materialien für Sie vorbereitet, um weiter zu lernen.

Zusammenfassung

Im Allgemeinen ist es in OpenResty die Grundlage einer guten Leistungsoptimierung, die Arten von blockierenden I/O-Operationen und ihre Lösungen zu erkennen. Sind Ihnen also in der tatsächlichen Entwicklung ähnliche blockierende I/O-Operationen begegnet? Wie finden und lösen Sie sie? Teilen Sie mir Ihre Erfahrungen gerne in den Kommentaren mit, und teilen Sie diesen Artikel gerne weiter.