GoSuda

HQQ: Isolierung von C-Bibliotheken gegenüber Go über cache-line-geteilten Speicher

By Lemon Mint views ...

gosuda.org/hqq ist eine zero-cgo Inter-Process-Communication-Laufzeitumgebung und Code-Generierungs-Toolchain für Go und C11. Sie isoliert native C-Bibliotheken in getrennten Betriebssystem-Worker-Prozessen und verbindet sie über speicherabgebildete, auf Cache-Zeilen ausgerichtete Shared-Memory-Ringpuffer mit Go-Aufrufern. Das Projekt kombiniert zwei Transportstufen (StandardLink für dedizierte Single-Producer-Single-Consumer-Sitzungen und Advanced für Multi-Worker-MPMC-Dispatch), den IDL-Compiler hqq-bindgen sowie einen manipulationsgeprüften Prozess-Supervisor (secureexec und launch).

1go get gosuda.org/hqq

Die Kosten der gemeinsamen Nutzung eines Adressraums mit C

Jeder Go-Systemingenieur steht früher oder später vor derselben Weggabelung. Sie entwickeln einen Go-Dienst, der mit CGO_ENABLED=0 in Sekunden kompiliert, als einzelnes statisches Binary ausgeliefert wird, ohne Sysroot von einem Laptop aus für Linux- oder Windows-Zielplattformen cross-kompiliert wird und innerhalb eines speichersicheren, garbage-collected Heaps läuft. Dann benötigen Sie eine ausgereifte C-Bibliothek: SQLite für eingebettete relationale Speicherung, Zstandard (libzstd) für Dictionary-Kompression oder eine hardwarebeschleunigte Medien- oder Kryptografie-Bibliothek.

Die Aktivierung von cgo, um diese C-Bibliothek in den Go-Prozess einzubinden, löst das unmittelbare Bindungsproblem, führt jedoch drei strukturelle Nachteile ein:

  1. Kopplung von Toolchain und Cross-Kompilierung: CGO_ENABLED=1 erfordert für jedes Zielbetriebssystem und jede Zielarchitektur einen C-Compiler, die Header der Ziel-libc und einen plattformspezifischen Linker. Der Bau von linux/arm64- oder windows/arm64-Artefakten von einer darwin/arm64-Workstation verwandelt einen einzigen go build-Befehl in eine Matrix aus Cross-Compilern und Sysroots.
  2. Overhead der Laufzeit-Schedulierung: Jeder cgo-Aufruf überschreitet die Grenze zur Go-Laufzeit, wechselt von einem wachsenden Goroutine-Stack auf einen Systemstack und fixiert einen Betriebssystem-Thread (M). Bei feingranularen Aufrufen summieren sich die Übergangskosten (ca. 50 bis 150 ns pro Aufruf) und die Scheduler-Konkurrenz rasch.
  3. Zusammenbruch der Fehlerdomäne: C-Code läuft im selben virtuellen Adressraum wie die Go-Laufzeit. Ein einzelner Out-of-Bounds-Schreibzugriff, Use-after-Free, Stack-Überlauf oder abort() innerhalb einer C-Funktion beschädigt die Metadaten des Go-Heaps oder beendet den gesamten Serverprozess augenblicklich, wobei recover() umgangen wird und jede laufende HTTP- oder RPC-Anfrage mit ihm endet.

Versuchen Teams, cgo zu vermeiden, erzwingen die üblichen Alternativen einen anderen Kompromiss. Rein in Go implementierte mechanische Übersetzungen von C-Codebasen bewahren CGO_ENABLED=0, hinken jedoch häufig den aktuellen C-Releases hinterher, enthalten eine emulierte virtuelle Speicherschicht und allozieren unter Last stark. Das Auslagern der C-Bibliothek in einen Hilfsprozess über Loopback-TCP oder Unix-Domain-Sockets stellt die Fehlerisolation wieder her, doch jeder Aufruf zahlt für zwei Kontextwechsel im Kernel, Kopien in Socket-Puffer und Wire-Serialisierung.

Wir haben HQQ entwickelt, nachdem wir eine direkte Frage gestellt hatten: Was wäre, wenn die C-Bibliothek in einem separaten Betriebssystemprozess mit eigenem virtuellen Adressraum lebt, während Go und C Frames direkt über gemeinsam genutzte CPU-Cache-Zeilen austauschen, ohne auf dem schnellen Pfad in den Kernel zu wechseln?

