GoSuda

HQQ: Izolarea bibliotecilor C din Go prin intermediul memoriei partajate la nivel de linie de cache

By Lemon Mint
views ...

gosuda.org/hqq este un runtime de comunicație între procese (IPC) fără cgo și un instrument de generare de cod pentru Go și C11. Acesta izolează bibliotecile C native în procese de lucru (worker) separate ale sistemului de operare și le conectează la apelanții Go prin inele de memorie partajată mapate în memorie și aliniate la linia de cache. Proiectul combină două niveluri de transport (StandardLink pentru sesiuni dedicate cu un singur producător și un singur consumator și Advanced pentru dispecerizări MPMC cu mai mulți lucrători), compilatorul IDL hqq-bindgen și un supervizor de procese verificat împotriva manipulării neautorizate (secureexec și launch).

1go get gosuda.org/hqq

Costul partajării unui spațiu de adrese cu C

Fiecare inginer de sisteme Go ajunge în cele din urmă la aceeași răscruce. Construiești un serviciu Go care se compilează în câteva secunde cu CGO_ENABLED=0, este livrat ca un singur binar static, se compila încrucișat de pe un laptop către ținte Linux sau Windows fără un sysroot și rulează în cadrul unui heap gestionat prin co colectare de gunoi și sigur din punct de vedere al memoriei. Apoi, ai nevoie de o bibliotecă C matură: SQLite pentru stocare relațională încorporată, Zstandard (libzstd) pentru compresie pe bază de dicționar sau o bibliotecă multimedia ori de criptografie accelerată hardware.

Activarea cgo pentru a legătura acea bibliotecă C în procesul Go rezolvă problema imediată a legăturii, introducând în același timp trei vulnerabilități structurale:

  1. Cuplarea lanțului de instrumente și a compilației încrucișate (Cross-Compilation): CGO_ENABLED=1 necesită un compilator C, anteturi libc țintă și un linker de platformă pentru fiecare sistem de operare și arhitectură țintă. Construirea de artefacte linux/arm64 sau windows/arm64 de pe o stație de lucru darwin/arm64 transformă o singură comandă go build într-o matrice de compilatoare încrucișate și sysroot-uri.
  2. Supraîncărcarea planificării la rulare (Runtime Scheduling Overhead): Fiecare apel cgo traversează limita runtime-ului Go, comută de la stiva unei goroutine extensibile la o stivă de sistem și fixează un fir de execuție al sistemului de operare (M). Pentru apelurile cu granulație fină, taxa de tranziție (~50 până la 150 ns per apel) și concurența planificatorului se acumulează rapid.
  3. Colapsul domeniului de eroare: Codul C rulează în același spațiu de adrese virtuale ca și runtime-ul Go. O singură scriere în afara limitelor (out-of-bounds), utilizare după eliberare (use-after-free), depășire de stivă (stack overflow) sau un apel abort() în cadrul unei funcții C corupe metadatele heap-ului Go sau termină imediat întregul proces server, ocolind recover() și ducând cu sine fiecare cerere HTTP sau RPC aflată în curs de desfășurare.

Atunci când echipele încearcă să evite cgo, alternativele obișnuite impun un compromis diferit. Traducerile mecanice în Go pur ale bazelor de cod C mențin CGO_ENABLED=0, dar adesea rămân în urmă față de lansările C amonte, transportă un strat de memorie virtuală emulat și alocă intensiv sub sarcină. Mutarea bibliotecii C într-un proces auxiliar prin TCP pe buclă inversă (loopback) sau socket-uri de domeniu Unix reface izolarea erorilor, dar fiecare apel plătește pentru două schimbări de context ale kernelului, copii în bufferul socket-ului și serializare pe fir.

Am construit HQQ după ce am adresat o întrebare directă: ce-ar fi dacă biblioteca C ar trăi într-un proces separat al sistemului de operare, cu propriul său spațiu de adrese virtuale, în timp ce Go și C ar face schimb de cadre (frames) direct prin linii de cache CPU partajate, fără a intra în kernel pe calea rapidă?

