HQQ : isoler les bibliothèques C de Go via une mémoire partagée alignée sur les lignes de cache

gosuda.org/hqq est un runtime de communication inter-processus sans cgo et une chaîne de génération de code pour Go et C11. Il isole les bibliothèques natives C dans des processus workers du système d'exploitation distincts et les connecte aux appelants Go via des anneaux de mémoire partagée mappés en mémoire, alignés sur les lignes de cache. Le projet combine deux niveaux de transport (StandardLink pour des sessions dédiées de type single-producer single-consumer et Advanced pour la répartition multi-workers MPMC), le compilateur IDL hqq-bindgen, et un superviseur de processus à vérification d'intégrité (secureexec et launch).
1go get gosuda.org/hqq
Le coût du partage d'un espace d'adressage avec C
Tout ingénieur systèmes Go finit par se trouver face au même embranchement. Vous construisez un service Go qui se compile en quelques secondes avec CGO_ENABLED=0, est distribué sous la forme d'un binaire statique unique, se compile en croisé depuis un ordinateur portable vers des cibles Linux ou Windows sans sysroot, et s'exécute dans un tas géré par un ramasse-miettes et sûr en mémoire. Puis 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 matérielle accélérée de traitement multimédia ou de cryptographie.
Activer cgo pour lier cette bibliothèque C au processus Go résout le problème immédiat de liaison, mais introduit trois faiblesses structurelles :
- Couplage entre chaîne d'outils et compilation croisée :
CGO_ENABLED=1exige un compilateur C, les en-têtes de la libc cible et un éditeur de liens propre à chaque système d'exploitation et chaque architecture cible. Construire des artefactslinux/arm64ouwindows/arm64depuis une station de travaildarwin/arm64transforme une simple commandego builden une matrice de compilateurs croisés et de sysroots. - Surcoût d'ordonnancement à l'exécution : chaque appel
cgofranchit la frontière du runtime Go, passe d'une pile de goroutine extensible à une pile système et immobilise un thread du système d'exploitation (M). Pour les appels à granularité fine, le coût de transition (environ 50 à 150 ns par appel) et la contention de l'ordonnanceur s'accumulent rapidement. - 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 écriture hors limites, une utilisation après libération, un dépassement de pile ou un appel à
abort()dans une fonction C corrompt les métadonnées du tas Go ou termine immédiatement l'intégralité du processus serveur, contournantrecover()et entraînant avec lui toutes les requêtes HTTP ou RPC en cours.
Lorsque les équipes tentent d'éviter cgo, les alternatives courantes imposent un autre compromis. Les traductions mécaniques purement Go de bases de code C préservent CGO_ENABLED=0, mais accusent souvent un retard par rapport aux versions amont de C, reposent sur une couche de mémoire virtuelle émulée et allouent massivement sous charge. Déplacer la bibliothèque C dans un processus auxiliaire via TCP en boucle locale ou des sockets de domaine Unix rétablit l'isolation des défaillances, mais chaque appel supporte deux changements de contexte noyau, des copies dans les tampons de socket et une sérialisation sur le fil.
Nous avons conçu HQQ en nous posant une question directe : et si la bibliothèque C vivait dans un processus du système d'exploitation distinct, disposant de son propre espace d'adressage virtuel, tandis que Go et C échangeraient des trames directement via les lignes de cache partagées du processeur, sans passer par le noyau sur le chemin rapide ?
| Modèle d'intégration | CGO_ENABLED=0 | Isolation des défaillances | Appels système sur le chemin rapide | Allocations de tas par appel | Fidélité à C11 amont |
|---|---|---|---|---|---|
Liaison in-process via cgo | Non | Aucune (tas partagé) | 0 | 0 à 1+ | C amont exact |
| RPC par sous-processus via socket / tube | Oui | Frontière de processus | 2 à 4 | Multiples (encadrement + E/S) | C amont exact |
| Traduction mécanique C vers Go | Oui | Go dans le processus | 0 | Élevées pour les appels complexes | Runtime C émulé |
| Mémoire partagée IPC HQQ | Oui | Frontière de processus | 0 (spin adaptatif) | 0 (StandardLink) | C amont exact |
Vue d'ensemble de l'architecture : quatre couches à travers les frontières de processus
HQQ sépare l'isolation des processus en quatre couches composables afin que la mécanique des anneaux en mémoire partagée, la génération de RPC typés, la vérification de l'intégrité binaire et la supervision des processus enfants restent indépendantes.
1+-------------------------------------------------------------------+
2| Processus applicatif Go (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Client Go généré | | Superviseur de processus | |
6| | (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Lien vital par |
11| | Niveau de transport | | tube anonyme |
12| | (StandardLink / | | (--parent-stdin) |
13| | MPMC Advanced) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|========= Mémoire partagée =====|===================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| Processus worker C11 (espace d'adressage isolé) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Serveur C11 généré |---->| Table de handles à | |
23| | et shims par lots | | vérification de génération | |
24| +-----------+-----------+ | (hqq_rpc.c) | |
25| | +---------------+---------------+ |
26| +----------------+----------------+ |
27| | |
28| +---------v---------+ |
29| | Bibliothèque C | |
30| | native (SQLite, | |
31| | Zstd...) | |
32| +-------------------+ |
33+-------------------------------------------------------------------+
1. Transport en mémoire partagée (gosuda.org/hqq et c/)
Le paquet de base gosuda.org/hqq et sa contrepartie C11 dans c/ implémentent deux modes de transport en mémoire partagée au moyen de mmap POSIX (Linux et macOS) et de CreateFileMappingW de Win32 (Windows). Chaque segment partagé commence par un superbloc HQQ_STL3 vérifié, qui fixe la version du protocole, la géométrie des tampons et les PID des points d'extrémité avant toute lecture d'index d'anneau.
Tous les mots de contrôle critiques et les en-têtes de slots sont alignés sur des frontières de lignes de cache de 64 octets, afin que les cœurs producteur et consommateur ne se disputent jamais la même ligne de cache par faux partage.
1Disposition d'un slot StandardLink (aligné sur ligne de cache de 64 octets)
2Décalage octet : 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Ligne d'en-tête : | seq (u64 atomique)| len (u32) | flags | réservé (remplissage à zéro) |
5 +-------------------+-------------------+-----------------------------+
6Zone de charge : | octets de charge [0 .. BufferSize - 1] (encodés / décodés directement) |
7 +---------------------------------------------------------------------+
| Champ | Décalage | Taille | Sémantique |
|---|---|---|---|
seq | 0x00 | 8 o | Compteur de séquence monotone. Les valeurs impaires signalent une écriture en cours ; les valeurs paires publient une trame validée via store-release. |
len | 0x08 | 4 o | Longueur en octets de la charge écrite dans le slot (0 <= len <= BufferSize). |
flags | 0x0C | 4 o | Bits de contrôle de trame (continuation, état d'erreur ou avis d'arrêt). |
payload | 0x40 | BufferSize | Zone de charge contiguë, lue et écrite sur place sans copies intermédiaires. |
Les appelants choisissent entre deux niveaux de transport selon la structure de concurrence :
StandardLink(session SPSC 1:1) : associe une session client et une session serveur sur des slots dédiés de requête et de réponse, protégés par un verrou de séquence (SeqLock). Sur le chemin rapide, l'écrivain sérialise directement dans le slot mappé et valide par un store-release atomique ; le lecteur effectue brièvement une attente active en espace utilisateur avant de recourir aux primitives de réveil du système (futexsous Linux,__ulock_waitsous macOS,WaitOnAddresssous Windows). Un aller-retour d'une seule trame s'achève en environ 200 ns, avec0 allocs/op.Advanced(anneau de répartition MPMC) : multiplexeMappelants Go concurrents surNthreads workers C, à l'aide d'un anneau de soumission multi-producteurs multi-consommateurs sans verrou, d'un pool de slots partagé et d'une table de complétion indexée. Les appelants réservent un slot, publient l'index du slot dans l'anneau, puis attendent leur entrée de complétion, tandis que les threads workers extraient les requêtes et les traitent dans le désordre.
Les deux niveaux suivent en continu la vivacité des pairs. Chaque point d'extrémité met à jour un horodatage monotone atomique via Heartbeat() et inspecte PeerAlive(timeout) pendant l'attente. Si un processus worker plante au milieu d'un appel, le pair détecte immédiatement le battement périmé ou le PID mort, interrompt les attentes bloquées avec ErrPeerGone, et permet une reprise atomique de session (Takeover) sans laisser de fichiers de mémoire partagée bloqués.
2. RPC sans allocation et handles à vérification de génération (gosuda.org/hqq/bindgen)
Écrire à la main le encadrement en mémoire partagée pour une bibliothèque C comportant des dizaines de fonctions est fastidieux et source d'erreurs. hqq-bindgen est un compilateur de schémas qui lit un fichier de langage de définition d'interface (.idl) (ou en génère directement un à partir d'un en-tête C .h via hqq-bindgen init), et produit un paquet client Go sans cgo ainsi qu'un squelette de serveur C11.
L'erreur la plus dangereuse dans toute liaison C consiste à exposer des pointeurs C bruts (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) à travers la frontière du langage. Si un appelant passe un pointeur après l'avoir libéré, passe 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. À la place, l'IDL modélise les ressources C à état sous forme de déclarations 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 handle 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) maintient une table de handles par session, régie par trois invariants :
- Validation de génération : chaque recherche vérifie que le slot est occupé, que sa génération mémorisée correspond aux 32 bits supérieurs du handle, et que son étiquette de type de handle correspond au type du paramètre (
DbouStmt). La réutilisation d'un handle fermé échoue immédiatement avecHQQRPC_E_BAD_HANDLE, avant tout appel à la bibliothèque C. - Propriété hiérarchique (
@owned_by) :Stmtétant annoté@owned_by(Db), la fermeture d'un handleDbparcourt automatiquement tous les handlesStmtenfants vivants appartenant à ceDbet exécute leurs destructeurs dans l'ordre inverse d'allocation (sqlite3_finalizeavantsqlite3_close_v2). - Balayage de fin de session : si un client Go se déconnecte ou plante sans fermer ses handles, le destructeur de session du serveur C balaie l'intégralité de la table de handles de la session dans l'ordre inverse, garantissant l'absence de fuite d'allocations C à travers les redémarrages du client.
Avant qu'une session ne traite son premier appel RPC, le client et le serveur échangent un condensé d'interface SHA-256 de 32 octets, calculé sur l'IDL canonisé (préfixe HQQ-BINDGEN-IDL-V1\n). Si le client Go et le worker C ont été compilés à partir de schémas IDL incompatibles, la poignée de main rejette la connexion dès l'initialisation.
3. Exécution binaire inviolable (gosuda.org/hqq/secureexec)
Déplacer du code C dans un binaire worker soulève une question de chaîne d'approvisionnement : comment le processus Go sait-il que le binaire worker présent 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, puis os/exec.Command(path).Start(), laisse une fenêtre de type Time-of-Check to Time-of-Use (TOCTOU) pendant laquelle un autre processus peut écraser ou substituer le fichier avant que le noyau ne l'exécute. gosuda.org/hqq/secureexec referme cette fenêtre :
- Sous Linux,
secureexecouvre le binaire, diffuse son contenu dans un descripteur de fichier mémoire anonyme créé parmemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)tout en calculant son empreinte SHA-256, vérifie l'empreinte par rapport à un condensé SHA-256 de 32 octets épinglé ou à un manifeste de version signé en Ed25519 (cmd/hqq-manifest), appliquefcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE)afin qu'aucun processus du système ne puisse modifier les pages, et lance l'enfant à partir du descripteur scellé. - Sous macOS et Windows,
secureexecmatérialise les binaires vérifiés dans des répertoires de cache protégés en permissions, indexés par empreinte SHA-256, et vérifie la taille du fichier ainsi que l'intégrité de son empreinte avant le lancement.
4. Supervision de processus par lien vital parental (gosuda.org/hqq/launch)
Lorsqu'une application Go lance un processus worker C en arrière-plan, le worker ne doit jamais survivre à son parent en devenant un processus orphelin, même si le parent Go est terminé par SIGKILL (kill -9) et ne peut pas exécuter ses gestionnaires de nettoyage defer.
gosuda.org/hqq/launch impose le couplage de durée de vie au moyen d'un tube anonyme du système d'exploitation transmis à l'entrée standard du worker avec l'indicateur --parent-stdin. Le parent Go détient l'extrémité d'écriture du tube et n'y écrit jamais ; le worker C surveille l'extrémité de lecture sur un thread de lien vital dédié. Si le processus Go se termine normalement, panique ou est tué par le noyau du système (SIGKILL ou le tueur OOM), le noyau ferme immédiatement le descripteur d'écriture. Le worker C reçoit un EOF sur le tube en quelques microsecondes, interrompt les appels actifs, exécute ses destructeurs de handles de session, supprime les fichiers de mémoire partagée et se termine proprement.
Démarrage rapide : appeler un service généré
Avec hqq-bindgen et launch, appeler un worker 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 en cours d'exécution sur l'écouteur de mémoire partagée %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}
Structure du dépôt
Chaque sous-système de gosuda.org/hqq est organisé en paquets ciblés aux frontières explicites :
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) et primitives d'anneau en mémoire partagée de bas niveau
3├── advanced.go / slotpool.go # Répartiteur Advanced (MPMC), pool de slots et table de complétion
4├── shm_*.go / wait_*.go # mmap/CreateFileMapping spécifiques au système et futex/ulock/WaitOnAddress
5├── c/ # Bibliothèque de transport C11 (hqq.c) et runtime 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 manifestes SHA-256 / Ed25519 et exécution scellée par memfd
9├── launch/ # Superviseur de processus worker avec gestion du lien vital --parent-stdin
10├── cmd/hqq-manifest/ # Outil en ligne de commande de génération et de signature des manifestes de binaires de version
11└── spec/ # Spécifications de protocole exécutables et vecteurs de test de référence (BINDGEN.md)
Pour voir comment HQQ alimente des remplacements de production directs de deux des bibliothèques cgo les plus utilisées de l'écosystème Go, découvrez les paquets complémentaires construits sur cette pile :
gosuda.org/sqlite3: pilotedatabase/sqlcompatible en remplacement direct degithub.com/mattn/go-sqlite3, exécutant le SQLite C natif3.54.1par mémoire partagée avec préchargement par lots en pipeline.gosuda.org/zstd: remplacement direct degithub.com/DataDog/zstd, exécutantlibzstd 1.5.7natif en C dans un worker isolé en mémoire partagée, avec des contextes sans allocation et un traitement par lots en masse.- Dépôt GitHub : https://github.com/gosuda/HQQ