[PYTHON] Schnellste und stärkste Webserver-Architektur

PyFes 2012.11 Präsentationsmaterial.

Einführung in die Meinheld-Architektur mit Schwerpunkt auf Systemaufrufen.

Ich schreibe eine Beispielimplementierung in Pure Python, um die Architektur zu erklären, aber sie funktioniert einwandfrei und ich unterbreche das Parsen von HTTP-Anforderungen, sodass ich über 10000 req / s erhalte. Wenn der Ablauf mit ereignisgesteuertem Code schwer zu verstehen ist, empfiehlt es sich, ihn während der Ablaufverfolgung auszuführen, z. B. "python -mtrace -t --ignore-module socket webserver1.py".

Annahme

Heute werde ich über das Verfolgen von Anforderungen / Sek. Unter der Bedingung sprechen, dass nur eine einfache Antwort zurückgegeben wird. Zum Beispiel gibt das Nginx-Lua-Modul nur "Hallo" zurück. Wir müssen über etwas anderes nachdenken, beispielsweise über einen Server, der statische Dateien liefert.

HTTP-Überprüfung

Siehe rfc2616 für Details

Das HTTP-Parsen mit Python ist kein Systemaufruf, sondern wird zu einem Engpass. In diesem Artikel wird das HTTP-Parsen daher nicht ordnungsgemäß durchgeführt.

HTTP-Anfrage

Die HTTP-Anfrage sieht folgendermaßen aus.

GET / HTTP/1.1
Host: localhost

POST /post HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded
Content-Length: 7

foo=bar

Die erste Zeile ist die Anforderungszeile in Form der Methode URI HTTP-Version. Der URI kann ein absoluter URI sein, der den Host enthält, oder ein absoluter Pfad, der den Host nicht enthält, aber der absolute Pfad ist häufiger.

Der Anforderungsheader ist von der zweiten Zeile bis zur leeren Zeile. Jede Zeile hat die Form Feldname: Feldwert. Bei dem Feldnamen wird die Groß- und Kleinschreibung nicht berücksichtigt.

Von der Anforderungszeile zum Anforderungsheader, gefolgt von einer Leerzeile, sind die Zeilenumbrüche CR LF. Es ist ein Zeilenvorschubcode, den Sie häufig unter Windows sehen.

Wenn die Methode POST usw. ist, wird der Nachrichtentext nach der Leerzeile hinzugefügt. Der Datentyp, für den der Nachrichtenkörper angegeben ist, wird im Header "Content-Type" angegeben, und seine Größe wird im Header "Content-Length" angegeben. Sie können "Content-Length" weglassen, aber darüber werden wir später sprechen.

Der Server verwendet möglicherweise VirtualHost. Wenn die Anforderungszeile kein absoluter URI ist, fügen Sie einen "Host" -Header hinzu, um den Hostnamen anzugeben.

HTTP-Antwort

Die HTTP-Antwort sieht folgendermaßen aus.

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 5

Hello

Es ist fast dasselbe wie eine HTTP-Anfrage, außer dass die erste Zeile die Statuszeile ist. Die Statuszeile hat die Form "http-Version Statuscode Grundsatz". Es ist wie "200 OK" oder "404 nicht gefunden".

Grundlagen des Webservers

Ein Webserver ist ein TCP-Server, der HTTP-Anforderungen empfängt und HTTP-Antworten zurückgibt.

  1. Binden Sie den TCP-Port und lauschen Sie.
  2. Akzeptieren Sie die Annahme neuer Verbindungen von Clients
  3. Recv, um die HTTP-Anfrage zu erhalten.
  4. Verarbeiten Sie die Anfrage
  5. Senden und senden Sie eine HTTP-Antwort.
  6. Schließen Sie, um die TCP-Verbindung zu trennen.

Sie können read, readv, recvmsg anstelle von recv in 3 verwenden. Sie können write, writev, writemsg verwenden, anstatt 5 einzusenden.

