HQQ: Izolowanie bibliotek C od Go za pośrednictwem współdzielonej pamięci poziomu Cache-Line
gosuda.org/hqq to środowisko wykonawcze komunikacji międzyprocesowej typu zero-cgo oraz zestaw narzędzi do generowania kodu dla języków Go i C11. Izoluje ono natywne biblioteki języka C w osobnych procesach roboczych systemu operacyjnego i łączy je z wywołującymi procedurami w języku Go za pomocą odwzorowanych w pamięci pierścieni pamięci współdzielonej wyrównanych do linii pamięci podręcznej. Projekt łączy dwie warstwy transportowe (StandardLink dla dedykowanych sesji typu pojedynczy producent – pojedynczy konsument oraz Advanced dla dyspozytora MPMC obsługującego wielu pracowników), kompilator języka definicji interfejsów (IDL) hqq-bindgen oraz weryfikowany pod kątem modyfikacji nadzorca procesów (secureexec i launch).
1go get gosuda.org/hqq
Koszt dzielenia przestrzeni adresowej z językiem C
Każdy inżynier systemowy języka Go w końcu dociera do tego samego rozwidlenia dróg. Tworzysz usługę w języku Go, która kompiluje się w kilka sekund z ustawieniem CGO_ENABLED=0, jest dostarczana jako pojedynczy statyczny plik binarny, kompiluje się skrośnie z laptopa na cele docelowe systemów Linux lub Windows bez sysrootu oraz działa wewnątrz bezpiecznej dla pamięci sterty zarządzanej przez odśmieczacz pamięci. Następnie potrzebujesz dojrzałej biblioteki w języku C: SQLite do osadzonego magazynu relacyjnego, Zstandard (libzstd) do kompresji słownikowej lub przyspieszanej sprzętowo biblioteki multimediów bądź kryptografii.
Włączenie parametru cgo w celu powiązania tej biblioteki języka C z procesem języka Go rozwiązuje natychmiastowy problem wiązania, wprowadzając jednocześnie trzy strukturalne obciążenia:
- Powiązanie zestawu narzędzi z kompilacją skrośną: Ustawienie
CGO_ENABLED=1wymaga obecności kompilatora języka C, nagłówków docelowej biblioteki libc oraz konsolidatora platformy dla każdego docelowego systemu operacyjnego i architektury. Budowanie artefaktów dla architekturlinux/arm64lubwindows/arm64ze stacji roboczejdarwin/arm64zamienia pojedyncze poleceniego buildw macierz kompilatorów skrośnych i sysrootów. - Narzut harmonogramowania środowiska wykonawczego: Każde wywołanie mechanizmu
cgoprzekracza granicę środowiska wykonawczego języka Go, przełącza się z rosnącego stosu gorutyny na stos systemowy i przypisuje wątek systemu operacyjnego (M). W przypadku wywołań o wysokiej częstotliwości narzut przejścia (od około 50 do 150 ns na wywołanie) oraz rywalizacja w harmonogramie szybko narastają. - Załamanie domeny błędów: Kod w języku C działa w tej samej wirtualnej przestrzeni adresowej co środowisko wykonawcze języka Go. Pojedynczy zapis poza granicami bufora, użycie wskaźnika po zwolnieniu, przepełnienie stosu lub wywołanie funkcji
abort()wewnątrz funkcji w języku C powoduje korupcję metadanych sterty języka Go lub natychmiastowe zakończenie działania całego procesu serwera, omijając mechanizmrecover()i pociągając za sobą każde przetwarzane żądanie HTTP lub RPC.
Kiedy zespoły próbują unikać mechanizmu cgo, powszechne alternatywy wymuszają inny kompromis. Czysto programowe tłumaczenia baz kodu z języka C na język Go zachowują wartość CGO_ENABLED=0, jednak często pozostają w tyle za aktualizacjami wersji źródłowych w języku C, przenoszą emulowaną warstwę pamięci wirtualnej i dokonują licznych alokacji pod obciążeniem. Przeniesienie biblioteki języka C do procesu pomocniczego za pośrednictwem lokalnej pętli TCP (loopback) lub domenowych gniazd systemów Unix przywraca izolację błędów, lecz każde wywołanie ponosi koszt dwóch przełączeń kontekstu jądra, kopiowania buforów gniazd oraz serializacji sieciowej.
Stworzyliśmy bibliotekę HQQ po zadaniu bezpośredniego pytania: co by było, gdyby biblioteka języka C znajdowała się w osobnym procesie systemu operacyjnego z własną wirtualną przestrzenią adresową, podczas gdy Go i C wymieniałyby ramki bezpośrednio przez współdzielone linie pamięci podręcznej procesora bez wchodzenia do jądra na ścieżce szybkiej?
| Model integracji | CGO_ENABLED=0 | Izolacja błędów | Wywołania systemowe szybkiej ścieżki | Alokacje sterty na wywołanie | Wierność wersji źródłowej C11 |
|---|---|---|---|---|---|
Wiązanie wewnątrzprocesowe cgo | Nie | Brak (wspólna sterta) | 0 | od 0 do 1+ | Dokładny kod źródłowy C |
| RPC procesu potomnego przez gniazdo / potok | Tak | Granica procesu | od 2 do 4 | Wiele (ramkowanie + we/wy) | Dokładny kod źródłowy C |
| Mechaniczne tłumaczenie C na Go | Tak | Go wewnątrzprocesowe | 0 | Wysoka przy złożonych wywołaniach | Emulowane środowisko C |
| IPC pamięci współdzielonej HQQ | Tak | Granica procesu | 0 (wirowanie adaptacyjne) | 0 (StandardLink) | Dokładny kod źródłowy C |
Przegląd architektury: Cztery warstwy ponad granicami procesów
HQQ dzieli izolację procesów na cztery komponowalne warstwy, dzięki czemu mechanika pierścieni pamięci współdzielonej, generowanie typowanego kodu RPC, weryfikacja integralności binarnej oraz nadzór nad procesami potomnymi pozostają niezależne.
1+-------------------------------------------------------------------+
2| Proces aplikacji Go (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Wygenerowany klient | | Nadzorca procesów | |
6| | Go (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Anonimowy potok |
11| | Warstwa transportowa | | ratunkowy |
12| | (StandardLink / | | (--parent-stdin) |
13| | Advanced MPMC) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|====== Pamięć współdzielona =====|===================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| Proces roboczy C11 (Izolowana przestrzeń adresowa) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Wygenerowany serwer |---->| Tabela uchwytów weryfikowana | |
23| | C11 i otoki wsadowe | | generacyjnie (hqq_rpc.c) | |
24| +-----------+-----------+ +---------------+---------------+ |
25| | | |
26| +----------------+----------------+ |
27| | |
28| +---------v---------+ |
29| | Natywna biblioteka| |
30| | C (SQLite, Zstd...) |
31| +-------------------+ |
32+-------------------------------------------------------------------+
1. Transport pamięci współdzielonej (gosuda.org/hqq oraz c/)
Pakiet bazowy gosuda.org/hqq oraz jego odpowiednik języka C11 w katalogu c/ implementują dwa tryby transportu opartego na pamięci współdzielonej za pomocą mechanizmów POSIX mmap (Linux i macOS) oraz Win32 CreateFileMappingW (Windows). Każdy segment współdzielony rozpoczyna się zweryfikowanym superblokiem HQQ_STL3, który ustala wersję protokołu, geometrię bufora oraz identyfikator PID punktu końcowego przed odczytem jakiegokolwiek indeksu pierścienia.
Wszystkie kluczowe słowa sterujące oraz nagłówki gniazd są wyrównane do granic 64-bajtowych linii pamięci podręcznej, dzięki czemu rdzenie producenta i konsumenta nigdy nie rywalizują o tę samą linię pamięci podręcznej w wyniku fałszywego współdzielenia (false sharing).
1Układ gniazda StandardLink (wyrównany do 64-bajtowej linii pamięci podręcznej)
2Przesunięcie bajtowe: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Linia nagłówka:| seq (atomowa u64) | len (u32) | flagi | zarezerwowane (wypełnione 0)|
5 +-------------------+-------------------+-----------------------------+
6Obszar danych:| bajty danych [0 .. BufferSize - 1] (bezpośrednio zakodowane / odkodowane)
7 +---------------------------------------------------------------------+
| Pole | Przesunięcie | Rozmiar | Semantyka |
|---|---|---|---|
seq | 0x00 | 8 B | Monotoniczny licznik sekwencji. Nieparzyste wartości oznaczają zapis w toku; parzyste wartości publikują zatwierdzoną ramkę poprzez operację store-release. |
len | 0x08 | 4 B | Długość danych w bajtach zapisana w gnieździe (0 <= len <= BufferSize). |
flags | 0x0C | 4 B | Bity sterowania ramką (kontynuacja, stan błędu lub powiadomienie o zamknięciu). |
payload | 0x40 | BufferSize | Spójny obszar danych odczytywany i zapisywany w miejscu bez kopii pośrednich. |
Wywołujący wybierają spośród dwóch warstw transportowych w zależności od struktury współbieżności:
StandardLink(sesja 1:1 SPSC): Łączy w parę sesję klienta i serwera za pomocą dedykowanych gniazd żądań i odpowiedzi chronionych blokadą sekwencji (SeqLock). Na szybkiej ścieżce autor zapisu serializuje dane bezpośrednio do zamapowanego gniazda i zatwierdza je za pomocą atomowej operacji store-release; czytnik wiruje przez chwilę w przestrzeni użytkownika przed przełączeniem się na prymitywy wybudzania systemu operacyjnego (futexw systemie Linux,__ulock_waitw systemie macOS,WaitOnAddressw systemie Windows). Pełny cykl wymiany pojedynczej ramki kończy się w czasie ok. 200 ns z wartością0 allocs/op.Advanced(pierścień dyspozytora MPMC): ZwielokrotniaMwspółbieżnych wywołujących z języka Go wNwątkach roboczych języka C przy użyciu bezblokadowego pierścienia przesyłania typu wielu producentów – wielu konsumentów (MPMC), współdzielonej puli gniazd oraz indeksowanej tabeli zakończeń. Wywołujący rezerwują gniazdo, publikują indeks gniazda w pierścieniu i oczekują na swój wpis zakończenia, podczas gdy wątki robocze pobierają z kolejki i kończą żądania poza kolejnością.
Obie warstwy stale monitorują żywotność partnera. Każdy punkt końcowy aktualizuje atomowy monotoniczny znacznik czasu za pomocą metody Heartbeat() i sprawdza stan PeerAlive(timeout) podczas oczekiwania. Jeśli proces roboczy ulegnie awarii w trakcie wywołania, partner natychmiast wykrywa przestarzałe bicie serca lub martwy identyfikator PID, przerywa zablokowane oczekiwania za pomocą błędu ErrPeerGone i obsługuje atomowe przejęcie sesji (Takeover) bez pozostawiania porzuconych plików pamięci współdzielonej.
2. RPC bez alokacji i weryfikowane generacyjnie uchwyty (gosuda.org/hqq/bindgen)
Ręczne pisanie ramkowania pamięci współdzielonej dla biblioteki języka C posiadającej dziesiątki funkcji jest nużące i podatne na błędy. Narzędzie hqq-bindgen to kompilator schematów, który odczytuje plik języka definicji interfejsu (.idl) (lub generuje go bezpośrednio z nagłówka .h języka C za pomocą polecenia hqq-bindgen init) i emituje pakiet klienta Go o zerowej alokacji przy udziale cgo wraz ze szkieletem serwera C11.
Najniebezpieczniejszym błędem w dowolnym wiązaniu języka C jest udostępnianie surowych wskaźników języka C (sqlite3*, sqlite3_stmt*, ZSTD_CCtx*) ponad granicą języków. Jeśli wywołujący przekazuje wskaźnik po jego zwolnieniu, przekazuje wskaźnik należący do innej sesji lub narusza adres całkowitoliczbowy, biblioteka języka C dereferencjuje nieprawidłową pamięć.
Narzędzie hqq-bindgen zabrania używania surowych wskaźników w przesyłanych danych. Zamiast tego IDL modeluje stanowe zasoby języka C jako typowane deklaracje 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}
W warstwie przesyłania danych każdy uchwyt jest 64-bitową liczbą całkowitą pakującą 32-bitowy indeks tabeli oraz 32-bitowy niezerowy licznik generacji: (uint64_t)generation << 32 | index. Środowisko wykonawcze języka C (c/src/hqq_rpc.c) utrzymuje tabelę uchwytów dla każdej sesji z zachowaniem trzech niezmienników:
- Walidacja generacji: Każde wyszukiwanie weryfikuje, czy gniazdo jest zajęte, czy jego przechowywana generacja odpowiada górnym 32 bitom uchwytu oraz czy znacznik typu uchwytu pasuje do typu parametru (
Dbw porównaniu doStmt). Ponowne użycie zamkniętego uchwytu kończy się natychmiastowym niepowodzeniem z kodemHQQRPC_E_BAD_HANDLEprzed wywołaniem funkcji biblioteki C. - Hierarchiczna własność (
@owned_by): Ponieważ elementStmtjest oznaczony adnotacją@owned_by(Db), zamknięcie uchwytuDbautomatycznie przechodzi przez wszystkie aktywne podrzędne uchwytyStmtnależące do tego obiektuDbi wykonuje ich destruktory w odwrotnej kolejności alokacji (sqlite3_finalizeprzedsqlite3_close_v2). - Czyszczenie przy zamykaniu sesji: Jeśli klient Go rozłączy się lub ulegnie awarii bez zamknięcia swoich uchwytów, destruktor sesji serwera C przeszukuje całą tabelę uchwytów sesji w odwrotnej kolejności, gwarantując brak wycieków alokacji języka C po ponownym uruchomieniu klienta.
Zanim sesja przetworzy swoje pierwsze wywołanie RPC, klient i serwer wymieniają 32-bajtowy skrót interfejsu SHA-256 obliczony na podstawie znormalizowanego pliku IDL (z przedrostkiem HQQ-BINDGEN-IDL-V1\n). Jeśli klient Go i proces roboczy C zostały skompilowane z niekompatybilnych schematów IDL, uścisk dłoni odrzuca połączenie na etapie rozruchu.
3. Niezmodyfikowane wykonanie binarne (gosuda.org/hqq/secureexec)
Przeniesienie kodu w języku C do pliku binarnego procesu roboczego rodzi pytanie dotyczące łańcucha dostaw: skąd proces Go wie, że plik binarny procesu roboczego na dysku nie został zmodyfikowany lub zastąpiony między weryfikacją a wykonaniem?
Wywołanie funkcji crypto/sha256 dla ścieżki pliku, po którym następuje os/exec.Command(path).Start(), pozostawia okno czasowe między sprawdzeniem a użyciem (TOCTOU), w którym inny proces może nadpisać lub podmienić plik, zanim jądro go wykona. Pakiet gosuda.org/hqq/secureexec zamyka to okno:
- W systemie Linux
secureexecotwiera plik binarny, przesyła jego zawartość strumieniowo do anonimowego deskryptora pliku w pamięci utworzonego za pomocą funkcjimemfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)podczas obliczania jego skrótu SHA-256, weryfikuje skrót względem przypiętego 32-bajtowego skrótu lub podpisanego kluczem Ed25519 manifestu wydania (cmd/hqq-manifest), stosuje wywołaniefcntl(F_ADD_SEALS, F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE), aby żaden proces w systemie nie mógł zmodyfikować stron pamięci, a następnie uruchamia proces potomny z zabezpieczonego deskryptora. - W systemach macOS i Windows
secureexecmaterializuje zweryfikowane pliki binarne w zablokowanych pod kątem uprawnień katalogach pamięci podręcznej powiązanych kluczem skrótu SHA-256 oraz weryfikuje rozmiar pliku i integralność skrótu przed uruchomieniem.
4. Nadzór nad procesem za pomocą potoku ratunkowego rodzica (gosuda.org/hqq/launch)
Gdy aplikacja w języku Go uruchamia w tle proces roboczy języka C, proces ten nigdy nie może przetrwać swojego nadrzędnego procesu jako proces osierocony, nawet jeśli proces nadrzędny w języku Go zostanie zakończony sygnałem SIGKILL (kill -9) i nie będzie w stanie wykonać procedur oczyszczających defer.
Pakiet gosuda.org/hqq/launch wymusza powiązanie czasu życia za pomocą anonimowego potoku systemowego przekazanego do standardowego wejścia procesu roboczego z flagą --parent-stdin. Proces nadrzędny w języku Go utrzymuje koniec zapisu potoku i nigdy do niego nie pisze; proces roboczy w języku C monitoruje koniec odczytu w dedykowanym wątku ratunkowym. Jeśli proces Go zakończy działanie w sposób normalny, wywoła panikę lub zostanie zabity przez jądro systemu operacyjnego (sygnałem SIGKILL lub mechanizmem OOM killer), jądro natychmiast zamyka deskryptor zapisu. Proces roboczy w języku C otrzymuje sygnał EOF na potoku w ciągu mikrosekund, przerywa wszelkie aktywne wywołania, uruchamia destruktory uchwytów sesji, usuwa powiązania plików pamięci współdzielonej i kończy działanie w sposób czysty.
Szybki start: Wywoływanie wygenerowanej usługi
Dzięki pakietom hqq-bindgen i launch, wywoływanie izolowanego procesu roboczego języka C z poziomu języka Go wygląda jak użycie zwykłego, typowanego klienta Go, przy jednoczesnym zachowaniu ustawienia 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}
Struktura repozytorium
Każdy podsystem w gosuda.org/hqq jest zorganizowany w skoncentrowane pakiety o wyraźnych granicach:
1gosuda.org/hqq
2├── standard.go / ring.go # StandardLink (SPSC) oraz niskopoziomowe prymitywy pierścienia pamięci współdzielonej
3├── advanced.go / slotpool.go # Dyspozytor Advanced (MPMC), pula gniazd oraz tabela zakończeń
4├── shm_*.go / wait_*.go # Specyficzne dla systemu operacyjnego mmap/CreateFileMapping oraz futex/ulock/WaitOnAddress
5├── c/ # Biblioteka transportowa C11 (hqq.c) i środowisko wykonawcze serwera RPC (hqq_rpc.c)
6├── bindgen/ # Parser IDL hqq-bindgen, weryfikator (V1-V41) i generator kodu Go/C11
7│ └── rt/ # Klient Go o zerowej alokacji dla wygenerowanych usług
8├── secureexec/ # Weryfikacja manifestu SHA-256 / Ed25519 oraz wykonanie zabezpieczone przez memfd
9├── launch/ # Nadzorca procesu roboczego z zarządzaniem potokiem ratunkowym --parent-stdin
10├── cmd/hqq-manifest/ # Narzędzie CLI do generowania i podpisywania manifestów binarnych wydania
11└── spec/ # Wykonywalne specyfikacje protokołu sieciowego i wzorcowe wektory testowe (BINDGEN.md)
Aby zobaczyć, w jaki sposób HQQ napędza gotowe zamienniki produkcyjne dla dwóch najszerzej używanych bibliotek mechanizmu cgo w ekosystemie języka Go, sprawdź powiązane pakiety zbudowane na tym stosie:
gosuda.org/sqlite3: Gotowy sterownikdatabase/sqlkompatybilny zgithub.com/mattn/go-sqlite3, uruchamiający natywną bibliotekę C SQLite3.54.1w pamięci współdzielonej z potokowym buforowaniem wstępnym wsadowym.gosuda.org/zstd: Bezpośredni zamiennik dlagithub.com/DataDog/zstd, uruchamiający natywną bibliotekę Clibzstd 1.5.7w izolowanym procesie roboczym pamięci współdzielonej z kontekstami bez alokacji i przetwarzaniem masowym.- Repozytorium GitHub: https://github.com/gosuda/HQQ