HQQ: Isolating C Libraries from Go Over Cache-Line Shared Memory
gosuda.org/hqq é um runtime de comunicação entre processos com zero-cgo e uma toolchain de geração de código para Go e C11. Ele isola bibliotecas nativas em C dentro de processos de trabalho do SO separados e os conecta a chamadores em Go por meio de anéis de memória compartilhada mapeados em memória e alinhados à linha de cache. O projeto combina duas camadas de transporte (StandardLink para sessões dedicadas de produtor único e consumidor único e Advanced para despacho MPMC de múltiplos trabalhadores), o compilador de IDL hqq-bindgen e um supervisor de processos com verificação de adulteração (secureexec e launch).
1go get gosuda.org/hqq
O Custo de Compartilhar um Espaço de Endereçamento com C
Todo engenheiro de sistemas Go eventualmente chega à mesma bifurcação no caminho. Você está construindo um serviço em Go que compila em segundos com CGO_ENABLED=0, é distribuído como um único binário estático, compila de forma cruzada de um laptop para alvos Linux ou Windows sem um sysroot, e é executado em um heap coletado como lixo com segurança de memória. Em seguida, você precisa de uma biblioteca madura em C: SQLite para armazenamento relacional embutido, Zstandard (libzstd) para compressão de dicionário, ou uma biblioteca de mídia ou criptografia acelerada por hardware.
Ativar cgo para vincular essa biblioteca em C ao processo Go resolve o problema imediato de vinculação, ao mesmo tempo em que introduz três passivos estruturais:
- Acoplamento de Toolchain e Compilação Cruzada:
CGO_ENABLED=1requer um compilador C, cabeçalhos de libc de destino e um linker de plataforma para cada SO e arquitetura de destino. Construir artefatoslinux/arm64ouwindows/arm64a partir de uma estação de trabalhodarwin/arm64transforma um único comandogo buildem uma matriz de compiladores cruzados e sysroots. - Sobrecarga de Agendamento em Tempo de Execução: Cada chamada de
cgocruza o limite do runtime do Go, muda de uma pilha de goroutine expansível para uma pilha de sistema e fixa uma thread do SO (M). Para chamadas de granulação fina, o custo de transição (~50 a 150 ns por chamada) e a contenção do agendador acumulam-se rapidamente. - Colapso do Domínio de Falhas: O código C é executado no mesmo espaço de endereço virtual que o runtime do Go. Uma única gravação fora dos limites, uso após liberação (use-after-free), estouro de pilha ou
abort()dentro de uma função C corrompe os metadados do heap do Go ou encerra o processo do servidor inteiro imediatamente, ignorandorecover()e levando consigo todas as solicitações HTTP ou RPC em andamento.
Quando as equipes tentam evitar cgo, as alternativas comuns forçam um compromisso diferente. Traduções mecânicas em Go puro de bases de código C preservam CGO_ENABLED=0, mas frequentemente ficam atrás das liberações upstream de C, carregam uma camada de memória virtual emulada e alocam intensamente sob carga. Mover a biblioteca C para um processo auxiliar sobre TCP de loopback ou sockets de domínio Unix restaura o isolamento de falhas, mas cada chamada paga por duas trocas de contexto de kernel, cópias de buffer de socket e serialização em fio.
Construímos o HQQ após fazer uma pergunta direta: e se a biblioteca C vivesse em um processo de SO separado com seu próprio espaço de endereço virtual, enquanto Go e C trocavam quadros diretamente através de linhas de cache de CPU compartilhadas sem entrar no kernel no caminho rápido?
| Modelo de Integração | CGO_ENABLED=0 | Isolamento de Falhas | Syscalls de Caminho Rápido | Alocações de Heap por Chamada | Fidelidade C11 Upstream |
|---|---|---|---|---|---|
Vinculação In-Process cgo | Não | Nenhum (heap compartilhado) | 0 | 0 a 1+ | C upstream exato |
| RPC de Subprocesso Socket / Pipe | Sim | Limite de processo | 2 a 4 | Múltiplas (enquadramento + I/O) | C upstream exato |
| Tradução Mecânica de C para Go | Sim | Go in-process | 0 | Alto em chamadas complexas | Runtime C emulado |
| IPC de Memória Compartilhada HQQ | Sim | Limite de processo | 0 (spin adaptativo) | 0 (StandardLink) | C upstream exato |
Visão Geral Arquitetural: Quatro Camadas Através de Limites de Processo
O HQQ separa o isolamento de processos em quatro camadas componíveis para que a mecânica do anel de memória compartilhada, a geração de código RPC tipado, a verificação de integridade binária e a supervisão de processos filhos permaneçam independentes.
1+-------------------------------------------------------------------+
2| Processo da Aplicação Go (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Cliente Go Gerado | | Supervisor de Processo | |
6| | (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Pipe Anônimo |
11| | Camada de Transporte | | Linha de Vida |
12| | (StandardLink / | | (--parent-stdin) |
13| | Advanced MPMC) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|======= Memória Compartilhada ===|===================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| Processo de Trabalho C11 (Espaço de Endereçamento Isolado) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Servidor C11 Gerado |---->| Tabela de Handles Verificada | |
23| | e Shims de Lote | | por Geração (hqq_rpc.c) | |
24| +-----------+-----------+ +---------------+---------------+ |
25| | | |
26| +----------------+----------------+ |
27| | |
28| +---------v---------+ |
29| | Biblioteca C Nativa| |
30| | (SQLite, Zstd...) | |
31| +-------------------+ |
32+-------------------------------------------------------------------+
1. Transporte de Memória Compartilhada (gosuda.org/hqq e c/)
O pacote base gosuda.org/hqq e sua contraparte C11 em c/ implementam dois modos de transporte de memória compartilhada sobre POSIX mmap (Linux e macOS) e Win32 CreateFileMappingW (Windows). Cada segmento compartilhado começa com um superbloco HQQ_STL3 verificado que fixa a versão do protocolo, a geometria do buffer e os PIDs dos endpoints antes que qualquer índice de anel seja lido.
Todas as palavras de controle ativas e cabeçalhos de slot são alinhados a limites de linha de cache de 64 bytes para que os núcleos produtor e consumidor nunca concorram na mesma linha de cache por meio de falso compartilhamento (false sharing).
1Layout de Slot do StandardLink (Alinhado à Linha de Cache de 64 Bytes)
2Deslocamento de Byte: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Linha de Cab.:| seq (u64 atômico) | len (u32) | flags | reservado (preenchido com 0)|
5 +-------------------+-------------------+-----------------------------+
6Área de Carga:| bytes da carga útil [0 .. BufferSize - 1] (decodificado/codificado dir.)|
7 +---------------------------------------------------------------------+
| Campo | Deslocamento | Tamanho | Semântica |
|---|---|---|---|
seq | 0x00 | 8 B | Contador de sequência monotônico. Valores ímpares marcam uma gravação em andamento; valores pares publicam um quadro consolidado por meio de store-release. |
len | 0x08 | 4 B | Comprimento em bytes da carga útil gravada no slot (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Bits de controle de quadro (continuação, status de erro ou aviso de encerramento). |
payload | 0x40 | BufferSize | Região de carga útil contígua lida e gravada no local sem cópias intermediárias. |
Os chamadores escolhem entre duas camadas de transporte dependendo da estrutura de concorrência:
StandardLink(Sessão 1:1 SPSC): Emparelha uma sessão de cliente e servidor sobre slots dedicados de solicitação e resposta protegidos por um bloqueio de sequência (SeqLock). No caminho rápido, o gravador serializa diretamente no slot mapeado e confirma com um store-release atômico; o leitor aguarda brevemente (spin) no espaço do usuário antes de recorrer às primitivas de despertar do SO (futexno Linux,__ulock_waitno macOS,WaitOnAddressno Windows). Uma viagem de ida e volta de quadro único é concluída em ~200 ns com0 allocs/op.Advanced(Anel de Despacho MPMC): MultiplexaMchamadores concorrentes em Go através deNthreads de trabalho em C usando um anel de submissão multi-produtor multi-consumidor sem bloqueio (lock-free), um pool de slots compartilhado e uma tabela de conclusão indexada. Os chamadores reservam um slot, publicam o índice do slot no anel e aguardam em sua entrada de conclusão enquanto as threads de trabalho retiram da fila e concluem as solicitações fora de ordem.
Ambas as camadas rastreiam a vivência dos pares continuamente. Cada endpoint atualiza um timestamp monotônico atômico via Heartbeat() e inspeciona PeerAlive(timeout) enquanto aguarda. Se um processo de trabalho falhar no meio de uma chamada, o par detecta o heartbeat desatualizado ou o PID morto imediatamente, aborta as esperas bloqueadas com ErrPeerGone e suporta a Takeover (tomada) atômica de sessão sem deixar arquivos de memória compartilhada travados para trás.
2. RPC com Zero Alocações e Handles Verificados por Geração (gosuda.org/hqq/bindgen)
Escrever enquadramento de memória compartilhada manualmente para uma biblioteca em C com dezenas de funções é entediante e propenso a erros. hqq-bindgen é um compilador de esquema que lê um arquivo de Linguagem de Definição de Interface (.idl) (ou inicializa um diretamente a partir de um cabeçalho .h de C via hqq-bindgen init) e emite um pacote de cliente Go sem cgo juntamente com um esqueleto de servidor C11.
O erro mais perigoso em qualquer vinculação de C é expor ponteiros brutos em C (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) através do limite da linguagem. Se um chamador passar um ponteiro após liberá-lo, passar um ponteiro pertencente a outra sessão ou corromper um endereço inteiro, a biblioteca em C desreferencia memória inválida.
hqq-bindgen proíbe ponteiros brutos no fio (wire). Em vez disso, a IDL modela recursos estaduais em C como declarações de handle tipadas:
1service sqlite3 {
2 handle Db;
3 handle Stmt @owned_by(Db);
4
5 fn open_v2(path: string, flags: i32) -> result<Db, i32>;
6 fn prepare_one(db: Db, sql: string) -> result<Stmt, i32>;
7 fn finalize(stmt: @consume Stmt) -> result<void, i32>;
8 fn close(db: @consume Db) -> result<void, i32>;
9}
No fio, cada handle é um inteiro de 64 bits empacotando um índice de tabela de 32 bits e um contador de geração de 32 bits diferente de zero: (uint64_t)generation << 32 | index. O runtime de C (c/src/hqq_rpc.c) mantém uma tabela de handles por sessão com três invariantes:
- Validação de Geração: Cada consulta verifica se o slot está ocupado, se sua geração armazenada corresponde aos 32 bits superiores do handle e se sua tag de tipo de handle corresponde ao tipo de parâmetro (
Dbvs.Stmt). Reutilizar um handle fechado falha imediatamente comHQQRPC_E_BAD_HANDLEantes de chamar a biblioteca em C. - Propriedade Hierárquica (
@owned_by): ComoStmté anotado com@owned_by(Db), fechar um handleDbpercorre automaticamente todos os handlesStmtfilhos ativos pertencentes a esseDbe executa seus destrutores em ordem de alocação reversa (sqlite3_finalizeantes desqlite3_close_v2). - Varredura de Encerramento de Sessão: Se um cliente Go se desconectar ou falhar sem fechar seus handles, o destrutor de sessão do servidor C varre toda a tabela de handles da sessão em ordem reversa, garantindo zero alocações de C vazadas através de reinicializações do cliente.
Antes que uma sessão processe sua primeira chamada RPC, o cliente e o servidor trocam um resumo de interface SHA-256 de 32 bytes computado sobre a IDL canônica (prefixo HQQ-BINDGEN-IDL-V1\n). Se o cliente Go e o trabalhador C foram compilados a partir de esquemas IDL incompatíveis, o handshake rejeita a conexão na inicialização.
3. Execução Binária à Prova de Adulteração (gosuda.org/hqq/secureexec)
Mover código C para um binário de trabalho levanta uma questão de cadeia de suprimentos: como o processo Go sabe que o binário de trabalho no disco não foi modificado ou substituído entre a verificação e a execução?
Chamar crypto/sha256 em um caminho de arquivo seguido por os/exec.Command(path).Start() deixa uma janela de Tempo de Verificação para Tempo de Uso (TOCTOU) onde outro processo pode sobrescrever ou trocar o arquivo antes que o kernel o execute. gosuda.org/hqq/secureexec fecha esta janela:
- No Linux, o
secureexecabre o binário, transmite seu conteúdo para um descritor de arquivo de memória anônimo criado viamemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)enquanto calcula seu hash SHA-256, verifica o hash em relação a um resumo fixo de 32 bytes ou um manifesto de lançamento assinado por Ed25519 (cmd/hqq-manifest), aplicafcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE)para que nenhum processo no sistema possa alterar as páginas, e gera o filho a partir do descritor vedado. - No macOS e Windows, o
secureexecmaterializa binários verificados em diretórios de cache bloqueados por permissão com chave baseada em resumo SHA-256 e verifica o tamanho do arquivo e a integridade do resumo antes da inicialização.
4. Supervisão de Processo por Linha de Vida do Pai (gosuda.org/hqq/launch)
Quando uma aplicação Go gera um processo de trabalho C em segundo plano, o trabalhador nunca deve sobreviver ao seu pai como um processo órfão, mesmo se o pai Go for encerrado com SIGKILL (kill -9) e não puder executar os manipuladores de limpeza defer.
gosuda.org/hqq/launch impõe o acoplamento de tempo de vida por meio de um pipe de SO anônimo passado para a entrada padrão do trabalhador com a flag --parent-stdin. O pai Go mantém a extremidade de gravação do pipe e nunca grava nela; o trabalhador C monitora a extremidade de leitura em uma thread de linha de vida dedicada. Se o processo Go sair normalmente, entrar em pânico ou for eliminado pelo kernel do SO (SIGKILL ou OOM killer), o kernel fecha imediatamente o descritor de gravação. O trabalhador C recebe EOF no pipe em microssegundos, interrompe quaisquer chamadas ativas, executa seus destrutores de handle de sessão, desvincula os arquivos de memória compartilhada e sai de forma limpa.
Início Rápido: Chamando um Serviço Gerado
Com hqq-bindgen e launch, chamar um trabalhador C isolado a partir de Go parece um cliente Go tipado comum, mantendo CGO_ENABLED=0:
1package main
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "time"
8
9 "gosuda.org/hqq/bindgen/rt"
10 "gosuda.org/hqq/launch"
11 "gosuda.org/hqq/secureexec"
12)
13
14func main() {
15 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
16 defer cancel()
17
18 hash, err := secureexec.ParseHash("7649b1b45dacd934cde8149f35f4fcc3937f6486fbac23ce976466719b3752bb")
19 if err != nil {
20 log.Fatal(err)
21 }
22
23 listener, err := launch.NewListenerName("demo")
24 if err != nil {
25 log.Fatal(err)
26 }
27
28 srv, err := launch.Start(ctx, launch.Config{
29 Binary: "/path/to/verified-worker",
30 SHA256: hash,
31 Listener: listener,
32 Args: []string{listener},
33 })
34 if err != nil {
35 log.Fatal(err)
36 }
37 defer srv.Close()
38
39 fmt.Printf("worker running on shared-memory listener %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}
Estrutura do Repositório
Cada subsistema em gosuda.org/hqq está organizado em pacotes focados com limites explícitos:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) e primitivas de anel de memória compartilhada de baixo nível
3├── advanced.go / slotpool.go # Despachante Advanced (MPMC), pool de slots e tabela de conclusão
4├── shm_*.go / wait_*.go # mmap/CreateFileMapping específicos do SO e futex/ulock/WaitOnAddress
5├── c/ # Biblioteca de transporte C11 (hqq.c) e runtime de servidor RPC (hqq_rpc.c)
6├── bindgen/ # Analisador IDL hqq-bindgen, verificador (V1-V41) e gerador de código Go/C11
7│ └── rt/ # Runtime de cliente Go com zero alocações para serviços gerados
8├── secureexec/ # Verificação de manifesto SHA-256 / Ed25519 e execução vedada por memfd
9├── launch/ # Supervisor de processo de trabalho com gerenciamento de linha de vida --parent-stdin
10├── cmd/hqq-manifest/ # Ferramenta CLI para gerar e assinar manifestos de binários de lançamento
11└── spec/ # Especificações de fio executáveis e vetores de teste de referência (BINDGEN.md)
Para ver como o HQQ alimenta substituições de produção diretas para duas das bibliotecas cgo mais amplamente utilizadas no ecossistema Go, confira os pacotes complementares construídos nesta pilha:
gosuda.org/sqlite3: Driverdatabase/sqldireto compatível comgithub.com/mattn/go-sqlite3, executando SQLite C nativo3.54.1sobre memória compartilhada com pré-busca em lote em pipeline.gosuda.org/zstd: Substituto direto paragithub.com/DataDog/zstd, executandolibzstd 1.5.7C nativo em um trabalhador de memória compartilhada isolado com contextos de zero alocação e processamento em lote em massa.- Repositório GitHub: https://github.com/gosuda/HQQ