Dieses Mal werden wir uns mit req / sec befassen, also ignoriere 4. Eigentlich muss ich Keep-Alive unterstützen, aber diesmal werde ich das auch ignorieren.

Das Schreiben bis zu diesem Punkt in Python sieht so aus. (Anfrage wird nicht analysiert)

webserver1.py


import socket


def server():
    # 1: bind & listen
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(('', 8000))
    server.listen(100)
    while 1:
        # 2: accept new connection
        con, _ = server.accept()
        # 3: read request
        con.recv(32*1024)
        # 4: process request and make response
        # 5: send response
        con.sendall(b"""HTTP/1.1 200 OK\r
Content-Type: text/plain\r
Content-Length: 5\r
\r
hello""")
        # 6: close connection
        con.close()

if __name__ == '__main__':
    server()

Parallelverarbeitung

Der obige Beispielcode kann jeweils nur mit einem Client kommunizieren. Es gibt verschiedene grundlegende Möglichkeiten, mehrere Verbindungen parallel zu verarbeiten, und unzählige Möglichkeiten, sie zu kombinieren. Die grundlegende Methode ist wie folgt.

Führen Sie die Verarbeitung nach accept () in einem anderen Thread oder Prozess durch

Es wird ein Arbeitermodell genannt. Der Vorteil besteht darin, dass die Anzahl der Arbeitsthreads / -prozesse einfach dynamisch angepasst werden kann, jedoch die Verbindung vom akzeptierten Thread / Prozess zum Arbeitsthread / -prozess übergeben werden muss. Es belastet auch den Kontextwechsel.

Führen Sie von accept () bis close () im Thread oder Prozess aus

Es wird ein Prefork-Modell genannt. Der Prozess von accept () bis close () bleibt einfach, sodass Sie hoffentlich maximale Leistung erzielen. Wenn die Anzahl der Threads / Prozesse jedoch gering ist, können keine paralleleren Zahlen verarbeitet werden. Wenn sie groß ist, wird die Belastung des Kontextschalters erhöht.

Multiplex mit Epoll, Select, Kqueue usw.

Es wird als ereignisgesteuertes Modell bezeichnet. Akzeptieren Sie, wann Sie akzeptieren können, recv, wenn Sie recv können, und senden Sie, wenn Sie senden können. Es ist kein Kontextwechsel erforderlich, aber jeder erfordert einen eigenen Systemaufruf, der diesen Overhead verursacht.

Schnellste Architektur (ich gebe Uneinigkeit zu)

Für einen Server, der wirklich nur Hallo zurückgibt, ist es am besten, so viele Prozesse zu erstellen, wie Kerne mit einem einfachen Prefork-Modell vorhanden sind.

webserver2.py


import multiprocessing
import socket
import time


def worker(sock):
    while 1:
        con, _ = sock.accept()
        con.recv(32 * 1024)
        con.sendall(b"""HTTP/1.1 200 OK\r
Content-Type: text/plain\r
Content-Length: 5\r
\r
hello""")
        con.close()


def server():
    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(('', 8000))
    sock.listen(100)

    ncpu = multiprocessing.cpu_count()
    procs = []
    for i in range(ncpu):
        proc = multiprocessing.Process(target=worker, args=(sock,))
        proc.start()
        procs.append(proc)

    while 1:
        time.sleep(0.5)

    for proc in procs:
        proc.terminate()
        proc.join(1)


if __name__ == '__main__':
    server()

Der Gunicorn-Synchronisationsarbeiter sollte diese Architektur haben.

Stärkste Architektur (Argument ist ry)

Der zuvor erwähnte Prefork-Server ist der schnellste, wenn es nur Hallo ist. In der Praxis besteht das Problem jedoch darin, dass der Prozess die nächste Anfrage nicht verarbeiten kann und die CPU abgespielt wird, wenn das Empfangen einer Anfrage oder das Senden einer Antwort lange dauert. Es soll enden.