IntegrationsmodellCGO_ENABLED=0FehlerisolationSyscalls auf dem schnellen PfadHeap-Allokationen pro AufrufTreue zu upstream C11
cgo In-Process-LinkingNeinKeine (geteilter Heap)00 bis 1+Exakt upstream C
Socket- / Pipe-Subprozess-RPCJaProzessgrenze2 bis 4Mehrere (Framing + E/A)Exakt upstream C
Mechanische C-zu-Go-ÜbersetzungJaIn-Process-Go0Hoch bei komplexen AufrufenEmulierte C-Laufzeit
HQQ Shared-Memory-IPCJaProzessgrenze0 (adaptives Spinning)0 (StandardLink)Exakt upstream C

Architekturüberblick: Vier Schichten über Prozessgrenzen hinweg

HQQ trennt die Prozessisolation in vier kombinierbare Schichten, sodass Shared-Memory-Ring-Mechanik, typisierte RPC-Codegenerierung, binäre Integritätsprüfung und die Aufsicht über Kindprozesse unabhängig voneinander 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 gemeinsame Segment beginnt mit einem verifizierten HQQ_STL3-Superblock, der Protokollversion, Puffergeometrie und Endpunkt-PIDs festlegt, bevor ein Ring-Index gelesen wird.

Alle heißen Steuerwörter und Slot-Header sind an 64-Byte-Cache-Zeilengrenzen ausgerichtet, sodass Producer- und Consumer-Kerne niemals durch False Sharing um dieselbe Cache-Zeile konkurrieren.

1StandardLink-Slot-Layout (an 64-Byte-Cache-Zeilen ausgerichtet)
2Byte-Offset:  0x00                0x08                0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Header-Zeile: | seq (u64 atomic)  | len (u32) | flags | reserved (mit Nullen aufgefüllt) |
5              +-------------------+-------------------+-----------------------------+
6Nutzlastbereich: | Nutzlast-Bytes [0 .. BufferSize - 1] (direkt kodiert / dekodiert)  |
7              +---------------------------------------------------------------------+
FeldOffsetGrößeBedeutung
seq0x008 BMonotoner Sequenzzähler. Ungerade Werte kennzeichnen einen laufenden Schreibvorgang; gerade Werte veröffentlichen einen committeten Frame per Store-Release.
len0x084 BLänge der in den Slot geschriebenen Nutzlast in Bytes (0 <= len <= BufferSize).
flags0x0C4 BFrame-Steuerbits (Fortsetzung, Fehlerstatus oder Abschaltmeldung).
payload0x40BufferSizeZusammenhängender Nutzlastbereich, der ohne Zwischenkopien direkt gelesen und geschrieben wird.

Aufrufer wählen je nach Nebenläufigkeitsstruktur zwischen zwei Transportstufen:

  • StandardLink (1:1-SPSC-Sitzung): Verbindet eine Client- und eine Server-Sitzung über dedizierte Request- und Response-Slots, die durch einen Sequenzlock (SeqLock) geschützt sind. Auf dem schnellen Pfad serialisiert der Schreiber direkt in den abgebildeten Slot und committet mit einem atomaren Store-Release; der Leser dreht kurz im Benutzerraum, bevor er auf Betriebssystem-Weckprimitive zurückgreift (futex unter Linux, __ulock_wait unter macOS, WaitOnAddress unter Windows). Ein Round-Trip mit einem einzelnen Frame wird in ca. 200 ns mit 0 allocs/op abgeschlossen.
  • Advanced (MPMC-Dispatch-Ring): Multiplext M nebenläufige Go-Aufrufer auf N C-Worker-Threads, wobei ein lock-freier Multi-Producer-Multi-Consumer-Submission-Ring, ein gemeinsamer Slot-Pool und eine indizierte Completion-Tabelle verwendet werden. Aufrufer reservieren einen Slot, veröffentlichen den Slot-Index im Ring und warten auf ihren Completion-Eintrag, während Worker-Threads Anfragen außer der Reihe entnehmen und abschließen.

