Vantagens e Desvantagens de `string` no OpenResty

API7.ai

December 8, 2022

OpenResty (NGINX + Lua)

No artigo anterior, nos familiarizamos com as funções de bloqueio comuns no OpenResty, que são frequentemente mal utilizadas por iniciantes. A partir deste artigo, entraremos no núcleo da otimização de desempenho, que envolverá muitas técnicas de otimização que podem nos ajudar a melhorar rapidamente o desempenho do código OpenResty, então não leve isso de forma leve.

Neste processo, precisamos escrever mais códigos de teste para experimentar como usar essas técnicas de otimização e verificar sua eficácia, para que possamos utilizá-las de forma adequada.

Por trás das cenas das dicas de otimização de desempenho

As técnicas de otimização fazem parte da parte "prática", então antes de fazermos isso, vamos falar sobre a "teoria" da otimização.

Os métodos de otimização de desempenho mudarão com as iterações do LuaJIT e do OpenResty. Alguns métodos podem ser diretamente otimizados pela tecnologia subjacente e não precisam mais ser dominados; ao mesmo tempo, haverá algumas novas técnicas de otimização. Portanto, é mais importante dominar o conceito constante por trás dessas técnicas de otimização.

Vamos dar uma olhada em algumas das ideias críticas sobre desempenho na programação OpenResty.

Teoria 1: O processamento de solicitações deve ser curto, simples e rápido

O OpenResty é um servidor web, então ele frequentemente lida com 1.000+, 10.000+, ou até 100.000+ solicitações de clientes simultaneamente. Portanto, para alcançar o maior desempenho geral, devemos garantir que as solicitações individuais sejam processadas rapidamente e que vários recursos, como memória, sejam recuperados.

  • O "curto" mencionado aqui significa que o ciclo de vida da solicitação deve ser curto, para não ocupar recursos por um longo tempo sem liberá-los; mesmo para conexões longas, um limite de tempo ou número de solicitações deve ser definido para liberar recursos regularmente.
  • O segundo "simples" refere-se a fazer apenas uma coisa em uma API. Divida a lógica de negócios complexa em várias APIs e mantenha o código simples.
  • Finalmente, "rápido" significa não bloquear o thread principal e não executar muitas operações de CPU. Mesmo que você precise fazer isso, não se esqueça de trabalhar com outros métodos que introduzimos no último artigo.

Essa consideração arquitetônica não é apenas adequada para o OpenResty, mas também para outras linguagens e plataformas de desenvolvimento, então espero que você possa entender e pensar sobre isso cuidadosamente.

Teoria 2: Evite gerar dados intermediários

Evitar dados inúteis no processo intermediário é, sem dúvida, a teoria de otimização mais dominante na programação OpenResty. Vamos ver um pequeno exemplo para explicar dados inúteis no processo intermediário.

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

Neste trecho de código, fizemos várias operações de concatenação na variável s para obter o resultado hello world!. Mas apenas o estado final hello world! de s é útil. O valor inicial de s e as atribuições intermediárias são todos dados intermediários que devem ser gerados o mínimo possível.

A razão é que esses dados temporários trarão perdas de desempenho de inicialização e GC. Não subestime essas perdas; se isso aparecer em código quente como loops, o desempenho será obviamente degradado. Também explicarei isso mais tarde com um exemplo de string.

strings são imutáveis

Agora, de volta ao assunto deste artigo, string. Aqui, estou destacando o fato de que strings são imutáveis em Lua.

Claro, isso não significa que strings não possam ser concatenadas, modificadas, etc., mas quando modificamos uma string, não alteramos a string original, mas criamos um novo objeto string e mudamos a referência para a string. Então, naturalmente, se a string original não tiver nenhuma outra referência, ela será recuperada pelo GC (coleta de lixo) do Lua.

O benefício aparente das strings imutáveis é que elas economizam memória. Dessa forma, haverá apenas uma cópia da mesma string na memória, e diferentes variáveis apontarão para o mesmo endereço de memória.

A desvantagem desse design é que, quando se trata de adicionar e recuperar strings, toda vez que você adiciona uma string, o LuaJIT precisa chamar lj_str_new para verificar se a string já existe; se não, ele precisa criar uma nova string. Se você fizer isso com muita frequência, isso terá um grande impacto no desempenho.

Vamos ver um exemplo concreto de uma operação de concatenação de string como a deste exemplo, que é encontrada em muitos projetos de código aberto do OpenResty.

$ resty -e 'local begin = ngx.now()
local s = ""
-- loop `for`, usando `..` para realizar a concatenação de strings
for i = 1, 100000 do
    s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'

O que este código de exemplo faz é realizar 100.000 concatenações de string na variável s e imprimir o tempo de execução. Embora o exemplo seja um pouco extremo, ele dá uma boa ideia da diferença entre antes e depois da otimização de desempenho. Sem otimização, este código roda por 0,4 segundos no meu laptop, o que ainda é relativamente lento. Então, como devemos otimizá-lo?

Nos artigos anteriores, a resposta foi dada, que é usar table para fazer uma camada de encapsulamento, removendo todas as strings intermediárias temporárias e mantendo apenas os dados originais e o resultado final. Vamos ver a implementação do código concreto.

