GoSuda

HQQ: Isolating C Libraries from Go Over Cache-Line Shared Memory

By Lemon Mint
views ...

gosuda.org/hqq ist eine Zero-Cgo-IPC-Laufzeitumgebung (Inter-Process Communication) und eine Codogenerierungstoolchain für Go und C11. Es isoliert native C-Bibliotheken in separaten BS-Arbeitsprozessen (Betriebssystem) und verbindet sie mit Go-Aufrufern über speicherabbildende, cache-line-ausgerichtete Shared-Memory-Ringe. Das Projekt kombiniert zwei Transportebenen (StandardLink für dedizierte Single-Producer-Single-Consumer-Sitzungen und Advanced für die MPMC-Verteilung mit mehreren Arbeitern), den hqq-bindgen-IDL-Compiler sowie einen manipulationsüberprüften Prozesssupervisor (secureexec und launch).

1go get gosuda.org/hqq

Die Kosten der gemeinsamen Nutzung eines Adressraums mit C

Jeder Go-Systemingenieur stößt früher oder später auf dieselbe Weggabelung. Sie entwickeln einen Go-Dienst, der sich in Sekunden mit CGO_ENABLED=0 kompilieren lässt, als einzelnes statisches Binärarchiv ausgeliefert wird, ohne Sysroot von einem Laptop auf Linux- oder Windows-Zielplattformen kreuzkompiliert werden kann und in einem speichersicheren, speicherbereinigten Heap ausgeführt wird. Dann benötigen Sie eine ausgereifte C-Bibliothek: SQLite für eingebetteten relationalen Speicher, Zstandard (libzstd) für die Wörterbuchkomprimierung oder eine hardwarebeschleunigte Medien- oder Kryptographiebibliothek.

Das Aktivieren von cgo, um diese C-Bibliothek in den Go-Prozess einzubinden, löst das unmittelbare Bindungsproblem und führt gleichzeitig drei strukturelle Verbindlichkeiten ein:

  1. Toolchain- und Kreuzkompilierungskopplung: CGO_ENABLED=1 erfordert für jedes Zielbetriebssystem und jede Architektur einen C-Compiler, Ziel-libc-Header und einen Plattform-Linker. Das Erstellen von linux/arm64- oder windows/arm64-Artefakten von einer darwin/arm64-Arbeitsstation verwandelt einen einzelnen go build-Befehl in eine Matrix aus Kreuzcompiiern und Sysroots.
  2. Laufzeit-Planungs-Overhead: Jeder cgo-Aufruf überschreitet die Grenze der Go-Laufzeitumgebung, wechselt von einem wachsenden Goroutine-Stack zu einem System-Stack und bindet einen BS-Thread (M). Bei feingranularen Aufrufen summieren sich die Übergangskosten (~50 bis 150 ns pro Aufruf) und die Planerkonkurrenz schnell.
  3. Fehlerdomänenkollaps: C-Code läuft im selben virtuellen Adressraum wie die Go-Laufzeitumgebung. Ein einzelner Schreibvorgang außerhalb der Grenzen, Use-After-Free, Stack-Überlauf oder abort() innerhalb einer C-Funktion beschädigt die Go-Heap-Metadaten oder beendet den gesamten Serverprozess sofort, wodurch recover() umgangen und jede laufende HTTP- oder RPC-Anfrage mitgerissen wird.

Wenn Teams versuchen, cgo zu vermeiden, erzwingen die gängigen Alternativen einen anderen Kompromiss. Rein in Go geschriebene mechanische Übersetzungen von C-Codebasen bewahren CGO_ENABLED=0, hinken jedoch häufig vorgelagerten C-Releases hinterher, tragen eine emulierte virtuelle Speicherschicht und allokieren unter Last stark. Das Verschieben der C-Bibliothek in einen Hilfsprozess über Loopback-TCP oder Unix Domain Sockets stellt die Fehlerisolation wieder her, aber jeder Aufruf bezahlt für zwei Kernel-Kontextwechsel, Socket-Pufferkopien und Drahtserialisierung.