Model de integrareCGO_ENABLED=0Izolarea erorilorApeluri de sistem pe calea rapidăAlocări de heap per apelFidelitate C11 Amonte (Upstream)
cgo In-Process LinkingNuNiciuna (heap partajat)00 la 1+C amonte exact
Socket / Pipe Subprocess RPCDaLimită de proces2 la 4Mai multe (cadrare + I/O)C amonte exact
Traducere mecanică C-to-GoDaGo în proces0Ridicată la apeluri complexeRuntime C emulat
HQQ Shared-Memory IPCDaLimită de proces0 (spin adaptiv)0 (StandardLink)C amonte exact

Prezentare generală a arhitecturii: Patru niveluri dincolo de limitele proceselor

HQQ separă izolarea proceselor în patru niveluri compozabile, astfel încât mecanica inelului de memorie partajată, generarea de cod RPC tipizat, verificarea integrității binare și supravegherea proceselor copil să rămână independente.

 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. Transport prin memorie partajată (gosuda.org/hqq și c/)

Pachetul de bază gosuda.org/hqq și echivalentul său C11 din c/ implementează două moduri de transport în memorie partajată prin POSIX mmap (Linux și macOS) și Win32 CreateFileMappingW (Windows). Fiecare segment partajat începe cu un superbloc HQQ_STL3 verificat, care fixează versiunea protocolului, geometria bufferului și PID-urile punctelor finale înainte de citirea vreunui index de inel.

Toate cuvintele de control critice (hot control words) și anteturile de slot sunt aliniate la limite de 64 de octeți ale liniei de cache, astfel încât nucleele producătorului și consumatorului să nu intre niciodată în contencios pe aceeași linie de cache prin partajare falsă (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              +---------------------------------------------------------------------+
CâmpDecalaj (Offset)DimensiuneSemantică
seq0x008 BContor de secvență monoton. Valorile impare marchează o scriere în curs; valorile pare publică un cadru confirmat prin store-release.
len0x084 BLungimea în octeți a datelor utile (payload) scrise în slot (0 <= len <= BufferSize).
flags0x0C4 BBiți de control ai cadrului (continuare, stare de eroare sau aviz de oprire).
payload0x40BufferSizeRegiune de date utile contiguă citită și scrisă pe loc, fără copii intermediare.

Apelanții aleg între două niveluri de transport, în funcție de structura de concurență:

  • StandardLink (Sesiune 1:1 SPSC): Asociază o sesiune de client și de server prin sloturi dedicate de cerere și răspuns, protejate de un blocaj de secvență (SeqLock). Pe calea rapidă, scriitorul se serializează direct în slotul mapat și confirmă cu un store-release atomic; cititorul efectuează o buclă scurtă (spin) în spațiul utilizatorului înainte de a recurge la primitivele de trezire ale sistemului de operare (futex pe Linux, __ulock_wait pe macOS, WaitOnAddress pe Windows). O călătorie dus-întors cu un singur cadru (round trip) se finalizează în ~200 ns cu 0 allocs/op.
  • Advanced (Inel de dispecerizere MPMC): Multiplexează M apelanți Go concurenți pe N fire de execuție de lucru C, utilizând un inel de trimitere multi-producător multi-consumator fără blocaje (lock-free), un fond de sloturi partajat și un tabel de finalizare indexat. Apelanții rezervă un slot, publică indexul slotului în inel și așteaptă intrarea lor de finalizare în timp ce firele de lucru scot din coadă și finalizează cererile în ordine oarecare.

Ambele niveluri urmăresc în mod continuu starea de funcționare a partenerului (peer liveness). Fiecare punct final actualizează o marcă temporală atomică monotonă prin Heartbeat() și inspectează PeerAlive(timeout) în timpul așteptării. Dacă un proces de lucru se blochează în mijlocul unui apel, partenerul detectează imediat pulsația învechită (stale heartbeat) sau PID-ul mort, abandonează așteptările blocate cu ErrPeerGone și acceptă preluarea atomică a sesiunii (Takeover), fără a lăsa în urmă fișiere de memorie partajată blocate.

2. RPC fără alocare și hanlere verificate prin generație (gosuda.org/hqq/bindgen)

Scrierea manuală a cadrării memoriei partajate pentru o bibliotecă C cu zeci de funcții este plictisitoare și predispusă la erori. hqq-bindgen este un compilator de scheme care citește un fișier cu limbajul de definiră a interfețelor (.idl) (sau inițializează unul direct dintr-un antet C .h prin hqq-bindgen init) și emite un pachet client Go fără cgo, alături de o structură de bază (skeleton) pentru serverul C11.

Cea mai periculoasă greșeală în orice legătură C este expunerea de pointeri C bruți (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) dincolo de limita limbajului. Dacă un apelant transmite un pointer după ce l-a eliberat, transmite un pointer aparținând altei sesiuni sau corupe o adresă întreagă, biblioteca C dereferențiază memorie invalidă.

hqq-bindgen interzice pointerii bruți pe fir. În schimb, IDL-ul modelează resursele C cu stare ca declarații tipizate de tip 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}

