I/O não bloqueante - A chave para melhorar o desempenho do OpenResty

API7.ai

December 2, 2022

OpenResty (NGINX + Lua)

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.