Beide Stufen überwachen die Lebendigkeit des Gegenübers kontinuierlich. Jeder Endpunkt aktualisiert über Heartbeat() einen atomaren, monotonen Zeitstempel und prüft während des Wartens PeerAlive(timeout). Stürzt ein Worker-Prozess mitten in einem Aufruf ab, erkennt die Gegenseite den veralteten Heartbeat oder die tote PID unverzüglich, bricht blockierte Wartevorgänge mit ErrPeerGone ab und unterstützt eine atomare Sitzungsübernahme (Takeover), ohne verwaiste Shared-Memory-Dateien zurückzulassen.

2. Zuweisungsfreier RPC und generierungsgeprüfte Handles (gosuda.org/hqq/bindgen)

Das manuelle Schreiben des Shared-Memory-Frammings für eine C-Bibliothek mit Dutzenden von Funktionen ist mühsam und fehleranfällig. hqq-bindgen ist ein Schema-Compiler, der eine Interface-Definition-Language-Datei (.idl) liest (oder direkt aus einem C-.h-Header über hqq-bindgen init eine solche erzeugt) und ein zero-cgo Go-Client-Paket zusammen mit einem C11-Server-Gerüst ausgibt.

Der gefährlichste Fehler bei jeder C-Bindung besteht darin, rohe C-Zeiger (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) über die Sprachgrenze hinweg offenzulegen. Übergibt ein Aufrufer einen Zeiger nach dessen Freigabe, übergibt er einen Zeiger, der zu einer anderen Sitzung gehört, oder verfälscht er eine ganzzahlige Adresse, dereferenziert die C-Bibliothek ungültigen Speicher.

hqq-bindgen verbietet rohe Zeiger auf dem Draht. 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 dem Draht ist jedes Handle eine 64-Bit-Ganzzahl, die einen 32-Bit-Tabellenindex und einen 32-Bit-Generationszähler ungleich null enthält: (uint64_t)generation << 32 | index. Die C-Laufzeit (c/src/hqq_rpc.c) verwaltet eine Handle-Tabelle pro Sitzung mit drei Invarianten:

  1. Generationsvalidierung: Jede Suche prüft, ob der Slot belegt ist, ob seine gespeicherte Generation mit den oberen 32 Bit des Handles übereinstimmt und ob sein Handle-Typ-Tag dem Parametertyp entspricht (Db vs. Stmt). Die Wiederverwendung eines geschlossenen Handles schlägt sofort mit HQQRPC_E_BAD_HANDLE fehl, bevor die C-Bibliothek aufgerufen wird.
  2. Hierarchische Eigentümerschaft (@owned_by): Da Stmt mit @owned_by(Db) annotiert ist, durchläuft das Schließen eines Db-Handles automatisch alle lebenden untergeordneten Stmt-Handles, die diesem Db gehören, und führt deren Destruktoren in umgekehrter Allokationsreihenfolge aus (sqlite3_finalize vor sqlite3_close_v2).
  3. Bereinigung der Sitzung: Trennt sich ein Go-Client oder stürzt er ab, ohne seine Handles zu schließen, durchläuft der Sitzungsdestruktor des C-Servers die gesamte Handle-Tabelle der Sitzung in umgekehrter Reihenfolge und garantiert so über Client-Neustarts hinweg null verlorene C-Allokationen.

Bevor eine Sitzung den ersten RPC-Aufruf verarbeitet, tauschen Client und Server einen 32-Byte-SHA-256-Interface-Digest aus, der über die kanonisierte IDL (Präfix HQQ-BINDGEN-IDL-V1\n) berechnet wird. Wurden der Go-Client und der C-Worker aus inkompatiblen IDL-Schemata kompiliert, weist der Handshake die Verbindung beim Start zurück.

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

Das Auslagern von C-Code in ein Worker-Binary wirft eine Lieferketten-Frage auf: Wie erkennt der Go-Prozess, dass das Worker-Binary auf dem Datenträger zwischen Prüfung und Ausführung nicht verändert oder ersetzt wurde?

