GoSuda

HQQ: 캐시 라인 공유 메모리를 활용한 Go 언어로부터의 C 라이브러리 격리

By Lemon Mint
views ...

gosuda.org/hqq는 Go와 C11을 위한 zero-cgo 프로세스 간 통신 런타임이자 코드 생성 툴체인입니다. 이는 네이티브 C 라이브러리를 별도의 OS 워커 프로세스 내부에 격리하고, 메모리 맵핑되며 캐시 라인에 정렬된 공유 메모리 링을 통해 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 라이브러리가 필요해집니다.

C 라이브러리를 Go 프로세스에 링크하기 위해 cgo를 활성화하는 것은 즉각적인 바인딩 문제를 해결하는 동시에 세 가지 구조적 부채를 유발합니다.

  1. 툴체인 및 교차 컴파일 결합: CGO_ENABLED=1은 모든 타깃 OS 및 아키텍처에 대해 C 컴파일러, 타깃 libc 헤더, 그리고 플랫폼 링커를 요구합니다. darwin/arm64 워크스테이션에서 linux/arm64 또는 windows/arm64 아티팩트를 빌드하는 것은 단일 go build 명령어를 교차 컴파일러와 sysroot의 매트릭스로 변환합니다.
  2. 런타임 스케줄링 오버헤드: 모든 cgo 호출은 Go 런타임 경계를 넘나들며, 확장 가능한 고루틴 스택에서 시스템 스택으로 전환하고, OS 스레드(M)를 고정합니다. 세밀한 호출의 경우, 전환 비용(호출당 약 50~150 ns)과 스케줄러 경합이 빠르게 누적됩니다.
  3. 결함 도메인 붕괴: C 코드는 Go 런타임과 동일한 가상 주소 공간에서 실행됩니다. C 함수 내부의 단일 범위를 벗어난 쓰기(out-of-bounds write), 해제된 메모리 사용(use-after-free), 스택 오버플로우, 또는 abort()는 Go 힙 메타데이터를 손상시키거나 전체 서버 프로세스를 즉시 종료시키며, recover()를 우회하고 진행 중인 모든 HTTP 또는 RPC 요청을 함께 중단시킵니다.

팀들이 cgo를 피하려고 할 때, 일반적인 대안들은 다른 타협을 강요합니다. C 코드베이스의 순수 Go 기계적 번역은 CGO_ENABLED=0을 유지하지만, 종종 업스트림 C 릴리스에 뒤처지고, 에뮬레이트된 가상 메모리 레이어를 유지하며, 부하 시 무거운 할당을 발생시킵니다. 루프백 TCP 또는 Unix 도메인 소켓을 통해 C 라이브러리를 도우미 프로세스로 이동하면 결함 격리가 복원되지만, 모든 호출은 두 번의 커널 컨텍스트 스위치, 소켓 버퍼 복사, 그리고 와이어 직렬화에 대한 대가를 치르게 됩니다.

우리는 직접적인 질문을 던진 후에 HQQ를 구축했습니다. 만약 C 라이브러리가 자체적인 가상 주소 공간을 가진 별도의 OS 프로세스에 거주하면서, Go와 C가 고속 경로에서 커널에 진입하지 않고 공유 CPU 캐시 라인을 통해 프레임을 직접 교환한다면 어떻게 될까?

통합 모델CGO_ENABLED=0결함 격리고속 경로 시스템 콜호출당 힙 할당업스트림 C11 충실도
cgo 인프로세스 링크아니오없음 (공유 힙)00 ~ 1+정확한 업스트림 C
소켓 / 파이프 서브프로세스 RPC예프로세스 경계2 ~ 4다중 (프레이밍 + I/O)정확한 업스트림 C
기계적 C-to-Go 번역예인프로세스 Go0복잡한 호출 시 높음에뮬레이트된 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와 c/의 C11 대응 부품은 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              +---------------------------------------------------------------------+
필드오프셋크기의미
seq0x008 B단조 증가하는 시퀀스 카운터. 홀수 값은 진행 중인 쓰기를 나타내며, 짝수 값은 스토어-릴리즈(store-release)를 통해 커밋된 프레임을 발행합니다.
len0x084 B슬롯에 기록된 페이로드 바이트 길이 (0 <= len <= BufferSize).
flags0x0C4 B프레임 제어 비트 (연속, 오류 상태, 또는 종료 통지).
payload0x40BufferSize중간 복사 없이 제자리에서 읽고 쓰는 연속적인 페이로드 영역.