Pe fir, fiecare hanler este un întreg pe 64 de biți care împachetează un index de tabel pe 32 de biți și un contor de generație diferit de zero pe 32 de biți: (uint64_t)generation << 32 | index. Runtime-ul C (c/src/hqq_rpc.c) menține un tabel de hanlere per sesiune cu trei invarianți:

  1. Validarea generației: Fiecare căutare verifică dacă slotul este ocupat, dacă generația stocată corespunde primilor 32 de biți superiori ai hanlerului și dacă eticheta tipului de hanler corespunde tipului de parametru (Db vs. Stmt). Reutilizarea unui hanler închis eșuează imediat cu HQQRPC_E_BAD_HANDLE înainte de a apela în biblioteca C.
  2. Proprietate ierarhică (@owned_by): Deoarece Stmt este adnotat cu @owned_by(Db), închiderea unui hanler Db parcurge automat toate hanlerele copil active Stmt deținute de acel Db și rulează destructorii lor în ordine inversă alocării (sqlite3_finalize înainte de sqlite3_close_v2).
  3. Rutina de curățare a sesiunii (Session Teardown Sweep): Dacă un client Go se deconectează sau se blochează fără a-și închide hanlerele, destructorul sesiunii serverului C mătură întregul tabel de hanlere al sesiunii în ordine inversă, garantând zero alocări C scurse după repornirile clientului.

Înainte ca o sesiune să proceseze primul său apel RPC, clientul și serverul schimbă un rezumat (digest) de interfață SHA-256 pe 32 de biți, calculat pe IDL-ul canonizat (prefix HQQ-BINDGEN-IDL-V1\n). Dacă clientul Go și lucrătorul C au fost compilați din scheme IDL incompatibile, conexiunea este respinsă la pornire de către handshake.

3. Execuție binară fără manipulare neautorizată (gosuda.org/hqq/secureexec)

Mutarea codului C într-un binar de lucru ridică o problemă legată de lanțul de aprovizionare: de unde știe procesul Go că binarul de lucru de pe disc nu a fost modificat sau înlocuit între verificare și execuție?

Apelarea crypto/sha256 pe o cale de fișier urmată de os/exec.Command(path).Start() lasă o fereastră de tipul Timpul verificării până la timpul utilizării (TOCTOU), în care un alt proces poate suprascrie sau schimba fișierul înainte ca nucleul să îl execute. gosuda.org/hqq/secureexec închide această fereastră:

  • Pe Linux, secureexec deschide binarul, transmite conținutul acestuia într-un descriptor de fișier de memorie anonim creat prin memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) în timp ce își calculează hash-ul SHA-256, verifică hash-ul în raport cu un rezumat fixat pe 32 de biți sau cu un manifest de lansare semnat Ed25519 (cmd/hqq-manifest), aplică fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE), astfel încât niciun proces din sistem să nu poată modifica paginile, și generează copilul din descriptorul sigilat.
  • Pe macOS și Windows, secureexec materializează binare verificate în directoare cache blocate cu permisiuni, indexate după rezumatul SHA-256, și verifică integritatea dimensiunii fișierului și a rezumatului înainte de lansare.

