GoSuda

HQQ: изоляция библиотек на C от Go посредством разделяемой памяти на уровне строк кэша

By Lemon Mint views ...

gosuda.org/hqq — это среда межпроцессного взаимодействия без использования cgo и инструментарий генерации кода для Go и C11. Она изолирует нативные C-библиотеки внутри отдельных рабочих процессов ОС и подключает их к вызывающим Go-программам через отображаемые в память кольцевые буферы в разделяемой памяти, выровненные по границам кэш-линий. Проект объединяет два транспортных уровня (StandardLink для выделенных односторонних сессий SPSC и Advanced для многоворкерной диспетчеризации MPMC), компилятор IDL hqq-bindgen и супервизор процессов с проверкой целостности (secureexec и launch).

1go get gosuda.org/hqq

Цена совместного использования адресного пространства с C

Каждый инженер систем на Go рано или поздно сталкивается с одной и той же развилкой. Вы создаёте сервис на Go, который компилируется за секунды с CGO_ENABLED=0, поставляется как один статический бинарный файл, кросс-компилируется с ноутбука под целевые платформы Linux или Windows без sysroot и работает в безопасной к памяти куче со сборкой мусора. Затем вам требуется зрелая C-библиотека: SQLite для встраиваемого реляционного хранилища, Zstandard (libzstd) для сжатия со словарями или аппаратно ускоренная библиотека для работы с медиа или криптографией.

Включение cgo для связывания этой C-библиотеки с Go-процессом решает непосредственную задачу привязки, но вводит три структурных недостатка:

  1. Связанность инструментария и кросс-компиляции: CGO_ENABLED=1 требует C-компилятор, заголовочные файлы целевой libc и платформенный компоновщик для каждой целевой ОС и архитектуры. Сборка артефактов linux/arm64 или windows/arm64 с рабочей станции darwin/arm64 превращает одну команду go build в матрицу кросс-компиляторов и sysroot.
  2. Накладные расходы планировщика времени выполнения: Каждый вызов cgo пересекает границу среды выполнения Go, переключается со стека горутины, который может расти, на системный стек и закрепляет за собой поток ОС (M). При детализированных вызовах издержки перехода (около 50–150 нс на вызов) и конкуренция за планировщик быстро накапливаются.
  3. Коллапс домена отказа: Код на C выполняется в том же виртуальном адресном пространстве, что и среда выполнения Go. Единственная запись за границами буфера, обращение к освобождённой памяти, переполнение стека или abort() внутри C-функции повреждает метаданные кучи Go или немедленно завершает весь серверный процесс, минуя recover() и обрывая все выполняющиеся HTTP- или RPC-запросы.

Когда команды пытаются избежать cgo, распространённые альтернативы требуют другого компромисса. Чисто Go-трансляции C-кодовой базы сохраняют CGO_ENABLED=0, но часто отстают от upstream-релизов C, содержат эмулируемый слой виртуальной памяти и интенсивно выделяют память под нагрузкой. Перенос C-библиотеки в вспомогательный процесс через loopback TCP или Unix-сокеты восстанавливает изоляцию отказов, однако каждый вызов несёт расходы на два переключения контекста ядра, копирование буферов сокетов и сериализацию в проводной формат.

Мы создали HQQ, задав прямой вопрос: что, если C-библиотека будет находиться в отдельном процессе ОС со своим собственным виртуальным адресным пространством, а Go и C будут обмениваться кадрами непосредственно через общие кэш-линии процессора, не обращаясь к ядру на быстром пути?

Модель интеграцииCGO_ENABLED=0Изоляция отказовСистемные вызовы на быстром путиВыделения кучи на вызовТочность относительно upstream C11
cgo связывание в процессеНетОтсутствует (общая куча)00 до 1+Точный upstream C
Субпроцесс RPC через сокет / каналДаГраница процесса2 до 4Несколько (кадрирование + I/O)Точный upstream C
Механическая трансляция C в GoДаВнутрипроцессный Go0Высоко на сложных вызовахЭмулируемая среда выполнения C
HQQ IPC через разделяемую памятьДаГраница процесса0 (адаптивное ожидание)0 (StandardLink)Точный upstream C

Архитектурный обзор: четыре уровня за границами процессов

