GoSuda

HQQ: Isolamento de Bibliotecas C em Relação ao Go por Meio de Memória Compartilhada Alinhada a Linhas de Cache

By Lemon Mint views ...

gosuda.org/hqq é um runtime de comunicação entre processos sem cgo e uma cadeia de ferramentas de geração de código para Go e C11. Ele isola bibliotecas nativas em C dentro de processos de trabalho separados do sistema operacional 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 do tipo single-producer single-consumer, e Advanced, para despacho MPMC com múltiplos workers), o compilador de IDL hqq-bindgen e um supervisor de processos com verificação de integridade (secureexec e launch).

1go get gosuda.org/hqq

O Custo de Compartilhar um Espaço de Endereçamento com C

Todo engenheiro de sistemas em Go, em algum momento, se depara com a mesma bifurcação. 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, é compilado de forma cruzada a partir de um notebook para alvos Linux ou Windows sem um sysroot e executa dentro de um heap com coleta de lixo e segurança de memória. Então, você precisa de uma biblioteca em C madura: SQLite para armazenamento relacional embarcado, Zstandard (libzstd) para compressão com dicionário, ou uma biblioteca de mídia ou criptografia com aceleração por hardware.

Habilitar cgo para vincular essa biblioteca em C ao processo Go resolve o problema imediato de ligação, mas introduz três passivos estruturais:

  1. Acoplamento de Toolchain e Compilação Cruzada: CGO_ENABLED=1 exige um compilador C, cabeçalhos da libc do alvo e um linker da plataforma para cada sistema operacional e arquitetura de destino. Construir artefatos linux/arm64 ou windows/arm64 a partir de uma estação darwin/arm64 transforma um único comando go build em uma matriz de compiladores cruzados e sysroots.
  2. Sobrecarga de Escalonamento em Runtime: Toda chamada cgo cruza a fronteira do runtime de Go, troca uma pilha de goroutine扩展ável por uma pilha de sistema e fixa uma thread do sistema operacional (M). Para chamadas de granularidade fina, o custo de transição (~50 a 150 ns por chamada) e a contenção do escalonador se acumulam rapidamente.
  3. Colapso do Domínio de Falhas: O código C executa no mesmo espaço de endereçamento virtual do runtime de Go. Uma única escrita fora dos limites, um uso após liberação, um estouro de pilha ou um abort() dentro de uma função C corrompe os metadados do heap de Go ou encerra imediatamente todo o processo do servidor, contornando recover() e derrubando consigo todas as requisições HTTP ou RPC em andamento.

Quando as equipes tentam evitar cgo, as alternativas comuns impõem outro compromisso. Traduções mecânicas puras de bases de código C para Go preservam CGO_ENABLED=0, mas frequentemente ficam atrás das versões upstream em C, carregam uma camada de memória virtual emulada e alocam intensamente sob carga. Mover a biblioteca C para um processo auxiliar por meio de TCP em loopback ou sockets de domínio Unix restaura o isolamento de falhas, mas cada chamada paga por duas trocas de contexto no kernel, cópias de buffers de socket e serialização de transporte.

Construímos o HQQ após nos fazermos uma pergunta direta: e se a biblioteca C residisse em um processo separado do sistema operacional, com seu próprio espaço de endereçamento virtual, enquanto Go e C trocassem quadros diretamente por meio de linhas de cache de CPU compartilhadas, sem entrar no kernel no caminho rápido?

Modelo de IntegraçãoCGO_ENABLED=0Isolamento de FalhasSyscalls no Caminho RápidoAlocações de Heap por ChamadaFidelidade ao C11 Upstream
Vinculação cgo In-ProcessNãoNenhum (heap compartilhado)00 a 1+C upstream exato
RPC de Subprocesso via Socket / PipeSimFronteira de processo2 a 4Múltiplas (enquadramento + E/S)C upstream exato
Tradução Mecânica de C para GoSimGo in-process0Alta em chamadas complexasRuntime de C emulado
HQQ IPC com Memória CompartilhadaSimFronteira de processo0 (spin adaptativo)0 (StandardLink)C upstream exato

Visão Arquitetural: Quatro Camadas Através das Fronteiras de Processo

O HQQ separa o isolamento de processos em quatro camadas combináveis, de modo que a mecânica dos anéis de memória compartilhada, a geração de 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 Processos       |  |
 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  |                     | Lifeline         |
12|  | (StandardLink /       |                     | (--parent-stdin) |
13|  |  Advanced MPMC)       |                     |                  |
14+--------------+---------------------------------+------------------+
15               |                                 |
16===============|========= Memória Compartilhada =|===================
17               |     (mmap / CreateFileMapping)  |
18+--------------v---------------------------------v------------------+
19| Processo Worker C11 (Espaço de Endereçamento Isolado)             |
20|                                                                   |
21|  +-----------------------+     +-------------------------------+  |
22|  | Servidor C11 Gerado   |---->| Tabela de Handles com         |  |
23|  | & Shims de Lote       |     | Verificação de Geração        |  |
24|  +-----------+-----------+     | (hqq_rpc.c)                   |  |
25|              |                 +---------------+---------------+  |
26|              +----------------+----------------+                  |
27|                               |                                   |
28|                     +---------v---------+                         |
29|                     | Biblioteca C      |                         |
30|                     | Nativa (SQLite,   |                         |
31|                     | Zstd...)          |                         |
32|                     +-------------------+                         |
33+-------------------------------------------------------------------+