Wir haben HQQ entwickelt, nachdem wir eine direkte Frage gestellt haben: Was wäre, wenn die C-Bibliothek in einem separaten BS-Prozess mit einem eigenen virtuellen Adressraum leben würde, während Go und C Frames direkt über gemeinsame CPU-Cache-Lines austauschen, ohne auf dem schnellen Pfad in den Kernel einzutreten?

IntegrationsmodellCGO_ENABLED=0FehlerisolationSystemaufrufe auf dem schnellen PfadHeap-Allokationen pro AufrufUpstream-C11-Fidulität
cgo In-Process-VerknüpfungNeinKeine (gemeinsamer Heap)00 bis 1+Exaktes Upstream-C
Socket- / Pipe-Subprozess-RPCJaProzessgrenze2 bis 4Mehrere (Framing + I/O)Exaktes Upstream-C
Mechanische C-zu-Go-ÜbersetzungJaIn-Process-Go0Hoch bei komplexen AufrufenEmulierte C-Laufzeitumgebung
HQQ Shared-Memory-IPCJaProzessgrenze0 (adaptives Spin)0 (StandardLink)Exaktes Upstream-C

Architektonische Übersicht: Vier Schichten über Prozessgrenzen hinweg

HQQ trennt die Prozessisolation in vier kompositionsfähige Schichten, sodass Shared-Memory-Ring-Mechaniken, typisierte RPC-Codogenierung, Integritätsüberprüfung von Binärdateien und die Überwachung von Kindprozessen unabhängig bleiben.

 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 und c/)

Das Basispaket gosuda.org/hqq und sein C11-Gegenstück in c/ implementieren zwei Shared-Memory-Transportmodi über POSIX mmap (Linux und macOS) und Win32 CreateFileMappingW (Windows). Jedes geteilte Segment beginnt mit einem verifizierten HQQ_STL3-Superblock, der Protokollversion, Puffergeometrie und Endpunkt-PIDs festlegt, bevor ein Ringindex gelesen wird.

Alle heißen Steuerwörter und Slot-Header sind an 64-Byte-Cache-Line-Grenzen ausgerichtet, sodass Erzeuger- und Verbraucherkerne niemals um dieselbe Cache-Line durch falsche Freigabe konkurrieren.

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              +---------------------------------------------------------------------+
FeldOffsetGrößeSemantik
seq0x008 BMonotoner Sequenzzähler. Ungerade Werte markieren einen laufenden Schreibvorgang; gerade Werte veröffentlichen einen festgeschriebenen Frame über Store-Release.
len0x084 BIn den Slot geschriebene Nutzdaten-Byte-Länge (0 <= len <= BufferSize).
flags0x0C4 BFrame-Steuerbits (Fortsetzung, Fehlerstatus oder Herunterfahrhinweis).
payload0x40BufferSizeZusammenhängender Nutzdatenbereich, der ohne Zwischenkopien direkt an Ort und Stelle gelesen und geschrieben wird.

Aufrufer wählen je nach Parallelitätsstruktur zwischen zwei Transportebenen:

  • StandardLink (1:1-SPSC-Sitzung): Verbindet eine Client- und eine Serversitzung über dedizierte Anfrage- und Antwort-Slots, die durch eine Sequenzsperre (SeqLock) geschützt sind. Auf dem schnellen Pfad serialisiert der Schreiber direkt in den abgebildeten Slot und führt das Commit mit einem atomaren Store-Release durch; der Leser rotiert kurz im Benutzerspaziergang (User Space), bevor er auf BS-Weckprimitive zurückgreift (futex unter Linux, __ulock_wait unter macOS, WaitOnAddress unter Windows). Ein Rundumlauf mit einem einzelnen Frame ist in ~200 ns mit 0 allocs/op abgeschlossen.
  • Advanced (MPMC-Verteilungsring): Multiplext M gleichzeitige Go-Aufrufer über N C-Arbeiter-Threads mithilfe eines sperrenfreien Multi-Producer-Multi-Consumer-Einreichungsrings, einem gemeinsamen Slot-Pool und einer indizierten Abschlusstabelle. Aufrufer reservieren einen Slot, veröffentlichen den Slot-Index im Ring und warten auf ihren Abschlusseintrag, während Arbeiter-Threads Anfragen außerhalb der Reihenfolge aus der Warteschlange entfernen und abschließen.

