GoSuda

HQQ: Isolamento delle librerie C dal Go tramite memoria condivisa su cache-line

By Lemon Mint views ...

gosuda.org/hqq è un runtime di comunicazione tra processi zero-cgo e una toolchain di generazione di codice per Go e C11. Isola le librerie native C all'interno di processi worker del sistema operativo separati e le collega ai chiamanti Go tramite ring di memoria condivisa mappata in memoria, allineati alla cache line. Il progetto combina due livelli di trasporto (StandardLink per sessioni dedicate single-producer single-consumer e Advanced per il dispatch MPMC multi-worker), il compilatore IDL hqq-bindgen e un supervisore di processi con verifica di integrità (secureexec e launch).

1go get gosuda.org/hqq

Il costo di condividere uno spazio di indirizzi con C

Ogni ingegnere di sistemi Go si trova prima o poi di fronte allo stesso bivio. Si sta costruendo un servizio Go che compila in pochi secondi con CGO_ENABLED=0, viene distribuito come un unico binario statico, esegue la cross-compilazione da un laptop verso target Linux o Windows senza un sysroot e opera all'interno di un heap con garbage collection e sicurezza della memoria. Successivamente, è necessaria una libreria C matura: SQLite per l'archiviazione relazionale embedded, Zstandard (libzstd) per la compressione basata su dizionario, oppure una libreria multimediale o crittografica con accelerazione hardware.

Abilitare cgo per collegare tale libreria C nel processo Go risolve il problema immediato del binding, ma introduce tre vincoli strutturali:

  1. Accoppiamento tra toolchain e cross-compilazione: CGO_ENABLED=1 richiede un compilatore C, gli header della libc di destinazione e un linker per ogni sistema operativo e architettura di destinazione. Costruire artefatti linux/arm64 o windows/arm64 da una workstation darwin/arm64 trasforma un singolo comando go build in una matrice di cross-compilatori e sysroot.
  2. Overhead 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 vincola un thread del sistema operativo (M). Per chiamate a grana fine, il costo di transizione (~50 a 150 ns per chiamata) e la contesa dello scheduler si accumulano rapidamente.
  3. Collasso del dominio di guasto: Il codice C viene eseguito nello stesso spazio di indirizzi virtuale del runtime Go. Una singola scrittura fuori dai limiti, un use-after-free, un overflow dello stack o un abort() all'interno di una funzione C corrompe i metadati dell'heap Go o termina immediatamente l'intero processo server, aggirando recover() e interrompendo ogni richiesta HTTP o RPC in corso.

Quando i team cercano di evitare cgo, le alternative comuni impongono un compromesso diverso. Le traduzioni meccaniche pure in Go di basi di codice C preservano CGO_ENABLED=0, ma spesso restano indietro rispetto alle release C upstream, introducono uno strato di memoria virtuale emulato e allocano pesantemente sotto carico. Spostare la libreria C in un processo ausiliario tramite TCP loopback o socket di dominio Unix ripristina l'isolamento dei guasti, ma ogni chiamata paga due cambi di contesto del kernel, copie nei buffer dei socket e serializzazione sul filo.

Abbiamo costruito HQQ ponendoci una domanda diretta: che 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 le cache line condivise della CPU, senza entrare nel kernel nel percorso veloce?

Modello di integrazioneCGO_ENABLED=0Isolamento dei guastiSyscall nel percorso veloceAllocazioni heap per chiamataFedeltà al C11 upstream
cgo con collegamento in-processNoNessuno (heap condiviso)00 a 1+C upstream esatto
RPC su subprocesso via socket / pipeSìConfine di processo2 a 4Multiple (framing + I/O)C upstream esatto
Traduzione meccanica da C a GoSìGo in-process0Alta nelle chiamate complesseRuntime C emulato
HQQ Shared-Memory IPCSìConfine di processo0 (spin adattivo)0 (StandardLink)C upstream esatto

Panoramica architetturale: quattro livelli attraverso i confini di processo

