Go上級 #5 unsafe と cgo — 安全領域の外へ

読了 6分

#4 reflectでランタイムに型を扱うツールを見たなら — 今回は型システムの外側へ一歩出る2つのツール。unsafecgo

この記事の目的は — 2つが存在する理由いつ登場するのかを知ること。直接使う機会はほぼありませんが、ライブラリの内側でたまに見かけます。

unsafe.Pointer — Go の void* #

Goは一般に — ポインタ算術や任意の型変換を防ぎます。*int*float64に変えられず、ポインタに1を足して次のメモリへ行くこともできません。

unsafe.Pointerは — その安全装置を回避するツール。

基本
import "unsafe"

x := int32(42)
p := unsafe.Pointer(&x)        // *int32 → unsafe.Pointer
y := (*float32)(p)              // unsafe.Pointer → *float32
fmt.Println(*y)                  // 32-bit メモリを float32 として解釈

Cのvoid*に近い位置づけ。型情報なしでポインタだけ

unsafe の 4 つのルール #

Goが公式に有効と保証する変換は4つのパターン:

1) *T1*T2変換 #

var x int64 = 0x0102030405060708
p := (*[8]byte)(unsafe.Pointer(&x))    // int64 メモリを byte 配列として

同じメモリ領域を別の型として解釈。ただし、アライメントとサイズが互換でなければなりません。

2) unsafe.Pointeruintptr #

addr := uintptr(unsafe.Pointer(&x))
fmt.Printf("%x\n", addr)

アドレスを整数に。ただし — uintptrはGCが追跡しないので短時間だけ使う。長く保持するとオブジェクトがGCされてダングリング。

3) struct のフィールドアドレス — unsafe.Offsetof #

フィールドオフセット
type S struct {
	A int32
	B int32
}

s := S{}
addrA := unsafe.Pointer(&s)
addrB := unsafe.Pointer(uintptr(addrA) + unsafe.Offsetof(s.B))

ほぼ — (&s).Bと同じ結果ですが、動的な場面(reflectに似た)で使われます。

4) unsafe.Slice / unsafe.String(Go 1.17+、1.20+) #

ポインタから slice を作る
arr := [5]int{1, 2, 3, 4, 5}
s := unsafe.Slice(&arr[0], 5)    // []int{1,2,3,4,5}

bytes := []byte("hello")
str := unsafe.String(&bytes[0], len(bytes))    // コピーなしで string として解釈

Cインターフェースやzero-copy変換に似合います。

unsafe が正当な場面 #

  • byte slice ↔ string変換(GCコストを避けたいとき)
  • structのメモリレイアウトを調査するデバッグツール
  • Cとデータをやり取りするとき(cgoと一緒に)
  • 非常にホットな場面で — reflectより速いシリアライゼーションライブラリ(例: easyjsonのような一部の実装)
byte→string コピーなし
func bytesToString(b []byte) string {
	return unsafe.String(unsafe.SliceData(b), len(b))
}

stringがimmutableである点を回避 — 危険ですが非常に速い。ライブラリの内側でだけ、それも十分なテストを経たコードだけで見られるパターンです。

unsafe が壊れる場面 #

よく出会う罠
// uintptr だけ持っていて unsafe.Pointer は失う
addr := uintptr(unsafe.Pointer(p))
runtime.GC()                        // p が GC されうる
ptr := unsafe.Pointer(addr)          // ✗ すでに死んだアドレスかもしれない

uintptr数値として扱われGC追跡されません。常にunsafe.Pointerの形で保持していなければ安全ではありません。

go vet -unsafeptr — 検査ツール #

go vetが — 上の4パターンに該当しないunsafe使用を一部検知してくれます。unsafeを使うなら常にvet通過を確認。

go vet ./...

cgo — Go と C の橋 #

GoコードからC関数を呼べるようにするツール。標準パッケージではなく — Goコンパイラのビルドシステムの一部です。

cgo 基本
package main

/*
#include <stdio.h>

void greet() {
	printf("Hello from C\n");
}
*/
import "C"

func main() {
	C.greet()
}

中核は2つ:

  • コメントの中にCコード — コンパイラがCコンパイラでビルド
  • import "C" — マジックインポート、他のimportと同じ行に置けない

