GoSuda

HQQ : Isoler les bibliothèques C de Go au moyen d'une mémoire partagée par ligne de cache

By Lemon Mint
views ...

gosuda.org/hqq est un runtime d'intercommunication inter-processus sans cgo et une chaîne d'outils de génération de code pour Go et C11. Il isole les bibliothèques C natives dans des processus de travail du système d'exploitation séparés et les connecte aux appelants Go via des anneaux de mémoire partagée mappés en mémoire et alignés sur les lignes de cache. Le projet combine deux niveaux de transport (StandardLink pour les sessions dédiées à un seul producteur et un seul consommateur et Advanced pour la distribution MPMC à plusieurs travailleurs), le compilateur IDL hqq-bindgen, et un superviseur de processus à vérification de falsification (secureexec et launch).

1go get gosuda.org/hqq

Le coût du partage d'un espace d'adressage avec C

Chaque ingénieur système Go arrive tôt ou tard à la même bifurcation. Vous construisez un service Go qui se compile en quelques secondes avec CGO_ENABLED=0, est livré sous la forme d'un unique binaire statique, se compile de manière croisée depuis un ordinateur portable vers des cibles Linux ou Windows sans sysroot, et s'exécute dans un tas géré par ramasse-miettes et sûr pour la mémoire. Ensuite, vous avez besoin d'une bibliothèque C mature : SQLite pour le stockage relationnel embarqué, Zstandard (libzstd) pour la compression par dictionnaire, ou une bibliothèque de médias ou de cryptographie accélérée par matériel.

Activer cgo pour lier cette bibliothèque C au processus Go résout le problème de liaison immédiat tout en introduisant trois responsabilités structurelles :

  1. Couplage de la chaîne d'outils et de la compilation croisée : CGO_ENABLED=1 nécessite un compilateur C, des en-têtes libc cibles et un éditeur de liens de plate-forme pour chaque système d'exploitation et architecture cibles. La construction d'artefacts linux/arm64 ou windows/arm64 à partir d'une station de travail darwin/arm64 transforme une seule commande go build en une matrice de compilateurs croisés et de sysroots.
  2. Surcharge de l'ordonnancement du runtime : Chaque appel cgo franchit la frontière du runtime Go, passe d'une pile de goroutines extensible à une pile système, et épingle un thread du système d'exploitation (M). Pour les appels à grain fin, la taxe de transition (~50 à 150 ns par appel) et la contention de l'ordonnanceur s'accumulent rapidement.
  3. Effondrement du domaine de défaillance : Le code C s'exécute dans le même espace d'adressage virtuel que le runtime Go. Une seule écriture hors limites, une utilisation après libération, un dépassement de pile ou un abort() à l'intérieur d'une fonction C corrompt les métadonnées du tas Go ou met fin immédiatement à l'ensemble du processus du serveur, contournant recover() et entraînant dans sa chute chaque requête HTTP ou RPC en cours.

Lorsque les équipes essaient d'éviter cgo, les alternatives courantes forcent un compromis différent. Les traductions mécaniques en pur Go de bases de code C préservent CGO_ENABLED=0, mais sont souvent en retard par rapport aux versions C en amont, transportent une couche de mémoire virtuelle émulée et allouent massivement sous charge. Déplacer la bibliothèque C dans un processus d'assistance sur le protocole TCP de boucle locale (loopback) ou des sockets de domaine Unix restaure l'isolement des pannes, mais chaque appel paie pour deux changements de contexte du noyau, des copies de tampons de sockets et une sérialisation sur le fil.

Nous avons construit HQQ après avoir posé une question directe : et si la bibliothèque C vivait dans un processus séparé du système d'exploitation doté de son propre espace d'adressage virtuel, tandis que Go et C échangeaient des cadres directement à travers les lignes de cache du CPU sans entrer dans le noyau sur le chemin rapide ?

Modèle d'intégrationCGO_ENABLED=0Isolement des pannesAppels système du chemin rapideAllocations de tas par appelFidélité C11 en amont
Liaison intra-processus cgoNonAucun (tas partagé)00 à 1+C en amont exact
RPC de sous-processus par socket/tuyauOuiLimite de processus2 à 4Multiple (cadrage + E/S)C en amont exact
Traduction mécanique C vers GoOuiGo intra-processus0Élevée sur les appels complexesRuntime C émulsé
IPC à mémoire partagée HQQOuiLimite de processus0 (rotation adaptative)0 (StandardLink)C en amont exact

Aperçu architectural : quatre couches à travers les limites des processus

HQQ sépare l'isolement des processus en quatre couches composables afin que la mécanique des anneaux de mémoire partagée, la génération de code RPC typé, la vérification de l'intégrité binaire et la supervision des processus enfants restent indépendantes.

 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 par mémoire partagée (gosuda.org/hqq et c/)

