Conseils pratiques : Identifier les concepts uniques et les pièges en Lua

API7.ai

October 12, 2022

OpenResty (NGINX + Lua)

Dans l'article précédent, nous avons appris les fonctions de bibliothèque liées aux tables dans LuaJIT. En plus de ces fonctions courantes, aujourd'hui, je vais vous présenter quelques concepts uniques ou moins courants de Lua ainsi que les pièges courants de Lua dans OpenResty.

Table Faible

Tout d'abord, il y a la table faible, un concept unique en Lua, qui est lié au ramasse-miettes. Comme d'autres langages de haut niveau, Lua dispose d'un ramasse-miettes automatique, vous n'avez pas à vous soucier de son implémentation, et vous n'avez pas à effectuer un GC explicitement. Le ramasse-miettes collectera automatiquement l'espace qui n'est pas référencé.

Mais le simple comptage de références ne suffit pas toujours, et parfois nous avons besoin d'un mécanisme plus flexible. Par exemple, si nous insérons un objet Lua Foo (table ou fonction) dans la table tb, cela crée une référence à cet objet Foo. Même s'il n'y a pas d'autre référence à Foo, la référence dans tb existera toujours, donc il n'y a aucun moyen pour le GC de récupérer la mémoire occupée par Foo. À ce stade, nous n'avons que deux options.

  • L'une est de libérer Foo manuellement.
  • La seconde est de le laisser résider en mémoire.

Par exemple, le code suivant.