データのやり取り #

文字列の受け渡し
import "C"

func cstring(s string) *C.char {
	return C.CString(s)    // Go string → C string (コピー)
}

func gostring(p *C.char) string {
	return C.GoString(p)   // C string → Go string (コピー)
}

ほぼすべての境界の通過は — メモリコピー。GoのGCとCのメモリ管理は分かれており、同じメモリを安全に共有するのは難しいです。

cgo のコスト #

呼び出し1回に — 通常数百ナノ秒のオーバーヘッド。

  • goroutineスケジューラを一時的に離れる — C関数が終わるまでOSスレッドを占有
  • メモリコピー — 文字列、スライスすべて
  • ビルド環境が複雑 — Cコンパイラ必要、クロスコンパイルが難しい
  • race detector / pprofとあまり相性が良くない
おおよそのコスト
通常の Go 関数呼び出し:    ~1 ns
cgo 呼び出し:              ~100-300 ns

ホットループでcgoを使うと — Goランタイムのスケジューリングが壊れます。大きな仕事を一度にcgoで渡す形式が良いです(ループをCの内側に)。

cgo が似合う場面 #

  • 既存のCライブラリの使用が本当に必要なとき(例: 特定のSDK、ネイティブバインディング)
  • OSシステムコールの直接使用
  • 性能が決定的で — 純粋なGo実装がない場合

ほとんどの場面は — 純粋なGo実装またはネットワークIPCのほうがすっきりします。cgoは本当に他の道がないとき。

cgo 事例 — SQLite #

mattn/go-sqlite3 — Goで最も有名なcgo依存ライブラリ。SQLiteはCライブラリで、Goで書き直すコストが大きすぎてcgoでまとめました。

代替として — 純粋なGo実装(modernc.org/sqlite)もありますが、自動変換されたコードなので互換性のトレードオフがあります。

cgo の GOEXPERIMENT — purego #

最近では — cgoなしで動的ライブラリを呼ぶ道も生まれています(puregoのようなライブラリ)。ただしすべてのケースをカバーできず — 依然としてcgoが標準です。

unsafe と cgo の重なり #

cgoから受け取った*C.charのようなポインタは — 通常unsafe.Pointerに変換して使われます。2つのツールがしばしば一緒に登場。

cgo + unsafe
buf := C.malloc(C.size_t(100))
defer C.free(buf)

slice := unsafe.Slice((*byte)(buf), 100)    // C メモリを Go slice として見る

Cのmalloc/freeは — Go GCが知らないメモリ。明示的にfreeしなければなりません。

「使わないのが正解」である理由 #

コスト影響
ビルド環境が複雑クロスコンパイルが難しくなる、CIが重くなる
デバッグが難しいrace detector、pprofが正常に動かない
メモリ安全性Goの大きな利点であるGC安全性を失う
可読性他人が読みにくい

“cgo is not Go.” — Go proverbs

標準ライブラリも — cgo依存がある場面(例: 一部のシステムコール)を純粋なGoに書き直す作業を進めています。Goチームの方向性もcgo回避。

決定木 #

unsafe / cgoを検討する前に — まず以下を確認:

  1. 純粋なGoライブラリがあるか? → あればそれ
  2. 標準ライブラリで解けるか? → 解ければそれ
  3. ネットワークIPCで分離できるか? → 可能なら別プロセス
  4. それでも駄目なら → cgo(またはunsafe)

まとめ #

今回の記事で整理した内容:

  • unsafe.Pointer — 型システム回避、Cのvoid*に類似
  • 4つの正当なパターン — 同じサイズ/アライメント変換、uintptr一時変換、Offsetof、Slice/String
  • uintptrはGC追跡されない — 常にunsafe.Pointerで保持
  • cgo — GoからC関数呼び出し、呼び出しあたり数百ns
  • ビルド環境が複雑、デバッグが難しい — 回避が基本
  • 決定木 — 純粋Go → 標準 → IPC → 次にcgo

次の記事(#6 プロファイリング)では — Goの標準ツールで性能を計測して改善する方法。pprofとbenchmark、race detectorまで整理します。

X