Avantages et inconvénients de `string` dans OpenResty

API7.ai

December 8, 2022

OpenResty (NGINX + Lua)

Dans l'article précédent, nous nous sommes familiarisés avec les fonctions de blocage courantes dans OpenResty, qui sont souvent mal utilisées par les débutants. À partir de cet article, nous allons entrer dans le cœur de l'optimisation des performances, ce qui impliquera de nombreuses techniques d'optimisation qui peuvent nous aider à améliorer rapidement les performances du code OpenResty, alors ne le prenez pas à la légère.

Dans ce processus, nous devons écrire plus de code de test pour expérimenter comment utiliser ces techniques d'optimisation et vérifier leur efficacité afin de pouvoir les utiliser à bon escient.

Les coulisses des astuces d'optimisation des performances

Les techniques d'optimisation font toutes partie de la partie "pratique", donc avant de les aborder, parlons de la "théorie" de l'optimisation.

Les méthodes d'optimisation des performances évolueront avec les itérations de LuaJIT et OpenResty. Certaines méthodes peuvent être directement optimisées par la technologie sous-jacente et ne nécessitent plus d'être maîtrisées ; en même temps, il y aura de nouvelles techniques d'optimisation. Par conséquent, il est plus important de maîtriser le concept constant derrière ces techniques d'optimisation.

Examinons quelques idées critiques sur les performances dans la programmation OpenResty.

Théorie 1 : Le traitement des requêtes doit être court, simple et rapide

OpenResty est un serveur web, donc il traite souvent 1 000, 10 000, voire 100 000+ requêtes client simultanément. Par conséquent, pour atteindre les performances globales les plus élevées, nous devons nous assurer que les requêtes individuelles sont traitées rapidement et que diverses ressources, comme la mémoire, sont récupérées.

  • Le "court" mentionné ici signifie que le cycle de vie de la requête doit être court pour ne pas occuper les ressources pendant une longue période sans les libérer ; même pour les connexions longues, un seuil de temps ou de nombre de requêtes doit être défini pour libérer régulièrement les ressources.
  • Le deuxième "simple" signifie ne faire qu'une seule chose dans une API. Divisez la logique métier complexe en plusieurs API et gardez le code simple.
  • Enfin, "rapide" signifie ne pas bloquer le thread principal et ne pas exécuter trop d'opérations CPU. Même si vous devez le faire, n'oubliez pas de travailler avec d'autres méthodes que nous avons introduites dans l'article précédent.

Cette considération architecturale est non seulement adaptée à OpenResty, mais aussi à d'autres langages et plateformes de développement, donc j'espère que vous pourrez la comprendre et y réfléchir attentivement.

Théorie 2 : Évitez de générer des données intermédiaires

Éviter les données inutiles dans le processus intermédiaire est sans doute la théorie d'optimisation la plus dominante dans la programmation OpenResty. Regardons un petit exemple pour expliquer les données inutiles dans le processus intermédiaire.

$ resty -e 'local s= "hello"
s = s .. " world"
s = s .. "!"
print(s)
'

Dans cet extrait de code, nous avons effectué plusieurs opérations de concaténation sur la variable s pour obtenir le résultat hello world!. Mais seul l'état final hello world! de s est utile. La valeur initiale de s et les affectations intermédiaires sont toutes des données intermédiaires qui devraient être générées le moins possible.

La raison est que ces données temporaires entraîneront une perte de performance lors de l'initialisation et du GC (garbage collection). Ne sous-estimez pas ces pertes ; si cela apparaît dans du code chaud comme des boucles, les performances seront clairement dégradées. Je l'expliquerai également plus tard avec un exemple de chaîne de caractères.

Les strings sont immuables

Revenons maintenant au sujet de cet article, les strings. Ici, je souligne le fait que les strings sont immuables en Lua.

