GoSuda

HQQ: Aislamiento de bibliotecas C desde Go mediante memoria compartida en líneas de caché

By Lemon Mint views ...

gosuda.org/hqq es un runtime de comunicación entre procesos sin cgo y un conjunto de herramientas de generación de código para Go y C11. Aísla las bibliotecas nativas en C dentro de procesos trabajadores del sistema operativo independientes y las conecta con los llamadores de Go mediante anillos de memoria compartida mapeados en memoria y alineados a líneas de caché. El proyecto combina dos niveles de transporte (StandardLink para sesiones dedicadas de productor único y consumidor único, y Advanced para el despacho MPMC con múltiples trabajadores), el compilador de IDL hqq-bindgen y un supervisor de procesos con verificación de integridad (secureexec y launch).

1go get gosuda.org/hqq

El costo de compartir un espacio de direcciones con C

Todo ingeniero de sistemas en Go termina enfrentándose tarde o temprano a la misma bifurcación. Usted está desarrollando un servicio en Go que compila en segundos con CGO_ENABLED=0, se distribuye como un único binario estático, se compila de forma cruzada desde una computadora portátil hacia destinos Linux o Windows sin un sysroot y se ejecuta dentro de un heap con recolección de basura y seguridad de memoria. Luego necesita una biblioteca de C madura: SQLite para almacenamiento relacional embebido, Zstandard (libzstd) para compresión con diccionario, o una biblioteca de medios o criptografía acelerada por hardware.

Habilitar cgo para enlazar esa biblioteca de C dentro del proceso de Go resuelve el problema inmediato de vinculación, pero introduce tres pasivos estructurales:

  1. Acoplamiento de la cadena de herramientas y de la compilación cruzada: CGO_ENABLED=1 requiere un compilador de C, cabeceras de la libc del destino y un enlazador de plataforma para cada sistema operativo y arquitectura de destino. Construir artefactos linux/arm64 o windows/arm64 desde una estación de trabajo darwin/arm64 convierte un único comando go build en una matriz de compiladores cruzados y sysroots.
  2. Sobrecarga de planificación en tiempo de ejecución: Cada llamada de cgo cruza la frontera del runtime de Go, cambia de una pila de goroutine expandible a una pila del sistema y fija un hilo del sistema operativo (M). En llamadas de grano fino, el costo de transición (~50 a 150 ns por llamada) y la contención del planificador se acumulan rápidamente.
  3. Colapso del dominio de fallos: El código en C se ejecuta en el mismo espacio de direcciones virtual que el runtime de Go. Una escritura fuera de límites, un uso tras liberación, un desbordamiento de pila o un abort() dentro de una función de C corrompe los metadatos del heap de Go o termina de inmediato todo el proceso del servidor, eludiendo recover() y arrastrando consigo cada solicitud HTTP o RPC en curso.

Cuando los equipos intentan evitar cgo, las alternativas habituales imponen un compromiso distinto. Las traducciones mecánicas puras en Go de bases de código en C preservan CGO_ENABLED=0, pero a menudo se retrasan respecto de las versiones upstream de C, incorporan una capa de memoria virtual emulada y asignan memoria de forma intensiva bajo carga. Trasladar la biblioteca de C a un proceso auxiliar mediante TCP de loopback o sockets de dominio Unix restaura el aislamiento de fallos, pero cada llamada paga dos cambios de contexto del kernel, copias de búferes de socket y serialización de red.

Construimos HQQ a partir de una pregunta directa: ¿qué ocurriría si la biblioteca de C residiera en un proceso del sistema operativo separado, con su propio espacio de direcciones virtual, mientras Go y C intercambian tramas directamente a través de líneas de caché de CPU compartidas, sin entrar en el kernel en la ruta rápida?

Modelo de integraciónCGO_ENABLED=0Aislamiento de fallosLlamadas al sistema en la ruta rápidaAsignaciones de heap por llamadaFidelidad al C11 upstream
Enlace en proceso con cgoNoNinguno (heap compartido)00 a 1+C upstream exacto
RPC de subproceso por socket / tuberíaSíFrontera de proceso2 a 4Múltiples (encuadre + E/S)C upstream exacto
Traducción mecánica de C a GoSíGo en proceso0Alta en llamadas complejasRuntime de C emulado
IPC de memoria compartida de HQQSíFrontera de proceso0 (espera activa adaptativa)0 (StandardLink)C upstream exacto

Visión general de la arquitectura: cuatro capas a través de las fronteras de proceso

