Why Does lua-resty-core Perform Better?

API7.ai

September 30, 2022

OpenResty (NGINX + Lua)

Como dissemos nas duas lições anteriores, Lua é uma linguagem de desenvolvimento embutida que mantém o núcleo curto e compacto. Você pode incorporar Lua no Redis e no NGINX para ajudá-lo a implementar lógicas de negócios de forma flexível. Lua também permite que você chame funções e estruturas de dados C existentes para evitar reinventar a roda.

Em Lua, você pode usar a API C do Lua para chamar funções C, e no LuaJIT, você pode usar FFI. Para o OpenResty:

  • No núcleo lua-nginx-module, a API para chamar funções C é feita usando a API C do Lua.
  • No lua-resty-core, algumas das APIs já presentes no lua-nginx-module são implementadas usando o modelo FFI.

Você provavelmente está se perguntando por que precisamos implementar com FFI?

Não se preocupe. Vamos pegar ngx.base64_decode, uma API simples, como exemplo e ver como a API C do Lua difere da implementação FFI. Você também pode ter uma compreensão intuitiva de seu desempenho.

Lua CFunction

Vamos dar uma olhada em como isso é implementado no lua-nginx-module usando a API C do Lua. Procuramos por decode_base64 no código do projeto e encontramos sua implementação em ngx_http_lua_string.c.

lua_pushcfunction(L, ngx_http_lua_ngx_decode_base64);
lua_setfield(L, -2, "decode_base64");

O código acima é irritante de se olhar, mas, felizmente, não precisamos nos aprofundar nas duas funções que começam com lua_ e o papel específico de seus argumentos; só precisamos saber uma coisa - há uma CFunction registrada aqui: ngx_http_lua_ngx_decode_base64, e ela corresponde ao ngx.base64_decode, que é a API exposta ao público.

Vamos em frente e "seguir o mapa" e procurar por ngx_http_lua_ngx_decode_base64 neste arquivo C, que é definido no início do arquivo em:

static int ngx_http_lua_ngx_decode_base64(lua_State *L);

Para aquelas funções C que podem ser chamadas em Lua, sua interface deve seguir a forma exigida pelo Lua, que é typedef int (*lua_CFunction)(lua_State* L). Ela contém um ponteiro L do tipo lua_State como argumento; seu tipo de retorno é um inteiro que indica o número de valores retornados, não o valor de retorno em si.

Ela é implementada da seguinte forma (aqui, removi o código de tratamento de erros).

static int
 ngx_http_lua_ngx_decode_base64(lua_State *L)
 {
     ngx_str_t p, src;

    src.data = (u_char *) luaL_checklstring(L, 1, &src.len);

     p.len = ngx_base64_decoded_length(src.len);

     p.data = lua_newuserdata(L, p.len);

     if (ngx_decode_base64(&p, &src) == NGX_OK) {
         lua_pushlstring(L, (char *) p.data, p.len);

     } else {
         lua_pushnil(L);
     }

     return 1;
 }

O principal neste código são ngx_base64_decoded_length e ngx_decode_base64, ambas funções C fornecidas pelo NGINX.

Sabemos que funções escritas em C não podem passar o valor de retorno para o código Lua, mas precisam passar os parâmetros de chamada e retornar um valor entre Lua e C através da pilha. É por isso que há muito código que não entendemos de primeira vista. Além disso, esse código não pode ser rastreado pelo JIT, então, para o LuaJIT, essas operações estão em uma caixa preta e não podem ser otimizadas.

LuaJIT FFI

Diferente do FFI, a parte interativa do FFI é implementada em Lua, que pode ser rastreada pelo JIT e otimizada; claro, o código também é mais conciso e fácil de entender.

Vamos pegar o exemplo de base64_decode, cuja implementação FFI está espalhada por dois repositórios: lua-resty-core e lua-nginx-module, e vamos olhar para o código implementado no primeiro.

