GoSuda

HQQ: C könyvtárak izolálása a Go nyelvtől cache-vonal szintű megosztott memória révén

By Lemon Mint
views ...

A gosuda.org/hqq egy zero-cgo inter-process communication futtatókörnyezet és kódegenerációs eszközlánc Go és C11 nyelvekhez. Elszigeteli a natív C könyvtárakat különálló OS worker folyamatokban, és a Go hívókhoz memóriára leképezett, cache-line-ig igazított shared-memory gyűrűken keresztül kapcsolja őket. A projekt két transzport réteget (dedikált single-producer single-consumer munkamenetekhez a StandardLink-et és multi-worker MPMC diszpécserhez az Advanced-et), a hqq-bindgen IDL fordítót, valamint egy hamisítás ellen védett folyamatfelügyelőt (secureexec és launch) ötvöz.

1go get gosuda.org/hqq

The Cost of Sharing an Address Space with C

Minden Go rendszermérnök elkerülhetetlenül eléri ugyanazt a válaszúthelyzetet. Egy olyan Go szolgáltatást épít, amely másodpercek alatt lefordul CGO_ENABLED=0 beállítással, egyetlen statikus binárisként szállítható, laptopról Linux vagy Windows célpontokra cross-compileolható sysroot nélkül, valamint egy memóriabiztonságos, szemétgyűjtött (garbage-collected) heapen fut. Ekkor azonban szükségessé válik egy érett C könyvtár: a SQLite beágyazott relációs tároláshoz, a Zstandard (libzstd) szótár-tömörítéshez, vagy egy hardverrel gyorsított média- illetve kriptográfiai könyvtár.

A cgo engedélyezése az említett C könyvtár Go folyamatba történő linkeléséhez megoldja az azonnali kötési problémát, miközben három strukturális hátrányt vezet be:

  1. Toolchain and Cross-Compilation Coupling: A CGO_ENABLED=1 beállításhoz C fordítóra, cél libc fejlécekre és platform linkerre van szükség minden egyes cél OS és architektúra esetén. A linux/arm64 vagy windows/arm64 artefaktumok építése egy darwin/arm64 munkaállomásról a keresztfordítók és sysrootok mátrixává alakít egyetlen go build parancsot.
  2. Runtime Scheduling Overhead: Minden egyes cgo hívás átlépi a Go futtatókörnyezet határát, növelhető goroutine vermről (stack) rendszer vermre vált, és leköt egy OS szálat (M). Finomszemcsés hívások esetén az átmeneti adó (~hívásonként 50 és 150 ns között) és a scheduler versengés gyorsan felhalmozódik.
  3. Fault Domain Collapse: A C kód ugyanabban a virtuális címtérben fut, mint a Go futtatókörnyezet. Egyetlen határokon túli írás (out-of-bounds write), use-after-free, verm túlcsordulás vagy abort() egy C függvényen belül azonnal károsítja a Go heap metaadatait, vagy leállítja a teljes szerverfolyamatot, megkerülve a recover() hívást, és magával rántva minden folyamatban lévő HTTP vagy RPC kérést.

Amikor a csapatok megpróbálják elkerülni a cgo-t, a gyakori alternatívák másfajta kompromisszumra kényszerítenek. A C kódbázisok tiszta Go-beli mechanikus fordításai megőrzik a CGO_ENABLED=0 beállítást, de gyakran lemaradnak az upstream C kiadások mögött, emulált virtuális memória réteget hordoznak, és terhelés alatt jelentős memóriafoglalást végeznek. A C könyvtár áthelyezése egy segédfolyamatba (helper process) loopback TCP-n vagy Unix domain socketeken keresztül visszaállítja a hibaelosztást (fault isolation), de minden hívás megfizeti két kernel kontextusváltás, a socket puffer másolatok és a vezetékes szerializáció árát.