1. Transporte por Memória Compartilhada (gosuda.org/hqq e c/)

O pacote base gosuda.org/hqq e sua contraparte em C11, em c/, implementam dois modos de transporte por memória compartilhada sobre mmap do POSIX (Linux e macOS) e CreateFileMappingW da Win32 (Windows). Todo segmento compartilhado começa com um superbloco HQQ_STL3 verificado, que fixa a versão do protocolo, a geometria dos buffers e os PIDs dos endpoints antes que qualquer índice de anel seja lido.

Todas as palavras de controle ativas e os cabeçalhos de slot são alinhados a fronteiras de linha de cache de 64 bytes, de modo que os núcleos produtor e consumidor nunca disputem a mesma linha de cache por meio de false sharing.

1Layout de Slot do StandardLink (Alinhado à Linha de Cache de 64 Bytes)
2Deslocamento em Bytes:  0x00              0x08              0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Linha de Cabeçalho:  | seq (u64 atômico) | len (u32) | flags | reservado (preenchido com zeros) |
5              +-------------------+-------------------+-----------------------------+
6Área de Payload:     | bytes de payload [0 .. BufferSize - 1] (codificados / decodificados diretamente) |
7              +---------------------------------------------------------------------+
CampoDeslocamentoTamanhoSemântica
seq0x008 BContador de sequência monotônico. Valores ímpares marcam uma escrita em andamento; valores pares publicam um quadro confirmado por meio de store-release.
len0x084 BTamanho em bytes do payload gravado no slot (0 <= len <= BufferSize).
flags0x0C4 BBits de controle do quadro (continuação, status de erro ou aviso de encerramento).
payload0x40BufferSizeRegião contígua de payload, lida e escrita no local, sem cópias intermediárias.

Os chamadores escolhem entre duas camadas de transporte, dependendo da estrutura de concorrência:

  • StandardLink (Sessão SPSC 1:1): Emparelha uma sessão de cliente e uma de servidor por meio de slots dedicados de requisição e resposta, protegidos por um lock de sequência (SeqLock). No caminho rápido, o escritor serializa diretamente no slot mapeado e confirma com um store-release atômico; o leitor faz um spin breve no espaço do usuário antes de recorrer às primitivas de despertar do sistema operacional (futex no Linux, __ulock_wait no macOS, WaitOnAddress no Windows). Uma ida e volta de quadro único é concluída em ~200 ns, com 0 allocs/op.
  • Advanced (Anel de Despacho MPMC): Multiplexa M chamadores concorrentes em Go entre N threads workers em C, usando um anel de submissão multi-producer multi-consumer sem locks, 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 sua entrada de conclusão, enquanto as threads workers retiram requisições do anel e as concluem fora de ordem.

Ambas as camadas monitoram continuamente a vitalidade dos pares. Cada endpoint atualiza um timestamp monotônico atômico por meio de Heartbeat() e consulta PeerAlive(timeout) durante a espera. Se um processo worker falhar no meio de uma chamada, o par detecta imediatamente o heartbeat obsoleto ou o PID inativo, interrompe as esperas bloqueadas com ErrPeerGone e oferece suporte a Takeover atômico de sessão, sem deixar arquivos de memória compartilhada travados.

2. RPC Sem Alocações e Handles com Verificação de Geração (gosuda.org/hqq/bindgen)

Escrever o enquadramento de memória compartilhada manualmente para uma biblioteca C com dezenas de funções é tedioso e propenso a erros. O hqq-bindgen é um compilador de esquemas que lê um arquivo de Linguagem de Definição de Interface (.idl) (ou gera um diretamente a partir de um cabeçalho C .h por meio de hqq-bindgen init) e emite um pacote cliente Go sem cgo, junto com um esqueleto de servidor C11.

O erro mais perigoso em qualquer binding para C é expor ponteiros C brutos (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) através da fronteira de linguagem. Se um chamador passar um ponteiro após tê-lo liberado, passar um ponteiro pertencente a outra sessão ou corromper um endereço inteiro, a biblioteca C desreferenciará memória inválida.