ngx.decode_base64 = function (s)
     local slen = #s
     local dlen = base64_decoded_length(slen)

     local dst = get_string_buf(dlen)
     local pdlen = get_size_ptr()
     local ok = C.ngx_http_lua_ffi_decode_base64(s, slen, dst, pdlen)
     if ok == 0 then
         return nil
     end
     return ffi_string(dst, pdlen[0])
 end

Você vai perceber que, comparado ao CFunction, o código da implementação FFI é muito mais limpo, sua implementação específica é ngx_http_lua_ffi_decode_base64 no repositório lua-nginx-module. Se você estiver interessado aqui, pode verificar o desempenho dessa função por conta própria. É bem direto, não vou postar o código aqui.

No entanto, se você for atento, percebeu algumas regras de nomenclatura de funções no trecho de código acima?

Sim, todas as funções no OpenResty têm convenções de nomenclatura, e você pode inferir seu uso pelo nome. Por exemplo:

  • ngx_http_lua_ffi_, a função Lua que usa FFI para lidar com solicitações HTTP do NGINX.
  • ngx_http_lua_ngx_, uma função Lua que usa função C para lidar com solicitações HTTP do NGINX.
  • As outras funções que começam com ngx e lua são funções internas do NGINX e Lua, respectivamente.

Além disso, o código C no OpenResty tem uma especificação de código rigorosa, e recomendo a leitura do guia de estilo de código C oficial aqui. Este é um documento essencial para desenvolvedores que desejam aprender o código C do OpenResty e enviar PRs. Caso contrário, mesmo que seu PR esteja bem escrito, você será repetidamente solicitado a alterá-lo devido a problemas de estilo de código.

Para mais APIs e detalhes sobre FFI, recomendamos que você leia os tutoriais oficiais do LuaJIT e a documentação. Colunas técnicas não substituem a documentação oficial; só posso ajudá-lo a apontar o caminho de aprendizado em um tempo limitado, com menos desvios; problemas difíceis ainda precisam ser resolvidos por você.

LuaJIT FFI GC

Ao usar FFI, podemos ficar confusos: quem vai gerenciar a memória solicitada no FFI? Devemos liberá-la manualmente em C, ou o LuaJIT deve recuperá-la automaticamente?

Aqui está um princípio simples: o LuaJIT só é responsável pelos recursos alocados por ele mesmo; ffi.

Por exemplo, se você solicitar um bloco de memória usando ffi.C.malloc, precisará liberá-lo com o ffi.C.free correspondente. A documentação oficial do LuaJIT tem um exemplo equivalente.

local p = ffi.gc(ffi.C.malloc(n), ffi.C.free)
 ...
 p = nil -- A última referência a p se foi.
 -- O GC eventualmente executará o finalizador: ffi.C.free(p)

Neste código, o ffi.C.malloc(n) solicita uma seção de memória, e ffi.gc registra uma função de retorno de destruição ffi.C.free, que será chamada automaticamente quando um cdata p for coletado pelo GC do LuaJIT para liberar a memória no nível C. E o cdata é coletado pelo GC do LuaJIT. O LuaJIT liberará automaticamente p no código acima.

Observe que, se você quiser solicitar um grande bloco de memória no OpenResty, recomendo usar ffi.C.malloc em vez de ffi.new. As razões também são claras.

  1. ffi.new retorna cdata, que faz parte da memória gerenciada pelo LuaJIT.
  2. O GC do LuaJIT tem um limite superior de gerenciamento de memória, e o LuaJIT no OpenResty não tem a opção GC64 habilitada. Portanto, o limite superior de memória para um único worker é de apenas 2G. Uma vez que o limite superior de gerenciamento de memória do LuaJIT é excedido, causará um erro.

Ao usar FFI, também precisamos prestar atenção especial a vazamentos de memória. No entanto, todos cometem erros, e, desde que humanos escrevam o código, sempre haverá bugs.

É aqui que a robusta cadeia de ferramentas de teste e depuração do OpenResty se torna útil.

Vamos falar sobre testes primeiro. No sistema OpenResty, usamos Valgrind para detectar vazamentos de memória.