4. Supravegherea proceselor prin linia de viață a părintelui (gosuda.org/hqq/launch)

Atunci când o aplicație Go generează un proces de lucru C în fundal, acesta nu trebuie să supraviețuiască niciodată părintelui său ca proces orfan, chiar dacă părintele Go este terminat cu SIGKILL (kill -9) și nu poate rula handlerii de curățare defer.

gosuda.org/hqq/launch impune cuplarea duratei de viață printr-un pipe anonim al sistemului de operare, transmis la intrarea standard a lucrătorului cu indicatorul --parent-stdin. Părintele Go deține capătul de scriere al conductei și nu scrie niciodată în el; lucrătorul C monitorizează capătul de citire pe un fir de execuție dedicat liniei de viață. Dacă procesul Go se încheie în mod normal, intră în panică sau este ucis de kernelul sistemului de operare (SIGKILL sau OOM killer), kernelul închide imediat descriptorul de scriere. Lucrătorul C primește EOF pe conductă în câteva microsecunde, întrerupe orice apel activ, rulează destructorii hanlerelor de sesiune, dezleagă fișierele de memorie partajată și se încheie curat.

Pornire rapidă: Apelarea unui serviciu generat

Cu hqq-bindgen și launch, apelarea unui lucrător C izolat din Go arată ca un client Go tipizat obișnuit, menținând în același timp 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}

Structura repository-ului

Fiecare subsistem din gosuda.org/hqq este organizat în pachete axate pe scopuri specifice, cu granițe explicite:

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) și primitive inel de memorie partajată de nivel inferior
 3├── advanced.go / slotpool.go  # Dispecer Advanced (MPMC), fond de sloturi și tabel de finalizare
 4├── shm_*.go / wait_*.go       # mmap specific SO / CreateFileMapping și futex / ulock / WaitOnAddress
 5├── c/                         # Biblioteca de transport C11 (hqq.c) și runtime server RPC (hqq_rpc.c)
 6├── bindgen/                   # Analizor IDL hqq-bindgen, verificator (V1-V41) și generator de cod Go/C11
 7│   └── rt/                    # Runtime client Go fără alocare pentru serviciile generate
 8├── secureexec/                # Verificare manifest SHA-256 / Ed25519 și execuție sigilată memfd
 9├── launch/                    # Supervizor proces de lucru cu gestionarea liniei de viață --parent-stdin
10├── cmd/hqq-manifest/          # Instrument CLI pentru generarea și semnarea manifestelor binare de lansare
11└── spec/                      # Specificații de rețea executabile și vectori de test de referință (BINDGEN.md)

Pentru a vedea cum alimentează HQQ înlocuitorii de producție plug-and-play pentru două dintre cele mai utilizate biblioteci cgo din ecosistemul Go, consultați pachetele însoțitoare construite pe această stivă:

  • gosuda.org/sqlite3: Driver database/sql plug-and-play compatibil cu github.com/mattn/go-sqlite3, care rulează SQLite C nativ 3.54.1 peste memoria partajată cu preîncărcare în loturi (batch prefetching) în conductă (pipelined).
  • gosuda.org/zstd: Înlocuitor plug-and-play pentru github.com/DataDog/zstd, care rulează libzstd 1.5.7 C nativ într-un lucrător de memorie partajată izolat, cu contexte fără alocare și prelucrare în masă în loturi (bulk batching).
  • Repository GitHub: https://github.com/gosuda/HQQ