Bien sûr, cela ne signifie pas que les strings ne peuvent pas être concaténées, modifiées, etc., mais lorsque nous modifions une string, nous ne changeons pas la string d'origine mais créons un nouvel objet string et changeons la référence à la string. Donc naturellement, si la string d'origine n'a aucune autre référence, elle sera récupérée par le GC (garbage collection) de Lua.

L'avantage évident des strings immuables est qu'elles économisent de la mémoire. Ainsi, il n'y aura qu'une seule copie de la même string en mémoire, et différentes variables pointeront vers la même adresse mémoire.

L'inconvénient de cette conception est que lorsqu'il s'agit d'ajouter et de récupérer des strings, chaque fois que vous ajoutez une string, LuaJIT doit appeler lj_str_new pour vérifier si la string existe déjà ; sinon, il doit créer une nouvelle string. Si vous faites cela très souvent, cela aura un impact énorme sur les performances.

Regardons un exemple concret d'une opération de concaténation de strings comme celle de cet exemple, que l'on trouve dans de nombreux projets OpenResty open source.

$ resty -e 'local begin = ngx.now()
local s = ""
-- Boucle `for`, utilisant `..` pour effectuer la concaténation de chaînes
for i = 1, 100000 do
    s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'

Ce que fait cet exemple de code est de faire 100 000 concaténations de strings sur la variable s et d'afficher le temps d'exécution. Bien que l'exemple soit un peu extrême, il donne une bonne idée de la différence entre avant et après l'optimisation des performances. Sans optimisation, ce code s'exécute en 0,4 seconde sur mon ordinateur portable, ce qui est relativement lent. Alors, comment devrions-nous l'optimiser ?

Dans les articles précédents, la réponse a été donnée, c'est d'utiliser table pour faire une encapsulation, en supprimant toutes les strings intermédiaires temporaires et en ne gardant que les données d'origine et le résultat final. Regardons la mise en œuvre concrète du code.

