GoSuda

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

By Lemon Mint
views ...

gosuda.org/hqq è un runtime di comunicazione inter-processo a zero-cgo e un toolchain di generazione di codice per Go e C11. Isola librerie C native all'interno di processi worker del sistema operativo separati e li collega a chiamate Go tramite anelli di memoria condivisa mappati in memoria e allineati alla linea di cache. Il progetto combina due livelli di trasporto (StandardLink per sessioni dedicate a singolo produttore e singolo consumatore e Advanced per l'invio MPMC multi-worker), il compilatore IDL hqq-bindgen e un supervisore di processo verificato contro la manomissione (secureexec e launch).

1go get gosuda.org/hqq

Il Costo della Condivisione di uno Spazio di Indirizzi con C

Ogni ingegnere di sistemi Go arriva inevitabilmente allo stesso bivio. Si sta costruendo un servizio Go che compila in pochi secondi con CGO_ENABLED=0, viene distribuito come un singolo binario statico, effettua la compilazione incrociata da un computer portatile a destinazioni Linux o Windows senza un sysroot, ed è eseguito all'interno di un heap gestito dal garbage collector e sicuro per la memoria. Dopodiché, si rende necessaria una libreria C matura: SQLite per l'archiviazione relazionale incorporata, Zstandard (libzstd) per la compressione a dizionario, oppure una libreria multimediale o crittografica accelerata via hardware.

Abilitare cgo per collegare tale libreria C nel processo Go risolve il problema immediato di binding introducendo tuttavia tre passività strutturali:

  1. Accoppiamento del Toolchain e della Compilazione Incrociata: CGO_ENABLED=1 richiede un compilatore C, intestazioni libc di destinazione e un linker di piattaforma per ogni sistema operativo e architettura di destinazione. La costruzione di artefatti linux/arm64 o windows/arm64 da una workstation darwin/arm64 trasforma un singolo comando go build in una matrice di compilatori incrociati e sysroot.
  2. Sovraccarico di Scheduling del Runtime: Ogni chiamata cgo attraversa il confine del runtime Go, passa da uno stack di goroutine espandibile a uno stack di sistema e fissa un thread del sistema operativo (M). Per chiamate a grana fine, l'imposta di transizione (da ~50 a 150 ns per chiamata) e la contesa dello scheduler si accumulano rapidamente.
  3. Crollo del Dominio dei Guasti: Il codice C viene eseguito nello stesso spazio di indirizzi virtuale del runtime Go. Una singola scrittura fuori dai limiti, un utilizzo dopo la liberazione (use-after-free), un overflow dello stack o un abort() all'interno di una funzione C corrompono i metadati dell'heap di Go o terminano immediatamente l'intero processo del server, bypassando recover() e trascinando con sé ogni richiesta HTTP o RPC in corso.

Quando i team tentano di evitare cgo, le alternative comuni impongono un compromesso differente. Le traduzioni meccaniche in puro Go di codebase C preservano CGO_ENABLED=0, ma spesso rimangono indietro rispetto alle release C upstream, trasportano un livello di memoria virtuale emulato ed effettuano allocazioni consistenti sotto carico. Lo spostamento della libreria C in un processo di supporto tramite TCP di loopback o socket di dominio Unix ripristina l'isolamento dai guasti, ma ogni chiamata paga per due cambi di contesto del kernel, copie di buffer di socket e serializzazione su cavo.

Abbiamo realizzato HQQ dopo aver posto una domanda diretta: cosa accadrebbe se la libreria C risiedesse in un processo del sistema operativo separato con il proprio spazio di indirizzi virtuale, mentre Go e C scambiassero frame direttamente attraverso linee di cache della CPU condivise senza entrare nel kernel nel percorso veloce?

Modello di IntegrazioneCGO_ENABLED=0Isolamento dai GuastiSyscall del Percorso VeloceAllocazioni dell'Heap per ChiamataFedeltà C11 Upstream
cgo Link In-ProcessNoNessuno (heap condiviso)0da 0 a 1+C upstream esatto
RPC in Sottoprocesso Socket / PipeSìLimite di processoda 2 a 4Molteplici (framing + I/O)C upstream esatto
Traduzione Meccanica da C a GoSìGo in-process0Elevata su chiamate complesseRuntime C emulato
IPC in Memoria Condivisa HQQSìLimite di processo0 (spin adattivo)0 (StandardLink)C upstream esatto