Le paquet de base gosuda.org/hqq et son équivalent C11 dans c/ implémentent deux modes de transport par mémoire partagée sur POSIX mmap (Linux et macOS) et Win32 CreateFileMappingW (Windows). Chaque segment partagé commence par un superbloc HQQ_STL3 vérifié qui épingle la version du protocole, la géométrie du tampon et les PID des points de terminaison avant qu'aucun index d'anneau ne soit lu.

Tous les mots de contrôle à chaud et les en-têtes de créneaux sont alignés sur des limites de lignes de cache de 64 octets afin que les cœurs producteur et consommateur ne soient jamais en contention sur la même ligne de cache par le biais d'un faux partage.

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              +---------------------------------------------------------------------+
ChampDécalageTailleSémantique
seq0x008 BCompteur de séquence monotonique. Les valeurs impaires marquent une écriture en cours ; les valeurs paires publient une trame validée via une opération store-release.
len0x084 BLongueur en octets de la charge utile écrite dans le créneau (0 <= len <= BufferSize).
flags0x0C4 BBits de contrôle de trame (continuation, état d'erreur ou avis d'arrêt).
payload0x40BufferSizeRégion de charge utile contiguë lue et écrite sur place sans copies intermédiaires.

Les appelants choisissent entre deux niveaux de transport en fonction de la structure de concurrence :

  • StandardLink (Session SPSC 1:1) : Associe un client et une session de serveur sur des créneaux de requête et de réponse dédiés protégés par un verrou de séquence (SeqLock). Sur le chemin rapide, le rédacteur sérialise directement dans le créneau mappé et valide avec un store-release atomique ; le lecteur tourne brièvement dans l'espace utilisateur avant de recourir à des primitives de réveil du système d'exploitation (futex sur Linux, __ulock_wait sur macOS, WaitOnAddress sur Windows). Un aller-retour à trame unique s'achève en ~200 ns avec 0 allocs/op.
  • Advanced (Anneau de distribution MPMC) : Multiplexe M appelants Go concurrents à travers N threads de travail C en utilisant un anneau de soumission multi-producteur multi-consommateur sans verrou, un pool de créneaux partagé et une table de complétion indexée. Les appelants réservent un créneau, publient l'index du créneau dans l'anneau et attendent leur entrée de complétion tandis que les threads de travail dépilent et complètent les requêtes dans le désordre.

Les deux niveaux suivent en continu la vivacité des pairs. Chaque point de terminaison met à jour un horodatage atomique monotonique via Heartbeat() et inspecte PeerAlive(timeout) tout en attendant. Si un processus de travail se plante au milieu d'un appel, le pair détecte immédiatement le battement de cœur périmé ou le PID mort, abandonne les attributions bloquées avec ErrPeerGone, et prend en charge la reprise atomique de session (Takeover) sans laisser de fichiers de mémoire partagée bloqués derrière lui.

2. RPC sans allocation et poignées vérifiées par génération (gosuda.org/hqq/bindgen)

Écrire à la main le cadrage de la mémoire partagée pour une bibliothèque C dotée de dizaines de fonctions est fastidieux et source d'erreurs. hqq-bindgen est un compilateur de schémas qui lit un fichier IDL (Interface Definition Language) (ou en amorce un directement à partir d'un en-tête C .h via hqq-bindgen init) et émet un paquet client Go sans cgo aux côtés d'un squelette de serveur C11.

L'erreur la plus dangereuse dans toute liaison C est d'exposer des pointeurs C bruts (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) de part et d'autre de la frontière des langages. Si un appelant transmet un pointeur après l'avoir libéré, transmet un pointeur appartenant à une autre session ou corrompt une adresse entière, la bibliothèque C déréférence une mémoire invalide.

hqq-bindgen interdit les pointeurs bruts sur le fil. Au lieu de cela, l'IDL modélise les ressources C avec état comme des déclarations de handle typées :

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}

Sur le fil, chaque poignée est un entier de 64 bits regroupant un index de table de 32 bits et un compteur de génération non nul de 32 bits : (uint64_t)generation << 32 | index. Le runtime C (c/src/hqq_rpc.c) gère une table de poignées par session avec trois invariants :

  1. Validation de la génération : Chaque recherche vérifie que le créneau est occupé, que sa génération stockée correspond aux 32 bits supérieurs de la poignée et que son étiquette de type de poignée correspond au type de paramètre (Db vs Stmt). La réutilisation d'une poignée fermée échoue immédiatement avec HQQRPC_E_BAD_HANDLE avant d'appeler la bibliothèque C.
  2. Propriété hiérarchique (@owned_by) : Puisque Stmt est annoté avec @owned_by(Db), la fermeture d'une poignée Db parcourt automatiquement toutes les poignées Stmt enfants actives possédées par ce Db et exécute leurs destructeurs dans l'ordre inverse de l'allocation (sqlite3_finalize avant sqlite3_close_v2).
  3. Balayage de suppression de session : Si un client Go se déconnecte ou plante sans fermer ses poignées, le destructeur de session du serveur C balaie toute la table des poignées de session dans l'ordre inverse, garantissant zéro allocation C perdue lors des redémarrages du client.

