HQQ: Izolace C knihoven od jazyka Go prostřednictvím sdílené paměti na úrovni cache line
gosuda.org/hqq je runtime pro interprocesovou komunikaci s nulovým cgo a nástrojový řetězec generování kódu pro Go a C11. Izoluje nativní knihovny v jazyce C uvnitř samostatných pracovních procesů operačního systému a připojuje je k volajícím v jazyce Go prostřednictvím kruhových vyrovnávacích pamětí ve sdílené paměti, které jsou mapované v paměti a zarovnané na hranice cache line. Projekt kombinuje dvě přenosové vrstvy (StandardLink pro vyhrazené relace s jedním producentským a jedním konzumentským vláknem a Advanced pro vícevláknové MPMC směrování), kompilátor IDL hqq-bindgen a procesní dozorčí modul s ověřením proti manipulaci (secureexec a launch).
1go get gosuda.org/hqq
Náklady na sdílení adresního prostoru s jazykem C
Každý systémový inženýr pracující v jazyce Go dříve či později narazí na stejnou křižovatku. Budujete službu v jazyce Go, která se zkompiluje během několika sekund s příkazem CGO_ENABLED=0, dodává se jako jediný statický binární soubor, křížově se kompiluje z notebooku na cílové systémy Linux nebo Windows bez systémového kořene (sysroot) a běží uvnitř paměťově bezpečné haldy se správou paměti (garbage collection). Poté potřebujete zralou knihovnu v jazyce C: SQLite pro vestavěné relační úložiště, Zstandard (libzstd) pro kompresi pomocí slovníků nebo hardwarově akcelerovanou knihovnu pro multimédia či kryptografii.
Povolení cgo za účelem propojení této knihovny v jazyce C s procesem v jazyce Go řeší okamžitý problém s vazbou, přičemž však zavádí tři strukturální závazky:
- Vazba nástrojového řetězce a křížové kompilace:
CGO_ENABLED=1vyžaduje kompiler jazyka C, hlavičkové soubory cílové libc a linkovací program pro každý cílový operační systém a architekturu. Sestavování artefaktů prolinux/arm64nebowindows/arm64z pracovní stanicedarwin/arm64mění jediný příkazgo buildv matici křížových kompilátorů a systémových kořenů. - Režie plánování za běhu: Každé volání
cgopřekračuje hranici runtime jazyka Go, přepíná ze zvětšitelného zásobníku gorutiny na systémový zásobník a připíná vlákno operačního systému (M). U jemně zrnitých volání se daň za přechod (přibližně 50 až 150 ns na volání) a spor o plánovač rychle kumulují. - Kolaps domény selhání: Kód v jazyce C běží ve stejném virtuálním adresním prostoru jako runtime jazyka Go. Jediný zápis mimo vyhrazenou paměť, použití uvolněné paměti (use-after-free), přetečení zásobníku nebo funkce
abort()uvnitř C funkce poškodí metadata haldy v jazyce Go nebo okamžitě ukončí celý proces serveru, čímž obchází mechanismusrecover()a strhává s sebou každý právě probíhající požadavek HTTP nebo RPC.
Když se týmy snaží vyhnout cgo, běžné alternativy vynucují jiný kompromis. Mechanické překlady kódových základen v jazyce C do čistého Go zachovávají nastavení CGO_ENABLED=0, ale často zaostávají za upstreamovými vydáními v jazyce C, nesou s sebou emulovanou vrstvu virtuální paměti a při zátěži silně alokují paměť. Přesun knihovny v jazyce C do pomocného procesu prostřednictvím zpětné smyčky TCP (loopback TCP) nebo soketů domény Unix obnovuje izolaci selhání, avšak každé volání platí za dva kontextové přepínače jádra, kopie vyrovnávací paměti soketu a serializaci na drátu.
HQQ jsme vytvořili po položení přímé otázky: co kdyby knihovna v jazyce C žila v samostatném procesu operačního systému s vlastním virtuálním adresním prostorem, zatímco Go a C vyměňovaly rámce přímo prostřednictvím linií mezipaměti procesoru (cache lines) bez vstupu do jádra na rychlé cestě?
| Model integrace | CGO_ENABLED=0 | Izolace selhání | Systémová volání rychlé cesty | Alokace haldy na jedno volání | Věrnost upstreamu C11 |
|---|---|---|---|---|---|
Propojení uvnitř procesu (cgo) | Ne | Žádná (sdílená halda) | 0 | 0 až 1+ | Přesný upstream C |
| RPC podprocesu přes soket / rouru | Ano | Hranice procesu | 2 až 4 | Více (framing + I/O) | Přesný upstream C |
| Mechanický překlad z C do Go | Ano | Go uvnitř procesu | 0 | Vysoká u složitých volání | Emulovaný C runtime |
| IPC přes sdílenou paměť HQQ | Ano | Hranice procesu | 0 (adaptivní spin) | 0 (StandardLink) | Přesný upstream C |
Architektonický přehled: Čtyři vrstvy napříč hranicemi procesů
HQQ odděluje izolaci procesů do čtyř skladebných vrstev tak, aby mechanika kruhové vyrovnávací paměti ve sdílené paměti, generování typového kódu RPC, ověřování binární integrity a dohled nad podřízenými procesy zůstaly nezávislé.
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. Přenos prostřednictvím sdílené paměti (gosuda.org/hqq a c/)
Základní balíček gosuda.org/hqq a jeho protějšek v jazyce C11 v adresáři c/ implementují dva režimy přenosu prostřednictvím sdílené paměti nad POSIX funkcí mmap (Linux a macOS) a Win32 funkcí CreateFileMappingW (Windows). Každý sdílený segment začíná ověřeným superblokem HQQ_STL3, který před přečtením jakéhokoli indexu kruhové vyrovnávací paměti fixuje verzi protokolu, geometrii vyrovnávací paměti a PID koncových bodů.
Všechna aktivní řídicí slova a hlavičky slotů jsou zarovnány na hranice 64bajtových linií mezipaměti (cache lines), takže producentská a konzumentská jádra nikdy nesoupeří o stejnou linii mezipaměti prostřednictvím falešného sdílení (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 +---------------------------------------------------------------------+
| Pole | Ofset | Velikost | Sémantika |
|---|---|---|---|
seq | 0x00 | 8 B | Monotonní čítač sekvencí. Liché hodnoty označují právě probíhající zápis; sudé hodnoty publikují potvrzený rámec pomocí operace store-release. |
len | 0x08 | 4 B | Délka datové části (payload) v bytech zapsaná do slotu (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Řídicí bity rámce (pokračování, stav chyby nebo oznámení o vypnutí). |
payload | 0x40 | BufferSize | Souvislá oblast datové části čtená a zapisovaná na místě bez mezilehlých kopií. |
Volající si vybírají mezi dvěma přenosovými vrstvami podle struktury souběžnosti:
StandardLink(1:1 SPSC relace): Páruje klientskou a serverovou relaci nad vyhrazenými sloty požadavků a odpovědí chráněnými zámkem sekvence (SeqLock). Na rychlé cestě zapisovatel serializuje přímo do mapovaného slotu a provede potvrzení pomocí atomického store-release; čtenář krátce rotuje (spins) v uživatelském prostoru, než přejde k primitivním funkcím probouzení operačního systému (futexv Linuxu,__ulock_waitv macOS,WaitOnAddressve Windows). Jednorámcová obousměrná cesta (round trip) se dokončí za ~200 ns s0 alokacemi na operaci.Advanced(MPMC kruhová vyrovnávací paměť pro odesílání): MultiplexujeMsouběžných volajících v jazyce Go napříčNpracovními vlákny v jazyce C pomocí odesílací kruhové vyrovnávací paměti bez zámků pro více producentů a více konzumentů (multi-producer multi-consumer), sdíleného fondu slotů a indexované tabulky dokončení. Volající si rezervují slot, publikují index slotu do kruhové vyrovnávací paměti a čekají na svou položku dokončení, zatímco pracovní vlákna odebírají a dokončují požadavky mimo pořadí.
Obě vrstvy nepřetržitě sledují živost protějšku. Každý koncový bod aktualizuje atomické monotónní časové razítko prostřednictvím funkce Heartbeat() a při čekání kontroluje funkci PeerAlive(timeout). Pokud pracovní proces havaruje uprostřed volání, protějšek okamžitě detekuje zastaralé srdeční tepy nebo mrtvý PID, přeruší zablokovaná čekání s chybou ErrPeerGone a podporuje atomické převzetí relace (Takeover), aniž by po sobě zanechal zablokované soubory sdílené paměti.
2. RPC s nulovou alokací a handlery kontrolované generací (gosuda.org/hqq/bindgen)
Ruční psaní rámců sdílené paměti pro knihovnu v jazyce C s desítkami funkcí je zdlouhavé a náchylné k chybám. hqq-bindgen je kompilátor schémat, který čte soubor jazyka pro definici rozhraní (.idl) (nebo jej zavádí přímo z hlavičkového souboru .h v jazyce C pomocí příkazu hqq-bindgen init) a generuje klientský balíček v jazyce Go s nulovým cgo spolu s kostrou serveru v jazyce C11.
Nejnebezpečnější chybou v jakékoli vazbě jazyka C je vystavení surových ukazatelů jazyka C (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) přes hranici jazyků. Pokud volající předá ukazatel po jeho uvolnění, předá ukazatel patřící jiné relaci nebo poškodí celočíselnou adresu, knihovna v jazyce C dereferencuje neplatnou paměť.
Nástroj hqq-bindgen zakazuje surové ukazatele na drátu. Místo toho IDL modeluje stavové prostředky jazyka 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 balící 32bitový index tabulky a 32bitový nenulový čítač generace: (uint64_t)generation << 32 | index. Runtime jazyka C (c/src/hqq_rpc.c) udržuje tabulku handlerů pro každou relaci se třemi invarianty:
- Ověření generace: Každé vyhledávání ověřuje, zda je slot obsazený, zda jeho uložená generace odpovídá horním 32 bitům handleru a zda se značka typu handleru shoduje s typem parametru (
Dbvs.Stmt). Opětovné použití uzavřeného handleru selže okamžitě s chybouHQQRPC_E_BAD_HANDLEpřed voláním knihovny v jazyce C. - Hierarchické vlastnictví (
@owned_by): Protože jeStmtopatřen anotací@owned_by(Db), uzavření handleruDbautomaticky projde všechny živé podřízené handleryStmtvlastněné toutoDba spustí jejich destruktory v opačném pořadí alokace (sqlite3_finalizepředsqlite3_close_v2). - Úklid při ukončení relace: Pokud se klient v jazyce Go odpojí nebo havaruje bez uzavření svých handlerů, destruktor relace serveru v jazyce C projde celou tabulku handlerů relace v opačném pořadí, což zaručuje nulové množství uniklých alokací v jazyce C napříč restarty klienta.
Předtím, než relace zpracuje své první volání RPC, vymění si klient a server 32bajtový souhrn (digest) rozhraní SHA-256 vypočtený nad kanynizovaným IDL (s předponou HQQ-BINDGEN-IDL-V1\n). Pokud klient v jazyce Go a pracovník v jazyce C byly zkompilovány z nekompatibilních schémat IDL, handshake připojení při spuštění odmítne.
3. Neměnná binární the execuace (gosuda.org/hqq/secureexec)
Přesun kódu v jazyce C do binárního souboru pracovníka vyvolává otázku dodavatelského řetězce: jak proces v jazyce Go ví, že binární soubor pracovníka na disku nebyl mezi ověřením a spuštěním modifikován nebo nahrazen?
Volání funkce crypto/sha256 na cestu k souboru následované příkazem os/exec.Command(path).Start() vytváří okno typu Time-of-Check to Time-of-Use (TOCTOU), ve kterém může jiný proces přepsat nebo vyměnit soubor dříve, než jej jádro spustí. Balíček gosuda.org/hqq/secureexec toto okno uzavírá:
- V systému Linux otevírá
secureexecbinární soubor, streamuje jeho obsah do anonymního paměťového souborového deskriptoru vytvořeného pomocímemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)a současně počítá jeho hash SHA-256, ověřuje hash proti fixovanému 32bajtovému souhrnu nebo manifestu vydání podepsanému klíčem 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 mutovat, a spouští potka z uzamčeného deskriptoru. - V systémech macOS a Windows materializuje
secureexecověřené binární soubory do adresářů mezipaměti s uzamčenými oprávněními, které jsou klíčovány podle souhrnu SHA-256, a před spuštěním ověřuje velikost souboru a integritu souhrnu.
4. Dohled nad procesem pomocí rodičovského záchranného lna (gosuda.org/hqq/launch)
Když aplikace v jazyce Go spouští pozadový pracovní proces v jazyce C, nesmí tento pracovník nikdy přežít svého rodiče jako osiřelý proces, a to ani v případě, že je rodič v jazyce Go ukončen příkazem SIGKILL (kill -9) a nemůže spustit obslužné rutiny pro úklid v příkazu defer.
Balíček gosuda.org/hqq/launch vynucuje vazbu životnosti prostřednictvím anonymní roury (pipe) operačního systému předané na standardní vstup pracovníka s příznakem --parent-stdin. Rodič v jazyce Go drží zapisovací konec roury a nikdy do něj nezapisuje; pracovník v jazyce C monitoruje čtecí konec na vyhrazeném záchranném vláknu (lifeline thread). Pokud proces v jazyce Go normálně skončí, vyvolá paniku nebo je usmrcen jádrem operačního systému (SIGKILL nebo OOM killer), jádro okamžitě zavře zapisovací deskriptor. Pracovník v jazyce C obdrží na rouře EOF během mikrosekund, přeruší všechna aktivní volání, spustí destruktory handlerů své relace, odpojí soubory sdílené paměti a čistě skončí.
Rychlý start: Volání generované služby
S nástroji hqq-bindgen a launch vypadá volání izolovaného pracovníka v jazyce C z Go jako běžný typový klient v jazyce Go, přičemž je zachováno nastavení 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}
Struktura úložiště
Každý subsystém v balíčku gosuda.org/hqq je uspořádán do cílených balíčků s explicitními hranicemi:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) and low-level shared-memory ring primitives
3├── advanced.go / slotpool.go # Advanced (MPMC) dispatcher, slot pool, and completion table
4├── shm_*.go / wait_*.go # OS-specific mmap/CreateFileMapping and futex/ulock/WaitOnAddress
5├── c/ # C11 transport library (hqq.c) and RPC server runtime (hqq_rpc.c)
6├── bindgen/ # hqq-bindgen IDL parser, verifier (V1-V41), and Go/C11 code generator
7│ └── rt/ # Zero-allocation Go client runtime for generated services
8├── secureexec/ # SHA-256 / Ed25519 manifest verification and memfd-sealed execution
9├── launch/ # Worker process supervisor with --parent-stdin lifeline management
10├── cmd/hqq-manifest/ # CLI tool for generating and signing release binary manifests
11└── spec/ # Executable wire specifications and golden test vectors (BINDGEN.md)
Chcete-li vidět, jak HQQ pohání náhrady pro produkční prostředí u 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: Náhrada typu drop-in pro ovladačdatabase/sqlkompatibilní s balíčkemgithub.com/mattn/go-sqlite3, která spouští nativní C SQLite3.54.1nad sdílenou pamětí s předběžným načítáním pipelined dávek.gosuda.org/zstd: Náhrada typu drop-in pro balíčekgithub.com/DataDog/zstd, která spouští nativní C knihovnulibzstd 1.5.7v izolovaném pracovníku se sdílenou pamětí s kontexty s nulovou alokací a hromadným dávkováním.- Úložiště GitHub: https://github.com/gosuda/HQQ