Panoramica Architetturale: Quattro Livelli Attraverso i Limiti dei Processi

HQQ separa l'isolamento dei processi in quattro livelli componibili affinché la meccanica degli anelli di memoria condivisa, la generazione di codice RPC tipizzato, la verifica dell'integrità binaria e la supervisione dei processi figlio rimangano indipendenti.

 1+-------------------------------------------------------------------+
 2| Go Application Process (CGO_ENABLED=0)                            |
 3|                                                                   |
 4|  +-----------------------+     +-------------------------------+  |
 5|  | Generated Go Client   |     | Process Supervisor            |  |
 6|  | (gosuda.org/hqq/      |     | (gosuda.org/hqq/launch +      |  |
 7|  |  bindgen/rt)          |     |  gosuda.org/hqq/secureexec)   |  |
 8|  +-----------+-----------+     +---------------+---------------+  |
 9|              |                                 |                  |
10|  +-----------v-----------+                     | Anonymous Pipe   |
11|  | Transport Tier        |                     | Lifeline         |
12|  | (StandardLink /       |                     | (--parent-stdin) |
13|  |  Advanced MPMC)       |                     |                  |
14+--------------+---------------------------------+------------------+
15               |                                 |
16===============|========= Shared Memory =========|===================
17               |     (mmap / CreateFileMapping)  |
18+--------------v---------------------------------v------------------+
19| C11 Worker Process (Isolated Address Space)                       |
20|                                                                   |
21|  +-----------------------+     +-------------------------------+  |
22|  | Generated C11 Server  |---->| Generation-Checked Handle     |  |
23|  | & Batch Shims         |     | Table (hqq_rpc.c)             |  |
24|  +-----------+-----------+     +---------------+---------------+  |
25|              |                                 |                  |
26|              +----------------+----------------+                  |
27|                               |                                   |
28|                     +---------v---------+                         |
29|                     | Native C Library  |                         |
30|                     | (SQLite, Zstd...) |                         |
31|                     +-------------------+                         |
32+-------------------------------------------------------------------+

1. Trasporto in Memoria Condivisa (gosuda.org/hqq e c/)

Il pacchetto base gosuda.org/hqq e la sua controparte C11 in c/ implementano due modalità di trasporto in memoria condivisa tramite mmap POSIX (Linux e macOS) e CreateFileMappingW Win32 (Windows). Ogni segmento condiviso inizia con un superblocco HQQ_STL3 verificato che fissa la versione del protocollo, la geometria del buffer e i PID degli endpoint prima che venga letto qualsiasi indice di anello.

Tutte le parole di controllo attive e le intestazioni degli slot sono allineate ai confini della linea di cache a 64 byte, in modo che i core produttori e consumatori non contendano mai sulla stessa linea di cache a causa di falsa condivisione (false sharing).

1StandardLink Slot Layout (64-Byte Cache-Line Aligned)
2Byte Offset:  0x00                0x08                0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Header Line:  | seq (u64 atomic)  | len (u32) | flags | reserved (zero-padded)      |
5              +-------------------+-------------------+-----------------------------+
6Payload Area: | payload bytes [0 .. BufferSize - 1] (directly encoded / decoded)    |
7              +---------------------------------------------------------------------+
CampoOffsetDimensioneSemantica
seq0x008 BContatore di sequenza monotono. I valori dispari contrassegnano una scrittura in corso; i valori pari pubblicano un frame sottoposto a commit tramite store-release.
len0x084 BLunghezza in byte del payload scritto nello slot (0 <= len <= BufferSize).
flags0x0C4 BBit di controllo del frame (continuazione, stato di errore o avviso di arresto).
payload0x40BufferSizeRegione di payload contigua letta e scritta in loco senza copie intermedie.