HQQ разделяет изоляцию процессов на четыре компонуемых уровня, чтобы механика кольцевых буферов в разделяемой памяти, генерация типизированного RPC-кода, проверка целостности бинарных файлов и supervision дочерних процессов оставались независимыми.

 1+-------------------------------------------------------------------+
 2| Процесс Go-приложения (CGO_ENABLED=0)                             |
 3|                                                                   |
 4|  +-----------------------+     +-------------------------------+  |
 5|  | Сгенерированный       |     | Супервизор процессов          |  |
 6|  | Go-клиент             |     | (gosuda.org/hqq/launch +      |  |
 7|  | (gosuda.org/hqq/      |     |  gosuda.org/hqq/secureexec)   |  |
 8|  |  bindgen/rt)          |     |                               |  |
 9|  +-----------+-----------+     +---------------+---------------+  |
10|              |                                 |                  |
11|  +-----------v-----------+                     | Анонимный канал  |
12|  | Транспортный уровень  |                     | Lifeline         |
13|  | (StandardLink /       |                     | (--parent-stdin) |
14|  |  Advanced MPMC)       |                     |                  |
15+--------------+---------------------------------+------------------+
16               |                                 |
17===============|========= Разделяемая память ====|===================
18               |     (mmap / CreateFileMapping)  |
19+--------------v---------------------------------v------------------+
20| Рабочий процесс C11 (изолированное адресное пространство)         |
21|                                                                   |
22|  +-----------------------+     +-------------------------------+  |
23|  | Сгенерированный       |---->| Таблица дескрипторов с        |  |
24|  | сервер C11 и         |     | проверкой поколений           |  |
25|  | пакетные обёртки      |     | (hqq_rpc.c)                   |  |
26|  +-----------+-----------+     +---------------+---------------+  |
27|              |                                 |                  |
28|              +----------------+----------------+                  |
29|                               |                                   |
30|                     +---------v---------+                         |
31|                     | Нативная C-       |                         |
32|                     | библиотека        |                         |
33|                     | (SQLite, Zstd...) |                         |
34|                     +-------------------+                         |
35+-------------------------------------------------------------------+

1. Транспорт через разделяемую память (gosuda.org/hqq и c/)

Базовый пакет gosuda.org/hqq и его C11-аналог в c/ реализуют два режима транспорта через разделяемую память поверх POSIX mmap (Linux и macOS) и Win32 CreateFileMappingW (Windows). Каждый разделяемый сегмент начинается с проверяемого суперблока HQQ_STL3, который фиксирует версию протокола, геометрию буферов и PID конечных точек до чтения любого индекса кольца.

Все горячие управляющие слова и заголовки слотов выровнены по границам кэш-линий размером 64 байта, чтобы ядра производителя и потребителя никогда не конкурировали за одну и ту же кэш-линию из-за ложного разделения.

1Схема слота StandardLink (выровнен по кэш-линии 64 байта)
2Смещение в байтах:  0x00              0x08              0x10                          0x40
3              +-------------------+-------------------+-----------------------------+
4Линия заголовка: | seq (u64 atomic)  | len (u32) | flags | reserved (дополнено нулями) |
5              +-------------------+-------------------+-----------------------------+
6Область полезной нагрузки: | байты payload [0 .. BufferSize - 1] (кодируются и декодируются напрямую) |
7              +---------------------------------------------------------------------+
ПолеСмещениеРазмерСемантика
seq0x008 БМонотонный счётчик последовательности. Нечётные значения обозначают незавершённую запись; чётные значения публикуют зафиксированный кадр через store-release.
len0x084 БДлина полезной нагрузки в байтах, записанной в слот (0 <= len <= BufferSize).
flags0x0C4 БУправляющие биты кадра (продолжение, статус ошибки или уведомление о завершении).
payload0x40BufferSizeНепрерывная область полезной нагрузки, читаемая и записываемая на месте без промежуточных копий.

Вызывающая сторона выбирает один из двух транспортных уровней в зависимости от структуры конкурентности:

  • StandardLink (сессия SPSC 1:1): Связывает клиентскую и серверную сессии через выделенные слоты запроса и ответа, защищённые блокировкой последовательности (SeqLock). На быстром пути писатель сериализует данные непосредственно в отображённый слот и фиксирует их атомарной операцией store-release; читатель кратковременно ожидает в пользовательском пространстве, прежде чем перейти к примитивам пробуждения ОС (futex в Linux, __ulock_wait в macOS, WaitOnAddress в Windows). Цикл одного кадра выполняется примерно за 200 нс с 0 allocs/op.
  • Advanced (кольцо диспетчеризации MPMC): Мультиплексирует M одновременных Go-вызывающих сторон между N рабочими потоками C, используя бесблокировочное кольцо отправки с несколькими производителями и потребителями, общий пул слотов и индексированную таблицу завершения. Вызывающие стороны резервируют слот, публикуют индекс слота в кольцо и ожидают своей записи о завершении, в то время как рабочие потоки извлекают запросы и выполняют их в произвольном порядке.