$ resty -e 'local begin = ngx.now()
local t = {}
-- loop `for` que usa um array para armazenar a string, contando o comprimento do array cada vez
for i = 1, 100000 do
    t[#t + 1] = "a"
end
-- Concatenando strings usando o método `concat` de arrays
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Podemos ver que este código salva cada string em sequência com table, e o índice é determinado por #t + 1, ou seja, o comprimento atual de table mais 1. Finalmente, use a função table.concat para concatenar cada elemento do array. Isso naturalmente ignora todas as strings temporárias e evita 100.000 chamadas de lj_str_new e GC.

Essa foi nossa análise de código, mas como funciona a otimização? O código otimizado leva apenas 0,007 segundos, o que significa uma melhoria de desempenho de mais de 50 vezes. Em um projeto real, a melhoria de desempenho pode ser ainda mais pronunciada, porque, neste exemplo, adicionamos apenas um caractere a de cada vez.

Qual seria a diferença de desempenho se a nova string tivesse o comprimento de 10x a?

Os 0,007 segundos de código são bons o suficiente para o nosso trabalho de otimização? Não, ainda pode ser otimizado. Vamos modificar mais uma linha de código e ver o resultado.

$ resty -e 'local begin = ngx.now()
local t = {}
-- loop `for`, usando um array para armazenar a string, mantendo o comprimento do array por si só
for i = 1, 100000 do
    t[i] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Desta vez, mudamos t[#t + 1] = "a" para t[i] = "a", e com apenas uma linha de código, podemos evitar 100.000 chamadas de função para obter o comprimento do array. Lembra da operação para obter o comprimento de um array que mencionamos na seção table anteriormente? Ela tem uma complexidade de tempo de O(n), uma operação relativamente cara. Então, aqui simplesmente mantemos nosso índice de array para contornar a operação de obter o comprimento do array. Como diz o ditado, se você não pode lidar com isso, pode evitá-lo.

Claro, esta é uma maneira mais simples de escrever. O código a seguir ilustra mais claramente como manter o índice de um array por nós mesmos.

$ 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)
'

Reduzir outras strings temporárias

Os erros que acabamos de falar, strings temporárias causadas por concatenação de strings, são aparentes. Com alguns lembretes do código de exemplo acima, acredito que não cometeremos erros semelhantes novamente. No entanto, algumas strings temporárias mais ocultas são geradas no OpenResty, que são muito menos facilmente detectadas. Por exemplo, a função de manipulação de string que discutiremos abaixo é frequentemente usada. Você pode imaginar que ela também gera strings temporárias?

Como sabemos, a função string.sub intercepta uma parte especificada de uma string. Como mencionamos anteriormente, strings em Lua são imutáveis, então interceptar uma nova string envolve lj_str_new e operações subsequentes de GC.

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

A função do código acima é pegar o primeiro caractere da string e imprimi-lo. Naturalmente, isso inevitavelmente gera uma string temporária. Existe uma maneira melhor de alcançar o mesmo efeito?

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

Naturalmente sim. Olhando para este código, primeiro usamos string.byte para obter o código numérico do primeiro caractere e, em seguida, usamos string.char para converter o número no caractere correspondente. Este processo não gera nenhuma string temporária. Portanto, é mais eficiente usar string.byte para fazer a varredura e análise relacionadas a strings.

Aproveitar o suporte do SDK para o tipo table

Depois de aprender como reduzir a string temporária, você está ansioso para tentar? Então, podemos pegar o resultado do código de exemplo acima e enviá-lo ao cliente como o conteúdo do corpo da resposta. Neste ponto, você pode pausar e tentar escrever este código você mesmo primeiro.

$ 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)
'

Se você conseguiu escrever este código, já está à frente da maioria dos desenvolvedores OpenResty. A API Lua do OpenResty já levou em consideração o uso de tables para concatenação de strings, então em ngx.say, ngx.print, ngx.log, cosocket:send e outras APIs que podem receber muitas strings, ela aceita não apenas string como parâmetro, mas também table como parâmetro.

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

Neste último trecho de código, omitimos o local response = table.concat(t, ""), o passo de concatenação de strings, e passamos o table diretamente para ngx.say. Isso transfere a tarefa de concatenação de strings do nível Lua para o nível C, evitando outra busca, geração e GC de string. Para strings longas, isso é outro ganho significativo de desempenho.

Resumo

Depois de ler este artigo, podemos ver que muita otimização de desempenho do OpenResty lida com vários detalhes. Portanto, precisamos conhecer bem o LuaJIT e a API Lua do OpenResty para alcançar o desempenho ideal. Isso também nos lembra que, se esquecermos o conteúdo anterior, devemos revisar e consolidá-lo a tempo.

Finalmente, pense em um problema: escreva as strings hello, world e ! no log de erros. Podemos escrever um código de exemplo sem concatenação de strings?

Além disso, não se esqueça da outra pergunta no texto. Qual seria a diferença de desempenho no código a seguir se as novas strings tivessem o comprimento 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)
'

Você também é bem-vindo a compartilhar este artigo com seus amigos para aprender e se comunicar.