I chiamanti scelgono tra due livelli di trasporto a seconda della struttura di concorrenza:

  • StandardLink (Sessione SPSC 1:1): Associa una sessione client e una sessione server su slot di richiesta e risposta dedicati protetti da un blocco di sequenza (SeqLock). Sul percorso veloce, il writer serializza direttamente nello slot mappato e finalizza con uno store-release atomico; il reader esegue brevemente il polling (spin) nello spazio utente prima di passare alle primitive di risveglio del sistema operativo (futex su Linux, __ulock_wait su macOS, WaitOnAddress su Windows). Un round-trip a singolo frame si completa in ~200 ns con 0 allocs/op.
  • Advanced (Anello di Invio MPMC): Multipassa M chiamanti Go concorrenti su N thread worker C utilizzando un anello di sottomissione multi-produttore multi-consumatore senza lock (lock-free), un pool di slot condiviso e una tabella di completamento indicizzata. I chiamanti riservano uno slot, pubblicano l'indice dello slot nell'anello e attendono sulla propria voce di completamento mentre i thread worker estraggono dalla coda e completano le richieste fuori ordine.

Entrambi i livelli tracciano la vivacità dei peer in modo continuo. Ogni endpoint aggiorna un timestamp atomico monotono tramite Heartbeat() e ispeziona PeerAlive(timeout) durante l'attesa. Se un processo worker va in crash a metà chiamata, il peer rileva immediatamente il battito cardiaco stanco (stale) o il PID morto, interrotto le attese bloccate con ErrPeerGone e supporta il Takeover atomico della sessione senza lasciare file di memoria condivisa bloccati.

2. RPC a Zero Allocazione e Handle Verificati per Generazione (gosuda.org/hqq/bindgen)

Scrivere manualmente il framing in memoria condivisa per una libreria C con decine di funzioni è noioso e soggetto a errori. hqq-bindgen è un compilatore di schema che legge un file IDL (Interface Definition Language) (o ne avvia uno direttamente da un'intestazione C .h tramite hqq-bindgen init) ed emette un pacchetto client Go a zero-cgo insieme a uno scheletro server C11.

L'errore più pericoloso in qualsiasi binding C è esporre puntatori C grezzi (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) oltre il confine del linguaggio. Se un chiamante passa un puntatore dopo averlo liberato, passa un puntatore appartenente a un'altra sessione o corrompe un indirizzo intero, la libreria C dereferenzia memoria non valida.

hqq-bindgen vieta i puntatori grezzi sul canale di trasmissione. Al contrario, l'IDL modella le risorse C con stato come dichiarazioni handle tipizzate:

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}

Sul canale di trasmissione, ogni handle è un intero a 64 bit che impacchetta un indice di tabella a 32 bit e un contatore di generazione non zero a 32 bit: (uint64_t)generation << 32 | index. Il runtime C (c/src/hqq_rpc.c) mantiene una tabella di handle per sessione con tre invarianti:

  1. Validazione della Generazione: Ogni ricerca verifica che lo slot sia occupato, che la sua generazione memorizzata corrisponda ai 32 bit superiori dell'handle e che il tag del tipo di handle corrisponda al tipo di parametro (Db contro Stmt). Il riutilizzo di un handle chiuso fallisce immediatamente con HQQRPC_E_BAD_HANDLE prima di effettuare la chiamata alla libreria C.
  2. Proprietà Gerarchica (@owned_by): Poiché Stmt è annotato con @owned_by(Db), la chiusura di un handle Db scorre automaticamente tutti gli handle Stmt figlio attivi posseduti da quel Db ed esegue i loro distruttori in ordine di allocazione inverso (sqlite3_finalize prima di sqlite3_close_v2).
  3. Scansione di Chiusura della Sessione: Se un client Go si disconnette o va in crash senza chiudere i suoi handle, il distruttore di sessione del server C scansiona l'intera tabella degli handle di sessione in ordine inverso, garantendo zero allocazioni C perse (leaked) tra i riavvii del client.

Prima che una sessione elabori la sua prima chiamata RPC, il client e il server si scambiano un digest dell'interfaccia SHA-256 a 32 byte calcolato sull'IDL canonizzato (prefisso HQQ-BINDGEN-IDL-V1\n). Se il client Go e il worker C sono stati compilati da schemi IDL incompatibili, l'handshake rifiuta la connessione in fase di bootstrap.

3. Esecuzione Binaria a Prova di Manomissione (gosuda.org/hqq/secureexec)

