[PYTHON] Architecture de serveur Web la plus rapide et la plus solide

PyFes 2012.11 Matériel de présentation.

Présentation de l'architecture meinheld avec un accent sur les appels système.

J'écris un exemple d'implémentation en Pure Python pour expliquer l'architecture, mais cela fonctionne bien et je romps l'analyse des requêtes HTTP, donc j'obtiens plus de 10000req / sec. Si le flux est difficile à comprendre avec du code événementiel, c'est une bonne idée de l'exécuter pendant le traçage, comme python -mtrace -t --ignore-module socket webserver1.py.

supposition

Aujourd'hui, je vais parler de la poursuite de req / sec à la condition qu'elle ne renvoie qu'une simple réponse. Par exemple, le module nginx lua renvoie simplement "hello". Nous devons penser à autre chose, comme un serveur qui fournit des fichiers statiques.

Examen HTTP

Voir rfc2616 pour plus de détails

L'analyse HTTP avec Python n'est pas un appel système, cela devient un goulot d'étranglement, donc cet article ne fait pas correctement l'analyse HTTP.

Requête HTTP

La requête HTTP ressemble à ceci.

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

La première ligne est request-line, sous la forme de `` method URI HTTP-version ''. L'URI peut être un URI absolu qui inclut l'hôte ou un chemin absolu qui n'inclut pas l'hôte, mais le chemin absolu est plus courant.

L'en-tête de la demande va de la deuxième ligne à la ligne vide. Chaque ligne a la forme `` field-name: field-value ''. field-name est insensible à la casse.

De la ligne de demande à l'en-tête de demande suivi d'une ligne vide, les sauts de ligne sont CR LF. C'est un code de saut de ligne que vous voyez souvent sur Windows.

Lorsque la méthode est POST, etc., le corps du message est ajouté après la ligne vide. Le type de données qui correspond au corps du message est spécifié dans l'en-tête "Content-Type" et sa taille est spécifiée dans l'en-tête "Content-Length". Vous pouvez omettre Content-Length, mais nous en reparlerons plus tard.

Le serveur utilise peut-être VirtualHost, donc si la ligne de demande n'est pas un URI absolu, ajoutez un en-tête "Host" pour spécifier le nom d'hôte.

Réponse HTTP

La réponse HTTP ressemble à ceci.

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

Hello

C'est presque la même chose qu'une requête HTTP, sauf que la première ligne est status-line. La ligne d'état se présente sous la forme «phrase-raison du code d'état de la version http». C'est comme "200 OK" ou "404 Not Found".

Principes de base du serveur Web

Un serveur Web est un serveur TCP qui reçoit des requêtes HTTP et renvoie des réponses HTTP.

  1. Liez le port TCP et écoutez.
  2. accepter d'accepter de nouvelles connexions de clients
  3. Recv pour recevoir la requête HTTP.
  4. Traitez la demande
  5. envoyer et renvoyer une réponse HTTP.
  6. Fermez pour déconnecter la connexion TCP.

Vous pouvez utiliser read, readv, recvmsg au lieu de recv dans 3. Vous pouvez utiliser write, writev, writemsg au lieu de send in 5.

Cette fois, nous allons creuser dans req / sec, alors ignorez 4. En fait, je dois soutenir Keep-Alive, mais je l'ignorerai également cette fois.