O hqq-bindgen proíbe ponteiros brutos no fio. Em vez disso, o IDL modela recursos C com estado como declarações tipadas de handle:

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, todo handle é um inteiro de 64 bits que empacota um índice de tabela de 32 bits e um contador de geração de 32 bits não nulo: (uint64_t)generation << 32 | index. O runtime C (c/src/hqq_rpc.c) mantém uma tabela de handles por sessão, com três invariantes:

  1. Validação de Geração: Toda busca verifica se o slot está ocupado, se sua geração armazenada corresponde aos 32 bits superiores do handle e se sua etiqueta de tipo de handle corresponde ao tipo do parâmetro (Db versus Stmt). Reutilizar um handle fechado falha imediatamente com HQQRPC_E_BAD_HANDLE, antes de qualquer chamada à biblioteca C.
  2. Propriedade Hierárquica (@owned_by): Como Stmt é anotado com @owned_by(Db), fechar um handle Db percorre automaticamente todos os handles Stmt filhos vivos pertencentes a esse Db e executa seus destrutores na ordem inversa de alocação (sqlite3_finalize antes de sqlite3_close_v2).
  3. Varredura de Encerramento da 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 inversa, garantindo zero alocações C vazadas entre reinicializações do cliente.

Antes que uma sessão processe sua primeira chamada RPC, o cliente e o servidor trocam um digest de interface SHA-256 de 32 bytes, calculado sobre o IDL canonicalizado (prefixo HQQ-BINDGEN-IDL-V1\n). Se o cliente Go e o worker C tiverem sido compilados a partir de esquemas IDL incompatíveis, o handshake rejeita a conexão já 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 worker levanta uma questão de cadeia de suprimentos: como o processo Go sabe que o binário do worker em disco não foi modificado ou substituído entre a verificação e a execução?

Chamar crypto/sha256 sobre um caminho de arquivo seguido de os/exec.Command(path).Start() deixa uma janela de tempo entre verificação e uso (TOCTOU, Time-of-Check to Time-of-Use) em que outro processo pode sobrescrever ou trocar o arquivo antes que o kernel o execute. O gosuda.org/hqq/secureexec fecha essa janela:

  • No Linux, o secureexec abre o binário, transmite seu conteúdo para um descritor de arquivo de memória anônimo criado via memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) enquanto calcula seu hash SHA-256, verifica o hash contra um digest fixado de 32 bytes ou contra um manifesto de release assinado com Ed25519 (cmd/hqq-manifest), aplica fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) para que nenhum processo do sistema possa mutar as páginas e cria o processo filho a partir do descritor selado.
  • No macOS e no Windows, o secureexec materializa binários verificados em diretórios de cache com permissões restritas, indexados pelo digest SHA-256, e verifica a integridade do tamanho e do digest do arquivo antes do lançamento.

4. Supervisão de Processos com Lifeline Parental (gosuda.org/hqq/launch)

Quando uma aplicação Go cria um processo worker C em segundo plano, o worker nunca deve sobreviver ao seu pai como um processo órfão, mesmo que o pai Go seja encerrado com SIGKILL (kill -9) e não consiga executar handlers de limpeza com defer.

O gosuda.org/hqq/launch impõe o acoplamento de tempo de vida por meio de um pipe anônimo do sistema operacional, passado à entrada padrão do worker com a flag --parent-stdin. O pai Go mantém a extremidade de escrita do pipe e nunca escreve nele; o worker C monitora a extremidade de leitura em uma thread lifeline dedicada. Se o processo Go terminar normalmente, entrar em pânico ou for morto pelo kernel do sistema operacional (SIGKILL ou pelo OOM killer), o kernel fecha imediatamente o descritor de escrita. O worker C recebe EOF no pipe em microssegundos, interrompe quaisquer chamadas ativas, executa seus destrutores de handles de sessão, remove os arquivos de memória compartilhada e encerra de forma limpa.

Início Rápido: Chamando um Serviço Gerado

Com o hqq-bindgen e o launch, chamar um worker C isolado a partir de Go se parece com 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 em execução no listener de memória compartilhada %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}

Estrutura do Repositório

Cada subsistema em gosuda.org/hqq é 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 do servidor RPC (hqq_rpc.c)
 6├── bindgen/                   # Parser IDL do hqq-bindgen, verificador (V1-V41) e gerador de código Go/C11
 7│   └── rt/                    # Runtime cliente Go sem alocações para serviços gerados
 8├── secureexec/                # Verificação de manifesto SHA-256 / Ed25519 e execução selada por memfd
 9├── launch/                    # Supervisor de processos worker com gerenciamento de lifeline --parent-stdin
10├── cmd/hqq-manifest/          # Ferramenta de linha de comando para gerar e assinar manifestos de binários de release
11└── spec/                      # Especificações executáveis de protocolo e vetores de teste dourados (BINDGEN.md)

Para ver como o HQQ viabiliza substitutos de produção prontos para uso de duas das bibliotecas cgo mais utilizadas no ecossistema Go, conheça os pacotes complementares construídos sobre essa pilha:

  • gosuda.org/sqlite3: Driver database/sql compatível com github.com/mattn/go-sqlite3, executando o SQLite nativo em C 3.54.1 sobre memória compartilhada, com pré-busca em lotes em pipeline.
  • gosuda.org/zstd: Substituto direto de github.com/DataDog/zstd, executando o libzstd 1.5.7 nativo em C em um worker isolado com memória compartilhada, com contextos sem alocações e processamento em lote.
  • Repositório no GitHub: https://github.com/gosuda/HQQ