Wenn Sie den Sync-Worker von gunicorn verwenden, wird daher empfohlen, nginx in den Vordergrund zu stellen und dort statische Dateien zu liefern oder Anforderungen und Antworten zu puffern.

Wenn Sie jedoch eine zweistufige Konfiguration verwenden, wird die Geschwindigkeit halbiert. Daher verwendet jeder Prozess ein ereignisgesteuertes Modell wie Epoll, um eine zeitaufwändige Sende- / Empfangsverarbeitung zu ermöglichen.

Schnelles ereignisgesteuertes Programm

Wenn Sie es wie im folgenden Code ereignisgesteuert machen, wird der Systemaufruf für ereignisgesteuert an alle Akzeptieren, Lesen und Schreiben angehängt, was den Overhead erhöht und das Hallo verlangsamt.

webserver4.py


import socket
import select

read_waits = {}
write_waits = {}

def wait_read(con, callback):
    read_waits[con.fileno()] = callback

def wait_write(con, callback):
    write_waits[con.fileno()] = callback

def evloop():
    while 1:
        rs, ws, xs = select.select(read_waits.keys(), write_waits.keys(), [])
        for rfd in rs:
            read_waits.pop(rfd)()
        for wfd in ws:
            write_waits.pop(wfd)()

class Server(object):
    def __init__(self, con):
        self.con = con

    def start(self):
        wait_read(self.con, self.on_acceptable)

    def on_acceptable(self):
        con, _ = self.con.accept()
        con.setblocking(0)
        Client(con)
        wait_read(self.con, self.on_acceptable)


class Client(object):
    def __init__(self, con):
        self.con = con
        wait_read(con, self.on_readable)

    def on_readable(self):
        data = self.con.recv(32 * 1024)
        self.buf = b"""HTTP/1.1 200 OK\r
Content-Type: text/plain\r
Content-Length: 6\r
\r
hello
"""
        wait_write(self.con, self.on_writable)

    def on_writable(self):
        wrote = self.con.send(self.buf)
        self.buf = self.buf[wrote:]
        if self.buf:
            wait_write(self.con, self.on_writable)
        else:
            self.con.close()


def serve():
    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(('', 8000))
    sock.listen(128)
    server = Server(sock)
    server.start()
    evloop()

if __name__ == '__main__':
    serve()

Um den Overhead zu reduzieren, werden wir nach Stellen suchen, an denen wait_read und wait_write reduziert werden können.

Zunächst startet das Betriebssystem automatisch TCP-Verbindungen bis zum Backlog (der im Listener-Argument angegebenen Nummer), ohne accept () aufzurufen. (Gibt ACK / SYN für SYN zurück.) Wenn Sie also () von der App akzeptieren, wurde die TCP-Verbindung möglicherweise tatsächlich gestartet und die Anforderung vom Client wurde möglicherweise empfangen. Wenn Sie also () akzeptieren, führen Sie recv () sofort aus, ohne auf wait_read () zu warten.

Wenn read () beendet ist, wird die Antwort gesendet, dies kann jedoch auch sofort gesendet werden, da der Socket-Puffer zunächst leer sein sollte. Hören wir auf zu warten_write ().

webserver5.py


import socket
import select

read_waits = {}
write_waits = {}

def wait_read(con, callback):
    read_waits[con.fileno()] = callback

def wait_write(con, callback):
    write_waits[con.fileno()] = callback

def evloop():
    while 1:
        rs, ws, xs = select.select(read_waits.keys(), write_waits.keys(), [])
        for rfd in rs:
            read_waits.pop(rfd)()
        for wfd in ws:
            write_waits.pop(wfd)()