HQQ separa el aislamiento de procesos en cuatro capas componibles, de modo que la mecánica de los anillos de memoria compartida, la generación de RPC tipado, la verificación de integridad binaria y la supervisión de procesos hijos 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   |                     | de vida          |
12|  | (StandardLink /       |                     | (--parent-stdin) |
13|  |  Advanced MPMC)       |                     |                  |
14+--------------+---------------------------------+------------------+
15               |                                 |
16===============|========= Memoria compartida ====|===================
17               |     (mmap / CreateFileMapping)  |
18+--------------v---------------------------------v------------------+
19| Proceso trabajador C11 (espacio de direcciones aislado)           |
20|                                                                   |
21|  +-----------------------+     +-------------------------------+  |
22|  | Servidor C11 generado |---->| Tabla de manejadores con      |  |
23|  | y shims por lotes     |     | verificación de generación    |  |
24|  +-----------+-----------+     | (hqq_rpc.c)                   |  |
25|              |                 +---------------+---------------+  |
26|              |                                 |                  |
27|              +----------------+----------------+                  |
28|                               |                                   |
29|                     +---------v---------+                         |
30|                     | Biblioteca C      |                         |
31|                     | nativa (SQLite,   |                         |
32|                     | Zstd...)          |                         |
33|                     +-------------------+                         |
34+-------------------------------------------------------------------+

1. Transporte de memoria compartida (gosuda.org/hqq y c/)

El paquete base gosuda.org/hqq y su contraparte en C11 dentro de c/ implementan dos modos de transporte por 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 de los búferes y los PID de los extremos antes de que se lea cualquier índice de anillo.

Todas las palabras de control activas y los encabezados de ranura están alineados a límites de línea de caché de 64 bytes, de modo que los núcleos productor y consumidor nunca compitan por la misma línea de caché mediante falso compartido.

1Disposición de ranura de StandardLink (alineada a líneas de caché de 64 bytes)
2Desplazamiento en bytes:  0x00              0x08              0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Línea de encabezado:  | seq (u64 atómico) | len (u32) | flags | reservado (relleno con ceros) |
5              +-------------------+-------------------+-----------------------------+
6Área de carga útil:   | bytes de carga útil [0 .. BufferSize - 1] (codificados / decodificados directamente) |
7              +---------------------------------------------------------------------+
CampoDesplazamientoTamañoSemántica
seq0x008 BContador de secuencia monótono. Los valores impares marcan una escritura en curso; los valores pares publican una trama confirmada mediante store-release.
len0x084 BLongitud en bytes de la carga útil escrita en la ranura (0 <= len <= BufferSize).
flags0x0C4 BBits de control de la trama (continuación, estado de error o aviso de cierre).
payload0x40BufferSizeRegión contigua de carga útil leída y escrita en su 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 otra de servidor mediante ranuras dedicadas de solicitud y respuesta protegidas por un cerrojo de secuencia (SeqLock). En la ruta rápida, el escritor serializa directamente en la ranura mapeada y confirma mediante un store-release atómico; el lector realiza una espera activa breve en espacio de usuario antes de recurrir a primitivas de despertar del sistema operativo (futex en Linux, __ulock_wait en macOS, WaitOnAddress en Windows). Un ciclo completo de una sola trama se completa en ~200 ns con 0 allocs/op.
  • Advanced (anillo de despacho MPMC): Multiplexa M llamadores concurrentes de Go sobre N hilos trabajadores de C mediante un anillo de envío multiproductor y multiconsumidor sin cerrojos, un conjunto compartido de ranuras y una tabla de finalización indexada. Los llamadores reservan una ranura, publican el índice de la ranura en el anillo y esperan su entrada de finalización mientras los hilos trabajadores extraen las solicitudes y las completan fuera de orden.

Ambos niveles supervisan la vitalidad del par de forma continua. Cada extremo actualiza una marca de tiempo monótona atómica mediante Heartbeat() e inspecciona PeerAlive(timeout) mientras espera. Si un proceso trabajador falla a mitad de una llamada, el par detecta de inmediato el latido obsoleto o el PID inactivo, interrumpe las esperas bloqueadas con ErrPeerGone y admite un Takeover atómico de la sesión sin dejar archivos de memoria compartida bloqueados.

2. RPC sin asignaciones y manejadores con verificación de generación (gosuda.org/hqq/bindgen)

Escribir a mano el encuadre de memoria compartida para una biblioteca de C con decenas 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 Interfaces (.idl) (o genera uno directamente a partir de una cabecera .h de C mediante hqq-bindgen init) y emite un paquete cliente de Go sin cgo junto con un esqueleto de servidor C11.

