HQQ: Izolácia C knižníc od Go cez Shared Memory s Cache-Line

gosuda.org/hqq je runtime pre medziprocesovú komunikáciu bez zero-cgo a toolchain na generovanie kódu pre Go a C11. Izoluje natívne C knižnice vo vnútri samostatných procesov OS workerov a prepája ich s volajúcimi v Go prostredníctvom zdieľaných kruhových bufferov (rings) v pamäti mapovanej do pamäte, zarovnaných na hranice cache riadkov. Projekt kombinuje dve úrovne transportu (StandardLink pre dedikované jednosmerné SPSC relácie a Advanced pre viacworkerové MPMC rozosielanie), kompilátor IDL hqq-bindgen a supervízor procesov s overovaním integrity (secureexec a launch).
1go get gosuda.org/hqq
Cena zdieľania adresného priestoru s C
Každý inžinier systémov v Go skôr či neskôr narazí na rovnakú rázcestie. Budujete službu v Go, ktorá sa skompiluje počas niekoľkých sekúnd s CGO_ENABLED=0, distribuuje sa ako jeden statický binárny súbor, krížovo sa kompiluje z notebooku pre cieľové systémy Linux alebo Windows bez sysroot a beží v pamäťovo bezpečnej heape spravovanej garbage collectorom. Potom potrebujete zrelú C knižnicu: SQLite pre vnorené relačné úložisko, Zstandard (libzstd) pre kompresiu so slovníkom alebo hardvérovo akcelerovanú knižnicu pre médiá alebo kryptografiu.
Povolenie cgo na prepojenie tejto C knižnice do procesu Go síce okamžite vyrieši problém väzby, no zavádza tri štrukturálne nevýhody:
- Previazanie toolchainu a krížovej kompilácie:
CGO_ENABLED=1vyžaduje C kompilátor, hlavičkové súbory cieľovej libc a platformový linker pre každý cieľový operačný systém a architektúru. Zostavenie artefaktovlinux/arm64alebowindows/arm64z pracovnej stanicedarwin/arm64mení jediný príkazgo buildna maticu krížových kompilátorov a sysroot. - Réžia plánovania behu: Každé volanie
cgoprechádza hranicou runtime jazyka Go, prepína sa z rastúceho zásobníka goroutine na systémový zásobník a viaže vlákno OS (M). Pri jemnozrnných volaniach sa daň za prechod (približne 50 až 150 ns na volanie) a konkurencia plánovača rýchlo nahromadia. - Kolaps domény porúch: Kód C beží vo rovnakom virtuálnom adresnom priestore ako runtime Go. Jediný zápis mimo hraníc, použitie po uvoľnení, pretečenie zásobníka alebo
abort()vnútri funkcie C poškodí metadáta heapy Go alebo okamžite ukončí celý serverový proces, obíderecover()a so sebou strhne každú prebiehajúcu požiadavku HTTP alebo RPC.
Keď sa tímy snažia vyhnúť cgo, bežné alternatívy vynucujú iný kompromis. Mechanické preklady kódovej základne C do čistého Go zachovávajú CGO_ENABLED=0, no často zaostávajú za upstream vydaniami C, nesú emulovanú vrstvu virtuálnej pamäte a pri záťaži intenzívne alokujú. Presun knižnice C do pomocného procesu cez loopback TCP alebo Unix doménové sokety obnoví izoláciu porúch, no každé volanie platí dvoma prepnutiami kontextu jadra, kopírovaním vyrovnávacích pamätí soketov a serializáciou na drôte.
HQQ sme vytvorili po položení priamej otázky: čo ak by knižnica C žila v samostatnom procese OS s vlastným virtuálnym adresným priestorom, zatiaľ čo Go a C by si rámce vymieňali priamo prostredníctvom zdieľaných riadkov cache procesora bez vstupu do jadra na rýchlej ceste?
| Model integrácie | CGO_ENABLED=0 | Izolácia porúch | Systémové volania na rýchlej ceste | Alokácie na heape na volanie | Verná kópia upstream C11 |
|---|---|---|---|---|---|
cgo lineárne prepojenie v procese | Nie | Žiadna (zdieľaná heap) | 0 | 0 až 1+ | Presné upstream C |
| RPC podprocesu cez sokety / rúry | Áno | Hranica procesu | 2 až 4 | Viacnásobné (rámcovanie + I/O) | Presné upstream C |
| Mechanický preklad C do Go | Áno | Go v rámci procesu | 0 | Vysoké pri zložitých volaniach | Emulovaný runtime C |
| HQQ IPC so zdieľanou pamäťou | Áno | Hranica procesu | 0 (adaptívne spinovanie) | 0 (StandardLink) | Presné upstream C |
Architektonický prehľad: štyri vrstvy naprieč hranicami procesov
HQQ rozdeľuje izoláciu procesov do štyroch skladateľných vrstiev, aby mechanika kruhových bufferov v zdieľanej pamäti, typovo bezpečné generovanie kódu RPC, overovanie binárnej integrity a správa podriadených procesov zostali nezávislé.
1+-------------------------------------------------------------------+
2| Aplikačný proces Go (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Generovaný klient Go | | Supervízor procesov | |
6| | (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Anonymná rúra |
11| | Transportná úroveň | | Lifeline |
12| | (StandardLink / | | (--parent-stdin) |
13| | Advanced MPMC) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|========= Zdieľaná pamäť =========|==================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| Worker proces C11 (izolovaný adresný priestor) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Generovaný server C11 |---->| Tabuľka handle s kontrolou | |
23| | a dávkové shimy | | generácie (hqq_rpc.c) | |
24| +-----------+-----------+ +---------------+---------------+ |
25| | | |
26| +----------------+----------------+ |
27| | |
28| +---------v---------+ |
29| | Natívna knižnica C| |
30| | (SQLite, Zstd...) | |
31| +-------------------+ |
32+-------------------------------------------------------------------+
1. Transport so zdieľanou pamäťou (gosuda.org/hqq a c/)
Základný balík gosuda.org/hqq a jeho náprotivok v C11 v adresári c/ implementujú dva režimy transportu so zdieľanou pamäťou nad POSIX mmap (Linux a macOS) a Win32 CreateFileMappingW (Windows). Každý zdieľaný segment začína overeným superblokom HQQ_STL3, ktorý pevne určuje verziu protokolu, geometriu bufferov a PID koncových bodov skôr, než sa prečíta akýkoľvek index kruhu.
Všetky horúce riadiace slová a hlavičky slotov sú zarovnané na hranice cache riadkov po 64 bajtoch, aby jadrá producenta a konzumenta nikdy nesúperili o rovnaký cache riadok vplyvom falošného zdieľania.
1Rozloženie slotu StandardLink (zarovnané na cache riadok 64 bajtov)
2Posun v bajtoch: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Riadok hlavičky: | seq (u64 atomic) | len (u32) | flags | rezerva (doplnená nulami) |
5 +-------------------+-------------------+-----------------------------+
6Oblasť payloadu: | bajty payloadu [0 .. BufferSize - 1] (kódované / dekódované priamo) |
7 +---------------------------------------------------------------------+
| Pole | Posun | Veľkosť | Význam |
|---|---|---|---|
seq | 0x00 | 8 B | Monotónny sekvenčný čítač. Nepárne hodnoty označujú zápis v procese; párne hodnoty publikujú potvrdený rámec cez store-release. |
len | 0x08 | 4 B | Dĺžka payloadu v bajtoch zapísaného do slotu (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Riadiace bity rámca (pokračovanie, stav chyby alebo oznámenie o ukončení). |
payload | 0x40 | BufferSize | Súvislá oblasť payloadu, čítaná a zapisovaná na mieste bez medziľahlých kópií. |
Volajúci si vyberajú medzi dvoma úrovňami transportu podľa štruktúry súbežnosti:
StandardLink(SPSC relácia 1:1): Spája klientsku a serverovú reláciu cez dedikované požiadavkové a odpoveďové sloty chránené sekvenčným zámkom (SeqLock). Na rýchlej ceste zapisovateľ serializuje údaje priamo do mapovaného slotu a potvrdí ich atomickým store-release; čitateľ krátko spinuje v používateľskom priestore a až potom sa uchýli k primitívam prebudenia OS (futexna Linuxe,__ulock_waitna macOS,WaitOnAddressna Windows). Jeden rámec v rámci kompletného cyklu sa vykoná približne za 200 ns s0 allocs/op.Advanced(MPMC rozosielací kruh): MultiplexujeMsúbežných volajúcich v Go cezNvlákien workerov C pomocou bezzámkového viacproducentského viackonzumentského odoslacieho kruhu, zdieľaného fondu slotov a indexovanej tabuľky dokončení. Volajúci si rezervujú slot, publikujú jeho index do kruhu a čakajú na svoj záznam o dokončení, zatiaľ čo vlákna workerov vyberajú požiadavky a dokončujú ich v ľubovoľnom poradí.
Obe úrovne priebežne sledujú dostupnosť partnera. Každý koncový bod aktualizuje atomickú monotónnu časovú pečiatku cez Heartbeat() a počas čakania kontroluje PeerAlive(timeout). Ak proces workera uprostred volania zlyhá, partner okamžite zistí neaktuálny heartbeat alebo mŕtvy PID, preruší blokované čakania s ErrPeerGone a podporuje atomické prevzatie relácie (Takeover) bez toho, aby zostali zaseknuté súbory zdieľanej pamäte.
2. RPC bez alokácií a handle s kontrolou generácie (gosuda.org/hqq/bindgen)
Ručné písanie rámcovania zdieľanej pamäte pre C knižnicu s desiatkami funkcií je únavné a náchylné na chyby. hqq-bindgen je kompilátor schém, ktorý načíta súbor Interface Definition Language (.idl) (alebo z hlavičky C .h priamo vytvorí počiatočný súbor príkazom hqq-bindgen init) a vygeneruje balík klienta Go bez zero-cgo spolu so skeletonom servera C11.
Najnebezpečnejšou chybou v akejkoľvek väzbe na C je vystavenie surových ukazovateľov C (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) cez hranicu jazyka. Ak volajúci odovzdá ukazovateľ po jeho uvoľnení, odovzdá ukazovateľ patriaci inej relácii alebo poškodí celočíselnú adresu, knižnica C dereferencuje neplatnú pamäť.
hqq-bindgen zakazuje surové ukazovatele na drôte. Namiesto toho IDL modeluje stavové prostriedky C ako typované deklarácie 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}
Na drôte je každý handle 64-bitové celé číslo, ktoré obsahuje 32-bitový index tabuľky a 32-bitový nenulový čítač generácie: (uint64_t)generation << 32 | index. Runtime C (c/src/hqq_rpc.c) udržiava tabuľku handle pre každú reláciu s tromi invariantmi:
- Overenie generácie: Každé vyhľadanie overí, že slot je obsadený, jeho uložená generácia sa zhoduje s hornými 32 bitmi handle a jeho typová značka handle zodpovedá typu parametra (
DbverzusStmt). Opätovné použitie uzavretého handle zlyhá okamžite sHQQRPC_E_BAD_HANDLEešte pred volaním knižnice C. - Hierarchické vlastníctvo (
@owned_by): KeďžeStmtje anotovaný@owned_by(Db), uzavretie handleDbautomaticky prejde všetky živé podriadené handleStmtpatriace tomutoDba spustí ich deštruktory v opačnom poradí alokácie (sqlite3_finalizepredsqlite3_close_v2). - Čistenie pri ukončení relácie: Ak sa klient Go odpojí alebo zlyhá bez uzavretia svojich handle, deštruktor relácie na serveri C prejde celú tabuľku handle relácie v opačnom poradí a zaručí nulový únik alokácií C pri reštartoch klienta.
Skôr než relácia spracuje prvé volanie RPC, klient a server si vymenia 32-bajtový digest rozhrania SHA-256 vypočítaný nad kanonizovaným IDL (predpona HQQ-BINDGEN-IDL-V1\n). Ak boli klient Go a worker C skompilované z nekompatibilných schém IDL, handshake pripojenie už pri inicializácii odmietne.
3. Binárne spúšťanie odolné voči manipulácii (gosuda.org/hqq/secureexec)
Presun kódu C do binárneho súboru workera vyvoláva otázku dodávateľského reťazca: ako proces Go zistí, že binárny súbor workera na disku nebol medzi overením a spustením zmenený alebo nahradený?
Výpočet crypto/sha256 nad cestou k súboru, za ktorým nasleduje os/exec.Command(path).Start(), ponecháva okno Time-of-Check to Time-of-Use (TOCTOU), v ktorom môže iný proces súbor prepísať alebo vymeniť skôr, než ho jadro spustí. gosuda.org/hqq/secureexec toto okno uzatvára:
- Na Linuxe
secureexecotvorí binárny súbor, preloží jeho obsah do anonymného deskriptora pamäťového súboru vytvoreného cezmemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)a zároveň vypočíta jeho hash SHA-256, overí hash voči pevne určenému 32-bajtovému digestu alebo manifestu vydania podpísanému Ed25519 (cmd/hqq-manifest), aplikujefcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE), aby žiadny proces v systéme nemohol stránky modifikovať, a potomka spustí z zapečateného deskriptora. - Na macOS a Windows
secureexecmaterializuje overené binárne súbory do adresárov vyrovnávacej pamäte s uzamknutými oprávneniami, ktorých názvy sú odvodené od digestu SHA-256, a pred spustením overuje integritu veľkosti súboru a digestu.
4. Supervízia procesu s väzbou na rodiča (gosuda.org/hqq/launch)
Keď aplikácia Go spustí proces workera C na pozadí, worker nesmie nikdy prežiť svojho rodiča ako osirelý proces, ani keď je rodičovský proces Go ukončený signálom SIGKILL (kill -9) a nemôže spustiť obslužné rutiny defer na upratovanie.
gosuda.org/hqq/launch vynucuje previazanie životnosti cez anonymnú rúru OS odovzdanú na štandardný vstup workera s príznakom --parent-stdin. Rodičovský proces Go drží zapisovací koniec rúry a nikdy doňho nezapisuje; worker C sleduje čítací koniec na vyhradenom vlákne lifeline. Ak proces Go skončí normálne, spadne panikou alebo je ukončený jadrom OS (SIGKILL alebo OOM killer), jadro okamžite uzavrie zapisovací deskriptor. Worker C dostane EOF na rúre v rámci mikrosekúnd, preruší všetky aktívne volania, spustí deštruktory svojich handle relácie, odstráni prepojenia súborov zdieľanej pamäte a čisto skončí.
Rýchly štart: volanie generovanej služby
S hqq-bindgen a launch sa volanie izolovaného workera C z Go správa ako bežný typovaný klient Go, pričom zostáva zachované 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 // Vytvorenie kontextu s časovým limitom 5 sekúnd
16 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
17 defer cancel()
18
19 // Spracovanie pevne určeného digestu SHA-256 overovaného workera
20 hash, err := secureexec.ParseHash("7649b1b45dacd934cde8149f35f4fcc3937f6486fbac23ce976466719b3752bb")
21 if err != nil {
22 log.Fatal(err)
23 }
24
25 // Vytvorenie názvu listenera pre zdieľanú pamäť
26 listener, err := launch.NewListenerName("demo")
27 if err != nil {
28 log.Fatal(err)
29 }
30
31 // Spustenie overeného workera s pripojením na listener
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 beží na zdieľanom listeneri %s (opts=%+v)\n", listener, rt.SessionOptions{})
44}
Štruktúra repozitára
Každý subsystém v gosuda.org/hqq je organizovaný do zameraných balíkov s jasne vymedzenými hranicami:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) a nízkoúrovňové primitíva kruhu zdieľanej pamäte
3├── advanced.go / slotpool.go # Rozosielač Advanced (MPMC), fond slotov a tabuľka dokončení
4├── shm_*.go / wait_*.go # Špecifické pre OS: mmap/CreateFileMapping a futex/ulock/WaitOnAddress
5├── c/ # Transportná knižnica C11 (hqq.c) a runtime servera RPC (hqq_rpc.c)
6├── bindgen/ # Parser IDL hqq-bindgen, verifikátor (V1-V41) a generátor kódu Go/C11
7│ └── rt/ # Runtime klienta Go bez alokácií pre generované služby
8├── secureexec/ # Overenie manifestu SHA-256 / Ed25519 a spúšťanie zapečateného cez memfd
9├── launch/ # Supervízor workerov s riadením lifeline cez --parent-stdin
10├── cmd/hqq-manifest/ # CLI nástroj na generovanie a podpisovanie manifestov binárnych vydaní
11└── spec/ # Spustiteľné špecifikácie drôtu a referenčné testovacie vektory (BINDGEN.md)
Ak chcete vidieť, ako HQQ poháňa priamu produkčnú náhradu za dve z najpoužívanejších knižníc cgo v ekosystéme Go, pozrite si doplnkové balíky postavené na tomto zásobníku:
gosuda.org/sqlite3: Priamy ovládačdatabase/sqlkompatibilný sgithub.com/mattn/go-sqlite3, ktorý beží nad natívnym C SQLite3.54.1cez zdieľanú pamäť s pipelinovým predbežným načítavaním dávok.gosuda.org/zstd: Priama náhrada zagithub.com/DataDog/zstd, ktorá beží nad natívnym Clibzstd 1.5.7v izolovanom workeri so zdieľanou pamäťou, s kontextami bez alokácií a hromadným dávkovaním.- Repozitár na GitHube: https://github.com/gosuda/HQQ