HQQ: Isolating C Libraries from Go Over Cache-Line Shared Memory
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:
- Accoppiamento del Toolchain e della Compilazione Incrociata:
CGO_ENABLED=1richiede un compilatore C, intestazioni libc di destinazione e un linker di piattaforma per ogni sistema operativo e architettura di destinazione. La costruzione di artefattilinux/arm64owindows/arm64da una workstationdarwin/arm64trasforma un singolo comandogo buildin una matrice di compilatori incrociati e sysroot. - Sovraccarico di Scheduling del Runtime: Ogni chiamata
cgoattraversa 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. - 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 unabort()all'interno di una funzione C corrompono i metadati dell'heap di Go o terminano immediatamente l'intero processo del server, bypassandorecover()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 Integrazione | CGO_ENABLED=0 | Isolamento dai Guasti | Syscall del Percorso Veloce | Allocazioni dell'Heap per Chiamata | Fedeltà C11 Upstream |
|---|---|---|---|---|---|
cgo Link In-Process | No | Nessuno (heap condiviso) | 0 | da 0 a 1+ | C upstream esatto |
| RPC in Sottoprocesso Socket / Pipe | Sì | Limite di processo | da 2 a 4 | Molteplici (framing + I/O) | C upstream esatto |
| Traduzione Meccanica da C a Go | Sì | Go in-process | 0 | Elevata su chiamate complesse | Runtime C emulato |
| IPC in Memoria Condivisa HQQ | Sì | Limite di processo | 0 (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 +---------------------------------------------------------------------+
| Campo | Offset | Dimensione | Semantica |
|---|---|---|---|
seq | 0x00 | 8 B | Contatore di sequenza monotono. I valori dispari contrassegnano una scrittura in corso; i valori pari pubblicano un frame sottoposto a commit tramite store-release. |
len | 0x08 | 4 B | Lunghezza in byte del payload scritto nello slot (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Bit di controllo del frame (continuazione, stato di errore o avviso di arresto). |
payload | 0x40 | BufferSize | Regione 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 (futexsu Linux,__ulock_waitsu macOS,WaitOnAddresssu Windows). Un round-trip a singolo frame si completa in ~200 ns con0 allocs/op.Advanced(Anello di Invio MPMC): MultipassaMchiamanti Go concorrenti suNthread 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:
- 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 (
DbcontroStmt). Il riutilizzo di un handle chiuso fallisce immediatamente conHQQRPC_E_BAD_HANDLEprima di effettuare la chiamata alla libreria C. - Proprietà Gerarchica (
@owned_by): PoichéStmtè annotato con@owned_by(Db), la chiusura di un handleDbscorre automaticamente tutti gli handleStmtfiglio attivi posseduti da quelDbed esegue i loro distruttori in ordine di allocazione inverso (sqlite3_finalizeprima disqlite3_close_v2). - 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,
secureexecapre il binario, ne trasmette i contenuti in un descrittore di file di memoria anonimo creato tramitememfd_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), applicafcntl(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,
secureexecmaterializza 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: Driverdatabase/sqlsostitutivo compatibile congithub.com/mattn/go-sqlite3, che esegue SQLite C nativo3.54.1su memoria condivisa con prefetching batch pipeline.gosuda.org/zstd: Sostituto diretto pergithub.com/DataDog/zstd, che eseguelibzstd 1.5.7in 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