A estrutura de teste que mencionamos no curso anterior, test::nginx, tem um modo especial de detecção de vazamento de memória para executar conjuntos de casos de teste unitários; você precisa definir a variável de ambiente TEST_NGINX_USE_VALGRIND=1. O projeto oficial OpenResty será totalmente registrado neste modo antes de liberar a versão, e entraremos em mais detalhes na seção de testes mais tarde. Entraremos em mais detalhes na seção de testes mais tarde.

O CLI resty do OpenResty também tem a opção --valgrind, que permite que você execute um código Lua sozinho, mesmo que você não tenha escrito um caso de teste.

Vamos olhar para as ferramentas de depuração.

O OpenResty fornece extensões baseadas em systemtap para realizar análise dinâmica ao vivo de programas OpenResty. Você pode procurar pela palavra-chave gc no conjunto de ferramentas deste projeto e verá duas ferramentas, lj-gc e lj-gc-objs.

Para análise offline como core dump, o OpenResty fornece um conjunto de ferramentas GDB, e você também pode procurar por gc nele e encontrar as três ferramentas lgc, lgcstat e lgcpath.

O uso específico dessas ferramentas de depuração será coberto em detalhes na seção de depuração mais tarde, para que você possa ter uma ideia agora. Afinal, o OpenResty tem um conjunto dedicado de ferramentas para ajudá-lo a localizar e resolver esses problemas.

lua-resty-core

A partir da comparação acima, podemos ver que a abordagem FFI não só é mais limpa em código, mas também pode ser otimizada pelo LuaJIT, o que é a melhor escolha. O OpenResty descontinuou a implementação CFunction, e o desempenho foi removido do código-fonte. As novas APIs agora são implementadas no repositório lua-resty-core através do FFI.

Antes do lançamento do OpenResty 1.15.8.1 em maio de 2019, lua-resty-core não era habilitado por padrão, o que resultava em perdas de desempenho e possíveis bugs, então recomendo fortemente que qualquer pessoa ainda usando a versão histórica habilite manualmente lua-resty-core. Você só precisa adicionar uma linha de código na fase init_by_lua.

require "resty.core"

Claro, a diretiva lua_load_resty_core foi adicionada no lançamento tardio de 1.15.8.1, e lua-resty-core é habilitado por padrão.

Eu pessoalmente sinto que o OpenResty ainda é muito cauteloso em habilitar lua-resty-core, e projetos de código aberto devem definir recursos semelhantes para serem habilitados por padrão o mais rápido possível.

lua-resty-core não apenas reimplementa algumas das APIs do projeto lua-nginx-module, como ngx.re.match, ngx.md5, etc., mas também implementa várias novas APIs, como ngx.ssl, ngx.base64, ngx.errlog, ngx.process, ngx.re.process, e ngx.ngx.md5. ngx.re.split, ngx.resp.add_header, ngx.balancer, ngx.semaphore, etc., que cobriremos mais tarde no capítulo de API do OpenResty.

Resumo

Tendo dito tudo isso, gostaria de concluir que o FFI, embora bom, não é uma bala de prata para desempenho. A principal razão pela qual ele é eficiente é que pode ser rastreado e otimizado pelo JIT. Se você escrever código Lua que não pode ser JIT e precisa ser executado no modo interpretado, então o FFI será menos eficiente.

Então, quais operações podem ser JIT e quais não podem? Como podemos evitar escrever código que não pode ser JIT? Vou revelar isso na próxima seção.

Finalmente, um problema de lição de casa prático: Você pode encontrar uma ou duas APIs tanto no lua-nginx-module quanto no lua-resty-core, e então comparar as diferenças em testes de desempenho? Você pode ver quão significativa é a melhoria de desempenho do FFI.

Sinta-se à vontade para deixar um comentário, e vou compartilhar seus pensamentos e ganhos, e convido você a compartilhar este artigo com seus colegas e amigos, juntos com a troca e o progresso.