GoSuda

HQQ: Izolarea bibliotecilor C de Go prin memorie partajată aliniată la linia de cache

By Lemon Mint views ...

gosuda.org/hqq este un runtime de comunicare inter-proces fără cgo și un set de instrumente de generare de cod pentru Go și C11. Acesta izolează bibliotecile C native în procese 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 single-producer single-consumer și Advanced pentru dispecerizare MPMC cu mai mulți worker), compilatorul IDL hqq-bindgen și un supervizor de procese verificat împotriva modificărilor (secureexec și launch).

1go get gosuda.org/hqq

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

Fiecare inginer de sisteme Go ajunge, mai devreme sau mai târziu, în aceeași bifurcație. Construiți un serviciu Go care se compilează în câteva secunde cu CGO_ENABLED=0, se livrează ca un singur binar static, se compilează încrucișat de pe un laptop către ținte Linux sau Windows fără sysroot și rulează într-un heap cu colectare a gunoiului și siguranță a memoriei. Apoi aveți nevoie de o bibliotecă C matură: SQLite pentru stocare relațională încorporată, Zstandard (libzstd) pentru compresie cu dicționar sau o bibliotecă hardware-accelerată pentru media sau criptografie.

Activarea cgo pentru a lega acea bibliotecă C în procesul Go rezolvă problema imediată a legării, dar introduce trei vulnerabilități structurale:

  1. Cuplarea lanțului de unelte și a compilării încrucișate: CGO_ENABLED=1 necesită un compilator C, anteturi libc ale țintei și un linker specific platformei 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. Costul de planificare la rulare: Fiecare apel cgo trece granița runtime-ului Go, trece de la o stivă de goroutine extensibilă la o stivă de sistem și fixează un fir de execuție al sistemului de operare (M). Pentru apeluri granulare, costul tranziției (~50 până la 150 ns per apel) și contenția planificatorului se acumulează rapid.
  3. Prăbușirea domeniului de defecțiune: Codul C rulează în același spațiu de adrese virtuale ca runtime-ul Go. Un singur acces în afara limitelor, o utilizare după eliberare, un depășire a stivei sau un abort() într-o funcție C corupe metadatele heap-ului Go sau termină imediat întregul proces server, ocolind recover() și doborând odată cu el fiecare cerere HTTP sau RPC aflată în curs.

Când echipele încearcă să evite cgo, alternativele uzuale impun un alt compromis. Traducerile mecanice pur Go ale bazelor de cod C păstrează CGO_ENABLED=0, dar rămân adesea în urma versiunilor C upstream, poartă un strat de memorie virtuală emulat și alocă intens sub sarcină. Mutarea bibliotecii C într-un proces ajutător prin TCP loopback sau socket-uri de domeniu Unix restabilește izolarea defecțiunilor, dar fiecare apel plătește pentru două comutări de context în nucleu, copieri ale buffer-elor de socket și serializare pe fir.

Am construit HQQ după ce ne-am pus o întrebare directă: ce s-ar întâmpla dacă biblioteca C ar trăi într-un proces separat al sistemului de operare, cu propriul spațiu de adrese virtuale, în timp ce Go și C ar schimba cadre direct prin liniile de cache ale procesorului, fără a intra în nucleu pe calea rapidă?

Model de integrareCGO_ENABLED=0Izolarea defecțiunilorApeluri de sistem pe calea rapidăAlocări heap per apelFidelitate față de C11 upstream
Legare cgo în procesNuNiciuna (heap comun)00 până la 1+C upstream exact
RPC subproces prin socket / pipeDaGranița procesului2 până la 4Multiple (încadrare + I/O)C upstream exact
Traducere mecanică C-to-GoDaGo în proces0Ridicată la apeluri complexeRuntime C emulat
Memorie partajată IPC HQQDaGranița procesului0 (spin adaptiv)0 (StandardLink)C upstream exact