Avant qu'une session ne traite son premier appel RPC, le client et le serveur échangent un condensé SHA-256 d'interface de 32 octets calculé sur l'IDL canonisé (préfixe HQQ-BINDGEN-IDL-V1\n). Si le client Go et le travailleur C ont été compilés à partir de schémas IDL incompatibles, la poignée de main rejette la connexion au démarrage.

3. Exécution binaire infalsifiable (gosuda.org/hqq/secureexec)

Déplacer du code C dans un binaire de travail soulève une question de chaîne d'approvisionnement : comment le processus Go sait-il que le binaire de travail sur le disque n'a pas été modifié ou remplacé entre la vérification et l'exécution ?

Appeler crypto/sha256 sur un chemin de fichier suivi de os/exec.Command(path).Start() laisse une fenêtre temporelle entre la vérification et l'utilisation (TOCTOU) où un autre processus peut écraser ou échanger le fichier avant que le noyau ne l'exécute. gosuda.org/hqq/secureexec ferme cette fenêtre :

  • Sur Linux, secureexec ouvre le binaire, diffuse son contenu dans un descripteur de fichier mémoire anonyme créé via memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) tout en calculant son hachage SHA-256, vérifie le hachage par rapport à un condensé épinglé de 32 octets ou à un manifeste de version signé Ed25519 (cmd/hqq-manifest), applique fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) afin qu'aucun processus sur le système ne puisse modifier les pages, et engendre l'enfant à partir du descripteur scellé.
  • Sur macOS et Windows, secureexec matérialise les binaires vérifiés dans des répertoires de cache verrouillés par permissions indexés par le condensé SHA-256 et vérifie la taille du fichier et l'intégrité du condensé avant le lancement.

4. Supervision des processus par bouée de sauvetage parentale (gosuda.org/hqq/launch)

Lorsqu'une application Go engendre un processus de travail C en arrière-plan, le travailleur ne doit jamais survivre à son parent en tant que processus orphelin, même si le parent Go est terminé par SIGKILL (kill -9) et ne peut pas exécuter les gestionnaires de nettoyage defer.

gosuda.org/hqq/launch impose un couplage de durée de vie par le biais d'un tuyau anonyme du système d'exploitation passé à l'entrée standard du travailleur avec l'indicateur --parent-stdin. Le parent Go détient l'extrémité d'écriture du tuyau et n'y écrit jamais ; le travailleur C surveille l'extrémité de lecture sur un thread de bouée de sauvetage dédié. Si le processus Go quitte normalement, panique ou est tué par le noyau du système d'exploitation (SIGKILL ou OOM killer), le noyau ferme immédiatement le descripteur d'écriture. Le travailleur C reçoit EOF sur le tuyau en quelques microsecondes, interrompt tous les appels actifs, exécute ses destructeurs de poignée de session, dissocie les fichiers de mémoire partagée et quitte proprement.

Démarrage rapide : appel d'un service généré

Avec hqq-bindgen et launch, appeler un travailleur C isolé depuis Go ressemble à un client Go typé ordinaire tout en conservant 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}

Structure du référentiel

Chaque sous-système de gosuda.org/hqq est organisé en paquets ciblés dotés de frontières explicites :

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) et primitives d'anneau de mémoire partagée de bas niveau
 3├── advanced.go / slotpool.go  # Répartiteur Advanced (MPMC), pool de créneaux et table de complétion
 4├── shm_*.go / wait_*.go       # mmap/CreateFileMapping spécifiques au SE et futex/ulock/WaitOnAddress
 5├── c/                         # Bibliothèque de transport C11 (hqq.c) et runtime de serveur RPC (hqq_rpc.c)
 6├── bindgen/                   # Analyseur IDL hqq-bindgen, vérificateur (V1-V41) et générateur de code Go/C11
 7│   └── rt/                    # Runtime client Go sans allocation pour les services générés
 8├── secureexec/                # Vérification de manifeste SHA-256 / Ed25519 et exécution scellée par memfd
 9├── launch/                    # Superviseur de processus de travail avec gestion de bouée de sauvetage --parent-stdin
10├── cmd/hqq-manifest/          # Outil CLI pour la génération et la signature de manifestes binaires de version
11└── spec/                      # Spécifications de fil exécutables et vecteurs de test de référence (BINDGEN.md)

Pour voir comment HQQ alimente des remplacements de production clés en main pour deux des bibliothèques cgo les plus largement utilisées dans l'écosystème Go, consultez les paquets compagnons construits sur cette pile :

  • gosuda.org/sqlite3 : Pilote database/sql clé en main compatible avec github.com/mattn/go-sqlite3, exécutant du C natif SQLite 3.54.1 sur de la mémoire partagée avec pré-extraction par lots en pipeline.
  • gosuda.org/zstd : Remplacement clé en main pour github.com/DataDog/zstd, exécutant du C natif libzstd 1.5.7 dans un travailleur de mémoire partagée isolé avec des contextes sans allocation et un traitement par lots en vrac.
  • Référentiel GitHub : https://github.com/gosuda/HQQ