GoSuda

HQQ: Izolace C knihoven od jazyka Go prostřednictvím sdílené paměti na úrovni cache line

By Lemon Mint
views ...

gosuda.org/hqq je runtime pro interprocesovou komunikaci s nulovým cgo a nástrojový řetězec generování kódu pro Go a C11. Izoluje nativní knihovny v jazyce C uvnitř samostatných pracovních procesů operačního systému a připojuje je k volajícím v jazyce Go prostřednictvím kruhových vyrovnávacích pamětí ve sdílené paměti, které jsou mapované v paměti a zarovnané na hranice cache line. Projekt kombinuje dvě přenosové vrstvy (StandardLink pro vyhrazené relace s jedním producentským a jedním konzumentským vláknem a Advanced pro vícevláknové MPMC směrování), kompilátor IDL hqq-bindgen a procesní dozorčí modul s ověřením proti manipulaci (secureexec a launch).

1go get gosuda.org/hqq

Náklady na sdílení adresního prostoru s jazykem C

Každý systémový inženýr pracující v jazyce Go dříve či později narazí na stejnou křižovatku. Budujete službu v jazyce Go, která se zkompiluje během několika sekund s příkazem CGO_ENABLED=0, dodává se jako jediný statický binární soubor, křížově se kompiluje z notebooku na cílové systémy Linux nebo Windows bez systémového kořene (sysroot) a běží uvnitř paměťově bezpečné haldy se správou paměti (garbage collection). Poté potřebujete zralou knihovnu v jazyce C: SQLite pro vestavěné relační úložiště, Zstandard (libzstd) pro kompresi pomocí slovníků nebo hardwarově akcelerovanou knihovnu pro multimédia či kryptografii.

Povolení cgo za účelem propojení této knihovny v jazyce C s procesem v jazyce Go řeší okamžitý problém s vazbou, přičemž však zavádí tři strukturální závazky:

  1. Vazba nástrojového řetězce a křížové kompilace: CGO_ENABLED=1 vyžaduje kompiler jazyka C, hlavičkové soubory cílové libc a linkovací program pro každý cílový operační systém a architekturu. Sestavování artefaktů pro linux/arm64 nebo windows/arm64 z pracovní stanice darwin/arm64 mění jediný příkaz go build v matici křížových kompilátorů a systémových kořenů.
  2. Režie plánování za běhu: Každé volání cgo překračuje hranici runtime jazyka Go, přepíná ze zvětšitelného zásobníku gorutiny na systémový zásobník a připíná vlákno operačního systému (M). U jemně zrnitých volání se daň za přechod (přibližně 50 až 150 ns na volání) a spor o plánovač rychle kumulují.
  3. Kolaps domény selhání: Kód v jazyce C běží ve stejném virtuálním adresním prostoru jako runtime jazyka Go. Jediný zápis mimo vyhrazenou paměť, použití uvolněné paměti (use-after-free), přetečení zásobníku nebo funkce abort() uvnitř C funkce poškodí metadata haldy v jazyce Go nebo okamžitě ukončí celý proces serveru, čímž obchází mechanismus recover() a strhává s sebou každý právě probíhající požadavek HTTP nebo RPC.

Když se týmy snaží vyhnout cgo, běžné alternativy vynucují jiný kompromis. Mechanické překlady kódových základen v jazyce C do čistého Go zachovávají nastavení CGO_ENABLED=0, ale často zaostávají za upstreamovými vydáními v jazyce C, nesou s sebou emulovanou vrstvu virtuální paměti a při zátěži silně alokují paměť. Přesun knihovny v jazyce C do pomocného procesu prostřednictvím zpětné smyčky TCP (loopback TCP) nebo soketů domény Unix obnovuje izolaci selhání, avšak každé volání platí za dva kontextové přepínače jádra, kopie vyrovnávací paměti soketu a serializaci na drátu.

HQQ jsme vytvořili po položení přímé otázky: co kdyby knihovna v jazyce C žila v samostatném procesu operačního systému s vlastním virtuálním adresním prostorem, zatímco Go a C vyměňovaly rámce přímo prostřednictvím linií mezipaměti procesoru (cache lines) bez vstupu do jádra na rychlé cestě?

Model integraceCGO_ENABLED=0Izolace selháníSystémová volání rychlé cestyAlokace haldy na jedno voláníVěrnost upstreamu C11
Propojení uvnitř procesu (cgo)NeŽádná (sdílená halda)00 až 1+Přesný upstream C
RPC podprocesu přes soket / rouruAnoHranice procesu2 až 4Více (framing + I/O)Přesný upstream C
Mechanický překlad z C do GoAnoGo uvnitř procesu0Vysoká u složitých voláníEmulovaný C runtime
IPC přes sdílenou paměť HQQAnoHranice procesu0 (adaptivní spin)0 (StandardLink)Přesný upstream C

Architektonický přehled: Čtyři vrstvy napříč hranicemi procesů

HQQ odděluje izolaci procesů do čtyř skladebných vrstev tak, aby mechanika kruhové vyrovnávací paměti ve sdílené paměti, generování typového kódu RPC, ověřování binární integrity a dohled nad podřízenými procesy zůstaly nezávislé.

 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. Přenos prostřednictvím sdílené paměti (gosuda.org/hqq a c/)