Prezentare arhitecturală: patru straturi peste granițele proceselor

HQQ separă izolarea proceselor în patru straturi componibile, astfel încât mecanica inelelor din memoria partajată, generarea tipizată de cod RPC, verificarea integrității binarelor și supravegherea procesului copil să rămână independente.

 1+-------------------------------------------------------------------+
 2| Proces aplicație Go (CGO_ENABLED=0)                               |
 3|                                                                   |
 4|  +-----------------------+     +-------------------------------+  |
 5|  | Client Go generat     |     | Supervizor de procese         |  |
 6|  | (gosuda.org/hqq/      |     | (gosuda.org/hqq/launch +      |  |
 7|  |  bindgen/rt)          |     |  gosuda.org/hqq/secureexec)   |  |
 8|  +-----------+-----------+     +---------------+---------------+  |
 9|              |                                 |                  |
10|  +-----------v-----------+                     | Pipe anonim      |
11|  | Nivel de transport    |                     | Legătură de viață|
12|  | (StandardLink /       |                     | (--parent-stdin) |
13|  |  Advanced MPMC)       |                     |                  |
14+--------------+---------------------------------+------------------+
15               |                                 |
16===============|========= Memorie partajată ======|===================
17               |     (mmap / CreateFileMapping)  |
18+--------------v---------------------------------v------------------+
19| Proces worker C11 (spațiu de adrese izolat)                       |
20|                                                                   |
21|  +-----------------------+     +-------------------------------+  |
22|  | Server C11 generat    |---->| Tabel de handle-uri cu        |  |
23|  | & shim-uri de lot     |     | verificare a generației       |  |
24|  |                       |     | (hqq_rpc.c)                   |  |
25|  +-----------+-----------+     +---------------+---------------+  |
26|              |                                 |                  |
27|              +----------------+----------------+                  |
28|                               |                                   |
29|                     +---------v---------+                         |
30|                     | Bibliotecă C nativă|                        |
31|                     | (SQLite, Zstd...) |                         |
32|                     +-------------------+                         |
33+-------------------------------------------------------------------+

1. Transport prin memorie partajată (gosuda.org/hqq și c/)

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

Toate cuvintele de control fierbinți și antetele sloturilor sunt aliniate la granițele de linie de cache de 64 de octeți, astfel încât nucleele producătorului și consumatorului să nu concureze niciodată pentru aceeași linie de cache din cauza partajării false.

1Structura sloturilor StandardLink (aliniată la linia de cache de 64 de octeți)
2Deplasament octet:  0x00              0x08              0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Linia antet:  | seq (u64 atomic)  | len (u32) | flags | rezervat (completat cu zero)|
5              +-------------------+-------------------+-----------------------------+
6Zona de sarcină: | octeți de sarcină [0 .. BufferSize - 1] (codificați/decodificați direct) |
7              +---------------------------------------------------------------------+
CâmpDeplasamentDimensiuneSemantică
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 sarcinii scrise în slot (0 <= len <= BufferSize).
flags0x0C4 BBiți de control ai cadrului (continuare, stare de eroare sau notificare de oprire).
payload0x40BufferSizeRegiune contiguă de sarcină, citită și scrisă pe loc, fără copii intermediare.

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

  • StandardLink (sesiune SPSC 1:1): Asociază o sesiune client și una server prin sloturi dedicate de cerere și răspuns, protejate printr-un seqlock (SeqLock). Pe calea rapidă, scriitorul serializează direct în slotul mapat și confirmă printr-un store-release atomic; cititorul așteaptă scurt în spațiul utilizator înainte de a recurge la primitivele de trezire ale sistemului de operare (futex pe Linux, __ulock_wait pe macOS, WaitOnAddress pe Windows). O rundă completă pentru un singur cadru se finalizează în ~200 ns cu 0 allocs/op.
  • Advanced (inel de dispecerizare MPMC): Multiplexează M apelanți Go concurenți peste N fire de execuție worker C, folosind un inel de trimitere multi-producer multi-consumer fără blocaje, un pool partajat de sloturi și un tabel indexat de completare. Apelanții rezervă un slot, publică indexul slotului în inel și așteaptă intrarea lor de completare, în timp ce firele worker extrag cereri și le finalizează în afara ordinii.