Оба уровня непрерывно отслеживают доступность пиров. Каждая конечная точка обновляет атомарную монотонную метку времени через Heartbeat() и проверяет PeerAlive(timeout) во время ожидания. Если рабочий процесс аварийно завершается в ходе вызова, пир немедленно обнаруживает устаревший heartbeat или мёртвый PID, прерывает заблокированные ожидания с ошибкой ErrPeerGone и поддерживает атомарную передачу сессии (Takeover) без оставления заблокированных файлов разделяемой памяти.

2. RPC без выделений памяти и дескрипторы с проверкой поколений (gosuda.org/hqq/bindgen)

Ручная запись кадрирования разделяемой памяти для C-библиотеки с десятками функций утомительна и подвержена ошибкам. hqq-bindgen — это компилятор схем, который читает файл языка описания интерфейсов (.idl) (или создаёт его непосредственно из C-заголовка .h командой hqq-bindgen init) и генерирует пакет Go-клиента без cgo вместе с каркасом сервера C11.

Наиболее опасная ошибка в любой C-обёртке — передача сырых C-указателей (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) через границу языка. Если вызывающая сторона передаёт указатель после его освобождения, передаёт указатель, принадлежащий другой сессии, или повреждает целочисленный адрес, C-библиотека разыменовывает недопустимую память.

hqq-bindgen запрещает сырые указатели в проводном формате. Вместо этого IDL моделирует состоятельные C-ресурсы как типизированные объявления 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}

В проводном формате каждый дескриптор представляет собой 64-битное целое, объединяющее 32-битный индекс таблицы и 32-битный ненулевой счётчик поколения: (uint64_t)generation << 32 | index. Среда выполнения C (c/src/hqq_rpc.c) поддерживает таблицу дескрипторов для каждой сессии и соблюдает три инварианта:

  1. Проверка поколения: Каждый поиск проверяет, что слот занят, что его сохранённое поколение совпадает со старшими 32 битами дескриптора и что тег типа дескриптора соответствует типу параметра (Db или Stmt). Повторное использование закрытого дескриптора немедленно завершается ошибкой HQQRPC_E_BAD_HANDLE до обращения к C-библиотеке.
  2. Иерархическое владение (@owned_by): Поскольку Stmt помечен аннотацией @owned_by(Db), закрытие дескриптора Db автоматически обходит все живые дочерние дескрипторы Stmt, принадлежащие этому Db, и вызывает их деструкторы в порядке, обратном порядку выделения (sqlite3_finalize до sqlite3_close_v2).
  3. Очистка сессии при завершении: Если клиент Go отключается или аварийно завершается, не закрыв свои дескрипторы, деструктор сессии на стороне C сервера обходит всю таблицу дескрипторов сессии в обратном порядке, гарантируя отсутствие утечек выделений C при перезапусках клиента.

Перед обработкой первого RPC-вызова сессией клиент и сервер обмениваются 32-байтовым дайджестом интерфейса SHA-256, вычисленным по канонизированному IDL (с префиксом HQQ-BINDGEN-IDL-V1\n). Если клиент Go и рабочий процесс C были скомпилированы из несовместимых схем IDL, рукопожатие отклоняет соединение на этапе инициализации.

3. Выполнение бинарных файлов без подмены (gosuda.org/hqq/secureexec)

Перенос C-кода в бинарный файл рабочего процесса порождает вопрос безопасности цепочки поставок: как процесс Go узнаёт, что бинарный файл рабочего процесса на диске не был изменён или заменён между проверкой и выполнением?

Вызов crypto/sha256 для пути к файлу с последующим os/exec.Command(path).Start() оставляет окно уязвимости «время проверки — время использования» (TOCTOU), в течение которого другой процесс может перезаписать или подменить файл до того, как ядро его выполнит. gosuda.org/hqq/secureexec закрывает это окно:

  • В Linux secureexec открывает бинарный файл, передаёт его содержимое в анонимный файловый дескриптор памяти, созданный через memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING), одновременно вычисляя его хеш SHA-256, проверяет хеш по закреплённому 32-байтовому дайджесту или по манифесту релиза, подписанному Ed25519 (cmd/hqq-manifest), применяет fcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE), чтобы ни один процесс в системе не мог изменить страницы, и запускает дочерний процесс из запечатанного дескриптора.
  • В macOS и Windows secureexec размещает проверенные бинарные файлы в каталогах кэша с ограниченными правами доступа, именованных по дайджесту SHA-256, и проверяет размер файла и целостность дайджеста перед запуском.

