GoSuda

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

By Lemon Mint views ...

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:

  1. Koppling mellan verktygskedja och korskompilering: CGO_ENABLED=1 kräver en C-kompilator, målplattformens libc-headerfiler och en plattformslänkare för varje mål-OS och varje arkitektur. Att bygga linux/arm64- eller windows/arm64-artefakter från en darwin/arm64-arbetsstation förvandlar ett enda go build-kommando till en matris av korskompilatorer och sysroots.
  2. 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.
  3. 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 av recover() 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?

IntegrationsmodellCGO_ENABLED=0FelisoleringSystemanrop på snabbvägenHeapallokeringar per anropTrohet mot upstream C11
cgo i-process-länkningNejIngen (delad heap)00 till 1+Exakt upstream C
Socket- / pipe-subprocess-RPCJaProcessgräns2 till 4Flera (inramning + I/O)Exakt upstream C
Mekanisk C-till-Go-översättningJaGo i process0Hög vid komplexa anropEmulerad C-runtime
HQQ delat-minne-IPCJaProcessgräns0 (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ältOffsetStorlekSemantik
seq0x008 BMonoton sekvensräknare. Udda värden markerar en pågående skrivning; jämna värden publicerar en committad ram via store-release.
len0x084 BPayloadens bytelängd skriven i slotten (0 <= len <= BufferSize).
flags0x0C4 BKontrollbitar för ramen (fortsättning, felstatus eller nedstängningsnotis).
payload0x40BufferSizeSammanhä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 (futex på Linux, __ulock_wait på macOS, WaitOnAddress på Windows). En rundresa med en enda ram slutförs på ungefär 200 ns med 0 allocs/op.
  • Advanced (MPMC-dispatchring): Multiplexerar M samtidiga Go-anropare över N C-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:

  1. 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 (Db mot Stmt). Återanvändning av ett stängt handtag misslyckas omedelbart med HQQRPC_E_BAD_HANDLE innan C-biblioteket anropas.
  2. Hierarkiskt ägarskap (@owned_by): Eftersom Stmt är annoterad med @owned_by(Db) går stängning av ett Db-handtag automatiskt igenom alla levande underordnade Stmt-handtag som ägs av det Db och kör deras destruktorer i omvänd allokeringsordning (sqlite3_finalize före sqlite3_close_v2).
  3. 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 secureexec binären, strömmar dess innehåll till en anonym minnesfildeskriptor skapad via memfd_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ämpar fcntl(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 secureexec verifierade 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ättande database/sql-drivrutin kompatibel med github.com/mattn/go-sqlite3, som kör inbyggd C SQLite 3.54.1 över delat minne med pipelinebaserad batchförhämtning.
  • gosuda.org/zstd: Direktersättning för github.com/DataDog/zstd, som kör inbyggd C libzstd 1.5.7 i en isolerad arbetare med delat minne, med anropsfria kontexter och bulkbatchning.
  • GitHub-repositorium: https://github.com/gosuda/HQQ