Základní balíček gosuda.org/hqq a jeho protějšek v jazyce C11 v adresáři c/ implementují dva režimy přenosu prostřednictvím sdílené paměti nad POSIX funkcí mmap (Linux a macOS) a Win32 funkcí CreateFileMappingW (Windows). Každý sdílený segment začíná ověřeným superblokem HQQ_STL3, který před přečtením jakéhokoli indexu kruhové vyrovnávací paměti fixuje verzi protokolu, geometrii vyrovnávací paměti a PID koncových bodů.

Všechna aktivní řídicí slova a hlavičky slotů jsou zarovnány na hranice 64bajtových linií mezipaměti (cache lines), takže producentská a konzumentská jádra nikdy nesoupeří o stejnou linii mezipaměti prostřednictvím falešného sdílení (false sharing).

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              +---------------------------------------------------------------------+
PoleOfsetVelikostSémantika
seq0x008 BMonotonní čítač sekvencí. Liché hodnoty označují právě probíhající zápis; sudé hodnoty publikují potvrzený rámec pomocí operace store-release.
len0x084 BDélka datové části (payload) v bytech zapsaná do slotu (0 <= len <= BufferSize).
flags0x0C4 BŘídicí bity rámce (pokračování, stav chyby nebo oznámení o vypnutí).
payload0x40BufferSizeSouvislá oblast datové části čtená a zapisovaná na místě bez mezilehlých kopií.

Volající si vybírají mezi dvěma přenosovými vrstvami podle struktury souběžnosti:

  • StandardLink (1:1 SPSC relace): Páruje klientskou a serverovou relaci nad vyhrazenými sloty požadavků a odpovědí chráněnými zámkem sekvence (SeqLock). Na rychlé cestě zapisovatel serializuje přímo do mapovaného slotu a provede potvrzení pomocí atomického store-release; čtenář krátce rotuje (spins) v uživatelském prostoru, než přejde k primitivním funkcím probouzení operačního systému (futex v Linuxu, __ulock_wait v macOS, WaitOnAddress ve Windows). Jednorámcová obousměrná cesta (round trip) se dokončí za ~200 ns s 0 alokacemi na operaci.
  • Advanced (MPMC kruhová vyrovnávací paměť pro odesílání): Multiplexuje M souběžných volajících v jazyce Go napříč N pracovními vlákny v jazyce C pomocí odesílací kruhové vyrovnávací paměti bez zámků pro více producentů a více konzumentů (multi-producer multi-consumer), sdíleného fondu slotů a indexované tabulky dokončení. Volající si rezervují slot, publikují index slotu do kruhové vyrovnávací paměti a čekají na svou položku dokončení, zatímco pracovní vlákna odebírají a dokončují požadavky mimo pořadí.

Obě vrstvy nepřetržitě sledují živost protějšku. Každý koncový bod aktualizuje atomické monotónní časové razítko prostřednictvím funkce Heartbeat() a při čekání kontroluje funkci PeerAlive(timeout). Pokud pracovní proces havaruje uprostřed volání, protějšek okamžitě detekuje zastaralé srdeční tepy nebo mrtvý PID, přeruší zablokovaná čekání s chybou ErrPeerGone a podporuje atomické převzetí relace (Takeover), aniž by po sobě zanechal zablokované soubory sdílené paměti.

2. RPC s nulovou alokací a handlery kontrolované generací (gosuda.org/hqq/bindgen)

Ruční psaní rámců sdílené paměti pro knihovnu v jazyce C s desítkami funkcí je zdlouhavé a náchylné k chybám. hqq-bindgen je kompilátor schémat, který čte soubor jazyka pro definici rozhraní (.idl) (nebo jej zavádí přímo z hlavičkového souboru .h v jazyce C pomocí příkazu hqq-bindgen init) a generuje klientský balíček v jazyce Go s nulovým cgo spolu s kostrou serveru v jazyce C11.

Nejnebezpečnější chybou v jakékoli vazbě jazyka C je vystavení surových ukazatelů jazyka C (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) přes hranici jazyků. Pokud volající předá ukazatel po jeho uvolnění, předá ukazatel patřící jiné relaci nebo poškodí celočíselnou adresu, knihovna v jazyce C dereferencuje neplatnou paměť.

Nástroj hqq-bindgen zakazuje surové ukazatele na drátu. Místo toho IDL modeluje stavové prostředky jazyka C jako typované deklarace handle:

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}