class Server(object):
    def __init__(self, con):
        self.con = con

    def start(self):
        wait_read(self.con, self.on_acceptable)

    def on_acceptable(self):
        try:
            while 1:
                con, _ = self.con.accept()
                con.setblocking(0)
                Client(con)
        except IOError:
            wait_read(self.con, self.on_acceptable)


class Client(object):
    def __init__(self, con):
        self.con = con
        self.on_readable()

    def on_readable(self):
        data = self.con.recv(32 * 1024)
        if not data:
            wait_read(self.con, self.on_readable)
            return
        self.buf = b"""HTTP/1.1 200 OK\r
Content-Type: text/plain\r
Content-Length: 6\r
\r
hello
"""
        self.on_writable()

    def on_writable(self):
        wrote = self.con.send(self.buf)
        self.buf = self.buf[wrote:]
        if self.buf:
            wait_write(self.con, self.on_writable)
        else:
            self.con.close()


def serve():
    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setblocking(0)
    sock.bind(('', 8000))
    sock.listen(128)
    server = Server(sock)
    server.start()
    evloop()

if __name__ == '__main__':
    serve()

Diese Methode funktioniert auch hervorragend mit Prefork.

Nach dem Akzeptieren akzeptiert es das nächste nicht, bis es alles tut, was es kann, sodass Sie das Akzeptieren an einen anderen Prozess übergeben können, der wirklich keinen Job hat.

Es kann auch donnernde Probleme lindern. Das donnernde Problem besteht darin, dass nur ein Client eine Verbindung herstellt und alle Prozesse aktiv sind und nur einer von ihnen erfolgreich ist, wenn mehrere Prozesse auf vorgefertigte Weise accept () aufrufen. Ein Prozess, bei dem accept () fehlschlägt, geht vollständig verloren. Es ist ein störender Schlaf. Wenn Sie dies mit einem Server mit 100 Prozessen auf einem 1-Core-Computer tun könnten, hätte sich dies nicht angesammelt.

Was accept () betrifft, wurde das donnernde Problem vollständig gelöst, da modernes Linux jetzt nur noch für einen Prozess accept zurückgibt, wenn eine Verbindung eingeht. Wenn Sie jedoch () auswählen und dann () akzeptieren, tritt dieses Problem erneut auf.

Wenn Sie die Anzahl der Prozesse auf die Anzahl der CPU-Kerne beschränken und die Auswahl vor dem Akzeptieren nur dann durchführen, wenn Sie wirklich frei sind, tritt das Phänomen "ausgewählt, aber nicht akzeptieren" nur dann auf, wenn die CPU wirklich frei ist. Ich kann es schaffen

Dies erfordert nur mehr Systemaufrufe für accept () zum Schließen () als Prefork mit setblocking (0). Übrigens gibt es in neuerem Linux einen Systemaufruf namens accept4, und Sie können gleichzeitig mit accept auch setblocking (0) ausführen.

Ich verlasse den User Space! Jojo!

Die bisherige Geschichte ist die schnellste und stärkste im Benutzerbereich. Sobald der Webserver im Kernelraum implementiert ist, müssen keine Systemaufrufe mehr ausgegeben werden.

https://github.com/KLab/recaro

Recommended Posts

Schnellste und stärkste Webserver-Architektur
Effektive und einfache Webserver-Sicherheitsmaßnahmen "Linux"
Starten Sie einen Webserver mit Python und Flask
Kommentar zum Aufbau des Webservers
One Liner Webserver
Ubuntu (18.04.3) Webserverkonstruktion
Führen Sie einen Befehl auf dem Webserver aus und zeigen Sie das Ergebnis an
Aufbau eines Websystems (super einfach) ③: Aufbau eines DB-Servers und grundlegende Bedienung
HTTP-Server und HTTP-Client mit Socket (+ Webbrowser) - Python3
Aufbau eines Websystems (super einfach) ②: Aufbau eines AP-Servers und grundlegende Bedienung
WEB-Scraping-Technologie und Bedenken