L'écriture jusqu'à ce point en Python ressemble à ceci. (La demande n'est pas analysée)

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()

Traitement parallèle

L'exemple de code ci-dessus ne peut communiquer qu'avec un client à la fois. Il existe plusieurs méthodes de base pour gérer plusieurs connexions en parallèle et une myriade de façons de les combiner. La méthode de base est la suivante.

Effectuer le traitement après accept () dans un autre thread ou processus

C'est ce qu'on appelle un modèle de travailleur. L'avantage est qu'il est facile d'ajuster dynamiquement le nombre de threads / processus de travail, mais cela nécessite le processus de transmission de la connexion du thread / processus accepté au thread / processus de travail. Cela met également une charge sur le changement de contexte.

Faire d'accepter () pour fermer () dans le thread ou le processus

C'est ce qu'on appelle un modèle préfork. Le processus d'acceptation () à close () reste simple, donc j'espère que vous obtiendrez des performances maximales. Cependant, si le nombre de threads / processus est petit, il ne peut pas gérer plus de nombres parallèles, et s'il est grand, la charge sur le changement de contexte sera augmentée.

Multiplex avec epoll, select, kqueue, etc.

C'est ce qu'on appelle un modèle événementiel. Acceptez quand vous pouvez accepter, recevez quand vous pouvez recevoir et envoyez quand vous pouvez envoyer. Aucun changement de contexte n'est requis, mais chacun nécessite son propre appel d'appel système, ce qui ajoute à la surcharge.

Architecture la plus rapide (j'admets le désaccord)

Pour un serveur qui ne renvoie vraiment que bonjour, il est préférable de créer autant de processus qu'il y a de cœurs avec un simple modèle préfork.

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()

L'ouvrier de synchronisation gunicorn devrait avoir cette architecture.

Architecture la plus forte (l'argument est ry)

Le serveur prefork que j'ai mentionné plus tôt est le plus rapide s'il s'agit simplement de bonjour, mais le problème dans la pratique est que s'il faut beaucoup de temps pour recevoir une demande ou envoyer une réponse, le processus ne pourra pas traiter la demande suivante et le processeur jouera. C'est pour finir.

Par conséquent, lors de l'utilisation de l'outil de synchronisation de gunicorn, il est recommandé de placer nginx au premier plan et d'y livrer des fichiers statiques, ou de tamponner les demandes et les réponses.

Cependant, si vous utilisez une configuration en deux étapes, la vitesse sera réduite de moitié. Par conséquent, chaque processus utilise un modèle événementiel tel que epoll pour permettre un traitement de transmission / réception chronophage.

Programme événementiel rapide

Si vous le faites en fonction des événements comme le code suivant, l'appel système pour les événements sera attaché à tous les types d'acceptation, de lecture et d'écriture, ce qui augmentera la surcharge et ralentira bonjour.

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()

Afin de réduire la surcharge, nous chercherons des endroits où wait_read et wait_write peuvent être réduits.

Tout d'abord, le système d'exploitation démarre automatiquement les connexions TCP jusqu'au backlog (le nombre spécifié dans l'argument d'écoute) sans appeler accept (). (Renvoie ACK / SYN pour SYN) Ainsi, lorsque vous acceptez () de l'application, la connexion TCP peut avoir effectivement démarré et la demande du client peut avoir été reçue. Donc, si vous acceptez (), faites immédiatement recv () sans wait_read ().

Lorsque read () est terminé, la réponse est envoyée, mais cela peut également être envoyé immédiatement car le tampon de socket doit être vide au début. Arrêtons de wait_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()

Cette méthode fonctionne également très bien avec prefork.

Après avoir accepté, il n'accepte pas le suivant tant qu'il n'a pas fait tout ce qu'il peut, vous pouvez donc passer l'acceptation à un autre processus qui n'a pas vraiment de travail.

Cela peut également atténuer les problèmes difficiles et tonitruants. Le problème épineux est que lorsque plusieurs processus appellent accept () de manière préfork, un seul client se connecte et tous les processus sont actifs et un seul d'entre eux réussit. Un processus dans lequel accept () échoue est complètement perdu. C'est un sommeil inquiétant. Si vous pouviez faire cela avec un serveur de 100 processus sur une machine à 1 cœur, cela ne se serait pas accumulé.

En ce qui concerne accept (), le problème difficile et tonitruant a été complètement résolu, car Linux moderne ne retourne désormais accepter que pour un processus lorsqu'une connexion arrive. Toutefois, si vous sélectionnez () puis acceptez (), ce problème se reproduit.

En limitant le nombre de processus au nombre de cœurs de processeur et en effectuant la sélection avant d'accepter uniquement lorsque vous êtes vraiment libre, le phénomène de «sélectionné mais ne peut pas accepter» ne se produira que lorsque le processeur est réellement libre. Je peux le faire.

Cela ne nécessite que plus d'appels système pour accept () pour fermer () que pour prefork avec setblocking (0). À propos, dans Linux récent, il existe un appel système appelé accept4, et vous pouvez également faire setblocking (0) en même temps que accept.

Je quitte l'espace utilisateur! Jojo!

Jusqu'à présent, l'histoire est la plus rapide et la plus forte de l'espace utilisateur. Une fois que le serveur Web est implémenté dans l'espace noyau, il n'est pas nécessaire d'émettre des appels système.

https://github.com/KLab/recaro

Recommended Posts

Architecture de serveur Web la plus rapide et la plus solide
Mesures de sécurité du serveur Web efficaces et simples «Linux»
Lancer un serveur Web avec Python et Flask
Commentaire sur la construction du serveur Web
Serveur Web One Liner
Construction du serveur Web Ubuntu (18.04.3)
Exécuter une commande sur le serveur Web et afficher le résultat
Construction du système Web (super basique) ③: Construction du serveur DB et fonctionnement de base
Serveur HTTP et client HTTP utilisant Socket (+ navigateur Web) --Python3
Construction du système Web (super basique) ②: construction du serveur AP et fonctionnement de base
Technologie de grattage WEB et préoccupations