HQQ: Isolating C Libraries from Go Over Cache-Line Shared Memory
gosuda.org/hqq es un entorno de ejecución de comunicación entre procesos (IPC) sin cgo y una cadena de herramientas de generación de código para Go y C11. Aislar bibliotecas de C nativas dentro de procesos de trabajo del sistema operativo separados y los conecta con llamadores de Go a través de anillos de memoria compartida mapeados en memoria y alineados con la línea de caché. El proyecto combina dos niveles de transporte (StandardLink para sesiones dedicadas de un solo productor y un solo consumidor, y Advanced para despacho MPMC de múltiples trabajadores), el compilador de IDL hqq-bindgen y un supervisor de procesos con verificación de manipulación (secureexec y launch).
1go get gosuda.org/hqq
El costo de compartir un espacio de direcciones con C
Todo ingeniero de sistemas de Go llega eventualmente a la misma bifurcación en el camino. Usted está construyendo un servicio en Go que se compila en segundos con CGO_ENABLED=0, se distribuye como un único binario estático, se compila de manera cruzada desde una computadora portátil hacia destinos Linux o Windows sin un sysroot, y se ejecuta dentro de un montón con recolección de basura y seguridad de memoria. Luego, necesita una biblioteca de C madura: SQLite para almacenamiento relacional incrustado, Zstandard (libzstd) para compresión de diccionarios, o una biblioteca de medios o criptografía acelerada por hardware.
Habilitar cgo para vincular esa biblioteca de C en el proceso de Go resuelve el problema de enlace inmediato al tiempo que introduce tres responsabilidades estructurales:
- Acoplamiento de la cadena de herramientas y la compilación cruzada:
CGO_ENABLED=1requiere un compilador de C, encabezados de libc de destino y un enlazador de plataforma para cada sistema operativo y arquitectura de destino. Construir artefactos delinux/arm64owindows/arm64desde una estación de trabajodarwin/arm64convierte un único comandogo builden una matriz de compiladores cruzados y sysroots. - Sobrecarga de programación en tiempo de ejecución: Cada llamada de
cgocruza el límite de tiempo de ejecución de Go, cambia de una pila de goroutine ampliable a una pila de sistema, y fija un hilo del sistema operativo (M). Para llamadas de grano fino, el impuesto de transición (~50 a 150 ns por llamada) y la contención del planificador se acumulan rápidamente. - Colapso del dominio de fallas: El código de C se ejecuta en el mismo espacio de direcciones virtuales que el tiempo de ejecución de Go. Una sola escritura fuera de los límites, un uso después de liberación (
use-after-free), desbordamiento de pila oabort()dentro de una función de C corrompe los metadatos del montón de Go o termina el proceso del servidor completo de inmediato, omitiendorecover()y derribando con él cada solicitud HTTP o RPC en curso.
Cuando los equipos intentan evitar cgo, las alternativas comunes imponen un compromiso diferente. Las traducciones mecánicas puramente en Go de bases de código de C preservan CGO_ENABLED=0, pero a menudo se quedan atrás de las versiones de C anteriores, transportan una capa de memoria virtual emulada y asignan intensamente bajo carga. Mover la biblioteca de C a un proceso auxiliar a través de TCP de bucle invertido (loopback) o sockets de dominio de Unix restaura el aislamiento de fallas, pero cada llamada paga por dos cambios de contexto del núcleo, copias de búfer de sockets y serialización en red.
Construimos HQQ después de hacer una pregunta directa: ¿qué pasaría si la biblioteca de C viviera en un proceso de sistema operativo separado con su propio espacio de direcciones virtuales, mientras que Go y C intercambiaban tramas directamente a través de líneas de caché de CPU compartidas sin entrar en el núcleo en la ruta rápida?
| Modelo de integración | CGO_ENABLED=0 | Aislamiento de fallas | Llamadas al sistema de ruta rápida | Asignaciones de montón por llamada | Fidelidad estricta de C11 |
|---|---|---|---|---|---|
Enlace en proceso de cgo | No | Ninguno (montón compartido) | 0 | 0 a 1+ | C original exacto |
| RPC de subproceso de socket / tubería | Sí | Límite de proceso | 2 a 4 | Múltiples (tramado + E/S) | C original exacto |
| Traducción mecánica de C a Go | Sí | Go en proceso | 0 | Alto en llamadas complejas | Tiempo de ejecución de C emulado |
| IPC de memoria compartida HQQ | Sí | Límite de proceso | 0 (giro adaptativo) | 0 (StandardLink) | C original exacto |
Descripción general de la arquitectura: Cuatro capas a través de los límites de los procesos
HQQ separa el aislamiento de procesos en cuatro capas componibles para que la mecánica del anillo de memoria compartida, la generación de código RPC tipado, la verificación de integridad binaria y la supervisión de procesos secundarios permanezcan independientes.
1+-------------------------------------------------------------------+
2| Proceso de la aplicación Go (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Cliente Go generado | | Supervisor de procesos | |
6| | (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Tubería anónima |
11| | Nivel de transporte | | Línea de vida |
12| | (StandardLink / | | (--parent-stdin) |
13| | Advanced MPMC) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|========= Memoria compartida ====|===================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| Proceso de trabajador C11 (Espacio de direcciones aislado) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Servidor C11 generado |---->| Tabla de identificadores con | |
23| | y fragmentos de lote | | verificación de generación | |
24| +-----------+-----------+ | (hqq_rpc.c) | |
25| | +---------------+---------------+ |
26| | | |
27| +----------------+----------------+ |
28| | |
29| +---------v---------+ |
30| | Biblioteca C nativa| |
31| | (SQLite, Zstd...) | |
32| +-------------------+ |
33+-------------------------------------------------------------------+
1. Transporte de memoria compartida (gosuda.org/hqq y c/)
El paquete base gosuda.org/hqq y su contraparte de C11 en c/ implementan dos modos de transporte de memoria compartida sobre mmap de POSIX (Linux y macOS) y CreateFileMappingW de Win32 (Windows). Cada segmento compartido comienza con un superbloque HQQ_STL3 verificado que fija la versión del protocolo, la geometría del búfer y los PID de los puntos finales antes de que se lea cualquier índice de anillo.
Todas las palabras de control activas y los encabezados de ranura están alineados con los límites de la línea de caché de 64 bytes para que los núcleos productores y consumidores nunca compitan por la misma línea de caché a través del falso intercambio.
1Diseño de ranura de StandardLink (Alineado a línea de caché de 64 bytes)
2Desplazamiento de bytes: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Línea de cabecera:| seq (u64 atómico) | len (u32) | flags | reservado (relleno de ceros)|
5 +-------------------+-------------------+-----------------------------+
6Área de datos:| bytes de datos [0 .. BufferSize - 1] (codificado / decodificado directamente) |
7 +---------------------------------------------------------------------+
| Campo | Desplazamiento | Tamaño | Semántica |
|---|---|---|---|
seq | 0x00 | 8 B | Contador de secuencias monotónico. Los valores impares marcan una escritura en curso; los valores pares publican una trama confirmada a través de store-release. |
len | 0x08 | 4 B | Longitud en bytes de los datos escritos en la ranura (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Bits de control de trama (continuación, estado de error o aviso de apagado). |
payload | 0x40 | BufferSize | Región de datos contigua leída y escrita en el lugar sin copias intermedias. |
Los llamadores eligen entre dos niveles de transporte según la estructura de concurrencia:
StandardLink(Sesión SPSC 1:1): Empareja una sesión de cliente y de servidor a través de ranuras de solicitud y respuesta dedicadas protegidas por un bloqueo de secuencia (SeqLock). En la ruta rápida, el escritor se serializa directamente en la ranura mapeada y se compromete con un store-release atómico; el lector gira brevemente en el espacio de usuario antes de recurrir a primitivas de activación del sistema operativo (futexen Linux,__ulock_waiten macOS,WaitOnAddressen Windows). Un viaje redondo de una sola trama se completa en ~200 ns con0 allocs/op.Advanced(Anillo de despacho MPMC): MultiplexaMllamadores concurrentes de Go enNhilos de trabajadores de C utilizando un anillo de envío multi-productor multi-consumidor sin bloqueos, un grupo de ranuras compartido y una tabla de finalización indexada. Los llamadores reservan una ranura, publican el índice de la ranura en el anillo y esperan en su entrada de finalización mientras los hilos de los trabajadores sacan de la cola y completan las solicitudes fuera de orden.
Ambos niveles rastrean la vitalidad del par de forma continua. Cada punto final actualiza una marca de tiempo monotónica atómica a través de Heartbeat() e inspecciona PeerAlive(timeout) mientras espera. Si un proceso de trabajador falla a mitad de la llamada, el par detecta inmediatamente el latido obsoleto o el PID muerto, aborta las esperas bloqueadas con ErrPeerGone y admite la Takeover (asunción) atómica de la sesión sin dejar archivos de memoria compartida trabados.
2. RPC sin asignación y controladores con verificación de generación (gosuda.org/hqq/bindgen)
Escribir el empaquetado de memoria compartida a mano para una biblioteca de C con docenas de funciones es tedioso y propenso a errores. hqq-bindgen es un compilador de esquemas que lee un archivo de Lenguaje de Definición de Interfaz (.idl) (o arranca uno directamente desde un encabezado .h de C a través de hqq-bindgen init) y emite un paquete de cliente Go sin cgo junto con un esqueleto de servidor C11.
El error más peligroso en cualquier enlace de C es exponer punteros de C sin procesar (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) a través del límite del lenguaje. Si un llamador pasa un puntero después de liberarlo, pasa un puntero perteneciente a otra sesión o corrompe una dirección entera, la biblioteca de C desreferencia memoria inválida.
hqq-bindgen prohíbe los punteros sin procesar en la red. En su lugar, el IDL modela recursos de C con estado como declaraciones de handle tipadas:
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}
En la red, cada identificador es un entero de 64 bits que empaqueta un índice de tabla de 32 bits y un contador de generación distinto de cero de 32 bits: (uint64_t)generation << 32 | index. El tiempo de ejecución de C (c/src/hqq_rpc.c) mantiene una tabla de identificadores por sesión con tres invariantes:
- Validación de generación: Cada búsqueda verifica que la ranura esté ocupada, que su generación almacenada coincida con los 32 bits superiores del identificador y que su etiqueta de tipo de identificador coincida con el tipo de parámetro (
Dbfrente aStmt). Reutilizar un identificador cerrado falla inmediatamente conHQQRPC_E_BAD_HANDLEantes de realizar la llamada a la biblioteca de C. - Propiedad jerárquica (
@owned_by): Debido a queStmtestá anotado con@owned_by(Db), cerrar un identificador deDbrecorre automáticamente todos los identificadores secundarios activos deStmtpropiedad de eseDby ejecuta sus destructores en orden de asignación inverso (sqlite3_finalizeantes desqlite3_close_v2). - Barrido de finalización de sesión: Si un cliente de Go se desconecta o falla sin cerrar sus identificadores, el destructor de sesión del servidor de C barre toda la tabla de identificadores de sesión en orden inverso, garantizando cero asignaciones de C filtradas a través de reinicios del cliente.
Antes de que una sesión procese su primera llamada RPC, el cliente y el servidor intercambian un resumen de interfaz SHA-256 de 32 bytes calculado sobre el IDL canonizado (prefijo HQQ-BINDGEN-IDL-V1\n). Si el cliente de Go y el trabajador de C se compilaron a partir de esquemas IDL incompatibles, el protocolo de enlace rechaza la conexión en el arranque.
3. Ejecución binaria a prueba de manipulaciones (gosuda.org/hqq/secureexec)
Mover el código de C a un binario de trabajador plantea una cuestión de cadena de suministro: ¿cómo sabe el proceso de Go que el binario del trabajador en el disco no fue modificado o reemplazado entre la verificación y la ejecución?
Llamar a crypto/sha256 en una ruta de archivo seguida de os/exec.Command(path).Start() deja una ventana de tiempo de comprobación a tiempo de uso (TOCTOU) donde otro proceso puede sobrescribir o intercambiar el archivo antes de que el núcleo lo ejecute. gosuda.org/hqq/secureexec cierra esta ventana:
- En Linux,
secureexecabre el binario, transmite su contenido a un descriptor de archivo de memoria anónimo creado a través dememfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)mientras calcula su hash SHA-256, verifica el hash con un resumen fijo de 32 bytes o un manifiesto de lanzamiento firmado con Ed25519 (cmd/hqq-manifest), aplicafcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE)para que ningún proceso en el sistema pueda mutar las páginas, e inicia el hijo a partir del descriptor sellado. - En macOS y Windows,
secureexecmaterializa binarios verificados en directorios de caché bloqueados por permisos indexados por resumen SHA-256 y verifica el tamaño del archivo y la integridad del resumen antes del lanzamiento.
4. Supervisión de procesos de línea de vida principal (gosuda.org/hqq/launch)
Cuando una aplicación de Go genera un proceso de trabajador de C en segundo plano, el trabajador nunca debe sobrevivir a su padre como un proceso huérfano, incluso si el padre de Go termina con SIGKILL (kill -9) y no puede ejecutar los controladores de limpieza de defer.
gosuda.org/hqq/launch aplica el acoplamiento de tiempo de vida a través de una tubería anónima del sistema operativo pasada a la entrada estándar del trabajador con el indicador --parent-stdin. El padre de Go mantiene el extremo de escritura de la tubería y nunca escribe en él; el trabajador de C monitorea el extremo de lectura en un hilo de línea de vida dedicado. Si el proceso de Go sale normalmente, entra en pánico o es eliminado por el núcleo del sistema operativo (SIGKILL o OOM killer), el núcleo cierra inmediatamente el descriptor de escritura. El trabajador de C recibe EOF en la tubería en microsegundos, interrumpe cualquier llamada activa, ejecuta sus destructores de identificadores de sesión, desvincula los archivos de memoria compartida y sale limpiamente.
Guía de inicio rápido: Llamar a un servicio generado
Con hqq-bindgen y launch, llamar a un trabajador de C aislado desde Go parece un cliente de Go tipado normal mientras se mantiene 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}
Estructura del repositorio
Cada subsistema en gosuda.org/hqq está organizado en paquetes enfocados con límites explícitos:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) y primitivas de anillos de memoria compartida de bajo nivel
3├── advanced.go / slotpool.go # Despachador Advanced (MPMC), grupo de ranuras y tabla de finalización
4├── shm_*.go / wait_*.go # mmap/CreateFileMapping y futex/ulock/WaitOnAddress específicos del SO
5├── c/ # Biblioteca de transporte C11 (hqq.c) y tiempo de ejecución del servidor RPC (hqq_rpc.c)
6├── bindgen/ # Analizador IDL de hqq-bindgen, verificador (V1-V41) y generador de código Go/C11
7│ └── rt/ # Tiempo de ejecución de cliente Go sin asignación para servicios generados
8├── secureexec/ # Verificación de manifiesto SHA-256 / Ed25519 y ejecución sellada con memfd
9├── launch/ # Supervisor de procesos de trabajadores con gestión de línea de vida --parent-stdin
10├── cmd/hqq-manifest/ # Herramienta CLI para generar y firmar manifiestos binarios de lanzamiento
11└── spec/ # Especificaciones de cable ejecutables y vectores de prueba de referencia (BINDGEN.md)
Para ver cómo HQQ potencia los reemplazos de producción directos para dos de las bibliotecas de cgo más utilizadas en el ecosistema de Go, consulte los paquetes complementarios construidos sobre esta pila:
gosuda.org/sqlite3: Controladordatabase/sqldirecto compatible congithub.com/mattn/go-sqlite3, que ejecuta SQLite de C nativo3.54.1sobre memoria compartida con prefetching de lotes en canalización.gosuda.org/zstd: Reemplazo directo paragithub.com/DataDog/zstd, que ejecutalibzstd 1.5.7de C nativo en un trabajador de memoria compartida aislado con contextos de asignación cero y procesamiento por lotes masivo.- Repositorio de GitHub: https://github.com/gosuda/HQQ