cgoとunsafeについてのメモ

背景

まず大前提として cgo や unsafe を使ったプログラムは Go の将来のバージョンで動く保証がないので極力避けるべきです(unsafeについてはGo 1 and the Future of Go Programs - The Go Programming Languageで明示的に互換性保証の対象外と書かれています。cgo は Go 1.12 Release Notes - The Go Programming Language に変更された実例があります)。

が、現実には cgo や unsafe を使いたいケースがあります。

一番の理由は C で書かれた資産が既にあってそれを使いたい場合です。安全性や保守性では Go に移植するほうが望ましいですが、難しかったり性能が出ない場合もあります。例えば VictoriaMetricsall: use gozstd instead of pure Go zstd for GOARCH=amd64 というコミットでは pure go の zstd を cgo の zstd に切り替えています。

また、別の理由として省メモリなデータ構造を作りたいというのがあります。 Go の slice や interface のサイズが気になったので以下のコードで調べてみました。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    fmt.Printf("slice          size=%d\n", unsafe.Sizeof([]byte{}))
    var a interface{}
    fmt.Printf("interface      size=%d\n", unsafe.Sizeof(a))
    var p unsafe.Pointer
    fmt.Printf("unsafe.Pointer size=%d\n", unsafe.Sizeof(p))
}

amd64 の Linux 環境では以下のような結果になりました。

$ go run main.go
slice          size=24
interface      size=16
unsafe.Pointer size=8

slice は長さ (len) とキャパシテイ (cap) とポインタがそれぞれ 8 バイトで計 24 バイト、 interface は型の種別とポインタがそれぞれ 8 バイトで計 16 バイトということだと思います。

C 言語だと union や Tagged pointer - Wikipedia のような手法を使えるので差が開きます。

CPU のキャッシュラインに載るようなサイズのデータ構造を設計するといった文脈では このサイズのハンディキャップは気になるところです。

と思っていた時に VictoriaMetrics/fastcachemalloc_mmap.go のコードを見ると GC を介さずにメモリを割り当てるために syscall.Mmap を使っていました(mmap はファイルをメモリアドレス空間にマップするのが本来の使い方ですがファイルにマップしない使い方も出来ます)。

なるほどこういう手もあるのかと感心しました。で、この機会に cgo と unsafe についてまとめておこうとこの記事を書きました。

cgo

go build --ldflags '-extldflags "-static"' file.go

unsafe

malloc

確実にメモリのアドレスが GC によって移動されないようにするには mmap か malloc を使う手がある。

#include <stdlib.h>C.malloc では不要だが C.free を使う場合には必要。

C.mallocnil を返さないと保証されているのは cgo - The Go Programming Language の最後に記載があった。なので C.malloc の戻り値の nil チェックは不要。

malloc と free を使うサンプルコード。

// +build cgo
package main

// #include <stdlib.h>
import "C"

import (
    "fmt"
    "log"
)

func main() {
    if err := run(); err != nil {
        log.Fatal(err)
    }
}

func run() error {
    data := C.malloc(8)
    defer C.free(data)

    *(*int64)(data) = 1234
    a := *(*int64)(data)
    fmt.Printf("a=%d\n", a)
    return nil
}

スタティックビルド。

$ go build --ldflags '-extldflags "-static"' -o main

スタティックバイナリになったことを確認。

$ ldd main
        not a dynamic executable

C.malloc を使う場合は細切れに割り当てるのではなく上記の malloc_mmap.go のように Region-based memory management - Wikipedia 方式が良さそうです。 手動メモリ管理の手間をなるべく減らすのとメモリ断片化を防ぐ意味で。

ただ GC の負荷が許容範囲内なら make[]byte をメモリ割り当てして unsafe.Pointer で参照するほうがメモリ管理を GC に任せられるので良いです。