4. Супервизия процесса через родительский канал жизни (gosuda.org/hqq/launch)

Когда приложение Go порождает фоновый рабочий процесс C, рабочий процесс не должен никогда пережить своего родителя в качестве осиротевшего процесса, даже если родительский процесс Go завершён сигналом SIGKILL (kill -9) и не может выполнить обработчики очистки defer.

gosuda.org/hqq/launch обеспечивает связь времени жизни через анонимный канал ОС, передаваемый на стандартный ввод рабочего процесса с флагом --parent-stdin. Родительский процесс Go держит конец канала для записи и никогда в него не пишет; рабочий процесс C отслеживает конец для чтения в выделенном потоке lifeline. Если процесс Go завершается штатно, возникает паника или он уничтожается ядром ОС (SIGKILL или OOM killer), ядро немедленно закрывает дескриптор записи. Рабочий процесс C получает EOF в канале в течение микросекунд, прерывает активные вызовы, выполняет деструкторы дескрипторов сессии, удаляет файлы разделяемой памяти и корректно завершается.

Быстрый старт: вызов сгенерированного сервиса

С hqq-bindgen и launch вызов изолированного рабочего процесса C из Go выглядит как обычный типизированный Go-клиент при сохранении 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	// Создаём контекст с таймаутом 5 секунд
16	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
17	defer cancel()
18
19	// Разбираем закреплённый SHA-256 дайджест рабочего процесса
20	hash, err := secureexec.ParseHash("7649b1b45dacd934cde8149f35f4fcc3937f6486fbac23ce976466719b3752bb")
21	if err != nil {
22		log.Fatal(err)
23	}
24
25	// Создаём имя слушателя разделяемой памяти
26	listener, err := launch.NewListenerName("demo")
27	if err != nil {
28		log.Fatal(err)
29	}
30
31	// Запускаем проверенный рабочий процесс
32	srv, err := launch.Start(ctx, launch.Config{
33		Binary:   "/path/to/verified-worker",
34		SHA256:   hash,
35		Listener: listener,
36		Args:     []string{listener},
37	})
38	if err != nil {
39		log.Fatal(err)
40	}
41	defer srv.Close()
42
43	fmt.Printf("worker running on shared-memory listener %s (opts=%+v)\n", listener, rt.SessionOptions{})
44}

Структура репозитория

Каждая подсистема gosuda.org/hqq организована в тематические пакеты с явными границами:

 1gosuda.org/hqq
 2├── standard.go / ring.go      # StandardLink (SPSC) и низкоуровневые примитивы кольца разделяемой памяти
 3├── advanced.go / slotpool.go  # Диспетчер Advanced (MPMC), пул слотов и таблица завершения
 4├── shm_*.go / wait_*.go       # Специфичные для ОС mmap/CreateFileMapping и futex/ulock/WaitOnAddress
 5├── c/                         # Транспортная библиотека C11 (hqq.c) и среда выполнения RPC-сервера (hqq_rpc.c)
 6├── bindgen/                   # Синтаксический анализатор IDL hqq-bindgen, верификатор (V1-V41) и генератор кода Go/C11
 7│   └── rt/                    # Клиентская среда выполнения Go без выделений памяти для сгенерированных сервисов
 8├── secureexec/                # Проверка манифестов SHA-256 / Ed25519 и выполнение из запечатанного memfd
 9├── launch/                    # Супервизор рабочих процессов с управлением родительским каналом --parent-stdin
10├── cmd/hqq-manifest/          # CLI-инструмент для генерации и подписи манифестов бинарных релизов
11└── spec/                      # Исполняемые спецификации проводного формата и эталонные тестовые векторы (BINDGEN.md)

Чтобы увидеть, как HQQ обеспечивает готовые к производству замены для двух наиболее широко используемых библиотек с cgo в экосистеме Go, ознакомьтесь с сопутствующими пакетами, построенными на этом стеке:

  • gosuda.org/sqlite3: Драйвер database/sql, совместимый с github.com/mattn/go-sqlite3, который выполняет нативный C SQLite 3.54.1 через разделяемую память с конвейерной предвыборкой пакетов.
  • gosuda.org/zstd: Замена с полной совместимостью для github.com/DataDog/zstd, которая выполняет нативный C libzstd 1.5.7 в изолированном рабочем процессе с разделяемой памятью, с контекстами без выделений памяти и пакетной обработкой.
  • Репозиторий GitHub: https://github.com/gosuda/HQQ