Az HQQ-t egy direkt kérdés feltetése után építettük fel: mi lenne, ha a C könyvtár egy különálló OS folyamatban élne a saját virtuális címtérével, miközben a Go és a C közvetlenül, közös CPU cache line-okon keresztül cserélne kereteket anélkül, hogy belépne a kernelbe a gyors utakon (fast path)?

Integration ModelCGO_ENABLED=0Fault IsolationFast-Path SyscallsPer-Call Heap AllocationsUpstream C11 Fidelity
cgo In-Process LinkingNemNincs (osztott heap)00 és 1+ közöttPontos upstream C
Socket / Pipe Subprocess RPCIgenFolyamathatár2 és 4 közöttTöbbszörös (keretezés + I/O)Pontos upstream C
Mechanical C-to-Go TranslationIgenFolyamaton belüli Go0Magas komplex hívásoknálEmulált C futtatókörnyezet
HQQ Shared-Memory IPCIgenFolyamathatár0 (adaptív spin)0 (StandardLink)Pontos upstream C

Architectural Overview: Four Layers Across Process Boundaries

Az HQQ négy komponálható rétegre választja szét a folyamatelszigetelést, így a shared-memory gyűrűmechanika, a típusos RPC kódegeneráció, a bináris integritás-ellenőrzés és a gyermekfolyamat-felügyelet függetlenek maradnak.

 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. Shared-Memory Transport (gosuda.org/hqq and c/)

A gosuda.org/hqq alapcsomag és a c/ könyvtárban található C11 párja két shared-memory transzport módot implementál a POSIX mmap (Linux és macOS) és a Win32 CreateFileMappingW (Windows) rendszereken keresztül. Minden megosztott szegmens egy ellenőrzött HQQ_STL3 szuperblokkal kezdődik, amely rögzíti a protokollverziót, a puffer geometriáját és a végpontok PID-jeit, mielőtt bármilyen gyűrű indexet olvasnának.

Minden forró vezérlőszó és slot fejléc 64 bájtos cache-line határokhoz van igazítva, így a producer és consumer magok soha nem versengenek ugyanazon a cache line-on hamis megosztás (false sharing) miatt.

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              +---------------------------------------------------------------------+
FieldOffsetSizeSemantics
seq0x008 BMonoton szekvenciaszámláló. A páratlan értékek egy folyamatban lévő írást jeleznek; a páros értékek egy kötelezett keretet (committed frame) publikálnak store-release műveleten keresztül.
len0x084 BA slotba írt adattartalom bájthossza (0 <= len <= BufferSize).
flags0x0C4 BKeretvezérlő bitek (folytatás, hibaállapot vagy leállítási értesítés).
payload0x40BufferSizeFolytonos adattartalom régió, amely helyben (in place) olvasható és írható köztes másolatok nélkül.

A hívók a koncurrency struktúrától függően két transzport réteg közül választanak:

  • StandardLink (1:1 SPSC Session): Egy klienst és egy szerver munkamenetet párosít dedikált kérés és válasz slotokon keresztül, amelyeket egy szekvenciazár (SeqLock) véd. A gyors útvonalon a író közvetlenül a leképezett slotba szerializál, és egy atomi store-release művelettel commitol; az olvasó röviden pörög a user space-ben, mielőtt visszatérne az OS ébresztési primitívekhez (futex Linuxon, __ulock_wait macOS-en, WaitOnAddress Windows-on). Egyetlen keretes oda-vissza út ~200 ns alatt fejeződik be 0 allocs/op értékkel.
  • Advanced (MPMC Dispatch Ring): M párhuzamos Go hívót multiplexel N C worker szálon keresztül egy lock-mentes multi-producer multi-consumer beküldési gyűrű, egy megosztott slot pool és egy indexelt befejezési tábla segítségével. A hívók lefoglalnak egy slotot, közzéteszik a slot indexét a gyűrűn, és a saját befejezési bejegyzésükre várakoznak, miközben a worker szálak kiveszik a sorból és teljesítik a kéréseket sorrenden kívül.