HQQ separa l'isolamento dei processi in quattro livelli componibili, così che la meccanica dei ring in memoria condivisa, la generazione di codice RPC tipizzato, la verifica di integrità binaria e la supervisione dei processi figli restino 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 a 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 a memoria condivisa tramite mmap POSIX (Linux e macOS) e CreateFileMappingW di Win32 (Windows). Ogni segmento condiviso inizia con un superblocco HQQ_STL3 verificato, che fissa la versione del protocollo, la geometria dei buffer e i PID degli endpoint prima che venga letto qualsiasi indice del ring.

Tutte le word di controllo attive e gli header degli slot sono allineati ai confini di cache line da 64 byte, in modo che i core produttore e consumatore non entrino mai in contesa sulla stessa cache line a causa del false sharing.

1Layout dello slot di StandardLink (allineato alla cache line da 64 byte)
2Offset in byte:  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 marcano una scrittura in corso; i valori pari pubblicano un frame confermato 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 notifica di chiusura).
payload0x40BufferSizeRegione contigua del payload letta e scritta sul posto senza copie intermedie.

I chiamanti scelgono tra due livelli di trasporto in base alla struttura di concorrenza:

  • StandardLink (sessione SPSC 1:1): Associa una sessione client e una sessione server tramite slot dedicati di richiesta e risposta, protetti da un seqlock (SeqLock). Nel percorso veloce, lo scrittore serializza direttamente nello slot mappato e conferma tramite store-release atomico; il lettore effettua brevemente spin in user space prima di ricorrere alle primitive di risveglio del sistema operativo (futex su Linux, __ulock_wait su macOS, WaitOnAddress su Windows). Un round trip di un singolo frame si completa in ~200 ns con 0 allocs/op.
  • Advanced (ring di dispatch MPMC): Multiplexa M chiamanti Go concorrenti su N thread worker C, utilizzando un ring di sottomissione lock-free multi-producer multi-consumer, un pool di slot condiviso e una tabella di completamento indicizzata. I chiamanti riservano uno slot, pubblicano l'indice dello slot nel ring e attendono la propria voce di completamento, mentre i thread worker estraggono le richieste e le completano fuori ordine.

Entrambi i livelli monitorano continuamente la vitalità dei peer. Ogni endpoint aggiorna un timestamp monotono atomico tramite Heartbeat() e verifica PeerAlive(timeout) durante l'attesa. Se un processo worker si arresta in modo anomalo durante una chiamata, il peer rileva immediatamente l'heartbeat obsoleto o il PID non più attivo, interrompe le attese bloccate con ErrPeerGone e supporta il Takeover atomico della sessione senza lasciare file di memoria condivisa bloccati.

2. RPC a allocazione zero e handle con verifica della generazione (gosuda.org/hqq/bindgen)

Scrivere a mano il framing a memoria condivisa per una libreria C con decine di funzioni è noioso e soggetto a errori. hqq-bindgen è un compilatore di schemi che legge un file di Interface Definition Language (.idl) (oppure ne crea uno direttamente da un header C .h tramite hqq-bindgen init) e produce un pacchetto client Go 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*) attraverso 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 filo. Al loro posto, 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 filo, ogni handle è un intero a 64 bit che combina un indice di tabella a 32 bit e un contatore di generazione non nullo 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 generazione memorizzata corrisponda ai 32 bit superiori dell'handle e che il tag di tipo dell'handle corrisponda al tipo del parametro (Db contro Stmt). Il riutilizzo di un handle chiuso fallisce immediatamente con HQQRPC_E_BAD_HANDLE prima di invocare la libreria C.
  2. Proprietà gerarchica (@owned_by): Poiché Stmt è annotato con @owned_by(Db), la chiusura di un handle Db percorre automaticamente tutti gli handle Stmt figli attivi di proprietà di quel Db ed esegue i loro distruttori in ordine inverso di allocazione (sqlite3_finalize prima di sqlite3_close_v2).
  3. Scansione di chiusura della sessione: Se un client Go si disconnette o si arresta in modo anomalo senza chiudere i propri handle, il distruttore di sessione del server C scansiona l'intera tabella degli handle della sessione in ordine inverso, garantendo l'assenza di allocazioni C trapelate tra un riavvio del client e l'altro.