Beide Ebenen verfolgen die Peer-Erreichbarkeit kontinuierlich. Jeder Endpunkt aktualisiert einen atomaren monotonen Zeitstempel über Heartbeat() und überprüft während des Wartens PeerAlive(timeout). Wenn ein Arbeiterprozess mitten im Aufruf abstürzt, erkennt der Peer den veralteten Heartbeat oder die tote PID sofort, bricht blockierte Wartevorgänge mit ErrPeerGone ab und unterstützt die atomare Sitzungsübernahme (Takeover), ohne zurückgebliebene, blockierte Shared-Memory-Dateien zu hinterlassen.

2. Zero-Allocation-RPC und generationsgeprüfte Handles (gosuda.org/hqq/bindgen)

Das manuelle Schreiben von Shared-Memory-Framing für eine C-Bibliothek mit Dutzenden von Funktionen ist mühsam und fehleranfällig. hqq-bindgen ist ein Schemacompiler, der eine Interface Definition Language-Datei (.idl) liest (oder direkt aus einem C .h-Header über hqq-bindgen init bootstrappt) und ein Zero-Cgo-Go-Clientpaket zusammen mit einem C11-Server-Skelett ausgibt.

Der gefährlichste Fehler bei jeder C-Bindung ist die Offenlegung roter C-Zeiger (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) über die Sprachgrenze hinweg. Wenn ein Aufrufer einen Zeiger übergibt, nachdem er ihn freigegeben hat, einen Zeiger übergibt, der zu einer anderen Sitzung gehört, oder eine Ganzzahlenadresse beschädigt, dereferenziert die C-Bibliothek ungültigen Speicher.

hqq-bindgen verbietet Rohzeiger auf der Leitung. Stattdessen modelliert die IDL zustandsbehaftete C-Ressourcen als typisierte handle-Deklarationen:

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}

Auf der Leitung ist jedes Handle eine 64-Bit-Ganzzahl, die einen 32-Bit-Tabellenindex und einen 32-Bit-Generationszähler ungleich Null packt: (uint64_t)generation << 32 | index. Die C-Laufzeitumgebung (c/src/hqq_rpc.c) unterhält eine sitzungsspezifische Handletabelle mit drei Invarianten:

  1. Generationsvalidierung: Jedes Nachschlagen verifiziert, dass der Slot belegt ist, seine gespeicherte Generation mit den oberen 32 Bits des Handles übereinstimmt und sein Handletyp-Tag mit dem Parametertyp übereinstimmt (Db vs. Stmt). Die Wiederverwendung eines geschlossenen Handles schlägt sofort mit HQQRPC_E_BAD_HANDLE fehl, bevor der Aufruf an die C-Bibliothek erfolgt.
  2. Hierarchischer Besitz (@owned_by): Da Stmt mit @owned_by(Db) annotiert ist, durchläuft das Schließen eines Db-Handles automatisch alle aktiven Unter-Stmt-Handles, die sich im Besitz dieses Db befinden, und führt deren Destruktoren in umgekehrter Allokationsreihenfolge aus (sqlite3_finalize vor sqlite3_close_v2).
  3. Sitzungsbereinigungsdurchlauf: Wenn ein Go-Client die Verbindung trennt oder abstürzt, ohne seine Handles zu schließen, durchläuft der Sitzungsdestruktor des C-Servers die gesamte Sitzungshandletabelle in umgekehrter Reihenfolge, wodurch Null durchgesickerte C-Allokationen über Client-Neustarts hinweg garantiert werden.

Bevor eine Sitzung ihren ersten RPC-Aufruf verarbeitet, tauschen der Client und der Server einen 32-Byte-SHA-256-Schnittstellendigest aus, der über die kanonisierte IDL (HQQ-BINDGEN-IDL-V1\n-Präfix) berechnet wurde. Wenn der Go-Client und der C-Arbeiter aus inkompatiblen IDL-Schemata kompiliert wurden, lehnt der Handshake die Verbindung beim Bootstrapping ab.