$ resty -e 'local begin = ngx.now()
local t = {}
-- Boucle for qui utilise un tableau pour stocker la chaîne, en comptant la longueur du tableau à chaque fois
for i = 1, 100000 do
    t[#t + 1] = "a"
end
-- Concaténation des chaînes en utilisant la méthode concat des tableaux
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Nous pouvons voir que ce code enregistre chaque chaîne successivement avec table, et l'index est déterminé par #t + 1, c'est-à-dire la longueur actuelle de table plus 1. Enfin, utilisez la fonction table.concat pour concaténer chaque élément du tableau. Cela saute naturellement toutes les chaînes temporaires et évite 100 000 appels à lj_str_new et au GC.

C'était notre analyse de code, mais comment fonctionne l'optimisation ? Le code optimisé prend seulement 0,007 seconde, ce qui signifie une amélioration des performances de plus de 50 fois. Dans un projet réel, l'amélioration des performances pourrait être encore plus prononcée car, dans cet exemple, nous n'avons ajouté qu'un seul caractère a à la fois.

Quelle serait la différence de performance si la nouvelle string avait une longueur de 10x a ?

Les 0,007 seconde de code sont-elles suffisantes pour notre travail d'optimisation ? Non, cela peut encore être optimisé. Modifions une ligne de code supplémentaire et voyons le résultat.

$ resty -e 'local begin = ngx.now()
local t = {}
-- Boucle for, utilisant un tableau pour stocker la chaîne, en maintenant la longueur du tableau elle-même
for i = 1, 100000 do
    t[i] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Cette fois, nous avons changé t[#t + 1] = "a" en t[i] = "a", et avec une seule ligne de code, nous pouvons éviter 100 000 appels de fonction pour obtenir la longueur du tableau. Rappelez-vous l'opération pour obtenir la longueur d'un tableau que nous avons mentionnée dans la section table plus tôt ? Elle a une complexité temporelle de O(n), une opération relativement coûteuse. Donc, ici, nous maintenons simplement notre index de tableau pour contourner l'opération d'obtention de la longueur du tableau. Comme on dit, si vous ne pouvez pas vous permettre de le gérer, vous pouvez l'éviter.

Bien sûr, c'est une manière plus simple de l'écrire. Le code suivant illustre plus clairement comment maintenir l'index d'un tableau par nous-mêmes.

$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Réduire les autres strings temporaires

Les erreurs dont nous venons de parler, les strings temporaires causées par la concaténation de strings, sont évidentes. Avec quelques rappels du code d'exemple ci-dessus, je crois que nous ne ferons plus d'erreurs similaires. Cependant, certaines strings temporaires plus cachées sont générées dans OpenResty, qui sont beaucoup moins facilement détectées. Par exemple, la fonction de manipulation de strings que nous allons discuter ci-dessous est souvent utilisée. Pouvez-vous imaginer qu'elle génère également des strings temporaires ?

Comme nous le savons, la fonction string.sub intercepte une partie spécifiée d'une string. Comme nous l'avons mentionné précédemment, les strings en Lua sont immuables, donc intercepter une nouvelle chaîne implique lj_str_new et les opérations de GC suivantes.

resty -e 'print(string.sub("abcd", 1, 1))'

La fonction du code ci-dessus est de récupérer le premier caractère de la string et de l'afficher. Naturellement, cela générera inévitablement une string temporaire. Existe-t-il une meilleure façon d'obtenir le même effet ?

resty -e 'print(string.char(string.byte("abcd")))'

Naturellement. En regardant ce code, nous utilisons d'abord string.byte pour obtenir le code numérique du premier caractère, puis nous utilisons string.char pour convertir le nombre en caractère correspondant. Ce processus ne génère aucune string temporaire. Par conséquent, il est plus efficace d'utiliser string.byte pour faire l'analyse et le balayage liés aux strings.

Tirer parti du support SDK pour le type table

Après avoir appris à réduire les strings temporaires, êtes-vous impatient d'essayer ? Ensuite, nous pouvons prendre le résultat du code d'exemple ci-dessus et le sortir vers le client comme contenu du corps de la réponse. À ce stade, vous pouvez faire une pause et essayer d'écrire ce code vous-même d'abord.

$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
local response = table.concat(t, "")
ngx.say(response)
'

Si vous pouvez écrire ce code, vous êtes déjà en avance sur la plupart des développeurs OpenResty. L'API Lua d'OpenResty a déjà pris en compte l'utilisation de tables pour la concaténation de strings, donc dans ngx.say, ngx.print, ngx.log, cosocket:send, et d'autres API qui peuvent prendre beaucoup de strings, il accepte non seulement string comme paramètre, mais aussi table comme paramètre.

resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
ngx.say(t)
'

Dans ce dernier extrait de code, nous avons omis l'étape de concaténation de strings local response = table.concat(t, "") et avons passé directement le table à ngx.say. Cela déplace la tâche de concaténation de strings du niveau Lua au niveau C, évitant une autre recherche, génération et GC de string. Pour les longues strings, c'est un autre gain de performance significatif.

Résumé

Après avoir lu cet article, nous pouvons voir qu'une grande partie de l'optimisation des performances d'OpenResty traite de divers détails. Par conséquent, nous devons bien connaître LuaJIT et l'API Lua d'OpenResty pour atteindre des performances optimales. Cela nous rappelle également que si nous avons oublié le contenu précédent, nous devons le revoir et le consolider en temps opportun.

Enfin, pensez à un problème : écrivez les chaînes hello, world, et ! dans le journal des erreurs. Pouvez-vous écrire un exemple de code sans concaténation de strings ?

N'oubliez pas non plus l'autre question dans le texte. Quelle serait la différence de performance dans le code suivant si les nouvelles strings avaient une longueur de 10x a ?

$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
    t[#t + 1] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Vous êtes également invités à partager cet article avec vos amis pour apprendre et échanger.