Prima che una sessione elabori la prima chiamata RPC, client e server si scambiano un digest di interfaccia SHA-256 di 32 byte calcolato sull'IDL canonicalizzato (con prefisso HQQ-BINDGEN-IDL-V1\n). Se il client Go e il worker C sono stati compilati a partire 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 di 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 di 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 il contenuto in un descrittore di file di memoria anonimo creato tramite memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) calcolandone contemporaneamente l'hash SHA-256, verifica l'hash rispetto a un digest fissato di 32 byte o a un manifest 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 nel sistema possa modificare le pagine, e avvia il processo figlio dal descrittore sigillato.
  • Su macOS e Windows, secureexec materializza i binari verificati in directory di cache con permessi bloccati, indicizzate tramite digest SHA-256, e verifica l'integrità della dimensione del file e del digest prima dell'avvio.

4. Supervisione dei processi con legame genitore (gosuda.org/hqq/launch)

Quando un'applicazione Go avvia un processo worker C in background, il worker non deve mai sopravvivere al proprio 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 del ciclo di vita 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 dedicato di legame vitale. Se il processo Go termina normalmente, va in panico 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 le chiamate attive, esegue i distruttori degli handle di sessione, rimuove i file di memoria condivisa ed esce in modo pulito.

Guida rapida: chiamare un servizio generato

Con hqq-bindgen e launch, chiamare 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	// Crea un contesto con timeout di 5 secondi
16	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
17	defer cancel()
18
19	// Analizza l'hash SHA-256 atteso del worker
20	hash, err := secureexec.ParseHash("7649b1b45dacd934cde8149f35f4fcc3937f6486fbac23ce976466719b3752bb")
21	if err != nil {
22		log.Fatal(err)
23	}
24
25	// Crea il listener di memoria condivisa
26	listener, err := launch.NewListenerName("demo")
27	if err != nil {
28		log.Fatal(err)
29	}
30
31	// Avvia il worker verificato
32	srv, err := launch.Start(ctx, launch.Config{
33		Binary:   "/path/to/verified-worker",
34		SHA256:   hash,
35		Listener: listener,
36		Args:     []string{listener},
37	})
38	if err != nil {
39		log.Fatal(err)
40	}
41	defer srv.Close()
42
43	fmt.Printf("worker running on shared-memory listener %s (opts=%+v)\n", listener, rt.SessionOptions{})
44}

Struttura del repository

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

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) e primitive di ring a memoria condivisa di basso livello
 3├── advanced.go / slotpool.go  # Dispatcher Advanced (MPMC), pool di slot e tabella di completamento
 4├── shm_*.go / wait_*.go       # mmap/CreateFileMapping specifici del sistema operativo e futex/ulock/WaitOnAddress
 5├── c/                         # Libreria di trasporto C11 (hqq.c) e runtime del server RPC (hqq_rpc.c)
 6├── bindgen/                   # Parser IDL di hqq-bindgen, verificatore (V1-V41) e generatore di codice Go/C11
 7│   └── rt/                    # Runtime client Go a allocazione zero per i servizi generati
 8├── secureexec/                # Verifica di manifest SHA-256 / Ed25519 ed esecuzione sigillata con memfd
 9├── launch/                    # Supervisore dei processi worker con gestione del legame --parent-stdin
10├── cmd/hqq-manifest/          # Strumento CLI per generare e firmare manifest dei binari di rilascio
11└── spec/                      # Specifiche di protocollo eseguibili e vettori di test di riferimento (BINDGEN.md)

Per vedere come HQQ alimenti sostituti drop-in di produzione per due tra le librerie cgo più utilizzate nell'ecosistema Go, consultate i pacchetti complementari costruiti su questo stack:

  • gosuda.org/sqlite3: Driver database/sql drop-in compatibile con github.com/mattn/go-sqlite3, che esegue la SQLite C nativa 3.54.1 su memoria condivisa con prefetch batch in pipeline.
  • gosuda.org/zstd: Sostituto drop-in di github.com/DataDog/zstd, che esegue libzstd 1.5.7 C nativa in un worker isolato a memoria condivisa con contesti a allocazione zero e batch bulk.
  • Repository GitHub: https://github.com/gosuda/HQQ