Na drátu je každý handle 64bitové celé číslo balící 32bitový index tabulky a 32bitový nenulový čítač generace: (uint64_t)generation << 32 | index. Runtime jazyka C (c/src/hqq_rpc.c) udržuje tabulku handlerů pro každou relaci se třemi invarianty:

  1. Ověření generace: Každé vyhledávání ověřuje, zda je slot obsazený, zda jeho uložená generace odpovídá horním 32 bitům handleru a zda se značka typu handleru shoduje s typem parametru (Db vs. Stmt). Opětovné použití uzavřeného handleru selže okamžitě s chybou HQQRPC_E_BAD_HANDLE před voláním knihovny v jazyce C.
  2. Hierarchické vlastnictví (@owned_by): Protože je Stmt opatřen anotací @owned_by(Db), uzavření handleru Db automaticky projde všechny živé podřízené handlery Stmt vlastněné touto Db a spustí jejich destruktory v opačném pořadí alokace (sqlite3_finalize před sqlite3_close_v2).
  3. Úklid při ukončení relace: Pokud se klient v jazyce Go odpojí nebo havaruje bez uzavření svých handlerů, destruktor relace serveru v jazyce C projde celou tabulku handlerů relace v opačném pořadí, což zaručuje nulové množství uniklých alokací v jazyce C napříč restarty klienta.

Předtím, než relace zpracuje své první volání RPC, vymění si klient a server 32bajtový souhrn (digest) rozhraní SHA-256 vypočtený nad kanynizovaným IDL (s předponou HQQ-BINDGEN-IDL-V1\n). Pokud klient v jazyce Go a pracovník v jazyce C byly zkompilovány z nekompatibilních schémat IDL, handshake připojení při spuštění odmítne.

3. Neměnná binární the execuace (gosuda.org/hqq/secureexec)

Přesun kódu v jazyce C do binárního souboru pracovníka vyvolává otázku dodavatelského řetězce: jak proces v jazyce Go ví, že binární soubor pracovníka na disku nebyl mezi ověřením a spuštěním modifikován nebo nahrazen?

Volání funkce crypto/sha256 na cestu k souboru následované příkazem os/exec.Command(path).Start() vytváří okno typu Time-of-Check to Time-of-Use (TOCTOU), ve kterém může jiný proces přepsat nebo vyměnit soubor dříve, než jej jádro spustí. Balíček gosuda.org/hqq/secureexec toto okno uzavírá:

  • V systému Linux otevírá secureexec binární soubor, streamuje jeho obsah do anonymního paměťového souborového deskriptoru vytvořeného pomocí memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) a současně počítá jeho hash SHA-256, ověřuje hash proti fixovanému 32bajtovému souhrnu nebo manifestu vydání podepsanému klíčem Ed25519 (cmd/hqq-manifest), aplikuje fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE), takže žádný proces v systému nemůže stránky mutovat, a spouští potka z uzamčeného deskriptoru.
  • V systémech macOS a Windows materializuje secureexec ověřené binární soubory do adresářů mezipaměti s uzamčenými oprávněními, které jsou klíčovány podle souhrnu SHA-256, a před spuštěním ověřuje velikost souboru a integritu souhrnu.

4. Dohled nad procesem pomocí rodičovského záchranného lna (gosuda.org/hqq/launch)

Když aplikace v jazyce Go spouští pozadový pracovní proces v jazyce C, nesmí tento pracovník nikdy přežít svého rodiče jako osiřelý proces, a to ani v případě, že je rodič v jazyce Go ukončen příkazem SIGKILL (kill -9) a nemůže spustit obslužné rutiny pro úklid v příkazu defer.

Balíček gosuda.org/hqq/launch vynucuje vazbu životnosti prostřednictvím anonymní roury (pipe) operačního systému předané na standardní vstup pracovníka s příznakem --parent-stdin. Rodič v jazyce Go drží zapisovací konec roury a nikdy do něj nezapisuje; pracovník v jazyce C monitoruje čtecí konec na vyhrazeném záchranném vláknu (lifeline thread). Pokud proces v jazyce Go normálně skončí, vyvolá paniku nebo je usmrcen jádrem operačního systému (SIGKILL nebo OOM killer), jádro okamžitě zavře zapisovací deskriptor. Pracovník v jazyce C obdrží na rouře EOF během mikrosekund, přeruší všechna aktivní volání, spustí destruktory handlerů své relace, odpojí soubory sdílené paměti a čistě skončí.

Rychlý start: Volání generované služby

S nástroji hqq-bindgen a launch vypadá volání izolovaného pracovníka v jazyce C z Go jako běžný typový klient v jazyce Go, přičemž je zachováno nastavení CGO_ENABLED=0:

 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}

Struktura úložiště

Každý subsystém v balíčku gosuda.org/hqq je uspořádán do cílených balíčků s explicitními hranicemi:

 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)

Chcete-li vidět, jak HQQ pohání náhrady pro produkční prostředí u dvou nejpoužívanějších knihoven cgo v ekosystému Go, podívejte se na doprovodné balíčky postavené na tomto zásobníku:

  • gosuda.org/sqlite3: Náhrada typu drop-in pro ovladač database/sql kompatibilní s balíčkem github.com/mattn/go-sqlite3, která spouští nativní C SQLite 3.54.1 nad sdílenou pamětí s předběžným načítáním pipelined dávek.
  • gosuda.org/zstd: Náhrada typu drop-in pro balíček github.com/DataDog/zstd, která spouští nativní C knihovnu libzstd 1.5.7 v izolovaném pracovníku se sdílenou pamětí s kontexty s nulovou alokací a hromadným dávkováním.
  • Úložiště GitHub: https://github.com/gosuda/HQQ