Der Aufruf von crypto/sha256 auf einem Dateipfad, gefolgt von os/exec.Command(path).Start(), lässt ein Time-of-Check-to-Time-of-Use-(TOCTOU-)Fenster offen, 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 Binary, streamt seinen Inhalt in einen anonymen Speicherdateideskriptor, der über memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) erzeugt wird, und berechnet dabei seinen SHA-256-Hash. Anschließend prüft es den Hash gegen einen fixierten 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 den Kindprozess aus dem versiegelten Deskriptor.
  • Unter macOS und Windows materialisiert secureexec verifizierte Binaries in berechtigungsgesperrten Cache-Verzeichnissen, die nach SHA-256-Digest benannt sind, und prüft Dateigröße und Digest-Integrität vor dem Start.

4. Aufsicht über Kindprozesse mit Parent-Lifeline (gosuda.org/hqq/launch)

Startet eine Go-Anwendung einen Hintergrund-C-Worker-Prozess, darf der Worker seinen Elternprozess niemals als verwaisten Prozess überleben, selbst wenn der Go-Elternprozess mit SIGKILL (kill -9) beendet wird und keine defer-Bereinigungsroutinen ausführen kann.

gosuda.org/hqq/launch erzwingt die Lebensdauerkopplung über eine anonyme OS-Pipe, die dem Worker über das Flag --parent-stdin als Standardeingabe übergeben wird. Der Go-Elternprozess hält das Schreibende der Pipe und schreibt niemals hinein; der C-Worker überwacht das Leseende in einem dedizierten Lifeline-Thread. Beendet sich der Go-Prozess normal, gerät in eine Panic oder wird er vom Betriebssystemkern beendet (SIGKILL oder OOM-Killer), schließt der Kernel den Schreibdeskriptor sofort. Der C-Worker empfängt innerhalb von Mikrosekunden EOF an der Pipe, unterbricht aktive Aufrufe, führt seine Destruktoren für Sitzungs-Handles aus, entfernt die Shared-Memory-Dateien und beendet sich sauber.

Schnellstart: Aufruf eines generierten Dienstes

Mit hqq-bindgen und launch wirkt der Aufruf eines isolierten C-Workers aus Go wie ein gewöhnlicher typisierter Go-Client, während CGO_ENABLED=0 erhalten bleibt:

 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 Teilsystem in gosuda.org/hqq ist in fokussierte Pakete mit klar definierten Grenzen gegliedert:

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) und systemnahe Shared-Memory-Ring-Primitive
 3├── advanced.go / slotpool.go  # Advanced-(MPMC-)Dispatcher, Slot-Pool und Completion-Tabelle
 4├── shm_*.go / wait_*.go       # Betriebssystemspezifisches mmap/CreateFileMapping und futex/ulock/WaitOnAddress
 5├── c/                         # C11-Transportbibliothek (hqq.c) und RPC-Server-Laufzeit (hqq_rpc.c)
 6├── bindgen/                   # hqq-bindgen IDL-Parser, Verifier (V1-V41) und Go/C11-Codegenerator
 7│   └── rt/                    # Zuweisungsfreie Go-Client-Laufzeit für generierte Dienste
 8├── secureexec/                # SHA-256-/Ed25519-Manifestprüfung und memfd-versiegelte Ausführung
 9├── launch/                    # Worker-Prozessaufsicht mit --parent-stdin-Lifeline-Verwaltung
10├── cmd/hqq-manifest/          # CLI-Werkzeug zur Erzeugung und Signierung von Release-Binary-Manifesten
11└── spec/                      # Ausführbare Wire-Spezifikationen und Golden-Testvektoren (BINDGEN.md)

Um zu sehen, wie HQQ direkt einsetzbare produktionsreife Ersatzimplementierungen für zwei der meistgenutzten cgo-Bibliotheken im Go-Ökosystem antreibt, sehen Sie sich die auf diesem Stack aufbauenden Begleitpakete an:

  • gosuda.org/sqlite3: Drop-in-database/sql-Treiber, kompatibel mit github.com/mattn/go-sqlite3, der nativ C-SQLite 3.54.1 über Shared Memory mit pipelinierendem Batch-Prefetching ausführt.
  • gosuda.org/zstd: Drop-in-Ersatz für github.com/DataDog/zstd, der nativ C-libzstd 1.5.7 in einem isolierten Shared-Memory-Worker mit zuweisungsfreien Kontexten und Bulk-Batching ausführt.
  • GitHub-Repository: https://github.com/gosuda/HQQ