HQQ: 캐시 라인 공유 메모리를 통한 Go와 C 라이브러리의 격리

gosuda.org/hqq는 zero-cgo 프로세스 간 통신(IPC) 런타임이자 Go 및 C11용 코드 생성 툴체인입니다. 이 프로젝트는 네이티브 C 라이브러리를 별도의 OS 워커 프로세스 내부에 격리하고, 메모리 매핑되고 캐시 라인에 정렬된 공유 메모리 링(ring)을 통해 이를 Go 호출자와 연결합니다. 이 프로젝트는 두 가지 전송 계층(단일 생산자 단일 소비자 전용 세션인 StandardLink와 다중 워커 MPMC 디스패치인 Advanced), hqq-bindgen IDL 컴파일러, 그리고 변조 검증을 수행하는 프로세스 감독기(secureexec 및 launch)를 결합합니다.
1go get gosuda.org/hqq
C와 주소 공간을 공유하는 데 따르는 비용
모든 Go 시스템 엔지니어는 결국 동일한 갈림길에 직면하게 됩니다. 여러분은 CGO_ENABLED=0으로 몇 초 만에 컴파일되고, 단일 정적 바이너리로 배포되며, sysroot 없이도 노트북에서 Linux 또는 Windows 대상으로 교차 컴파일할 수 있고, 메모리 안전성이 보장된 가비지 컬렉션 힙 안에서 실행되는 Go 서비스를 구축하고 있습니다. 그런데 SQLite(임베디드 관계형 저장소), 사전 기반 압축을 위한 Zstandard(libzstd), 또는 하드웨어 가속 미디어 또는 암호화 라이브러리와 같은 성숙한 C 라이브러리가 필요해집니다.
cgo를 활성화하여 해당 C 라이브러리를 Go 프로세스에 링크하면 당장의 바인딩 문제는 해결되지만, 세 가지 구조적 부담이 발생합니다.
- 툴체인 및 교차 컴파일 결합:
CGO_ENABLED=1은 모든 대상 OS 및 아키텍처마다 C 컴파일러, 대상 libc 헤더, 플랫폼 링커를 요구합니다.darwin/arm64워크스테이션에서linux/arm64또는windows/arm64아티팩트를 빌드하려면 단일go build명령이 교차 컴파일러와 sysroot의 매트릭스로 바뀝니다. - 런타임 스케줄링 오버헤드: 모든
cgo호출은 Go 런타임 경계를 넘고, 확장 가능한 고루틴 스택에서 시스템 스택으로 전환하며, OS 스레드(M)를 고정합니다. 세밀한 호출의 경우 전환 비용(호출당 약 50~150 ns)과 스케줄러 경합이 빠르게 누적됩니다. - 장애 도메인 붕괴: C 코드는 Go 런타임과 동일한 가상 주소 공간에서 실행됩니다. C 함수 내부의 단일 범위 외 쓰기, 해제 후 사용(use-after-free), 스택 오버플로, 또는
abort()는 Go 힙 메타데이터를 손상시키거나 전체 서버 프로세스를 즉시 종료시키며,recover()를 우회하여 진행 중인 모든 HTTP 또는 RPC 요청을 함께 중단시킵니다.
팀이 cgo를 피하려고 할 때 흔히 선택하는 대안은 다른 종류의 절충을 강요합니다. C 코드베이스를 순수 Go로 기계적으로 변환하면 CGO_ENABLED=0은 유지되지만, 상류 C 릴리스를 따라가지 못하는 경우가 많고, 에뮬레이션된 가상 메모리 계층을 포함하며, 부하 상황에서 메모리를 대량으로 할당합니다. C 라이브러리를 루프백 TCP 또는 Unix 도메인 소켓을 통해 헬퍼 프로세스로 옮기면 장애 격리는 회복되지만, 모든 호출이 두 번의 커널 컨텍스트 전환, 소켓 버퍼 복사, 와이어 직렬화 비용을 지불해야 합니다.
우리는 하나의 직접적인 질문을 던진 뒤 HQQ를 만들었습니다. C 라이브러리를 자체 가상 주소 공간을 가진 별도의 OS 프로세스에 두고, Go와 C가 고속 경로에서 커널에 진입하지 않고도 CPU 캐시 라인을 통해 프레임을 직접 교환할 수 있다면 어떨까?
| 통합 모델 | CGO_ENABLED=0 | 장애 격리 | 고속 경로 시스템 콜 | 호출당 힙 할당 | 상류 C11 충실도 |
|---|---|---|---|---|---|
cgo 인프로세스 링크 | 아니오 | 없음 (공유 힙) | 0 | 0 ~ 1+ | 정확한 상류 C |
| 소켓 / 파이프 서브프로세스 RPC | 예 | 프로세스 경계 | 2 ~ 4 | 다수 (프레이밍 + I/O) | 정확한 상류 C |
| 기계적 C-to-Go 변환 | 예 | 프로세스 내 Go | 0 | 복잡한 호출에서 높음 | 에뮬레이션된 C 런타임 |
| HQQ 공유 메모리 IPC | 예 | 프로세스 경계 | 0 (적응형 스핀) | 0 (StandardLink) | 정확한 상류 C |
아키텍처 개요: 프로세스 경계를 넘나드는 네 개의 계층
HQQ는 공유 메모리 링 메커니즘, 타입화된 RPC 코드 생성, 바이너리 무결성 검증, 자식 프로세스 감독이 서로 독립적으로 유지되도록 프로세스 격리를 조합 가능한 네 개의 계층으로 분리합니다.
1+-------------------------------------------------------------------+
2| Go Application Process (CGO_ENABLED=0) |
3| |
4| +-----------------------+ +-------------------------------+ |
5| | Generated Go Client | | Process Supervisor | |
6| | (gosuda.org/hqq/ | | (gosuda.org/hqq/launch + | |
7| | bindgen/rt) | | gosuda.org/hqq/secureexec) | |
8| +-----------+-----------+ +---------------+---------------+ |
9| | | |
10| +-----------v-----------+ | Anonymous Pipe |
11| | Transport Tier | | Lifeline |
12| | (StandardLink / | | (--parent-stdin) |
13| | Advanced MPMC) | | |
14+--------------+---------------------------------+------------------+
15 | |
16===============|========= Shared Memory =========|===================
17 | (mmap / CreateFileMapping) |
18+--------------v---------------------------------v------------------+
19| C11 Worker Process (Isolated Address Space) |
20| |
21| +-----------------------+ +-------------------------------+ |
22| | Generated C11 Server |---->| Generation-Checked Handle | |
23| | & Batch Shims | | Table (hqq_rpc.c) | |
24| +-----------+-----------+ +---------------+---------------+ |
25| | | |
26| +----------------+----------------+ |
27| | |
28| +---------v---------+ |
29| | Native C Library | |
30| | (SQLite, Zstd...) | |
31| +-------------------+ |
32+-------------------------------------------------------------------+
1. 공유 메모리 전송 (gosuda.org/hqq 및 c/)
기본 패키지 gosuda.org/hqq와 그 C11 대응 구현인 c/는 POSIX mmap(Linux 및 macOS)과 Win32 CreateFileMappingW(Windows)를 기반으로 두 가지 공유 메모리 전송 모드를 구현합니다. 모든 공유 세그먼트는 링 인덱스를 읽기 전에 프로토콜 버전, 버퍼 형상, 엔드포인트 PID를 고정하는 검증된 HQQ_STL3 슈퍼블록으로 시작합니다.
모든 핫 제어 워드와 슬롯 헤더는 64바이트 캐시 라인 경계에 정렬되어, 생산자와 소비자 코어가 거짓 공유(false sharing)를 통해 동일한 캐시 라인에서 경합하지 않습니다.
1StandardLink Slot Layout (64-Byte Cache-Line Aligned)
2Byte Offset: 0x00 0x08 0x10 0x40
3 +-------------------+-------------------+-----------------------------+
4Header Line: | seq (u64 atomic) | len (u32) | flags | reserved (zero-padded) |
5 +-------------------+-------------------+-----------------------------+
6Payload Area: | payload bytes [0 .. BufferSize - 1] (directly encoded / decoded) |
7 +---------------------------------------------------------------------+
| 필드 | 오프셋 | 크기 | 의미 |
|---|---|---|---|
seq | 0x00 | 8 B | 단조 증가 시퀀스 카운터. 홀수 값은 진행 중인 쓰기를 나타내고, 짝수 값은 store-release를 통해 커밋된 프레임을 게시합니다. |
len | 0x08 | 4 B | 슬롯에 기록된 페이로드 바이트 길이(0 <= len <= BufferSize). |
flags | 0x0C | 4 B | 프레임 제어 비트(연속, 오류 상태, 또는 종료 알림). |
payload | 0x40 | BufferSize | 중간 복사 없이 제자리에서 읽고 쓰는 연속 페이로드 영역. |
호출자는 동시성 구조에 따라 두 가지 전송 계층 중 하나를 선택합니다.
StandardLink(1:1 SPSC 세션): 시퀀스 락(SeqLock)으로 보호되는 전용 요청 및 응답 슬롯을 통해 클라이언트와 서버 세션을 짝지습니다. 고속 경로에서 쓰기 측은 매핑된 슬롯에 직접 직렬화하고 원자적 store-release로 커밋합니다. 읽기 측은 OS 대기 프리미티브(Linux의futex, macOS의__ulock_wait, Windows의WaitOnAddress)로 되돌아가기 전에 사용자 공간에서 짧게 스핀합니다. 단일 프레임 왕복은0 allocs/op로 약 200 ns에 완료됩니다.Advanced(MPMC 디스패치 링): 락-프리 다중 생산자 다중 소비자 제출 링, 공유 슬롯 풀, 인덱스 방식 완료 테이블을 사용하여M개의 동시 Go 호출자를N개의 C 워커 스레드에 다중화합니다. 호출자는 슬롯을 예약하고, 슬롯 인덱스를 링에 게시한 뒤, 완료 항목에서 대기하며, 워커 스레드는 요청을 비순차적으로 가져와 완료합니다.
두 계층 모두 피어 생존 상태를 지속적으로 추적합니다. 각 엔드포인트는 Heartbeat()를 통해 원자적 단조 타임스탬프를 갱신하고, 대기 중에는 PeerAlive(timeout)을 확인합니다. 워커 프로세스가 호출 도중 충돌하면 피어는 오래된 하트비트나 종료된 PID를 즉시 감지하고, 차단된 대기를 ErrPeerGone으로 중단시키며, 공유 메모리 파일을 남기지 않고 원자적 세션 Takeover를 지원합니다.
2. 제로 할당 RPC 및 세대 검사 핸들 (gosuda.org/hqq/bindgen)
수십 개의 함수를 가진 C 라이브러리를 위해 공유 메모리 프레이밍을 손으로 작성하는 것은 지루하고 오류가 발생하기 쉽습니다. hqq-bindgen은 Interface Definition Language(.idl) 파일을 읽거나(또는 hqq-bindgen init을 통해 C .h 헤더로부터 직접 부트스트랩하여) zero-cgo Go 클라이언트 패키지와 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}
와이어 상에서 모든 핸들은 32비트 테이블 인덱스와 32비트 0이 아닌 세대 카운터를 묶은 64비트 정수입니다: (uint64_t)generation << 32 | index. C 런타임(c/src/hqq_rpc.c)은 세 가지 불변 조건을 유지하는 세션별 핸들 테이블을 관리합니다.
- 세대 검증: 모든 조회는 슬롯이 점유되어 있는지, 저장된 세대가 핸들의 상위 32비트와 일치하는지, 그리고 핸들 타입 태그가 매개변수 타입(
Db대Stmt)과 일치하는지 확인합니다. 닫힌 핸들을 재사용하면 C 라이브러리를 호출하기 전에HQQRPC_E_BAD_HANDLE로 즉시 실패합니다. - 계층적 소유권(
@owned_by):Stmt에@owned_by(Db)가 주석으로 달려 있으므로,Db핸들을 닫으면 해당Db가 소유한 살아 있는 모든 자식Stmt핸들을 자동으로 순회하고, 할당의 역순으로 소멸자를 실행합니다(sqlite3_close_v2이전에sqlite3_finalize). - 세션 종료 스윕: Go 클라이언트가 핸들을 닫지 않은 채 연결을 끊거나 충돌하면, C 서버의 세션 소멸자가 세션 핸들 테이블 전체를 역순으로 스윕하여 클라이언트 재시작 간에 C 할당이 누수되지 않음을 보장합니다.
세션이 첫 번째 RPC 호출을 처리하기 전에, 클라이언트와 서버는 정규화된 IDL(HQQ-BINDGEN-IDL-V1\n 접두사 포함)에 대해 계산된 32바이트 SHA-256 인터페이스 다이제스트를 교환합니다. Go 클라이언트와 C 워커가 호환되지 않는 IDL 스키마로부터 컴파일되었다면, 핸드셰이크는 부트스트랩 단계에서 연결을 거부합니다.
3. 변조 방지 바이너리 실행 (gosuda.org/hqq/secureexec)
C 코드를 워커 바이너리로 옮기면 공급망 문제가 제기됩니다. Go 프로세스는 검증과 실행 사이에 디스크상의 워커 바이너리가 수정되거나 교체되지 않았음을 어떻게 알 수 있을까요?
파일 경로에 대해 crypto/sha256을 호출한 뒤 os/exec.Command(path).Start()를 호출하면, 커널이 파일을 실행하기 전에 다른 프로세스가 파일을 덮어쓰거나 교체할 수 있는 검사 시점과 사용 시점 간(TOCTOU) 간극이 남습니다. gosuda.org/hqq/secureexec는 이 간극을 차단합니다.
- Linux에서
secureexec는 바이너리를 열고, SHA-256 해시를 계산하면서 그 내용을memfd_create(MFD_CLOEXEC | MFD_ALLOW_SEALING)으로 생성된 익명 메모리 파일 디스크립터에 스트리밍합니다. 해시를 고정된 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 플래그와 함께 워커의 표준 입력으로 전달되는 익명 OS 파이프를 통해 수명 결합을 강제합니다. Go 부모는 파이프의 쓰기 끝을 보유하며 절대 그곳에 쓰지 않고, C 워커는 전용 생명선 스레드에서 읽기 끝을 감시합니다. Go 프로세스가 정상적으로 종료하거나, 패닉을 일으키거나, OS 커널에 의해 종료(SIGKILL 또는 OOM 킬러)되면, 커널은 즉시 쓰기 디스크립터를 닫습니다. C 워커는 마이크로초 이내에 파이프에서 EOF를 수신하고, 활성 호출을 중단하며, 핸들 소멸자를 실행하고, 공유 메모리 파일의 링크를 해제한 뒤 깔끔하게 종료합니다.
빠른 시작: 생성된 서비스 호출하기
hqq-bindgen과 launch를 사용하면, 격리된 C 워커를 Go에서 호출하는 작업은 CGO_ENABLED=0을 유지하면서도 일반적인 타입화된 Go 클라이언트처럼 보입니다.
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}
저장소 구조
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 # OS별 mmap/CreateFileMapping 및 futex/ulock/WaitOnAddress
5├── c/ # C11 전송 라이브러리(hqq.c) 및 RPC 서버 런타임(hqq_rpc.c)
6├── bindgen/ # hqq-bindgen IDL 파서, 검증기(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가 Go 생태계에서 가장 널리 사용되는 두 cgo 라이브러리를 위한 즉시 적용 가능한 프로덕션 대체재를 어떻게 구동하는지 보려면, 이 스택 위에 구축된 동반 패키지를 확인하십시오.
gosuda.org/sqlite3:github.com/mattn/go-sqlite3와 호환되는 드롭인database/sql드라이버로, 파이프라인 방식 배치 프리페칭과 함께 공유 메모리를 통해 네이티브 C SQLite3.54.1을 실행합니다.gosuda.org/zstd:github.com/DataDog/zstd의 드롭인 대체재로, 제로 할당 컨텍스트와 대량 배칭을 갖춘 격리된 공유 메모리 워커에서 네이티브 Clibzstd 1.5.7을 실행합니다.- GitHub 저장소: https://github.com/gosuda/HQQ