El error más peligroso en cualquier enlace con C es exponer punteros de C sin procesar (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) a través de la frontera 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 el canal. En su lugar, el IDL modela los recursos con estado de C como declaraciones 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 el canal, cada manejador es un entero de 64 bits que empaqueta un índice de tabla de 32 bits y un contador de generación de 32 bits distinto de cero: (uint64_t)generation << 32 | index. El runtime de C (c/src/hqq_rpc.c) mantiene una tabla de manejadores por sesión con tres invariantes:

  1. 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 manejador y que su etiqueta de tipo de manejador coincida con el tipo del parámetro (Db frente a Stmt). Reutilizar un manejador cerrado falla de inmediato con HQQRPC_E_BAD_HANDLE antes de invocar la biblioteca de C.
  2. Propiedad jerárquica (@owned_by): Como Stmt está anotado con @owned_by(Db), cerrar un manejador Db recorre automáticamente todos los manejadores Stmt hijos vivos que pertenecen a ese Db y ejecuta sus destructores en orden inverso de asignación (sqlite3_finalize antes de sqlite3_close_v2).
  3. Barrido de terminación de sesión: Si un cliente de Go se desconecta o falla sin cerrar sus manejadores, el destructor de sesión del servidor de C recorre toda la tabla de manejadores de la sesión en orden inverso, garantizando cero asignaciones de C filtradas entre 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 canonicalizado (con el 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 durante el arranque.

3. Ejecución binaria a prueba de manipulaciones (gosuda.org/hqq/secureexec)

Trasladar código de C a un binario trabajador plantea una cuestión de cadena de suministro: ¿cómo sabe el proceso de Go que el binario trabajador en disco no fue modificado ni reemplazado entre la verificación y la ejecución?

Calcular crypto/sha256 sobre una ruta de archivo y, a continuación, ejecutar os/exec.Command(path).Start() deja una ventana de tiempo de verificación frente a tiempo de uso (TOCTOU), durante la cual otro proceso puede sobrescribir o intercambiar el archivo antes de que el kernel lo ejecute. gosuda.org/hqq/secureexec cierra esta ventana:

  • En Linux, secureexec abre el binario, transmite su contenido a un descriptor de archivo de memoria anónimo creado mediante memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING) mientras calcula su hash SHA-256, verifica el hash contra un resumen fijado de 32 bytes o contra un manifiesto de versión firmado con Ed25519 (cmd/hqq-manifest), aplica fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE) para que ningún proceso del sistema pueda modificar las páginas, y lanza el proceso hijo a partir del descriptor sellado.
  • En macOS y Windows, secureexec materializa los binarios verificados en directorios de caché con bloqueo de permisos, indexados por el resumen SHA-256, y verifica la integridad del tamaño del archivo y del resumen antes del lanzamiento.

4. Supervisión de procesos mediante cordón de vida con el padre (gosuda.org/hqq/launch)

Cuando una aplicación de Go lanza un proceso trabajador de C en segundo plano, el trabajador nunca debe sobrevivir a su padre como proceso huérfano, incluso si el padre de Go termina con SIGKILL (kill -9) y no puede ejecutar los manejadores de limpieza de defer.

gosuda.org/hqq/launch impone el acoplamiento de vida mediante una tubería anónima del sistema operativo que se pasa a la entrada estándar del trabajador con la bandera --parent-stdin. El padre de Go conserva el extremo de escritura de la tubería y nunca escribe en ella; el trabajador de C supervisa el extremo de lectura en un hilo de cordón de vida dedicado. Si el proceso de Go termina normalmente, entra en pánico o es eliminado por el kernel del sistema operativo (SIGKILL o el asesino de OOM), el kernel cierra de inmediato el descriptor de escritura. El trabajador de C recibe EOF en la tubería en cuestión de microsegundos, interrumpe las llamadas activas, ejecuta sus destructores de manejadores de sesión, desvincula los archivos de memoria compartida y finaliza de forma limpia.

Inicio rápido: invocar un servicio generado

Con hqq-bindgen y launch, invocar desde Go un trabajador de C aislado se parece a un cliente tipado y ordinario de Go, manteniendo 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("trabajador en ejecución en el listener de memoria compartida %s (opts=%+v)\n", listener, rt.SessionOptions{})
40}

Estructura del repositorio

Cada subsistema de 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 anillo de memoria compartida de bajo nivel
 3├── advanced.go / slotpool.go  # Despachador Advanced (MPMC), conjunto de ranuras y tabla de finalización
 4├── shm_*.go / wait_*.go       # mmap/CreateFileMapping específicos del sistema operativo y futex/ulock/WaitOnAddress
 5├── c/                         # Biblioteca de transporte C11 (hqq.c) y runtime del servidor RPC (hqq_rpc.c)
 6├── bindgen/                   # Analizador de IDL de hqq-bindgen, verificador (V1-V41) y generador de código Go/C11
 7│   └── rt/                    # Runtime cliente de Go sin asignaciones para servicios generados
 8├── secureexec/                # Verificación de manifiestos SHA-256 / Ed25519 y ejecución sellada con memfd
 9├── launch/                    # Supervisor de procesos trabajadores con gestión de cordón --parent-stdin
10├── cmd/hqq-manifest/          # Herramienta CLI para generar y firmar manifiestos de binarios de versión
11└── spec/                      # Especificaciones de protocolo ejecutables y vectores de prueba de referencia (BINDGEN.md)

Para ver cómo HQQ impulsa reemplazos de producción directamente intercambiables para dos de las bibliotecas basadas en cgo más utilizadas del ecosistema de Go, consulte los paquetes complementarios construidos sobre esta pila:

  • gosuda.org/sqlite3: Controlador database/sql directamente intercambiable, compatible con github.com/mattn/go-sqlite3, que ejecuta SQLite nativo de C 3.54.1 sobre memoria compartida con captación previa de lotes en cadena.
  • gosuda.org/zstd: Reemplazo directamente intercambiable de github.com/DataDog/zstd, que ejecuta libzstd 1.5.7 nativo de C en un trabajador aislado de memoria compartida con contextos sin asignaciones y procesamiento por lotes masivo.
  • Repositorio de GitHub: https://github.com/gosuda/HQQ