E/S non bloquante - La clé pour améliorer les performances d'OpenResty
API7.ai
December 2, 2022
Dans le chapitre sur l'optimisation des performances, je vais vous guider à travers tous les aspects de l'optimisation des performances dans OpenResty et résumer les éléments mentionnés dans les chapitres précédents pour créer un guide complet de codage OpenResty, afin que vous puissiez écrire du code OpenResty de meilleure qualité.
Améliorer les performances n'est pas facile. Nous devons prendre en compte l'optimisation de l'architecture du système, l'optimisation de la base de données, l'optimisation du code, les tests de performance, l'analyse des graphiques de flamme, et d'autres étapes. Mais il est facile de réduire les performances, et comme le titre de l'article d'aujourd'hui le suggère, vous pouvez réduire les performances par un facteur de 10 ou plus en ajoutant simplement quelques lignes de code. Si vous utilisez OpenResty pour écrire votre code, mais que les performances ne se sont pas améliorées, c'est probablement à cause des I/O bloquants.
Ainsi, avant d'entrer dans les détails de l'optimisation des performances, examinons un principe important dans la programmation OpenResty : Les I/O non bloquants en premier.
Depuis notre enfance, nos parents et enseignants nous ont appris à ne pas jouer avec le feu et à ne pas toucher les prises, car ce sont des comportements dangereux. Le même type de comportement dangereux existe dans OpenResty. Si vous devez effectuer des opérations d'I/O bloquantes dans votre code, cela entraînera une chute dramatique des performances, et l'objectif initial d'utiliser OpenResty pour construire un serveur haute performance sera compromis.
Pourquoi ne pouvons-nous pas utiliser des opérations d'I/O bloquantes ?
Comprendre quels comportements sont dangereux et les éviter est la première étape de l'optimisation des performances. Commençons par examiner pourquoi les opérations d'I/O bloquantes peuvent affecter les performances d'OpenResty.
OpenResty peut maintenir des performances élevées simplement parce qu'il emprunte la gestion des événements de NGINX et les coroutines de Lua, donc :
- Lorsque vous rencontrez une opération telle qu'une I/O réseau qui nécessite d'attendre un retour avant de continuer, vous appelez la coroutine Lua
yield
pour vous suspendre, puis vous enregistrez un rappel dans NGINX. - Après que l'opération d'I/O est terminée (ou qu'un délai d'attente ou une erreur se produit), NGINX appelle
resume
pour réveiller la coroutine Lua.
Un tel processus garantit qu'OpenResty peut toujours utiliser efficacement les ressources du CPU pour traiter toutes les requêtes.
Dans ce flux de traitement, LuaJIT ne donne pas le contrôle à la boucle d'événements de NGINX si elle n'utilise pas une méthode d'I/O non bloquante comme cosocket
, mais utilise plutôt une fonction d'I/O bloquante pour gérer les I/O. Cela entraîne que d'autres requêtes attendent en file d'attente que l'événement d'I/O bloquant soit terminé avant de recevoir une réponse.
En résumé, dans la programmation OpenResty, nous devons être particulièrement prudents avec les appels de fonctions qui peuvent bloquer les I/O ; sinon, une seule ligne de code d'I/O bloquant peut faire chuter les performances de l'ensemble du service.
Ci-dessous, je vais présenter quelques problèmes courants, certaines fonctions d'I/O bloquantes souvent mal utilisées ; voyons également comment utiliser la manière la plus simple de "gâcher" et de faire rapidement chuter les performances de votre service par un facteur de 10.
Exécuter des commandes externes
Dans de nombreux scénarios, les développeurs n'utilisent pas seulement OpenResty comme un serveur web, mais lui attribuent plus de logique métier. Dans ce cas, il peut être nécessaire d'appeler des commandes et des outils externes pour aider à accomplir certaines opérations.
Par exemple, pour tuer un processus.
os.execute("kill -HUP " .. pid)
Ou pour des opérations plus longues comme la copie de fichiers, l'utilisation d'OpenSSL pour générer des clés, etc.
os.execute(" cp test.exe /tmp ")
os.execute(" openssl genrsa -des3 -out private.pem 2048 ")
En apparence, os.execute
est une fonction intégrée en Lua, et dans le monde Lua, c'est effectivement la manière d'appeler des commandes externes. Cependant, il est important de se rappeler que Lua est un langage de programmation embarqué et aura des utilisations recommandées différentes dans d'autres contextes.
Dans l'environnement d'OpenResty, os.execute
bloque la requête actuelle. Donc, si le temps d'exécution de cette commande est particulièrement court, alors l'impact n'est pas très important. Mais si la commande prend des centaines de millisecondes, voire des secondes à s'exécuter, alors il y aura une chute brutale des performances.
Nous comprenons le problème, alors comment devrions-nous le résoudre ? Généralement, il existe deux solutions.
1. S'il existe une bibliothèque FFI
disponible, alors nous privilégions la manière FFI pour l'appeler
Par exemple, si nous avons utilisé la ligne de commande OpenSSL pour générer la clé ci-dessus, nous pouvons la changer pour utiliser FFI
pour appeler la fonction C d'OpenSSL pour la contourner.
Pour tuer un processus, vous pouvez utiliser lua-resty-signal
, une bibliothèque fournie avec OpenResty, pour le résoudre de manière non bloquante. L'implémentation du code est la suivante. Bien sûr, ici, lua-resty-signal
est également résolu en utilisant FFI
pour appeler les fonctions système.
local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")
De plus, le site officiel de LuaJIT a une page particulière qui présente diverses bibliothèques de liaison FFI dans différentes catégories. Par exemple, lorsque vous traitez des images, le chiffrement et le déchiffrement des opérations intensives en CPU, vous pouvez y aller d'abord pour voir s'il existe des bibliothèques qui ont été encapsulées et peuvent être utilisées directement.
2. Utiliser la bibliothèque lua-resty-shell
basée sur ngx.pipe
Comme décrit précédemment, vous pouvez exécuter vos commandes dans shell.run
, une opération d'I/O non bloquante.
$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
shell.run([[echo "hello, world"]])
ngx.say(stdout) '
I/O disque
Examinons le scénario de gestion des I/O disque. Dans une application côté serveur, il est courant de lire un fichier de configuration local, comme le code suivant.
local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()
Ce code utilise io.open
pour obtenir le contenu d'un certain fichier. Cependant, bien que ce soit une opération d'I/O bloquante, n'oubliez pas que les choses doivent être considérées dans un scénario réel. Donc, si vous l'appelez dans init
et init worker
, c'est une action ponctuelle qui n'affecte aucune requête client et est parfaitement acceptable.
Bien sûr, cela devient inacceptable si chaque requête utilisateur déclenche une lecture ou une écriture sur le disque. À ce moment-là, vous devez sérieusement envisager la solution.
Tout d'abord, nous pouvons utiliser lua-io-nginx-module
, un module C tiers. Il fournit une API Lua d'I/O non bloquante pour OpenResty, mais vous ne pouvez pas l'utiliser comme vous le feriez avec cosocket
. Parce que la consommation d'I/O disque ne disparaît pas sans raison, c'est juste une manière différente de faire les choses.
Cette approche fonctionne car lua-io-nginx-module
tire parti du pool de threads de NGINX pour déplacer les opérations d'I/O disque du thread principal vers un autre thread pour les traiter, de sorte que le thread principal ne soit pas bloqué par les opérations d'I/O disque.
Vous devez recompiler NGINX lorsque vous utilisez cette bibliothèque, car c'est un module C. Elle est utilisée de la même manière que la bibliothèque I/O de Lua.
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()
Deuxièmement, essayez un ajustement architectural. Pouvons-nous changer notre manière de faire pour ce type d'I/O disque et arrêter de lire et écrire sur les disques locaux ?
Permettez-moi de vous donner un exemple pour que vous puissiez apprendre par analogie. Il y a quelques années, je travaillais sur un projet qui nécessitait de journaliser sur un disque local à des fins de statistiques et de dépannage.
À l'époque, les développeurs utilisaient ngx.log
pour écrire ces journaux, comme suit.
ngx.log(ngx.WARN, "info")
Cette ligne de code appelle l'API Lua fournie par OpenResty, et il semble qu'il n'y ait aucun problème. L'inconvénient, cependant, est que vous ne pouvez pas l'appeler très souvent. Premièrement, ngx.log
lui-même est un appel de fonction coûteux ; deuxièmement, même avec un tampon, des écritures fréquentes et importantes sur le disque peuvent sérieusement affecter les performances.
Alors, comment résoudre cela ? Revenons au besoin initial - les statistiques, le dépannage, et l'écriture des journaux sur le disque local n'auraient été qu'un des moyens d'atteindre l'objectif.
Vous pouvez donc également envoyer les journaux à un serveur de journalisation distant pour utiliser cosocket
pour faire de la communication réseau non bloquante ; c'est-à-dire, déléguer l'I/O disque bloquant au service de journalisation pour éviter de bloquer le service externe. Vous pouvez utiliser lua-resty-logger-socket
pour cela.
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)
Comme vous l'avez probablement remarqué, les deux méthodes ci-dessus sont les mêmes : si l'I/O bloquant est inévitable, ne bloquez pas le thread worker principal ; déléguez-le à d'autres threads ou services extérieurs.
luasocket
Enfin, parlons de luasocket
, une bibliothèque intégrée de Lua facilement utilisée par les développeurs et souvent confondue avec cosocket
fourni par OpenResty. luasocket
peut également effectuer des fonctions de communication réseau. Cependant, elle n'a pas l'avantage de la non-blocage. Par conséquent, si vous utilisez luasocket
, les performances chutent considérablement.
Cependant, luasocket a également ses propres scénarios d'utilisation uniques. Par exemple, je ne sais pas si vous vous souvenez que cosocket
n'est pas disponible dans plusieurs phases, et nous pouvons généralement le contourner en utilisant ngx.timer
. De plus, vous pouvez utiliser luasocket
pour les fonctions de cosocket
dans des phases ponctuelles comme init_by_lua*
et init_worker_by_lua*
. Plus vous êtes familier avec les similitudes et les différences entre OpenResty et Lua, plus vous trouverez des solutions intéressantes comme celles-ci.
De plus, lua-resty-socket
est un wrapper secondaire pour une bibliothèque open source qui rend luasocket
et cosocket
compatibles. Ce contenu mérite également d'être approfondi. Si vous êtes encore intéressé, j'ai préparé des matériaux pour que vous puissiez continuer à apprendre.
Résumé
En général, dans OpenResty, reconnaître les types d'opérations d'I/O bloquantes et leurs solutions est la base d'une bonne optimisation des performances. Alors, avez-vous déjà rencontré des opérations d'I/O bloquantes similaires dans le développement réel ? Comment les trouvez-vous et les résolvez-vous ? N'hésitez pas à partager votre expérience avec moi dans les commentaires, et n'hésitez pas à partager cet article.