Go上級 #5 unsafe と cgo — 安全領域の外へ
#4 reflectでランタイムに型を扱うツールを見たなら — 今回は型システムの外側へ一歩出る2つのツール。unsafeとcgo。
この記事の目的は — 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.Pointer ↔ uintptr
#
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+)
#
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のような一部の実装)
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コンパイラのビルドシステムの一部です。
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つのツールがしばしば一緒に登場。
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を検討する前に — まず以下を確認:
- 純粋なGoライブラリがあるか? → あればそれ
- 標準ライブラリで解けるか? → 解ければそれ
- ネットワークIPCで分離できるか? → 可能なら別プロセス
- それでも駄目なら → 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まで整理します。