I/O não bloqueante - A chave para melhorar o desempenho do OpenResty
API7.ai
December 2, 2022
No capítulo de Otimização de Desempenho, vou guiá-lo por todos os aspectos da otimização de desempenho no OpenResty e resumir os pontos mencionados nos capítulos anteriores em um guia abrangente de codificação OpenResty, para que você possa escrever código OpenResty de melhor qualidade.
Melhorar o desempenho não é fácil. Devemos considerar a otimização da arquitetura do sistema, otimização do banco de dados, otimização de código, testes de desempenho, análise de gráficos de chamas e outras etapas. Mas é fácil reduzir o desempenho, e como o título do artigo de hoje sugere, você pode reduzir o desempenho em 10 vezes ou mais apenas adicionando algumas linhas de código. Se você está usando o OpenResty para escrever seu código, mas o desempenho não melhorou, provavelmente é devido a I/O bloqueante.
Portanto, antes de entrarmos nos detalhes da otimização de desempenho, vamos olhar para um princípio importante na programação OpenResty: I/O não bloqueante primeiro.
Desde crianças, nossos pais e professores nos ensinaram a não brincar com fogo e não tocar em tomadas, que são comportamentos perigosos. O mesmo tipo de comportamento perigoso existe no OpenResty. Se você tiver que usar operações de I/O bloqueante em seu código, isso causará uma queda drástica no desempenho, e o propósito original de usar o OpenResty para construir um servidor de alto desempenho será derrotado.
Por que não podemos usar operações de I/O bloqueante?
Entender quais comportamentos são perigosos e evitá-los é o primeiro passo na otimização de desempenho. Vamos começar revisando por que operações de I/O bloqueante podem afetar o desempenho do OpenResty.
O OpenResty pode manter um alto desempenho simplesmente porque empresta o tratamento de eventos do NGINX e as corrotinas do Lua, então:
- Quando você encontra uma operação como I/O de rede que exige que você espere por um retorno antes de continuar, você chama a corrotina Lua
yield
para se suspender e então registra um callback no NGINX. - Após a operação de I/O ser concluída (ou ocorrer um timeout ou erro), o NGINX chama
resume
para acordar a corrotina Lua.
Esse processo garante que o OpenResty possa sempre usar os recursos da CPU de forma eficiente para processar todas as solicitações.
Nesse fluxo de processamento, o LuaJIT não entrega o controle para o loop de eventos do NGINX se não usar um método de I/O não bloqueante como cosocket
, mas em vez disso usa uma função de I/O bloqueante para lidar com I/O. Isso resulta em outras solicitações esperando na fila para que o evento de I/O bloqueante termine de ser processado antes de receberem uma resposta.
Resumindo, na programação OpenResty, devemos ter especial cuidado com chamadas de função que podem bloquear I/O; caso contrário, uma única linha de código de I/O bloqueante pode derrubar o desempenho de todo o serviço.
Abaixo, vou apresentar alguns problemas comuns, algumas funções de I/O bloqueante frequentemente mal utilizadas; vamos também experimentar como usar a maneira mais fácil de "estragar" e rapidamente fazer o desempenho do seu serviço cair 10 vezes.
Executar comandos externos
Em muitos cenários, os desenvolvedores não usam o OpenResty apenas como um servidor web, mas o dotam de mais lógica de negócios. Nesse caso, pode ser necessário chamar comandos e ferramentas externas para ajudar a completar algumas operações.
Por exemplo, para matar um processo.
os.execute("kill -HUP " .. pid)
Ou para operações mais demoradas, como copiar arquivos, usar OpenSSL para gerar chaves, etc.
os.execute(" cp test.exe /tmp ")
os.execute(" openssl genrsa -des3 -out private.pem 2048 ")
Na superfície, os.execute
é uma função embutida no Lua, e no mundo Lua, é de fato a maneira de chamar comandos externos. No entanto, é importante lembrar que Lua é uma linguagem de programação embutida e terá usos recomendados diferentes em outros contextos.
No ambiente do OpenResty, os.execute
bloqueia a solicitação atual. Então, se o tempo de execução desse comando for particularmente curto, o impacto não será muito grande. Mas se o comando levar centenas de milissegundos ou até segundos para ser executado, haverá uma queda drástica no desempenho.
Entendemos o problema, então como devemos resolvê-lo? Geralmente, há duas soluções.
1. Se houver uma biblioteca FFI
disponível, então damos preferência à maneira FFI para chamá-la
Por exemplo, se usamos a linha de comando OpenSSL para gerar a chave acima, podemos mudar para usar FFI
para chamar a função C do OpenSSL para contornar isso.
Para matar um processo, você pode usar lua-resty-signal
, uma biblioteca que vem com o OpenResty, para resolvê-lo de forma não bloqueante. A implementação do código é a seguinte. Claro, aqui, lua-resty-signal
também é resolvido usando FFI
para chamar funções do sistema.
local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")
Além disso, o site oficial do LuaJIT tem uma página específica que introduz várias bibliotecas de ligação FFI em diferentes categorias. Por exemplo, ao lidar com imagens, criptografia e descriptografia de operações intensivas em CPU, você pode ir lá primeiro para ver se há bibliotecas que foram encapsuladas e podem ser usadas diretamente.
2. Use a biblioteca lua-resty-shell
baseada em ngx.pipe
Como descrito anteriormente, você pode executar seus comandos em shell.run
, uma operação de I/O não bloqueante.
$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
shell.run([[echo "hello, world"]])
ngx.say(stdout) '
I/O de disco
Vamos olhar para o cenário de lidar com I/O de disco. Em uma aplicação do lado do servidor, é uma operação comum ler um arquivo de configuração local, como o seguinte código.
local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()
Esse código usa io.open
para obter o conteúdo de um determinado arquivo. No entanto, embora seja uma operação de I/O bloqueante, não se esqueça de que as coisas devem ser consideradas em um cenário real. Então, se você chamá-lo em init
e init worker
, é uma ação única que não afeta nenhuma solicitação do cliente e é perfeitamente aceitável.
Claro, torna-se inaceitável se cada solicitação do usuário disparar uma leitura ou escrita no disco. Nesse ponto, você precisa considerar seriamente a solução.
Primeiramente, podemos usar o lua-io-nginx-module
, um módulo C de terceiros. Ele fornece uma API Lua de I/O não bloqueante para o OpenResty, mas você não pode usá-lo como gosta com cosocket
. Porque o consumo de I/O de disco não desaparece sem motivo, é apenas uma maneira diferente de fazer as coisas.
Essa abordagem funciona porque o lua-io-nginx-module
aproveita o pool de threads do NGINX para mover operações de I/O de disco da thread principal para outra thread para processá-las, de modo que a thread principal não seja bloqueada por operações de I/O de disco.
Você precisa recompilar o NGINX ao usar essa biblioteca, pois é um módulo C. Ele é usado da mesma maneira que a biblioteca I/O do 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()
Em segundo lugar, tente um ajuste arquitetônico. Podemos mudar nossa abordagem para esse tipo de I/O de disco e parar de ler e escrever em discos locais?
Deixe-me dar um exemplo para que você possa aprender por analogia. Anos atrás, eu estava trabalhando em um projeto que exigia registro em disco local para fins estatísticos e de solução de problemas.
Na época, os desenvolvedores usavam ngx.log
para escrever esses logs, como o seguinte.
ngx.log(ngx.WARN, "info")
Essa linha de código chama a API Lua fornecida pelo OpenResty, e parece que não há problemas. A desvantagem, no entanto, é que você não pode chamá-la com muita frequência. Primeiro, ngx.log
em si é uma chamada de função custosa; segundo, mesmo com um buffer, grandes e frequentes escritas em disco podem impactar seriamente o desempenho.
Então, como resolvemos isso? Vamos voltar à necessidade original - estatísticas, solução de problemas, e escrever logs no disco local teria sido apenas um dos meios para atingir o objetivo.
Então você também pode enviar logs para um servidor de logs remoto para usar cosocket
para fazer comunicação de rede não bloqueante; ou seja, jogar o I/O de disco bloqueante para o serviço de logs para evitar bloquear o serviço externo. Você pode usar lua-resty-logger-socket
para fazer isso.
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)
Como você deve ter notado, ambos os métodos acima são os mesmos: se o I/O bloqueante for inevitável, não bloqueie a thread principal do worker; jogue-o para outras threads ou serviços externos.
luasocket
Finalmente, vamos falar sobre luasocket
, uma biblioteca embutida do Lua facilmente usada por desenvolvedores e frequentemente confundida entre luasocket
e o cosocket
fornecido pelo OpenResty. luasocket
também pode realizar funções de comunicação de rede. No entanto, ele não tem a vantagem de não bloquear. Como resultado, se você usar luasocket
, o desempenho cai drasticamente.
No entanto, luasocket também tem seus cenários de uso únicos. Por exemplo, eu não sei se você se lembra que cosocket
não está disponível em várias fases, e geralmente podemos contornar isso usando ngx.timer
. Além disso, você pode usar luasocket
para funções de cosocket
em fases únicas como init_by_lua*
e init_worker_by_lua*
. Quanto mais familiarizado você estiver com as semelhanças e diferenças entre OpenResty e Lua, mais soluções interessantes como essas você encontrará.
Além disso, lua-resty-socket
é um wrapper secundário para uma biblioteca de código aberto que torna luasocket e cosocket` compatíveis. Esse conteúdo também merece estudo adicional. Se você ainda estiver interessado, preparei materiais para você continuar aprendendo.
Resumo
Em geral, no OpenResty, reconhecer os tipos de operações de I/O bloqueante e suas soluções é a base de uma boa otimização de desempenho. Então, você já encontrou operações de I/O bloqueante semelhantes no desenvolvimento real? Como você as encontra e resolve? Sinta-se à vontade para compartilhar sua experiência comigo nos comentários, e sinta-se à vontade para compartilhar este artigo.