Mindkét réteg folyamatosan követi a partner élhetőségét (liveness). Minden végpont frissít egy atomi monoton időbélyeget a Heartbeat() függvényen keresztül, és vizsgálja a PeerAlive(timeout) állapotot várakozás közben. Ha a worker folyamat összeomlik a hívás közben, a partner azonnal érzékeli az elavult szívverést vagy a halott PID-t, megszakítja a blokkolt várakozásokat az ErrPeerGone hibával, és támogatja az atomi munkamenet-átvételt (Takeover) anélkül, hogy beragadt shared-memory fájlokat hagyna hátra.

2. Zero-Allocation RPC and Generation-Checked Handles (gosuda.org/hqq/bindgen)

A shared-memory keretezés kézi megírása egy tucatnyi függvénnyel rendelkező C könyvtárhoz fárasztó és hibalehetőségeket rejt. A hqq-bindgen egy sémafordító, amely beolvas egy Interfész Definíciós Nyelv (.idl) fájlt (vagy közvetlenül indít egyet egy C .h fejlécből a hqq-bindgen init paranccsal), és egy zero-cgo Go kliens csomagot, valamint egy C11 szervervázat generál.

A legveszélyesebb hiba bármilyen C kötésekben a nyers C mutatók (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) expozíciója a nyelvi határon túlra. Ha egy hívó átad egy mutatót annak felszabadítása után, átad egy másik munkamenethez tartozó mutatót, vagy meghamisít egy egész címet, a C könyvtár érvénytelen memóriát dereferál.

A hqq-bindgen tiltja a nyers mutatókat a vezetéken (on the wire). Ehelyett az IDL az állapottal rendelkező C erőforrásokat típusos handle deklarációkként modellezi:

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}

A vezetéken minden handle egy 64 bites egész szám, amely egy 32 bites táblázatindexet és egy 32 bites, nem nulla generációs számlálót csomagol össze: (uint64_t)generation << 32 | index. A C futtatókörnyezet (c/src/hqq_rpc.c) munkamenetenkénti handle táblázatot tart fenn három invariánssal:

  1. Generation Validation: Minden keresés ellenőrzi, hogy a slot foglalt-e, a tárolt generáció megegyezik-e a handle felső 32 bitjével, és a handle típuscímkéje megegyezik-e a paraméter típusával (Db vs. Stmt). Egy lezárt handle újrafelhasználása azonnal meghiúsul a HQQRPC_E_BAD_HANDLE hibával, mielőtt még meghívná a C könyvtárat.
  2. Hierarchical Ownership (@owned_by): Mivel a Stmt meg van jelölve a @owned_by(Db) annotációval, egy Db handle lezárása automatikusan bejárja az összes élő, az adott Db által birtokolt gyermek Stmt handle-t, és lefutattja azok destruktorait a lefoglalási sorrend fordítottjában (sqlite3_finalize az sqlite3_close_v2 előtt).
  3. Session Teardown Sweep: Ha egy Go kliens lekapcsolódik vagy összeomlik anélkül, hogy lezárná a handle-jeit, a C szerver munkamenet-destruktora a munkamenet teljes handle táblázatát végigsöpri fordított sorrendben, biztosítva a szivárgásmentes C memóriafoglalásokat a kliens újraindulásai során.

Mielőtt egy munkamenet feldolgozná az első RPC hívását, a kliens és a szerver kicserél egy 32 bájtos SHA-256 interfész kivonatot (digest), amelyet a kanonizált IDL alapján számítanak ki (HQQ-BINDGEN-IDL-V1\n előtaggal). Ha a Go kliens és a C worker nem kompatibilis IDL sémákból lett lefordítva, a kézfogás (handshake) az indításkor elutasítja a kapcsolatot.

3. Tamper-Free Binary Execution (gosuda.org/hqq/secureexec)

A C kód áthelyezése egy worker binárisba felvet egy ellátási lánc (supply-chain) kérdést: honnan tudja a Go folyamat, hogy a lemezen lévő worker bináris nem lett módosítva vagy kicserélve az ellenőrzés és a végrehajtás között?