3. Manipulationsfreie Binärausführung (gosuda.org/hqq/secureexec)

Das Verschieben von C-Code in ein Arbeiterbinärarchiv wirft eine Lieferkettenfrage auf: Woher weiß der Go-Prozess, dass das Arbeiterbinärarchiv auf der Festplatte zwischen Verifizierung und Ausführung nicht modifiziert oder ersetzt wurde?

Das Aufrufen von crypto/sha256 auf einen Dateipfad gefolgt von os/exec.Command(path).Start() hinterlässt ein Zeitfenster von Time-of-Check zu Time-of-Use (TOCTOU), in dem ein anderer Prozess die Datei überschreiben oder austauschen kann, bevor der Kernel sie ausführt. gosuda.org/hqq/secureexec schließt dieses Fenster:

  • Unter Linux öffnet secureexec das Binärarchiv, streamt dessen Inhalte in einen anonymen Speicherdateideskriptor, der über memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) erstellt wurde, während sein SHA-256-Hash berechnet wird, verifiziert den Hash gegen einen angehefteten 32-Byte-Digest oder ein Ed25519-signiertes Release-Manifest (cmd/hqq-manifest), wendet fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) an, sodass kein Prozess im System die Seiten verändern kann, und startet das Kind aus dem versiegelten Deskriptor.
  • Unter macOS und Windows materialisiert secureexec verifizierte Binärdateien in berechtigungsgesperrten Cache-Verzeichnissen, die durch den SHA-256-Digest verschlüsselt sind, und überprüft Dateigröße und Digest-Integrität vor dem Start.

4. Eltern-Rettungsleinen-Prozessüberwachung (gosuda.org/hqq/launch)

Wenn eine Go-Anwendung einen C-Hintergrundarbeiterprozess startet, darf der Arbeiter seinen Elternprozess niemals als Waisenprozess überleben, selbst wenn der Go-Elternprozess mit SIGKILL (kill -9) beendet wird und keine defer-Bereinigungs-Handler ausführen kann.

gosuda.org/hqq/launch erzwingt die Lebensdauerkopplung durch eine anonyme BS-Pipe, die mit dem Flag --parent-stdin an die Standardeingabe des Arbeiters übergeben wird. Das Go-Elternelement hält das Schreibende der Pipe und schreibt nie hinein; der C-Arbeiter überwacht das Leseende in einem dedizierten Rettungsleinen-Thread. Wenn der Go-Prozess normal beendet wird, eine Panik auslöst oder vom BS-Kernel (SIGKILL oder OOM-Killer) getötet wird, schließt der Kernel den Schreibdeskriptor sofort. Der C-Arbeiter empfängt EOF auf der Pipe innerhalb von Mikrosekunden, unterbricht alle aktiven Aufrufe, führt seine Sitzungshandle-Destruktoren aus, hebt die Verknüpfung der Shared-Memory-Dateien auf und wird sauber beendet.

Schnellstart: Aufrufen eines generierten Dienstes

Mit hqq-bindgen und launch sieht das Aufrufen eines isolierten C-Arbeiters aus Go wie ein regulärer typisierter Go-Client aus, während CGO_ENABLED=0 beibehalten wird:

 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-Struktur

Jedes Subsystem in gosuda.org/hqq ist in fokussierte Pakete mit expliziten Grenzen organisiert:

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) und 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)

Um zu sehen, wie HQQ direkte Produktionsersatzlösungen für zwei der am häufigsten verwendeten cgo-Bibliotheken im Go-Ökosystem antreibt, werfen Sie einen Blick auf die Begleitpakete, die auf diesem Stack aufbauen:

  • gosuda.org/sqlite3: Direkter database/sql-Treiber, kompatibel mit github.com/mattn/go-sqlite3, der natives C-SQLite 3.54.1 über Shared Memory mit gepipelintem Batch-Prefetching ausführt.
  • gosuda.org/zstd: Direkter Ersatz für github.com/DataDog/zstd, der natives C libzstd 1.5.7 in einem isolierten Shared-Memory-Arbeiter mit Zero-Allocation-Kontexten und Massen-Batching ausführt.
  • GitHub-Repository: https://github.com/gosuda/HQQ