Ambele niveluri urmăresc continuu vitalitatea peer-ului. Fiecare punct final actualizează un timestamp monoton atomic prin Heartbeat() și verifică PeerAlive(timeout) în timpul așteptării. Dacă un proces worker se prăbușește în mijlocul unui apel, peer-ul detectează imediat heartbeat-ul expirat sau PID-ul mort, întrerupe așteptările blocate cu ErrPeerGone și permite preluarea atomică a sesiunii prin Takeover, fără a lăsa în urmă fișiere de memorie partajată blocate.

2. RPC fără alocări și handle-uri cu verificarea generației (gosuda.org/hqq/bindgen)

Scrierea manuală a încadrării în memorie partajată pentru o bibliotecă C cu zeci de funcții este obositoare și predispusă la erori. hqq-bindgen este un compilator de scheme care citește un fișier de limbaj de definire a interfețelor (.idl) (sau care generează unul direct dintr-un antet C .h prin hqq-bindgen init) și emite un pachet client Go fără cgo, alături de un schelet de server C11.

Cea mai periculoasă greșeală în orice legare C este expunerea pointerilor C bruti (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) peste granița 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 bruti pe fir. În schimb, IDL modelează resursele C cu stare ca declarații tipizate 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 handle este un întreg de 64 de biți care împachetează un index de tabel de 32 de biți și un contor de generație nenul de 32 de biți: (uint64_t)generation << 32 | index. Runtime-ul C (c/src/hqq_rpc.c) menține un tabel de handle-uri pentru fiecare sesiune, cu trei invariante:

  1. Validarea generației: Fiecare căutare verifică faptul că slotul este ocupat, că generația stocată se potrivește cu cei 32 de biți superiori ai handle-ului și că eticheta de tip a handle-ului se potrivește cu tipul parametrului (Db față de Stmt). Reutilizarea unui handle închis eșuează imediat cu HQQRPC_E_BAD_HANDLE, înainte de a apela biblioteca C.
  2. Proprietate ierarhică (@owned_by): Deoarece Stmt este adnotat cu @owned_by(Db), închiderea unui handle Db parcurge automat toate handle-urile Stmt copil active, deținute de acel Db, și rulează destructorii acestora în ordinea inversă a alocării (sqlite3_finalize înaintea sqlite3_close_v2).
  3. Măturarea la încheierea sesiunii: Dacă un client Go se deconectează sau se prăbușește fără a închide handle-urile, destructorul sesiunii serverului C parcurge în ordine inversă întregul tabel de handle-uri al sesiunii, garantând absența alocărilor C scurse între reporniri ale clientului.

Înainte ca o sesiune să proceseze primul apel RPC, clientul și serverul schimbă un digest de interfață SHA-256 de 32 de octeți, calculat peste IDL-ul canonicalizat (prefixul HQQ-BINDGEN-IDL-V1\n). Dacă clientul Go și worker-ul C au fost compilate din scheme IDL incompatibile, handshake-ul respinge conexiunea la inițializare.

3. Execuție binară protejată împotriva modificărilor (gosuda.org/hqq/secureexec)

Mutarea codului C într-un binar worker ridică o întrebare de lanț de aprovizionare: cum știe procesul Go că binarul worker 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 tip Time-of-Check to Time-of-Use (TOCTOU), în care un alt proces poate suprascrie sau înlocui fișierul înainte ca nucleul să-l execute. gosuda.org/hqq/secureexec închide această fereastră:

  • Pe Linux, secureexec deschide binarul, transmite conținutul său într-un descriptor de fișier de memorie anonim creat prin memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) și calculează hash-ul SHA-256 în timpul acestui proces, verifică hash-ul față de un digest fixat de 32 de octeți sau față de un manifest de release semnat cu 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 pornește procesul copil din descriptorul sigilat.
  • Pe macOS și Windows, secureexec materializează binarele verificate în directoare de cache cu permisiuni blocate, indexate după digest-ul SHA-256, și verifică integritatea dimensiunii fișierului și a digest-ului înainte de lansare.

