Incorporando e Sendo Incorporado: Interoperabilidade com Rust
Toda vez que o código Rust fala com Python, Node.js ou C/C++, dois runtimes que não sabem nada sobre os internos um do outro precisam cooperar através de uma linha dura sem um compilador do outro lado para verificar suposições. Sentir-se confortável com a interoperabilidade nativa significa tratar essa linha como um contrato: um acordo explícito, renegociado a cada chamada, sobre quem possui qual memória, como os erros são sinalizados e qual runtime tem permissão para assumir o controle.
Esta página é a âncora conceitual para a seção de interoperabilidade nativa. As outras páginas aqui mostram como escrever o contrato para um host específico - PyO3 para Python, napi-rs para Node, cbindgen para C/C++. Esta página explica por que o contrato tem a forma que tem, para que as páginas específicas da ferramenta leiam como instâncias de uma ideia em vez de três receitas não relacionadas.
Resumo
- Um limite FFI é um contrato que ambas as linguagens devem honrar sobre layout de memória, transferência de propriedade, sinalização de erros e suposições de threading, porque o compilador que impõe a segurança em um lado não tem visibilidade sobre o outro.
- Por que Importa: As garantias de segurança do Rust param nos blocos
unsafe extern "C"; todo bug que a interoperabilidade nativa introduz é uma violação de uma suposição que um lado fez e que o outro lado não compartilhou. - Conceitos Chave: ABI (interface binária de aplicação), transferência de propriedade, incorporação vs. ser incorporado, limite de panic, limite de GC, convenção de chamada.
- Quando Usar: Acelerar um caminho quente de Python ou Node com Rust, expor uma biblioteca central Rust para consumidores C/C++, ou incorporar uma linguagem de script dentro de um processo de propriedade do Rust.
- Limitações / Trade-offs: Nenhum limite FFI é seguro em termos de memória por construção; a segurança no limite é uma propriedade que você constrói e mantém por convenção, não uma que o Rust lhe dá gratuitamente.
- Tópicos Relacionados: ABIs estáveis, garbage collection, desenrolamento de panics, convenções de chamada, sandboxing WASM.
Fundamentos
Em sua forma mais simples, trabalho de interface de função estrangeira (FFI) significa chamar código escrito em uma linguagem de outra, ou permitir que outra linguagem chame o seu. A parte interessante não é a chamada em si; é tudo o que a chamada implicitamente depende e que uma chamada de função dentro de uma linguagem toma como garantido. Uma chamada de função Rust confia no compilador para verificar tipos de argumentos, gerenciar a pilha e garantir que o chamado não deixará dados compartilhados em um estado inválido. Através de um limite FFI, nenhuma dessa confiança existe, porque os dois lados foram compilados por toolchains diferentes contra suposições diferentes sobre o layout da memória.
A primeira pergunta que toda decisão de interoperabilidade responde é: quem possui main? Se o Rust possui o ponto de entrada do processo e inicializa um runtime de script incorporado dentro dele, o Rust está incorporando a outra linguagem - esta é a forma de um CLI Rust hospedando um plugin Python, ou qualquer aplicação Rust que hospeda uma linguagem de plugin. Se o outro runtime possui o processo e carrega o Rust como uma biblioteca, o Rust está sendo incorporado - esta é a forma de uma extensão Python construída com PyO3 e maturin, um addon nativo Node construído com napi-rs, ou um cdylib consumido por uma aplicação C++. A direção da propriedade decide quem controla a ordem de inicialização, quem instala manipuladores de sinais e qual alocador é o autoritário para o processo.
Independentemente da direção do relacionamento, ambos os lados precisam concordar com uma ABI: uma descrição estável em nível binário de como os argumentos são passados, como as structs são dispostas na memória e qual convenção de chamada é usada. O próprio layout em linguagem do Rust (repr(Rust)) é intencionalmente instável entre as versões do compilador, porque o compilador é livre para reordenar campos de struct para otimização. No momento em que um tipo cruza um limite de linguagem, ele deve ser fixado em repr(C), e as funções devem ser declaradas extern "C", para que ambos os compiladores concordem com os mesmos bytes exatos. Pense no limite como controle de passaporte entre dois países: seus documentos internos não são válidos do outro lado. Você apresenta um documento padronizado que ambos os países concordaram em honrar, e nada mais.
Mecânicas e Interações
O contrato tem quatro cláusulas de sustentação que recorrem em todos os hosts: propriedade, panics, modelo de gerenciamento de memória e threading.
Propriedade é a cláusula mais consequente. Cada ponteiro, buffer ou handle que cruza o limite precisa de uma resposta inequívoca para "quem libera isso?" O modelo de propriedade do Rust rastreia isso automaticamente, mas esse rastreamento evapora no instante em que um valor sai do sistema de tipos do Rust como um ponteiro bruto. A convenção que sobrevive na prática: qualquer lado que alocou um recurso é o único lado permitido a liberá-lo, exposto através de uma função explícita _free ou _destroy em vez do próprio caminho de desalocação do host. Um Buffer do Node liberado pelo alocador do Rust, ou um Vec do Rust liberado pelo free() do C, é um double-free ou um heap corrompido esperando para acontecer, porque os dois alocadores não compartilham contabilidade.
Panics são a cláusula que os novatos mais frequentemente erram. Um panic do Rust desenrola a pilha procurando por um limite catch_unwind; se atingir uma função extern "C" sem ser capturado, o comportamento é indefinido, porque C não tem conceito do mecanismo de desenrolamento do Rust para devolver o controle. Cada ponto de entrada exportado que pode entrar em pânico precisa envolver seu corpo em std::panic::catch_unwind e converter o resultado em um código de erro que o host entenda. Isso não é uma preferência de estilo; é a diferença entre um erro recuperável e um processo host travado.
Incompatibilidades do modelo de gerenciamento de memória aparecem mais fortemente nas fronteiras Python e Node, porque ambos hospedam um garbage collector enquanto o Rust não. O token Python<'_> do PyO3 existe para provar, em tempo de compilação, que o código chamador detém o Global Interpreter Lock (GIL) do Python antes de tocar em objetos Python - sem essa prova, o código Rust poderia observar um objeto Python em meio à coleta. napi-rs enfrenta o problema inverso: o motor JavaScript é single-threaded por convenção, então chamar de volta para JS a partir de uma thread do sistema operacional iniciada pelo Rust requer uma ThreadsafeFunction que encaminha a chamada para o loop de eventos JS em vez de invocá-la diretamente. Ambos os mecanismos respondem à mesma pergunta subjacente: o garbage collector de qual runtime pode assumir conhecimento exclusivo de referências vivas a qualquer instante?
Suposições de threading agravam tudo o que foi dito acima. Um limite projetado assumindo chamadas síncronas e single-threaded corromperá silenciosamente o estado no momento em que qualquer um dos lados introduzir concorrência sem renegociar o contrato - é por isso que "isso é seguro contra múltiplas threads" pertence ao cabeçalho ou docstring de cada função exportada, não para ser descoberto.
// Panics nunca cruzam o limite sem serem capturados; é por isso que cada
// ponto de entrada exportado envolve seu corpo, não apenas os "arriscados".
#[no_mangle]
pub extern "C" fn engine_step(handle: *mut Engine) -> i32 {
let result = std::panic::catch_unwind(|| {
let engine = unsafe { &mut *handle };
engine.step() // pode entrar em panic internamente
});
match result {
Ok(_) => 0,
Err(_) => -1, // o host vê um código de erro, nunca um unwind
}
}O trecho acima não é sobre a mecânica de Engine::step; é sobre a forma que cada ponto de entrada FFI precisa, independentemente do que ele faz internamente, porque a alternativa é um processo host que pode travar devido a um bug do lado Rust que ele não tem como observar ou recuperar.
Considerações Avançadas e Aplicações
A estabilidade da ABI não é uniforme entre os hosts, e essa diferença impulsiona decisões reais de ferramentas. A própria ABI C é efetivamente permanente - ela não mudou significativamente em décadas, é por isso que extern "C" é o lingua franca para o qual todas as outras ferramentas de interoperabilidade eventualmente compilam. A API C do Python, por outro lado, é versionada e pode mudar entre lançamentos menores; PyO3 aborda isso com o recurso abi3, visando o subconjunto estável da ABI do Python para que uma roda compilada funcione em várias versões menores do Python em vez de reconstruir por lançamento. A história de módulos nativos do Node resolveu o problema equivalente com N-API, uma ABI versionada e retrocompatível que napi-rs tem como alvo por padrão, é por isso que binários .node pré-compilados são distribuídos sem exigir que os consumidores possuam uma toolchain Rust.
É aqui também que as quatro estratégias de interoperabilidade nativa divergem genuinamente, e escolher a correta é uma decisão arquitetônica, não uma preferência:
| Abordagem | Força | Fraqueza | Melhor Ajuste |
|---|---|---|---|
| PyO3 + maturin | Integração profunda de tipos Python, segurança ciente do GIL | A extensão pode mudar se não for construída contra abi3 | Acelerar caminhos quentes de Python, manter a orquestração em Python |
| napi-rs | ABI N-API estável, gera tipos TypeScript | Assíncrono e threading precisam de configuração explícita do runtime | Cargas de trabalho Node vinculadas à CPU distribuídas como pacotes npm |
extern "C" + cbindgen | Universalmente estável, zero dependência de runtime | Nenhuma rede de segurança; o chamador pode violar qualquer suposição | SDKs, bindings móveis, integração com C/C++ legado |
| wasm-bindgen / WASM | Sandboxed, portátil entre navegador e edge | Fronteira com cópia pesada, superfície de syscall restrita | Código não confiável, plugins multiplataforma sem compilações por SO |
O ponto mais profundo por trás dessa tabela é que WASM não é meramente "FFI nativo, mas mais lento" - ele troca o acesso direto à memória por um modelo de confiança isolado, que é por isso que é a resposta certa quando o código do outro lado não é confiável, não apenas quando a portabilidade importa.
A observabilidade na fronteira merece uma nota honesta: um segfault originado em código FFI dá um stack trace que para na fronteira do idioma da perspectiva do host, e ferramentas como valgrind, sanitizers ou cargo miri só veem o lado Rust. Depurar um bug de fronteira geralmente significa reproduzi-lo isoladamente no lado Rust primeiro, porque o próprio depurador do host não tem visibilidade sobre o que um bloco unsafe realmente fez.
Conceitos Erroneos Comuns
- "FFI é apenas chamar uma função Rust de outra linguagem." - Comporta-se como uma chamada de função sintaticamente, mas semanticamente é um contrato renegociado sem aplicação do compilador em nenhuma das pontas.
- Estruturas
repr(Rust)são próximas o suficiente do layout C para apenas cruzar a fronteira." - O compilador reordena campos para otimização e não oferece garantia de layout entre versões; apenas tiposrepr(C)têm um layout definido e estável. - "Uma vez que uma roda ou addon nativo é construído, sua ABI é estável para sempre." - Verdadeiro para
extern "C"e, com cuidado, para rodasabi3e addons N-API, mas não automático; pular o recurso de ABI estável significa reconstruir a cada atualização do runtime host. - "Um panic do Rust se comporta como uma exceção que a linguagem host pode capturar." - Não se comporta; um panic não capturado que atinge uma fronteira
extern "C"é comportamento indefinido, não um erro capturável. - "Passar um
StringouVecde propriedade através de FFI é bom porque Rust gerencia a memória automaticamente." - O rastreamento de propriedade é uma garantia interna do Rust, apenas em tempo de compilação; uma vez que um valor cruza para a forma de ponteiro bruto, a contabilidade automática desaparece e a convenção manual assume. - "WASM existe porque FFI nativo é muito difícil." - WASM existe porque algum código precisa de um sandbox que o FFI nativo estruturalmente não pode fornecer, não como um substituto mais simples para o mesmo problema.
FAQs
O que exatamente é "a fronteira FFI", se não é apenas uma chamada de função?
É o ponto onde as garantias de tempo de compilação do compilador de uma linguagem param de se aplicar. Após esse ponto, a correção depende de ambos os lados honrarem uma convenção não imposta sobre layout de memória, propriedade e sinalização de erros.
Por que um panic do Rust não pode simplesmente se propagar para o código C como uma exceção?
C não tem um mecanismo de desenrolamento compatível com a implementação de panic do Rust. Um desenrolamento que atinge um frame extern "C" sem ser capturado é comportamento indefinido, não um erro gracioso, então cada ponto de entrada exportado envolve seu corpo em catch_unwind.
Como o PyO3 impede que o código Rust corrompa o garbage collector do Python?
Através do token Python<'_>, que só pode ser obtido enquanto o GIL está ativo. Sua presença em uma assinatura de função é uma prova em tempo de compilação de que é seguro tocar em objetos Python naquele ponto.
Por que o napi-rs precisa de `ThreadsafeFunction` apenas para chamar de volta para JavaScript?
Porque os motores JavaScript assumem execução single-threaded. Chamar uma função JS diretamente de uma thread do sistema operacional iniciada pelo Rust violaria essa suposição; ThreadsafeFunction encaminha a chamada de volta para o loop de eventos JS em vez disso.
A ABI C é realmente padronizada em algum lugar?
Não por uma única especificação formal, mas na prática ela tem sido estável entre plataformas e toolchains por décadas, é por isso que todas as outras camadas de interoperabilidade (PyO3, napi-rs, wasm-bindgen) eventualmente compilam para convenções extern "C" por baixo.
Por que não posso simplesmente passar um `String` Rust através da fronteira?
String carrega um layout interno do Rust (ponteiro, comprimento, capacidade) sem representação C definida, e sua memória é de propriedade do alocador do Rust. O host não tem como lê-lo ou liberá-lo com segurança diretamente; converta para um CString ou buffer de bytes com regras de propriedade explícitas em vez disso.
Quando o Rust deve incorporar uma linguagem host versus ser incorporado por uma?
Incorpore a outra linguagem quando um processo de propriedade do Rust precisar de script ou extensão (um CLI Rust executando plugins Python). Seja incorporado quando a aplicação host já existir e você estiver acelerando uma parte dela (um pacote Python ou Node com um núcleo Rust).
Uma fronteira WASM é realmente mais lenta que uma fronteira FFI nativa?
Geralmente, sim, porque as chamadas WASM normalmente envolvem a cópia de dados através de uma memória linear isolada em vez de compartilhar ponteiros diretamente. A troca é intencional: você está comprando isolamento, não apenas portabilidade.
Por que a estabilidade da ABI importa mais para extensões Python do que para uma biblioteca C bruta?
Uma biblioteca C bruta visa a ABI C permanentemente estável. Uma extensão Python visa a própria API C do Python, que é versionada e pode mudar entre lançamentos menores, a menos que a extensão opte explicitamente pelo subconjunto estável abi3.
Como sei quem é responsável por liberar um determinado ponteiro através de uma fronteira?
Qualquer lado que alocou a memória é o único lado que a libera, exposto através de uma função explícita (engine_destroy, rust_string_free) em vez do caminho de desalocação nativo do host.
Uma fronteira FFI pode ser tornada totalmente segura em termos de memória?
Não por construção. Crates como cxx e wrappers tipados como #[pyclass] do PyO3 reduzem a área de superfície para erros, mas a fronteira sempre tem um núcleo unsafe que depende de uma convenção ser honrada, não de uma garantia ser imposta.
Por que as equipes geralmente escolhem uma ferramenta de interop por fronteira em vez de misturar abordagens?
Cada ferramenta (PyO3, napi-rs, cbindgen) codifica um contrato internamente consistente para seu host de destino: regras de propriedade, tratamento de panics, modelo de threading. Misturar ferramentas para a mesma fronteira significa reconciliar dois conjuntos de suposições sobre a mesma memória, multiplicando as maneiras pelas quais o contrato pode ser violado.
Relacionados
- Fundamentos de Interoperabilidade - a matriz de decisão para escolher uma ferramenta de fronteira por host
- PyO3 - a instância específica do Python deste contrato, incluindo regras do GIL
- Node.js com napi-rs - a instância específica do Node, incluindo a estabilidade da N-API
- Marshalling de Dados - padrões concretos para a cláusula de propriedade do contrato
- Construindo Bindings Sólidos - disciplina de solidez para a camada bruta
extern "C"por baixo de cada ferramenta específica do host
Versões da Stack: Esta página é conceitual, mas seus exemplos assumem Rust 1.97.0 (edição 2024) e as versões das ferramentas usadas em outras partes desta seção: PyO3 visando a ABI estável
abi3do Python, e napi-rs visando N-API.