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

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-процессом решает непосредственную задачу привязки, но вводит три структурных недостатка:
- Связанность инструментария и кросс-компиляции:
CGO_ENABLED=1требует C-компилятор, заголовочные файлы целевой libc и платформенный компоновщик для каждой целевой ОС и архитектуры. Сборка артефактовlinux/arm64илиwindows/arm64с рабочей станцииdarwin/arm64превращает одну командуgo buildв матрицу кросс-компиляторов и sysroot. - Накладные расходы планировщика времени выполнения: Каждый вызов
cgoпересекает границу среды выполнения Go, переключается со стека горутины, который может расти, на системный стек и закрепляет за собой поток ОС (M). При детализированных вызовах издержки перехода (около 50–150 нс на вызов) и конкуренция за планировщик быстро накапливаются. - Коллапс домена отказа: Код на 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 связывание в процессе | Нет | Отсутствует (общая куча) | 0 | 0 до 1+ | Точный upstream C |
| Субпроцесс RPC через сокет / канал | Да | Граница процесса | 2 до 4 | Несколько (кадрирование + I/O) | Точный upstream C |
| Механическая трансляция C в Go | Да | Внутрипроцессный Go | 0 | Высоко на сложных вызовах | Эмулируемая среда выполнения 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 +---------------------------------------------------------------------+
| Поле | Смещение | Размер | Семантика |
|---|---|---|---|
seq | 0x00 | 8 Б | Монотонный счётчик последовательности. Нечётные значения обозначают незавершённую запись; чётные значения публикуют зафиксированный кадр через store-release. |
len | 0x08 | 4 Б | Длина полезной нагрузки в байтах, записанной в слот (0 <= len <= BufferSize). |
flags | 0x0C | 4 Б | Управляющие биты кадра (продолжение, статус ошибки или уведомление о завершении). |
payload | 0x40 | BufferSize | Непрерывная область полезной нагрузки, читаемая и записываемая на месте без промежуточных копий. |
Вызывающая сторона выбирает один из двух транспортных уровней в зависимости от структуры конкурентности:
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) поддерживает таблицу дескрипторов для каждой сессии и соблюдает три инварианта:
- Проверка поколения: Каждый поиск проверяет, что слот занят, что его сохранённое поколение совпадает со старшими 32 битами дескриптора и что тег типа дескриптора соответствует типу параметра (
DbилиStmt). Повторное использование закрытого дескриптора немедленно завершается ошибкойHQQRPC_E_BAD_HANDLEдо обращения к C-библиотеке. - Иерархическое владение (
@owned_by): ПосколькуStmtпомечен аннотацией@owned_by(Db), закрытие дескриптораDbавтоматически обходит все живые дочерние дескрипторыStmt, принадлежащие этомуDb, и вызывает их деструкторы в порядке, обратном порядку выделения (sqlite3_finalizeдоsqlite3_close_v2). - Очистка сессии при завершении: Если клиент 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 SQLite3.54.1через разделяемую память с конвейерной предвыборкой пакетов.gosuda.org/zstd: Замена с полной совместимостью дляgithub.com/DataDog/zstd, которая выполняет нативный Clibzstd 1.5.7в изолированном рабочем процессе с разделяемой памятью, с контекстами без выделений памяти и пакетной обработкой.- Репозиторий GitHub: https://github.com/gosuda/HQQ