Spostare il codice C in un binario worker solleva una questione legata alla supply-chain: come fa il processo Go a sapere che il binario worker su disco non è stato modificato o sostituito tra la verifica e l'esecuzione?

Chiamare crypto/sha256 su un percorso di file seguito da os/exec.Command(path).Start() lascia una finestra temporale di tipo Time-of-Check to Time-of-Use (TOCTOU) in cui un altro processo può sovrascrivere o sostituire il file prima che il kernel lo esegua. gosuda.org/hqq/secureexec chiude questa finestra:

  • Su Linux, secureexec apre il binario, ne trasmette i contenuti in un descrittore di file di memoria anonimo creato tramite memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) mentre calcola il suo hash SHA-256, verifica l'hash rispetto a un digest bloccato a 32 byte o a un manifesto di rilascio firmato con Ed25519 (cmd/hqq-manifest), applica fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) in modo che nessun processo sul sistema possa mutare le pagine, e genera il figlio dal descrittore sigillato.
  • Su macOS e Windows, secureexec materializza i binari verificati in directory di cache protette da autorizzazioni e indicizzate tramite digest SHA-256, verificando la dimensione del file e l'integrità del digest prima dell'avvio.

4. Supervisione del Processo tramite Lifeline del Genitore (gosuda.org/hqq/launch)

Quando un'applicazione Go genera un processo worker C in background, il worker non deve mai sopravvivere al suo genitore come processo orfano, anche se il genitore Go viene terminato con SIGKILL (kill -9) e non può eseguire gli handler di pulizia defer.

gosuda.org/hqq/launch impone l'accoppiamento della durata vitale tramite una pipe anonima del sistema operativo passata allo standard input del worker con il flag --parent-stdin. Il genitore Go detiene l'estremità di scrittura della pipe e non vi scrive mai; il worker C monitora l'estremità di lettura su un thread lifeline dedicato. Se il processo Go esce normalmente, genera un panic o viene ucciso dal kernel del sistema operativo (SIGKILL o OOM killer), il kernel chiude immediatamente il descrittore di scrittura. Il worker C riceve EOF sulla pipe entro pochi microsecondi, interrompe qualsiasi chiamata attiva, esegue i distruttori degli handle di sessione, scollega i file di memoria condivisa ed esce in modo pulito.

Guida Rapida: Chiamata a un Servizio Generato

Con hqq-bindgen e launch, la chiamata a un worker C isolato da Go appare come un normale client Go tipizzato, pur mantenendo 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}

Struttura del Repository

Ogni sottosistema in gosuda.org/hqq è organizzato in pacchetti mirati con confini espliciti:

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) and low-level shared-memory ring primitives
 3├── advanced.go / slotpool.go  # Advanced (MPMC) dispatcher, slot pool, and completion table
 4├── shm_*.go / wait_*.go       # OS-specific mmap/CreateFileMapping and futex/ulock/WaitOnAddress
 5├── c/                         # C11 transport library (hqq.c) and RPC server runtime (hqq_rpc.c)
 6├── bindgen/                   # hqq-bindgen IDL parser, verifier (V1-V41), and Go/C11 code generator
 7│   └── rt/                    # Zero-allocation Go client runtime for generated services
 8├── secureexec/                # SHA-256 / Ed25519 manifest verification and memfd-sealed execution
 9├── launch/                    # Worker process supervisor with --parent-stdin lifeline management
10├── cmd/hqq-manifest/          # CLI tool for generating and signing release binary manifests
11└── spec/                      # Executable wire specifications and golden test vectors (BINDGEN.md)

Per vedere come HQQ alimenta sostituzioni di produzione pronte all'uso per due delle librerie cgo più ampiamente utilizzate nell'ecosistema Go, consultare i pacchetti complementari basati su questo stack:

  • gosuda.org/sqlite3: Driver database/sql sostitutivo compatibile con github.com/mattn/go-sqlite3, che esegue SQLite C nativo 3.54.1 su memoria condivisa con prefetching batch pipeline.
  • gosuda.org/zstd: Sostituto diretto per github.com/DataDog/zstd, che esegue libzstd 1.5.7 in C nativo all'interno di un worker in memoria condivisa isolato con contesti a zero allocazione e batching massivo.
  • Repository GitHub: https://github.com/gosuda/HQQ