GoSuda

HQQ: Isolating C Libraries from Go Over Cache-Line Shared Memory

By Lemon Mint
views ...

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:

  1. Acoplamento de Toolchain e Compilação Cruzada: CGO_ENABLED=1 requer um compilador C, cabeçalhos de libc de destino e um linker de plataforma para cada SO e arquitetura de destino. Construir artefatos linux/arm64 ou windows/arm64 a partir de uma estação de trabalho darwin/arm64 transforma um único comando go build em uma matriz de compiladores cruzados e sysroots.
  2. Sobrecarga de Agendamento em Tempo de Execução: Cada chamada de cgo cruza 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.
  3. 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, ignorando recover() 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çãoCGO_ENABLED=0Isolamento de FalhasSyscalls de Caminho RápidoAlocações de Heap por ChamadaFidelidade C11 Upstream
Vinculação In-Process cgoNãoNenhum (heap compartilhado)00 a 1+C upstream exato
RPC de Subprocesso Socket / PipeSimLimite de processo2 a 4Múltiplas (enquadramento + I/O)C upstream exato
Tradução Mecânica de C para GoSimGo in-process0Alto em chamadas complexasRuntime C emulado
IPC de Memória Compartilhada HQQSimLimite de processo0 (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              +---------------------------------------------------------------------+
CampoDeslocamentoTamanhoSemântica
seq0x008 BContador de sequência monotônico. Valores ímpares marcam uma gravação em andamento; valores pares publicam um quadro consolidado por meio de store-release.
len0x084 BComprimento em bytes da carga útil gravada no slot (0 <= len <= BufferSize).
flags0x0C4 BBits de controle de quadro (continuação, status de erro ou aviso de encerramento).
payload0x40BufferSizeRegiã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 (futex no Linux, __ulock_wait no macOS, WaitOnAddress no Windows). Uma viagem de 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 através de N threads 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:

  1. 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 (Db vs. Stmt). Reutilizar um handle fechado falha imediatamente com HQQRPC_E_BAD_HANDLE antes de chamar a biblioteca em 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 ativos pertencentes a esse Db e executa seus destrutores em ordem de alocação reversa (sqlite3_finalize antes de sqlite3_close_v2).
  3. 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 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 em relação a um resumo fixo de 32 bytes ou um manifesto de lançamento assinado por 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 no sistema possa alterar as páginas, e gera o filho a partir do descritor vedado.
  • No macOS e Windows, o secureexec materializa 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: Driver database/sql direto compatível com github.com/mattn/go-sqlite3, executando SQLite C nativo 3.54.1 sobre memória compartilhada com pré-busca em lote em pipeline.
  • gosuda.org/zstd: Substituto direto para github.com/DataDog/zstd, executando libzstd 1.5.7 C 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