A crypto/sha256 meghívása egy fájlútvonalon, amit az os/exec.Command(path).Start() követ, létrehoz egy Time-of-Check to Time-of-Use (TOCTOU) ablakot, ahol egy másik folyamat felülírhatja vagy kicserélheti a fájlt, mielőtt a kernel végrehajtaná azt. A gosuda.org/hqq/secureexec bezárja ezt az ablakot:

  • Linuxon a secureexec megnyitja a bináris fájlt, streameli annak tartalmát egy névtelen memória fájlleíróba, amelyet a memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) hívás hoz létre, miközben kiszámítja annak SHA-256 hash értékét, ellenőrzi a hash-t egy rögzített 32 bájtos kivonathoz vagy egy Ed25519-aláírt kiadási manifesthez (cmd/hqq-manifest), alkalmazza a fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) hívást, így a rendszeren semmilyen folyamat nem tudja módosítani az oldalakat, majd a lepecsételt leíróból indítja el a gyermeket.
  • macOS-en és Windows-on a secureexec az ellenőrzött binárisokat engedéllyel zárolt cache könyvtárakba materializálja, amelyeket az SHA-256 kivonat kulcsol, és az indítás előtt ellenőrzi a fájlméretet és a kivonat integritását.

4. Parent-Lifeline Process Supervision (gosuda.org/hqq/launch)

Amikor egy Go alkalmazás egy háttérbeli C worker folyamatot indít, a worker soha nem élheti túl a szülőjét árva folyamatként, még akkor sem, ha a Go szülőt SIGKILL (kill -9) paranccsal szüntetik meg, és az nem tudja futtatni a defer takarítási kezelőket.

A gosuda.org/hqq/launch élettartam-kapcsolást kényszerít ki egy névtelen OS csövön (pipe) keresztül, amelyet a worker szabványos bemenetére adnak át a --parent-stdin jelzővel. A Go szülő tartja a cső írási végét, és soha nem ír bele; a C worker egy dedikált mentőöv (lifeline) szállal figyeli az olvasási véget. Ha a Go folyamat normál módon kilép, pánikba esik, vagy az OS kernel öli meg (SIGKILL vagy OOM killer), a kernel azonnal lezárja az írási leírót. A C worker mikromásodperceken belül EOF jelzést kap a csövön, megszakítja az aktív hívásokat, lefuttatja a munkamenet handle destruktorait, megszünteti a shared-memory fájlok hivatkozásait, és tisztán kilép.

Quickstart: Calling a Generated Service

A hqq-bindgen és a launch használatával egy elszigetelt C worker hívása Go-ból úgy néz ki, mint egy hagyományos típusos Go kliens, miközben fenntartja a CGO_ENABLED=0 beállítást:

 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}

Repository Structure

A gosuda.org/hqq minden alrendszere fókuszált csomagokba van szervezve explicit határokkal:

 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)

Annak megtekintéséhez, hogy az HQQ hogyan biztosít csereszabatos, éles környezetbe szánt helyettesítőket a Go ökoszisztéma két legszélesebb körben használt cgo könyvtárához, tekintse meg az ezen a stacken épült kiegészítő csomagokat:

  • gosuda.org/sqlite3: Csereszabatos database/sql illesztőprogram (driver), amely kompatibilis a github.com/mattn/go-sqlite3 csomaggal, és a natív C SQLite 3.54.1-et futtatja shared memory-n keresztül pipeline-olt batch előzetes lekéréssel (prefetching).
  • gosuda.org/zstd: Csereszabatos helyettesítő a github.com/DataDog/zstd csomaghoz, amely a natív C libzstd 1.5.7-et futtatja egy elszigetelt shared-memory workerben zero-allocation kontextusokkal és tömeges kötegeléssel.
  • GitHub Repository: https://github.com/gosuda/HQQ