4. Supravegherea procesului prin legătură de viață cu părintele (gosuda.org/hqq/launch)

Când o aplicație Go pornește un proces worker C în fundal, worker-ul nu trebuie să supraviețuiască niciodată părintelui său ca proces orfan, chiar dacă procesul Go părinte este terminat cu SIGKILL (kill -9) și nu poate rula handler-ele de curățare defer.

gosuda.org/hqq/launch impune cuplarea duratei de viață printr-un pipe anonim al sistemului de operare, transmis pe intrarea standard a worker-ului cu flag-ul --parent-stdin. Procesul Go părinte deține capătul de scriere al pipe-ului și nu scrie niciodată în el; worker-ul C monitorizează capătul de citire pe un fir dedicat de legătură de viață. Dacă procesul Go iese normal, intră în panică sau este ucis de nucleul sistemului de operare (SIGKILL sau OOM killer), nucleul închide imediat descriptorul de scriere. Worker-ul C primește EOF pe pipe în câteva microsecunde, întrerupe apelurile active, rulează destructorii handle-urilor de sesiune, elimină fișierele de memorie partajată și iese curat.

Ghid rapid: apelarea unui serviciu generat

Cu hqq-bindgen și launch, apelarea unui worker C izolat din Go arată ca un client Go tipizat obișnuit, în timp ce CGO_ENABLED=0 rămâne activ:

 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 rulând pe ascultătorul de memorie partajată %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}

Structura depozitului

Fiecare subsistem din gosuda.org/hqq este organizat în pachete focalizate, cu limite explicite:

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) și primitivele de inel de memorie partajată de nivel jos
 3├── advanced.go / slotpool.go  # Dispecerul Advanced (MPMC), pool-ul de sloturi și tabelul de completare
 4├── shm_*.go / wait_*.go       # mmap/CreateFileMapping specifice sistemului de operare și futex/ulock/WaitOnAddress
 5├── c/                         # Biblioteca de transport C11 (hqq.c) și runtime-ul serverului RPC (hqq_rpc.c)
 6├── bindgen/                   # Parserul IDL hqq-bindgen, verificatorul (V1-V41) și generatorul de cod Go/C11
 7│   └── rt/                    # Runtime client Go fără alocări pentru serviciile generate
 8├── secureexec/                # Verificarea manifestelor SHA-256 / Ed25519 și execuția sigilată prin memfd
 9├── launch/                    # Supervizorul proceselor worker cu gestionarea legăturii de viață --parent-stdin
10├── cmd/hqq-manifest/          # Instrument CLI pentru generarea și semnarea manifestelor binarelor de release
11└── spec/                      # Specificații de protocol executabile și vectori de test de referință (BINDGEN.md)

Pentru a vedea cum HQQ alimentează înlocuiri de producție, gata de utilizare, pentru două dintre cele mai utilizate biblioteci cgo din ecosistemul Go, consultați pachetele companion construite pe această stivă:

  • gosuda.org/sqlite3: Driver database/sql compatibil cu github.com/mattn/go-sqlite3, care rulează SQLite nativ C 3.54.1 prin memorie partajată, cu prefetch pipelined al loturilor.
  • gosuda.org/zstd: Înlocuire directă pentru github.com/DataDog/zstd, care rulează libzstd 1.5.7 nativ C într-un worker izolat cu memorie partajată, cu contexte fără alocări și procesare în loturi.
  • Repository GitHub: https://github.com/gosuda/HQQ