HQQ: Isolering av C-bibliotek från Go via cache-line-delat minne

gosuda.org/hqq är en zero-cgo-runtime för interprocesskommunikation och verktygskedja för kodgenerering för Go och C11. Den isolerar inbyggda C-bibliotek i separata OS-arbetsprocesser och ansluter dem till Go-anropare via minnesmappade, cache-linjeanpassade delade minnesringar. Projektet kombinerar två transportnivåer (StandardLink för dedicerade single-producer single-consumer-sessioner och Advanced för multi-worker MPMC-dispatch), IDL-kompilatorn hqq-bindgen samt en tamper-verifierad processupervisor (secureexec och launch).
1go get gosuda.org/hqq
Kostnaden för att dela adressrymd med C
Varje systemingenjör inom Go står förr eller senare inför samma vägskäl. Du bygger en Go-tjänst som kompileras på sekunder med CGO_ENABLED=0, distribueras som en enda statisk binär, korskompileras från en bärbar dator till Linux- eller Windows-mål utan sysroot och körs i en minnessäker, skräpsamlad heap. Sedan behöver du ett moget C-bibliotek: SQLite för inbäddad relationell lagring, Zstandard (libzstd) för ordboksbaserad komprimering eller ett hårdvaruaccelererat bibliotek för media eller kryptografi.
Att aktivera cgo för att länka C-biblioteket in i Go-processen löser det omedelbara bindningsproblemet, men introducerar tre strukturella nackdelar:
- Koppling mellan verktygskedja och korskompilering:
CGO_ENABLED=1kräver en C-kompilator, målplattformens libc-headerfiler och en plattformslänkare för varje mål-OS och varje arkitektur. Att byggalinux/arm64- ellerwindows/arm64-artefakter från endarwin/arm64-arbetsstation förvandlar ett endago build-kommando till en matris av korskompilatorer och sysroots. - Schemaläggningskostnader i runtime: Varje
cgo-anrop korsar gränsen till Go-runtime, byter från en växande goroutine-stack till en systemstack och fäster en OS-tråd (M). För finkorniga anrop ackumuleras övergångskostnaden (ungefär 50 till 150 ns per anrop) och schemaläggningskonkurrensen snabbt. - Kollaps av felområde: C-kod körs i samma virtuella adressrymd som Go-runtime. En enda out-of-bounds-skrivning, use-after-free, stackspill eller
abort()inuti en C-funktion korrumperar Go-heapmetadata eller avslutar hela serverprocessen omedelbart, med förbigående avrecover()och med alla pågående HTTP- eller RPC-förfrågningar som följd.
När team försöker undvika cgo tvingar de vanliga alternativen fram en annan kompromiss. Rena Go-översättningar av C-kodbaser bevarar CGO_ENABLED=0, men ligger ofta efter upstream-releaser av C, bär på ett emulerat virtuellt minneslager och allokerar kraftigt under belastning. Att flytta C-biblioteket till en hjälpprocess över loopback-TCP eller Unix-domänsockets återställer felisoleringen, men varje anrop betalar för två kontextbyten i kärnan, kopiering av socketbuffertar och wire-serialisering.
Vi byggde HQQ efter att ha ställt en direkt fråga: tänk om C-biblioteket levde i en separat OS-process med egen virtuell adressrymd, medan Go och C utbytte ramar direkt via delade CPU-cache-linjer utan att gå in i kärnan på snabbvägen?
| Integrationsmodell | CGO_ENABLED=0 | Felisolering | Systemanrop på snabbvägen | Heapallokeringar per anrop | Trohet mot upstream C11 |
|---|---|---|---|---|---|
cgo i-process-länkning | Nej | Ingen (delad heap) | 0 | 0 till 1+ | Exakt upstream C |
| Socket- / pipe-subprocess-RPC | Ja | Processgräns | 2 till 4 | Flera (inramning + I/O) | Exakt upstream C |
| Mekanisk C-till-Go-översättning | Ja | Go i process | 0 | Hög vid komplexa anrop | Emulerad C-runtime |
| HQQ delat-minne-IPC | Ja | Processgräns | 0 (adaptiv spinning) | 0 (StandardLink) | Exakt upstream C |
Arkitektonisk översikt: fyra lager över processgränser
HQQ delar upp processisolering i fyra sammansättningsbara lager, så att mekaniken för delade minnesringar, typad RPC-kodgenerering, verifiering av binär integritet och övervakning av barnprocesser förblir oberoende av varandra.
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 via delat minne (gosuda.org/hqq och c/)
Baspaketet gosuda.org/hqq och dess C11-motsvarighet i c/ implementerar två transportlägen för delat minne över POSIX mmap (Linux och macOS) och Win32 CreateFileMappingW (Windows). Varje delat segment inleds med en verifierad HQQ_STL3-superblock som fastställer protokollversion, buffertgeometri och endpoint-PID:ar innan något ringindex läses.
Alla heta kontrollord och slotheaders är justerade till 64-byte cache-linjegränser, så att producent- och konsumentkärnor aldrig konkurrerar om samma cache-linje genom falsk delning.
1StandardLink-slotlayout (64-byte cache-linjeanpassad)
2Byteoffset: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Headerlinje: | seq (u64 atomic) | len (u32) | flags | reserved (nollutfylld) |
5 +-------------------+-------------------+-----------------------------+
6Payloadyta: | payloadbytes [0 .. BufferSize - 1] (kodas / avkodas direkt) |
7 +---------------------------------------------------------------------+
| Fält | Offset | Storlek | Semantik |
|---|---|---|---|
seq | 0x00 | 8 B | Monoton sekvensräknare. Udda värden markerar en pågående skrivning; jämna värden publicerar en committad ram via store-release. |
len | 0x08 | 4 B | Payloadens bytelängd skriven i slotten (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Kontrollbitar för ramen (fortsättning, felstatus eller nedstängningsnotis). |
payload | 0x40 | BufferSize | Sammanhängande payloadregion som läses och skrivs på plats utan mellanliggande kopior. |
Anropare väljer mellan två transportnivåer beroende på konkurrensstruktur:
StandardLink(1:1 SPSC-session): Parar en klient- och en serversession över dedicerade request- och response-slottar skyddade av ett sekvenslås (SeqLock). På snabbvägen serialiserar skrivaren direkt in i den mappade slotten och committar med en atomär store-release; läsaren spinnar kort i användarutrymme innan den faller tillbaka på OS-väckningsprimitiver (futexpå Linux,__ulock_waitpå macOS,WaitOnAddresspå Windows). En rundresa med en enda ram slutförs på ungefär 200 ns med0 allocs/op.Advanced(MPMC-dispatchring): MultiplexerarMsamtidiga Go-anropare överNC-arbetartrådar med hjälp av en lock-fri multi-producer multi-consumer-inlämningsring, en delad slotpool och en indexerad komplettringstabell. Anropare reserverar en slot, publicerar slotindexet till ringen och väntar på sin komplettringspost, medan arbetartrådar köar ut och slutför förfrågningar i godtycklig ordning.
Båda nivåerna följer kontinuerligt peer-livslängd. Varje endpoint uppdaterar en atomär monoton tidsstämpel via Heartbeat() och kontrollerar PeerAlive(timeout) medan den väntar. Om en arbetarprocess kraschar mitt i ett anrop upptäcker peer:en den inaktuella heartbeaten eller det döda PID:t omedelbart, avbryter blockerade väntanden med ErrPeerGone och stödjer atomär sessionsövertagning (Takeover) utan att lämna kvar fastlåsta filer för delat minne.
2. Anropsfri RPC och generationskontrollerade handtag (gosuda.org/hqq/bindgen)
Att skriva delad minnesinramning för hand för ett C-bibliotek med dussintals funktioner är tråkigt och felbenäget. hqq-bindgen är en schemakompilator som läser en Interface Definition Language-fil (.idl) (eller bootstrappar en sådan direkt från en C-headerfil .h via hqq-bindgen init) och genererar ett zero-cgo-Go-klientpaket tillsammans med ett C11-serverskelett.
Det allvarligaste misstaget i varje C-bindning är att exponera råa C-pekare (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) över språkgränsen. Om en anropare skickar en pekare efter att ha frigjort den, skickar en pekare som tillhör en annan session eller korrumperar en heltalsadress, derefererar C-biblioteket ogiltigt minne.
hqq-bindgen förbjuder råa pekare på transportvägen. I stället modellerar IDL:en tillståndsbärande C-resurser som typade handle-deklarationer:
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}
På transportvägen är varje handtag ett 64-bitars heltal som packar ett 32-bitars tabellindex och en 32-bitars icke-noll generationsräknare: (uint64_t)generation << 32 | index. C-runtime (c/src/hqq_rpc.c) underhåller en handtagstabell per session med tre invarianter:
- Generationsvalidering: Varje uppslagning verifierar att slotten är upptagen, att dess lagrade generation matchar de övre 32 bitarna i handtaget och att dess handtagstyptagg matchar parametertypen (
DbmotStmt). Återanvändning av ett stängt handtag misslyckas omedelbart medHQQRPC_E_BAD_HANDLEinnan C-biblioteket anropas. - Hierarkiskt ägarskap (
@owned_by): EftersomStmtär annoterad med@owned_by(Db)går stängning av ettDb-handtag automatiskt igenom alla levande underordnadeStmt-handtag som ägs av detDboch kör deras destruktorer i omvänd allokeringsordning (sqlite3_finalizeföresqlite3_close_v2). - Svep vid sessionsavslut: Om en Go-klient kopplar från eller kraschar utan att stänga sina handtag, sveper sessionsdestruktorn på C-servern hela sessionens handtagstabell i omvänd ordning, vilket garanterar noll läckta C-allokeringar över klientomstarter.
Innan en session behandlar sitt första RPC-anrop utväxlar klienten och servern en 32-byte SHA-256-gränssnittsdigest beräknad över den kanoniserade IDL:en (med prefixet HQQ-BINDGEN-IDL-V1\n). Om Go-klienten och C-arbetaren kompilerades från inkompatibla IDL-scheman avvisar handskakningen anslutningen vid bootstrap.
3. Manipulationssäker binärexekvering (gosuda.org/hqq/secureexec)
Att flytta C-kod till en arbetarbinär väcker en leveranskedjefråga: hur vet Go-processen att arbetarbinären på disken inte har ändrats eller ersatts mellan verifiering och exekvering?
Att anropa crypto/sha256 på en sökväg följt av os/exec.Command(path).Start() lämnar ett Time-of-Check till Time-of-Use (TOCTOU)-fönster där en annan process kan skriva över eller byta ut filen innan kärnan exekverar den. gosuda.org/hqq/secureexec stänger detta fönster:
- På Linux öppnar
secureexecbinären, strömmar dess innehåll till en anonym minnesfildeskriptor skapad viamemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)medan dess SHA-256-hash beräknas, verifierar hashen mot en fastställd 32-byte-digest eller ett Ed25519-signerat releasemanifest (cmd/hqq-manifest), tillämparfcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE)så att ingen process i systemet kan mutera sidorna, och startar barnet från den förseglade deskriptorn. - På macOS och Windows materialiserar
secureexecverifierade binärer i behörighetslåsta cachekataloger nycklade efter SHA-256-digest och verifierar filstorlek och digestintegritet före start.
4. Förälderlivlina för processövervakning (gosuda.org/hqq/launch)
När en Go-applikation startar en C-arbetarprocess i bakgrunden får arbetaren aldrig överleva sin förälder som en föräldralös process, även om Go-föräldern avslutas med SIGKILL (kill -9) och inte kan köra defer-städhanterare.
gosuda.org/hqq/launch upprätthåller livstidskoppling via en anonym OS-pipe som skickas till arbetarens standard input med flaggan --parent-stdin. Go-föräldern håller pipens skrivände och skriver aldrig till den; C-arbetaren övervakar läsänden på en dedicerad livlinetråd. Om Go-processen avslutas normalt, panikar eller dödas av operativsystemets kärna (SIGKILL eller OOM-dödaren) stänger kärnan omedelbart skrivdeskriptorn. C-arbetaren får EOF på pipen inom mikrosekunder, avbryter eventuella aktiva anrop, kör sina sessionshandtagsdestruktorer, avlänkar de delade minnesfilerna och avslutas på ett rent sätt.
Snabbstart: Anrop av en genererad tjänst
Med hqq-bindgen och launch ser anrop av en isolerad C-arbetare från Go ut som en vanlig typad Go-klient, samtidigt som CGO_ENABLED=0 bibehålls:
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}
Repositoriestruktur
Varje delsystem i gosuda.org/hqq är organiserat i fokuserade paket med tydliga gränser:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) och primitiver för ringar i delat minne på låg nivå
3├── advanced.go / slotpool.go # Advanced (MPMC)-dispatcher, slotpool och komplettringstabell
4├── shm_*.go / wait_*.go # OS-specifik mmap/CreateFileMapping och futex/ulock/WaitOnAddress
5├── c/ # C11-transportbibliotek (hqq.c) och RPC-serverruntime (hqq_rpc.c)
6├── bindgen/ # hqq-bindgen IDL-parser, verifierare (V1-V41) och Go/C11-kodgenerator
7│ └── rt/ # Anropsfri Go-klientruntime för genererade tjänster
8├── secureexec/ # SHA-256- / Ed25519-manifestverifiering och memfd-förseglad exekvering
9├── launch/ # Arbetarprocessupervisor med hantering av --parent-stdin-livlina
10├── cmd/hqq-manifest/ # CLI-verktyg för att generera och signera manifest för releasebinärer
11└── spec/ # Körbara wirespecifikationer och referensvektorer för tester (BINDGEN.md)
För att se hur HQQ driver direkta produktionsersättningar för två av de mest använda cgo-biblioteken i Go-ekosystemet, ta en titt på de kompletterande paketen som byggts på denna stack:
gosuda.org/sqlite3: Direktersättandedatabase/sql-drivrutin kompatibel medgithub.com/mattn/go-sqlite3, som kör inbyggd C SQLite3.54.1över delat minne med pipelinebaserad batchförhämtning.gosuda.org/zstd: Direktersättning förgithub.com/DataDog/zstd, som kör inbyggd Clibzstd 1.5.7i en isolerad arbetare med delat minne, med anropsfria kontexter och bulkbatchning.- GitHub-repositorium: https://github.com/gosuda/HQQ