HQQ: Izolace C knihoven od Go prostřednictvím sdílené paměti zarovnané na cache line

gosuda.org/hqq je runtime pro meziprocesovou komunikaci (IPC) bez zero-cgo a nástroj pro generování kódu pro Go a C11. Izoluje nativní C knihovny uvnitř samostatných procesů operačního systému (worker procesů) a připojuje je k volajícím v Go prostřednictvím sdílených paměťových kruhových front (ring) mapovaných do paměti, zarovnaných na hranice cache line. Projekt kombinuje dvě úrovně přenosu (StandardLink pro vyhrazené relace typu single-producer single-consumer a Advanced pro multi-worker MPMC dispatch), kompilátor IDL hqq-bindgen a supervizor procesů s ověřováním integrity (secureexec a launch).
1go get gosuda.org/hqq
Cena sdílení adresního prostoru s C
Každý inženýr systémů v Go se dříve či později dostane na stejnou křižovatku. Vytváříte službu v Go, která se kompiluje během několika sekund s CGO_ENABLED=0, je distribuována jako jediný statický binární soubor, lze ji křížově kompilovat z notebooku pro cílové systémy Linux nebo Windows bez sysroot a běží uvnitř paměťově bezpečné haldy spravované garbage collectorem. Následně potřebujete vyzrálou C knihovnu: SQLite pro vestavěné relační úložiště, Zstandard (libzstd) pro kompresi se slovníkem, nebo hardwarově akcelerovanou knihovnu pro média či kryptografii.
Povolení cgo pro propojení této C knihovny do procesu Go sice vyřeší bezprostřední problém vazby, avšak zavádí tři strukturální nevýhody:
- Vazba na toolchain a křížovou kompilaci:
CGO_ENABLED=1vyžaduje C kompilátor, hlavičkové soubory cílové libc a platformní linker pro každý cílový operační systém a architekturu. Sestavení artefaktůlinux/arm64nebowindows/arm64z pracovní stanicedarwin/arm64promění jediný příkazgo buildv matici křížových kompilátorů a sysroot. - Režie plánování za běhu: Každé volání
cgopřekračuje hranici runtime jazyka Go, přepíná z rostoucího zásobníku goroutiny na systémový zásobník a váže OS vlákno (M). U jemnozrnných volání se rychle nahromadí náklady na přechod (přibližně 50 až 150 ns na volání) a konkurence plánovače. - Kolaps doménы poruchy: Kód C běží ve stejném virtuálním adresním prostoru jako runtime Go. Jediný zápis mimo hranice, použití po uvolnění (use-after-free), přetečení zásobníku nebo
abort()uvnitř funkce C poškodí metadata haldy Go nebo okamžitě ukončí celý serverový proces, čímž obejderecover()a shodí každý probíhající požadavek HTTP nebo RPC.
Když se týmy pokusí cgo obejít, běžné alternativy vynucují jiný kompromis. Čistě Go mechanické překlady C kódu zachovávají CGO_ENABLED=0, avšak často zaostávají za upstream vydáními C, nesou emulovanou vrstvu virtuální paměti a při zátěži intenzivně alokují. Přesun C knihovny do pomocného procesu přes loopback TCP nebo Unix doménové sockety obnoví izolaci poruch, ale každé volání platí dvěma přepnutími kontextu jádra, kopírováním socketových bufferů a serializací na drátu.
HQQ jsme vytvořili poté, co jsme si položili přímou otázku: co kdyby C knihovna žila v samostatném procesu operačního systému s vlastním virtuálním adresním prostorem, zatímco Go a C by si rámce vyměňovaly přímo prostřednictvím sdílených cache line procesoru, bez vstupu do jádra na rychlé cestě?
| Model integrace | CGO_ENABLED=0 | Izolace poruch | Systémová volání na rychlé cestě | Alokace na haldě na volání | Věrnost upstream C11 |
|---|---|---|---|---|---|
cgo propojení v procesu | Ne | Žádná (sdílená halda) | 0 | 0 až 1+ | Přesné upstream C |
| Socket / Pipe subprocesové RPC | Ano | Hranice procesu | 2 až 4 | Několik (rámcování + I/O) | Přesné upstream C |
| Mechanický překlad C do Go | Ano | Proces v Go | 0 | Vysoké u složitých volání | Emulovaný runtime C |
| HQQ sdílená paměť IPC | Ano | Hranice procesu | 0 (adaptivní spin) | 0 (StandardLink) | Přesné upstream C |
Architektonický přehled: čtyři vrstvy napříč hranicemi procesů
HQQ rozděluje izolaci procesů do čtyř skladatelných vrstev, takže mechanika sdílených kruhových front, typově bezpečné generování RPC kódu, ověřování binární integrity a dohled nad podřízenými procesy zůstávají nezávislé.
1+-------------------------------------------------------------------+
2| Procesu aplikace v Go (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Generovaný klient Go | | Dohled nad procesy | |
6| | (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Anonymní pipe |
11| | Vrstva přenosu | | Lifeline |
12| | (StandardLink / | | (--parent-stdin) |
13| | Advanced MPMC) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|========= Sdílená paměť =========|===================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| Worker proces C11 (izolovaný adresní prostor) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Generovaný server C11 |---->| Tabulka handle s kontrolou | |
23| | a dávkové shimy | | generací (hqq_rpc.c) | |
24| +-----------+-----------+ +---------------+---------------+ |
25| | | |
26| +----------------+----------------+ |
27| | |
28| +---------v---------+ |
29| | Nativní knihovna C| |
30| | (SQLite, Zstd...) | |
31| +-------------------+ |
32+-------------------------------------------------------------------+
1. Přenos přes sdílenou paměť (gosuda.org/hqq a c/)
Základní balíček gosuda.org/hqq a jeho protějšek v C11 v adresáři c/ implementují dva režimy přenosu přes sdílenou paměť nad POSIX mmap (Linux a macOS) a Win32 CreateFileMappingW (Windows). Každý sdílený segment začíná ověřeným superblokem HQQ_STL3, který fixuje verzi protokolu, geometrii bufferů a PID koncových bodů dříve, než je přečten jakýkoli index kruhové fronty.
Všechna řídicí slova za běhu a hlavičky slotů jsou zarovnána na hranice cache line o velikosti 64 bajtů, aby producent a konzument na různých jádrech nikdy nesoupeřili o stejnou cache line kvůli falešnému sdílení (false sharing).
1Rozložení slotu StandardLink (zarovnáno na cache line 64 bajtů)
2Posun v bajtech: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Řádek hlavičky: | seq (u64 atomic) | len (u32) | flags | rezervováno (doplněno nulami) |
5 +-------------------+-------------------+-----------------------------+
6Oblast payloadu: | bajty payloadu [0 .. BufferSize - 1] (kódovány / dekódovány přímo) |
7 +---------------------------------------------------------------------+
| Pole | Posun | Velikost | Význam |
|---|---|---|---|
seq | 0x00 | 8 B | Monotónní sekvenční čítač. Liché hodnoty označují zápis v procesu; sudé hodnoty publikují potvrzený rámec pomocí store-release. |
len | 0x08 | 4 B | Délka payloadu v bajtech zapsaného do slotu (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Řídicí bity rámce (pokračování, stav chyby nebo oznámení o ukončení). |
payload | 0x40 | BufferSize | Souvislá oblast payloadu čtená a zapisovaná na místě bez mezikopírování. |
Volající volí mezi dvěma úrovněmi přenosu podle struktury souběžnosti:
StandardLink(relace SPSC 1:1): Páruje klientskou a serverovou relaci přes vyhrazené požadavkové a odpovědní sloty chráněné sekvenčním zámkem (SeqLock). Na rychlé cestě zapisovatel serializuje data přímo do mapovaného slotu a potvrzuje je atomickým store-release; čtenář krátce spinuje v uživatelském prostoru, než se uchýlí k primitivům probuzení OS (futexna Linuxu,__ulock_waitna macOS,WaitOnAddressna Windows). Jeden rámec v round tripu se dokončí přibližně za 200 ns s0 allocs/op.Advanced(MPMC dispatch ring): MultiplexujeMsouběžných volajících v Go přesNvláken C workeru pomocí lock-free multi-producer multi-consumer odesílací fronty, sdíleného fondu slotů a indexované tabulky dokončení. Volající rezervuje slot, publikuje jeho index do fronty a čeká na svou položku dokončení, zatímco vlákna workeru vyjímají požadavky z fronty a dokončují je mimo pořadí.
Obě úrovně průběžně sledují živost protějšku. Každý koncový bod aktualizuje atomické monotónní časové razítko přes Heartbeat() a při čekání kontroluje PeerAlive(timeout). Pokud worker proces během volání spadne, protějšek okamžitě zjistí zastaralý heartbeat nebo mrtvé PID, přerušené čekání ukončí chybou ErrPeerGone a podporuje atomické převzetí relace (Takeover) bez ponechání zablokovaných souborů sdílené paměti.
2. RPC bez alokací a handly s kontrolou generací (gosuda.org/hqq/bindgen)
Ruční psaní rámcování sdílené paměti pro C knihovnu s desítkami funkcí je zdlouhavé a náchylné k chybám. hqq-bindgen je kompilátor schémat, který čte soubor v jazyce pro definici rozhraní (Interface Definition Language, .idl) (nebo jej může přímo vytvořit z hlavičky C .h příkazem hqq-bindgen init) a generuje klientský balíček pro Go bez zero-cgo spolu s kostrou serveru v C11.
Nejnebezpečnějším omylem v jakékoli vazbě na C je vystavení surových ukazatelů C (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) přes hranici jazyka. Pokud volající předá ukazatel poté, co jej uvolnil, předá ukazatel patřící jiné relaci, nebo poškodí celočíselnou adresu, knihovna C dereferencuje neplatnou paměť.
hqq-bindgen zakazuje surové ukazatele na drátu. Místo toho IDL modeluje stavové prostředky C jako typované deklarace 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átu je každý handle 64bitové celé číslo, které obsahuje 32bitový index tabulky a 32bitový nenulový čítač generace: (uint64_t)generation << 32 | index. Runtime v C (c/src/hqq_rpc.c) udržuje pro každou relaci tabulku handlů se třemi invarianty:
- Validace generace: Každé vyhledání ověří, že slot je obsazen, že jeho uložená generace odpovídá horním 32 bitům handlu a že jeho typová značka handlu odpovídá typu parametru (
Dbvs.Stmt). Opětovné použití uzavřeného handlu okamžitě selže sHQQRPC_E_BAD_HANDLE, aniž by se volala knihovna C. - Hierarchické vlastnictví (
@owned_by): ProtožeStmtje označen@owned_by(Db), uzavření handluDbautomaticky projde všechny živé podřízené handlyStmt, které tomutoDbpatří, a spustí jejich destruktory v opačném pořadí alokace (sqlite3_finalizepředsqlite3_close_v2). - Úklid relace při ukončení: Pokud se klient v Go odpojí nebo spadne, aniž by uzavřel své handly, destruktor relace na serveru C projde celou tabulku handlů relace v obráceném pořadí a zaručí nulový únik alokací C napříč restarty klienta.
Předtím, než relace zpracuje první volání RPC, si klient a server vymění 32bajtový SHA-256 digest rozhraní, vypočtený nad kanonicky zapsaným IDL (prefix HQQ-BINDGEN-IDL-V1\n). Pokud byl klient v Go a worker v C zkompilován z nekompatibilních schémat IDL, handshake spojení při inicializaci odmítne.
3. Binární spouštění odolné proti manipulaci (gosuda.org/hqq/secureexec)
Přesun kódu C do binárního workeru vyvolává otázku dodavatelského řetězce: jak proces v Go pozná, že binární soubor workeru na disku nebyl mezi ověřením a spuštěním změněn nebo nahrazen?
Výpočet crypto/sha256 nad cestou k souboru následovaný voláním os/exec.Command(path).Start() ponechává okno typu Time-of-Check to Time-of-Use (TOCTOU), v němž může jiný proces soubor přepsat nebo vyměnit dříve, než jej jádro spustí. gosuda.org/hqq/secureexec toto okno uzavírá:
- Na Linuxu
secureexecotevře binární soubor, streamuje jeho obsah do anonymního paměťového souboru (deskriptoru) vytvořeného přesmemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)a přitom počítá jeho SHA-256 hash, ověří hash proti připnutému 32bajtovému digestu nebo podepsanému manifestu vydání Ed25519 (cmd/hqq-manifest), aplikujefcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE), takže žádný proces v systému nemůže stránky změnit, a podřízený proces spustí z zapečetěného deskriptoru. - Na macOS a Windows
secureexecověřené binární soubory materializuje do adresářů uzamčených oprávněními, indexovaných podle SHA-256 digestu, a před spuštěním ověřuje integritu velikosti souboru a digestu.
4. Dohled nad procesy s parent-lifeline (gosuda.org/hqq/launch)
Když aplikace v Go spustí worker proces C na pozadí, worker nesmí nikdy přežít svého rodiče jako osiřelý proces, a to ani tehdy, když je rodičovský proces v Go ukončen signálem SIGKILL (kill -9) a nemůže spustit úklidové obslužné rutiny defer.
gosuda.org/hqq/launch vynucuje provázání životnosti prostřednictvím anonymní systémové roury (pipe), předané na standardní vstup workeru s příznakem --parent-stdin. Rodičovský proces v Go drží zápisový konec roury a nikdy do něj nezapisuje; worker C sleduje čtecí konec na vyhrazeném vlákně lifeline. Pokud proces Go skončí normálně, spadne na panice nebo je ukončen jádrem operačního systému (SIGKILL nebo OOM killer), jádro okamžitě uzavře zapisovací deskriptor. Worker C obdrží EOF na rouře během několika mikrosekund, přeruší všechna aktivní volání, spustí destruktory handlů relace, odstraní soubory sdílené paměti (unlink) a čistě se ukončí.
Rychlý start: volání generované služby
S nástroji hqq-bindgen a launch vypadá volání izolovaného worker procesu C z Go jako běžný typovaný klient v Go, a přitom zůstává zachováno 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 běží na posluchači sdílené paměti %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}
Struktura repozitáře
Každý subsystém v gosuda.org/hqq je organizován do zaměřených balíčků s explicitními hranicemi:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) a nízkoúrovňové primitivy kruhové fronty ve sdílené paměti
3├── advanced.go / slotpool.go # Dispatcher Advanced (MPMC), fond slotů a tabulka dokončení
4├── shm_*.go / wait_*.go # Specifické pro OS: mmap/CreateFileMapping a futex/ulock/WaitOnAddress
5├── c/ # Transportní knihovna C11 (hqq.c) a runtime RPC serveru (hqq_rpc.c)
6├── bindgen/ # Parser IDL hqq-bindgen, verifikátor (V1-V41) a generátor kódu Go/C11
7│ └── rt/ # Klientský runtime pro Go bez alokací pro generované služby
8├── secureexec/ # Ověřování manifestů SHA-256 / Ed25519 a spouštění zapečetěné přes memfd
9├── launch/ # Dohled nad worker procesy se správou lifeline přes --parent-stdin
10├── cmd/hqq-manifest/ # Nástroj CLI pro generování a podepisování manifestů vydaných binárních souborů
11└── spec/ # Spustitelné specifikace drátového protokolu a referenční testovací vektory (BINDGEN.md)
Chcete-li vidět, jak HQQ pohání plnohodnotné produkční náhrady dvou nejpoužívanějších knihoven cgo v ekosystému Go, podívejte se na doprovodné balíčky postavené na tomto zásobníku:
gosuda.org/sqlite3: Drop-in ovladačdatabase/sqlkompatibilní sgithub.com/mattn/go-sqlite3, který běží nativní C SQLite3.54.1přes sdílenou paměť s pipelinovým předběžným načítáním dávek.gosuda.org/zstd: Drop-in náhrada zagithub.com/DataDog/zstd, která běží nativní Clibzstd 1.5.7v izolovaném workeru se sdílenou pamětí s kontexty bez alokací a hromadným dávkováním.- Repozitář na GitHubu: https://github.com/gosuda/HQQ