호출자는 동시성 구조에 따라 두 가지 전송 계층 중에서 선택합니다.

  • StandardLink (1:1 SPSC 세션): 시퀀스 록(SeqLock)으로 보호되는 전용 요청 및 응답 슬롯을 통해 클라이언트와 서버 세션을 쌍으로 연결합니다. 고속 경로에서 작성자는 매핑된 슬롯에 직접 직렬화하고 원자적 스토어-릴리즈로 커밋하며, 독자는 OS 대기 기본 원시(Linux의 futex, macOS의 __ulock_wait, Windows의 WaitOnAddress)로 폴백하기 전에 사용자 공간에서 잠시 스핀합니다. 단일 프레임 왕복은 0 allocs/op로 약 200 ns 만에 완료됩니다.
  • Advanced (MPMC 디스패치 링): 락 프리(lock-free) 다중 생산자 다중 소비자 제출 링, 공유 슬롯 풀, 인덱싱된 완료 테이블을 사용하여 M개의 동시 Go 호출자를 N개의 C 워커 스레드에 다중화합니다. 호출자는 슬롯을 예약하고, 슬롯 인덱스를 링에 게시하며, 워커 스레드가 순서에 상관없이 요청을 디큐하고 완료하는 동안 자신의 완료 항목에서 대기합니다.

두 계층 모두 피어의 생존 상태를 지속적으로 추적합니다. 각 엔드포인트는 Heartbeat()를 통해 원자적 단조 타임스탬프를 업데이트하고 대기하는 동안 PeerAlive(timeout)를 검사합니다. 워커 프로세스가 호출 중간에 충돌하는 경우, 피어는 오래된 하트비트 또는 죽은 PID를 즉시 감지하고, ErrPeerGone으로 차단된 대기를 중단하며, 웨지(wedged)된 공유 메모리 파일을 남기지 않고 원자적 세션 Takeover를 지원합니다.

2. 제로 할당 RPC 및 세대 검증된 핸들 (gosuda.org/hqq/bindgen)

수십 개의 함수가 있는 C 라이브러리를 위해 공유 메모리 프레이밍을 수동으로 작성하는 것은 지루하고 오류가 발생하기 쉽습니다. hqq-bindgen은 인터페이스 정의 언어(.idl) 파일을 읽거나(hqq-bindgen init을 통해 C .h 헤더에서 직접 부트스트랩함) C11 서버 스켈레톤과 함께 제로 cgo Go 클라이언트 패키지를 발행하는 스키마 컴파일러입니다.

모든 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)은 세 가지 불변성을 가진 세션별 핸들 테이블을 유지합니다.

  1. 세대 검증: 모든 조회는 슬롯이 점유되어 있는지, 저장된 세대가 핸들의 상위 32비트와 일치하는지, 그리고 핸들 타입 태그가 파라미터 타입(Db 대 Stmt)과 일치하는지 검증합니다. 닫힌 핸들을 재사용하면 C 라이브러리를 호출하기 전에 HQQRPC_E_BAD_HANDLE과 함께 즉시 실패합니다.
  2. 계층적 소유권 (@owned_by): Stmt가 @owned_by(Db)로 주석 처리되어 있으므로, Db 핸들을 닫으면 해당 Db가 소유한 모든 활성 자식 Stmt 핸들을 자동으로 순회하여 역할당 순서(sqlite3_close_v2보다 먼저 sqlite3_finalize)로 소멸자를 실행합니다.
  3. 세션 해제 스윕: 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 코드 generator
 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: 파이프라인화된 배치 프리페칭을 통해 공유 메모리 상에서 네이티브 C SQLite 3.54.1을 실행하며 github.com/mattn/go-sqlite3와 호환되는 드롭인 database/sql 드라이버.
  • gosuda.org/zstd: 제로 할당 컨텍스트와 대량 일괄 처리를 통해 격리된 공유 메모리 워커에서 네이티브 C libzstd 1.5.7을 실행하는 github.com/DataDog/zstd의 드롭인 대체품.
  • GitHub 리포지토리: https://github.com/gosuda/HQQ