$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
print(#tb) -- 2

collectgarbage()
print(#tb) -- 2

table.remove(tb, 1)
print(#tb) -- 1

Cependant, je pense que vous ne voulez pas garder la mémoire occupée par des objets que vous n'utilisez pas, surtout que LuaJIT a une limite de mémoire de 2G. Le moment de la libération manuelle n'est pas facile et ajoute de la complexité à votre code.

C'est alors que la table faible entre en jeu. Regardez son nom, table faible. D'abord, c'est une table, et ensuite tous les éléments de cette table sont des références faibles. Le concept est toujours abstrait, alors commençons par regarder un morceau de code légèrement modifié.

$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "v"})
print(#tb)  -- 2

collectgarbage()
print(#tb) -- 0
'

Comme vous pouvez le voir, les objets qui ne sont pas utilisés sont libérés. Le plus important ici est la ligne de code suivante.

setmetatable(tb, {__mode = "v"})

Cela vous rappelle quelque chose ? N'est-ce pas l'opération d'une métatable ? Oui, une table est une table faible lorsqu'elle a un champ __mode dans sa métatable.

  • Si la valeur de __mode est k, la clé de la table est une référence faible.
  • Si la valeur de __mode est v, alors la valeur de la table est une référence faible.
  • Bien sûr, vous pouvez également la définir à kv, indiquant que les clés et les valeurs de cette table sont des références faibles.

L'une de ces trois tables faibles verra son objet clé-valeur entier récupéré une fois que sa clé ou sa valeur sera récupérée.

Dans l'exemple de code ci-dessus, la valeur de __mode est v, tb est un tableau, et la valeur du tableau est la table et l'objet fonction afin qu'ils puissent être recyclés automatiquement. Cependant, si vous changez la valeur de __mode en k, elle ne sera pas libérée, par exemple, si vous regardez le code suivant.

$ resty -e 'local tb = {}
tb[1] = {red}
tb[2] = function() print("func") end
setmetatable(tb, {__mode = "k"})
print(#tb)  -- 2

collectgarbage()
print(#tb) -- 2
'

Nous ne démontrons que les tables faibles où la valeur est une référence faible, c'est-à-dire les tables faibles de type tableau. Naturellement, vous pouvez également construire une table faible de type table de hachage en utilisant un objet comme clé, par exemple, comme suit.

$ resty -e 'local tb = {}
tb[{color = red}] = "red"
local fc = function() print("func") end
tb[fc] = "func"
fc = nil

setmetatable(tb, {__mode = "k"})
for k,v in pairs(tb) do
     print(v)
end

collectgarbage()
print("----------")
for k,v in pairs(tb) do
     print(v)
end
'

Après avoir appelé manuellement collectgarbage() pour forcer le GC, tous les éléments de la table tb auront été libérés. Bien sûr, dans le code réel, nous n'avons pas besoin d'appeler collectgarbage() manuellement, il s'exécutera automatiquement en arrière-plan, et nous n'avons pas à nous en soucier.

Cependant, puisque nous avons mentionné la fonction collectgarbage(), je vais en dire quelques mots de plus. Cette fonction peut recevoir plusieurs options différentes et par défaut, elle utilise collect, qui est un GC complet. Une autre option utile est count, qui retourne la quantité d'espace mémoire occupé par Lua. Cette statistique est utile pour voir s'il y a une fuite de mémoire et nous rappelle de ne pas approcher la limite supérieure de 2G.

Le code lié aux tables faibles est plus compliqué à écrire en pratique, moins facile à comprendre, et par conséquent, contient plus de bugs cachés. Pas besoin de se précipiter. Plus tard, je présenterai un projet open source, utilisant les tables faibles pour résoudre un problème de fuite de mémoire.

Fermeture et upvalue

Passons aux fermetures et aux upvalues, comme je l'ai souligné précédemment, toutes les valeurs sont des citoyens de première classe en Lua, y compris les fonctions. Cela signifie que les fonctions peuvent être stockées dans des variables, passées comme arguments et retournées comme valeurs d'une autre fonction. Par exemple, cet exemple de code apparaît dans la table faible ci-dessus.

tb[2] = function() print("func") end

C'est une fonction anonyme qui est stockée comme valeur d'une table.

En Lua, la définition des deux fonctions dans le code suivant est équivalente. Cependant, notez que la dernière assigne une fonction à une variable, une méthode que nous utilisons souvent.

local function foo() print("foo") end
local foo = fuction() print("foo") end

De plus, Lua supporte l'écriture d'une fonction à l'intérieur d'une autre fonction, c'est-à-dire des fonctions imbriquées, comme dans l'exemple de code suivant.

$ resty -e '
local function foo()
     local i = 1
     local function bar()
         i = i + 1
         print(i)
     end
     return bar
end

local fn = foo()
print(fn()) -- 2
'

Vous pouvez voir que la fonction bar peut lire la variable locale i à l'intérieur de la fonction foo et modifier sa valeur, même si la variable n'est pas définie à l'intérieur de bar. Cette fonctionnalité est appelée portée lexicale.

Ces fonctionnalités de Lua sont la base des fermetures. Une fermeture est simplement une fonction qui accède à une variable dans la portée lexicale d'une autre fonction.

Par définition, toutes les fonctions en Lua sont en fait des fermetures, même si vous ne les imbriquez pas. C'est parce que le compilateur Lua prend en dehors du script Lua et l'enveloppe avec une autre couche de la fonction principale. Par exemple, les lignes de code simples suivantes.

local foo, bar
local function fn()
     foo = 1
     bar = 2
end

Après compilation, cela ressemblera à ceci.

function main(...)
     local foo, bar
     local function fn()
         foo = 1
         bar = 2
     end
end

Et la fonction fn capture deux variables locales de la fonction principale, donc c'est aussi une fermeture.

Bien sûr, nous savons que le concept de fermeture existe dans de nombreux langages, et il n'est pas unique à Lua, donc vous pouvez comparer et contraster pour mieux comprendre. Ce n'est qu'en comprenant les fermetures que vous pouvez comprendre ce que nous allons dire sur upvalue.

upvalue est un concept unique à Lua, qui est la variable en dehors de la portée lexicale capturée dans la fermeture. Continuons avec le code ci-dessus.

local foo, bar
local function fn()
     foo = 1
     bar = 2
end

Vous pouvez voir que la fonction fn capture deux variables locales, foo et bar, qui ne sont pas dans leur propre portée lexicale et que ces deux variables sont, en fait, les upvalue de la fonction fn.

Pièges Courants

Après avoir introduit quelques concepts en Lua, je vais parler des pièges liés à Lua que j'ai rencontrés dans le développement OpenResty.

Dans la section précédente, nous avons mentionné certaines des différences entre Lua et d'autres langages de développement, comme l'indexation commençant à 1, les variables globales par défaut, etc. Dans le développement de code réel d'OpenResty, nous rencontrerons plus de problèmes liés à Lua et LuaJIT, et je vais en parler quelques-uns des plus courants ci-dessous.

Voici un rappel que même si vous connaissez tous les pièges, vous devrez inévitablement les traverser vous-même pour être impressionné. La différence, bien sûr, est que vous serez capable de sortir du trou et de trouver le nœud du problème de manière beaucoup plus efficace.

L'index commence-t-il à 0 ou 1 ?

Le premier piège est que l'indexation de Lua commence à 1, comme nous l'avons mentionné à plusieurs reprises auparavant.

Mais je dois dire que ce n'est pas toute la vérité. Parce que dans LuaJIT, les tableaux créés avec ffi.new sont indexés à partir de 0 à nouveau :

local buf = ffi_new("char[?]", 128)

Donc, si vous voulez accéder au buf cdata dans le code ci-dessus, rappelez-vous que l'index commence à 0, pas 1. Soyez particulièrement attentif à cet endroit lorsque vous utilisez FFI pour interagir avec C.

Correspondance de Modèle Régulier

Le deuxième piège est le problème de correspondance de modèle régulier, et il y a deux ensembles de méthodes de correspondance de chaînes en parallèle dans OpenResty : la bibliothèque string de Lua et l'API ngx.re.* d'OpenResty.

La correspondance de modèle régulier de Lua est son format unique et est écrite différemment de PCRE. Voici un exemple simple.

resty -e 'print(string.match("foo 123 bar", "%d%d%d"))'123

Ce code extrait la partie numérique de la chaîne, et vous remarquerez qu'elle est complètement différente de nos expressions régulières familières. La bibliothèque de correspondance régulière de Lua est coûteuse à maintenir et peu performante - JIT ne peut pas l'optimiser, et les modèles qui ont été compilés une fois ne sont pas mis en cache.

Donc, lorsque vous utilisez la bibliothèque string intégrée de Lua pour find, match, etc., n'hésitez pas à utiliser ngx.re d'OpenResty à la place si vous avez besoin de quelque chose comme une expression régulière. Lorsque vous recherchez une chaîne fixe, nous envisageons d'utiliser le mode simple pour appeler la bibliothèque string.

Voici une suggestion : Dans OpenResty, nous privilégions toujours l'API d'OpenResty, puis l'API de LuaJIT, et utilisons les bibliothèques Lua avec prudence.

L'encodage JSON ne distingue pas entre tableau et dictionnaire

Le troisième piège est que l'encodage JSON ne distingue pas entre tableau et dictionnaire ; puisque Lua n'a qu'une seule structure de données, table, lorsque JSON encode une table vide, il n'y a aucun moyen de déterminer si c'est un tableau ou un dictionnaire.

resty -e 'local cjson = require "cjson"
local t = {}
print(cjson.encode(t))
'

Par exemple, le code ci-dessus affiche {}, ce qui montre que la bibliothèque cjson d'OpenResty encode une table vide comme un dictionnaire par défaut. Bien sûr, nous pouvons changer ce comportement global par défaut en utilisant la fonction encode_empty_table_as_object.

resty -e 'local cjson = require "cjson"
cjson.encode_empty_table_as_object(false)
local t = {}
print(cjson.encode(t))
'

Cette fois, la table vide est encodée comme un tableau [].

Cependant, ce réglage global a un impact significatif, alors pouvons-nous spécifier les règles d'encodage pour une table particulière ? La réponse est naturellement oui, et il y a deux façons de le faire.

La première façon est d'assigner le userdata cjson.empty_array à la table spécifiée afin qu'elle soit traitée comme un tableau vide lors de l'encodage JSON.

$ resty -e 'local cjson = require "cjson"
local t = cjson.empty_array
print(cjson.encode(t))
'

Cependant, parfois nous ne sommes pas sûrs si la table spécifiée est toujours vide. Nous voulons l'encoder comme un tableau lorsqu'elle est vide, donc nous utilisons la fonction cjson.empty_array_mt, qui est notre deuxième méthode.

Elle marquera la table spécifiée et l'encodera comme un tableau lorsque la table est vide. Comme vous pouvez le voir par le nom cjson.empty_array_mt, elle est définie en utilisant une metatable, comme dans l'opération de code suivante.

$ resty -e 'local cjson = require "cjson"
local t = {}
setmetatable(t, cjson.empty_array_mt)
print(cjson.encode(t))
t = {123}
print(cjson.encode(t))
'

Limite sur le nombre de variables

Regardons le quatrième piège, la limite sur le nombre de variables. Lua a une limite supérieure sur le nombre de variables locales et le nombre d'upvalue dans une fonction, comme vous pouvez le voir dans le code source de Lua.

/*
@@ LUAI_MAXVARS est le nombre maximum de variables locales par fonction
@* (doit être inférieur à 250).
*/
#define LUAI_MAXVARS            200


/*
@@ LUAI_MAXUPVALUES est le nombre maximum d'upvalues par fonction
@* (doit être inférieur à 250).
*/
#define LUAI_MAXUPVALUES        60

Ces deux seuils sont codés en dur à 200 et 60, respectivement, et bien que vous puissiez modifier manuellement le code source pour ajuster ces deux valeurs, elles ne peuvent être définies qu'à un maximum de 250.

Généralement, nous ne dépassons pas ce seuil. Cependant, lorsque vous écrivez du code OpenResty, vous devez faire attention à ne pas abuser des variables locales et des upvalue, mais à utiliser do ... end autant que possible pour réduire le nombre de variables locales et d'upvalue.

Par exemple, regardons le pseudo-code suivant.

local re_find = ngx.re.find
function foo() ... end
function bar() ... end
function fn() ... end

Si seule la fonction foo utilise re_find, alors nous pouvons la modifier comme suit :

do
    local re_find = ngx.re.find
    function foo() ... end
end
function bar() ... end
function fn() ... end

Résumé

Du point de vue de "poser plus de questions", d'où vient le seuil de 250 dans Lua ? C'est notre question de réflexion pour aujourd'hui. Vous êtes invités à laisser vos commentaires et à partager cet article avec vos collègues et amis. Nous communiquerons et améliorerons ensemble.