ข้ามไปยังเนื้อหา

Cgo และ FFI

ทุกบทก่อนหน้าในโมดูลนี้อยู่ภายใน Go runtime ทั้งหมด: memory model ปกครอง goroutine ของ Go, scheduler ย้าย goroutine ของ Go, garbage collector track ค่าของ Go cgo คือจุดเดียวที่โค้ด Go ก้าวออกจากทั้งสามอย่างนั้นโดยตั้งใจ — โปรแกรม Go เรียก function ของ C แล้วตลอดช่วงเวลาของ call นั้น การรับประกันของ memory model, งานบัญชีปกติของ scheduler และมุมมองของ garbage collector ต่อโลกทั้งหมดมีรอยต่ออยู่ การอ่านโค้ด cgo ให้เก่งคือการรู้ว่ารอยต่อนั้นอยู่ตรงไหนแน่ ๆ

import "C" ไม่ใช่ import จริง — มันคือ signal ให้ tool go รู้ว่าไฟล์นี้ต้องจัดการเป็นพิเศษ เมื่อ import นั้นมี comment อยู่ติดกันทันที (ไม่มีบรรทัดว่างคั่น) tool จะถือว่า comment นั้นคือ C header เรียกว่า preamble แล้ว compile มันเป็นฝั่ง C ของ package บรรทัด #cgo CFLAGS: และ #cgo LDFLAGS: ใน preamble เพิ่ม flag ให้ compiler และ linker — ถูกต่อรวมกันข้ามทุกไฟล์ cgo ใน package

package main
/*
#cgo CFLAGS: -DGREETING_LEN=5
#include <string.h>
static int greetingLen(void) {
return GREETING_LEN;
}
*/
import "C"
import "fmt"
func main() {
fmt.Println("greeting length from -D flag:", int(C.greetingLen()))
}
greeting length from -D flag: 5

ชื่อทุกตัวที่ประกาศใน preamble จะใช้ได้เป็น C.name ในโค้ด Go — function, ตัวแปร (ยกเว้นตัวที่เป็น static) และ type ที่มี prefix struct_/union_/enum_ go doc cmd/cgo คือ reference จริงของกฎการตั้งชื่อและแปลง type ทั้งหมดที่ใช้ต่อจากนี้ บทนี้ครอบคลุมเฉพาะส่วนที่เจอบ่อยตอนอ่านโค้ดจริง

int ของ C ไม่ใช่ int ของ Go — cgo เปิดให้ใช้เป็น type แยกต่างหากคือ C.int และกฎการแปลง type ที่เข้มงวดของ Go แปลว่าค่าทุกตัวที่ข้ามผ่านต้องมี C.int(...) หรือ int(...) แปลงชัดเจนเสมอ string ต้องระวังมากกว่านั้นอีก: string ของ Go ไม่มี NUL ปิดท้ายแต่ string ของ C มี ดังนั้น C.CString กับ C.GoString จึงมีไว้แปลงระหว่างสองแบบนี้โดยเฉพาะ แต่ละตัว copy ข้อมูลทั้งชุด

package main
/*
#include <stdio.h>
#include <stdlib.h>
static int add(int a, int b) {
return a + b;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
sum := C.add(C.int(3), C.int(4))
fmt.Println("sum:", int(sum))
cs := C.CString("hello from Go")
defer C.free(unsafe.Pointer(cs)) // CString allocates on the C heap -- Go's GC never sees it
C.puts(cs)
back := C.GoString(cs) // copies the C bytes into a new Go string
fmt.Println("back:", back)
}
sum: 7
hello from Go
back: hello from Go

C.CString เรียก malloc ของ C อยู่ข้างใน (go doc cmd/cgo เขียนไว้ตรง ๆ ว่า “the caller’s responsibility to arrange for it to be freed, such as by calling C.free”) memory นั้นมองไม่เห็นจาก garbage collector ของ Go เลย — มันอยู่บน C heap ตราบใดที่ยังมีอะไรจำ pointer ไว้ ไม่ว่าจะมีค่า Go ตัวไหนชี้ไปที่มันอยู่หรือไม่ก็ตาม C.free ต้องมี #include <stdlib.h> ใน preamble ตามที่ใช้ข้างบน

[]byte หรือ []int32 ที่ไม่มี pointer อยู่ข้างในเลย ส่งให้ C เป็น raw pointer ไปยัง element แรกได้ — unsafe.SliceData ให้ pointer นั้นโดยไม่ต้องเขียน &s[0] แบบเก่าที่อ่านผิดพลาดง่ายกว่า C.GoBytes copy memory ของ C กลับมาเป็น []byte ใหม่ของ Go struct ของ C กลายเป็น struct ธรรมดาของ Go ที่มีชื่อ field ตรงกัน และส่งแบบ by value เหมือน struct ทั่วไป

package main
/*
#include <stdint.h>
static void sumInto(const int32_t *data, int n, int32_t *out) {
int32_t total = 0;
for (int i = 0; i < n; i++) {
total += data[i];
}
*out = total;
}
typedef struct {
int32_t x;
int32_t y;
} Point;
static int32_t manhattan(Point p) {
int32_t ax = p.x < 0 ? -p.x : p.x;
int32_t ay = p.y < 0 ? -p.y : p.y;
return ax + ay;
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
nums := []int32{1, 2, 3, 4, 5}
var total C.int32_t
// nums holds no Go pointers, so passing its backing array to C for the
// duration of this one call is allowed under the rules in the next section.
C.sumInto((*C.int32_t)(unsafe.Pointer(unsafe.SliceData(nums))), C.int(len(nums)), &total)
fmt.Println("sum:", int32(total))
p := C.Point{x: -3, y: 4}
fmt.Println("manhattan:", int32(C.manhattan(p)))
raw := C.GoBytes(unsafe.Pointer(unsafe.SliceData(nums)), C.int(len(nums)*4))
fmt.Println("raw bytes len:", len(raw))
}
sum: 15
manhattan: 7
raw bytes len: 20

go vet บนไฟล์นี้ไม่รายงานอะไรเลย — การส่ง pointer ไปยัง backing array ของ slice ที่ไม่มี pointer อยู่ข้างใน คือสิ่งที่กฎในหัวข้อถัดไปอนุญาตพอดี

Go เป็นภาษา garbage collected — collector ต้องรู้ว่า pointer ทุกตัวที่ชี้ไป Go memory อยู่ที่ไหน go doc cmd/cgo ระบุกฎจริงไว้ใต้หัวข้อ “Passing pointers” — ยกมาตรง ๆ เพราะนี่คือข้อเท็จจริงแบบที่กฎของคอร์สนี้บอกให้ตรวจกับ tool ไม่ใช่จากความจำ:

All Go pointers passed to C must point to pinned Go memory. Go pointers passed as function arguments to C functions have the memory they point to implicitly pinned for the duration of the call. Go memory reachable from these function arguments must be pinned as long as the C code has access to it.

Go code may pass a Go pointer to C provided the memory to which it points does not contain any Go pointers to memory that is unpinned. […] C code may not keep a copy of a Go pointer after the call returns, unless the memory it points to is pinned with runtime.Pinner […]

พูดง่าย ๆ คือ: Go pointer จะปลอดภัยในมือ C แค่ตลอดช่วง call เดียวเท่านั้น และปลอดภัยเฉพาะเมื่อสิ่งที่มันชี้ไปไม่มี Go pointer อื่น ที่ unpinned อยู่ข้างใน นี่คือเงื่อนไขข้อสองที่ล้มเหลว ในโค้ดที่ AI assistant เขียนได้จริงสำหรับ “register callback context กับ C library”:

package main
/*
#include <stdint.h>
static void *stashed_ctx;
static void register_callback(void *ctx) {
stashed_ctx = ctx; // C keeps this pointer well past this call returns
}
*/
import "C"
import (
"fmt"
"unsafe"
)
type callbackCtx struct {
name string // a string header is itself a Go pointer
total *int // and this field is a Go pointer too
}
func register(name string, counter *int) {
ctx := &callbackCtx{name: name, total: counter}
C.register_callback(unsafe.Pointer(ctx)) // ctx contains Go pointers
}
func main() {
n := 0
register("jobs", &n)
fmt.Println("registered")
}
panic: runtime error: argument of cgo function has Go pointer to unpinned Go pointer
goroutine 1 [running]:
main.register.func1(...)
main.go:26
main.register({...}, ...)
main.go:26 +0x88

panic นั้นคือ GODEBUG=cgocheck=1 — ค่า default เพราะ go doc cmd/cgo บอกว่ามันคือ “reasonably cheap dynamic checks” ที่จุดขอบเขต Go-ไป-C มีสองเรื่องที่ควรรู้ให้แม่นเกี่ยวกับมัน ทั้งคู่ verify แล้วบนเครื่องนี้:

  • มันเช็กแค่ที่ขอบเขต ไม่เช็กว่า C ทำอะไรต่อหลังจากนั้น function C ที่เอา raw pointer ไปเก็บใน global ของตัวเอง (โดยที่ argument เองไม่มีอะไรน่าสงสัย เช่น void *) มองไม่เห็นจาก instrumentation ของ Go เลย — มันรันแล้ว “สำเร็จ” ทุกครั้ง นี่แหละคือเหตุผลที่ bug ประเภทนี้รอดไปถึง production GODEBUG=cgocheck=0 ปิดการเช็กที่ขอบเขตนี้ไปเลย ส่วนตัวที่เข้มกว่าคือ GOEXPERIMENT=cgocheck2 (build-time setting ที่ doc เดียวกันเรียกว่า “complete checking of pointer handling, at some cost in run time”) จับได้มากขึ้นที่ขอบเขต แต่ก็ยังมองไม่เห็นข้างในโค้ด C เองอยู่ดี
  • cgocall analyzer ของ go vet แคบกว่านั้นอีก doc comment ของมันเองบอกว่ามัน “detect[s] some violations of the cgo pointer passing rules” และเจาะจงว่า “warns about attempts to pass a Go chan, map, func, or slice to C, either directly, or via a pointer, array, or struct” รัน go vet บน snippet ข้างบนแล้วไม่รายงานอะไรเลย — struct ที่มี pointer field ธรรมดากับ string อยู่นอกขอบเขตที่ static check ตัวนี้มองหา นี่คือสิ่งที่มันจับได้จริง บน case ที่สร้างมาให้ trigger มันโดยเฉพาะ:
m := map[string]int{"a": 1}
C.take(unsafe.Pointer(&m)) // passing a Go map (via pointer) to C
main.go:13:9: possibly passing Go type with embedded pointer to C

วิธีแก้ case register callback ข้างบนคือ runtime/cgo.Handle: ส่งเลขจำนวนเต็มที่ไม่มีความหมายให้ C แทน pointer จริง แล้วเปิดหาค่า Go กลับด้วยเลขนั้นตอน C เรียกกลับมา

func register(name string, counter *int) cgo.Handle {
h := cgo.NewHandle(callbackCtx{name: name, total: counter})
C.register_callback(C.uintptr_t(h)) // an integer, not a Go pointer
return h
}
registered

ไม่มี panic แล้ว และ go vet ก็ยังสะอาด: ในมุมมองของ C ค่า handle เป็นแค่ uintptr_t ธรรมดา ไม่มี Go pointer อยู่ข้างในเลย

function ของ Go ก็ถูกเรียกจากฝั่ง C ได้เหมือนกัน ด้วย //export ให้มันทำงานได้ต้องใช้สองไฟล์ในแพ็กเกจเดียวกัน: ไฟล์ของ function ที่ export เอง กับไฟล์ .c จริง ๆ ที่ include header _cgo_export.h ที่ cgo generate ให้ (header นี้จะมีอยู่ก็ต่อเมื่อ cgo ประมวลผล comment //export ไปแล้ว ดังนั้นจะ include จาก preamble ของไฟล์ Go อีกไฟล์ใน build เดียวกันไม่ได้)

export.go
package main
/*
#include <stdint.h>
// Declared here, defined in bridge.c (compiled once _cgo_export.h exists).
void invokeFromC(uintptr_t handle, int value);
*/
import "C"
import (
"fmt"
"runtime/cgo"
)
//export goCallback
func goCallback(h C.uintptr_t, value C.int) {
handle := cgo.Handle(h)
label := handle.Value().(string)
fmt.Printf("callback for %q: value=%d\n", label, int(value))
}
func main() {
h := cgo.NewHandle("job-A")
defer h.Delete()
C.invokeFromC(C.uintptr_t(h), 7)
}
bridge.c
#include "_cgo_export.h"
void invokeFromC(uintptr_t handle, int value) {
goCallback(handle, value);
}
callback for "job-A": value=7

h.Delete() สำคัญพอ ๆ กับ C.free ก่อนหน้า: cgo.Handle ที่ไม่เคย delete จะทำให้ค่านั้น reachable ตลอดไปในมุมมองของ Go ผ่าน lookup table ที่ runtime เก็บไว้เพื่อจุดประสงค์นี้โดยเฉพาะ — มันคือเวอร์ชันที่ managed และปลอดภัยต่อ Go pointer ของการลืม C.free

cgo call ไม่ฟรีแม้ฝั่ง C จะไม่ทำอะไรเลย: มันต้องสลับ stack และข้ามขอบเขตที่ compiler inline ผ่านไม่ได้ วัดบนเครื่องนี้ (Apple M3 Pro, darwin/arm64, Go 1.26.5, testing.B กับ b.Loop(), -benchtime=1s -count=3):

Callns/op
เรียก function ของ Go ธรรมดา~2.68
cgo call (C.cAdd(a, b), C function ว่างเปล่า)~24.2

cgo call มีต้นทุนประมาณ 9 เท่า ของ Go call ธรรมดาบนเครื่องนี้ — เป็น overhead จริง แต่อยู่ในระดับ nanosecond ไม่ได้สำคัญสำหรับ call เดียว มันสำคัญตอนที่ cgo call เกิดขึ้นครั้งละหนึ่ง element ของ slice ขนาดใหญ่ แทนที่จะเกิดครั้งเดียวสำหรับทั้ง slice:

วิธีทำ (1,000 element ของ int32)ns/opเวลารวม
เรียก C.square(x) ครั้งละหนึ่ง element~24,780~24.8 µs
เรียก C.squareAll(...) ครั้งเดียวทั้ง slice~440~0.44 µs

การ batch ทำให้เร็วขึ้นประมาณ 56 เท่า สำหรับปริมาณงานเท่าเดิม — วิธีแก้ตรงกับที่หัวข้อการส่ง slice ข้างบนแสดงไปแล้ว: ส่ง pointer กับความยาวครั้งเดียว แล้ววน loop ข้างในฝั่ง C

เพราะ cgo รันใน Playground บนเบราว์เซอร์ของคอร์สนี้ไม่ได้ นี่คือรูปร่างเดียวกันของต้นทุนที่สาธิตด้วยสิ่งที่รันได้จริง: runtime.LockOSThread บวก syscall จริง มีโครงสร้างเดียวกันกับการเดินทาง “ออกจาก Go ไปทำงานที่อื่น แล้วกลับมา” ที่ cgo call ทำ เพียงแต่ข้ามเข้าไปใน kernel แทนที่จะเข้าไปใน C

package main
import (
"fmt"
"runtime"
"syscall"
"time"
)
// A plain Go function call: the compiler can even inline this.
func goAdd(a, b int) int {
return a + b
}
// syscallCost measures a real trip out of the Go runtime and back: LockOSThread
// pins the calling goroutine to one OS thread (the same guarantee cgo code
// that needs thread affinity relies on), then Getpid is a minimal syscall --
// structurally the same shape as a cgo call: leave Go, do work elsewhere, return.
func syscallCost(n int) time.Duration {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
start := time.Now()
for i := 0; i < n; i++ {
syscall.Getpid()
}
return time.Since(start)
}
func goCallCost(n int) time.Duration {
sink := 0
start := time.Now()
for i := 0; i < n; i++ {
sink = goAdd(sink, i)
}
_ = sink
return time.Since(start)
}
func main() {
const n = 2_000_000
// warm up
goCallCost(n)
syscallCost(n)
goDur := goCallCost(n)
sysDur := syscallCost(n)
fmt.Printf("plain Go call: %6.1f ns/op\n", float64(goDur.Nanoseconds())/n)
fmt.Printf("LockOSThread + syscall: %6.1f ns/op\n", float64(sysDur.Nanoseconds())/n)
fmt.Printf("syscall is ~%.0fx a plain Go call -- the same order of magnitude a cgo call pays for crossing into C\n",
float64(sysDur)/float64(goDur))
}

Output จริงบนเครื่องนี้: plain Go call: 0.4 ns/op, LockOSThread + syscall: 12.2 ns/op ประมาณ 32 เท่า — ตัวคูณที่แน่นอนขึ้นกับเครื่องและ syscall ที่ใช้ แต่รูปร่าง (ต้นทุนคงที่จริงต่อการข้ามหนึ่งครั้ง) คือเหตุผลเดียวกับที่ cgo call ไม่ฟรี

บท Scheduler อธิบายไปแล้วว่า goroutine ที่ block อยู่ใน syscall จะถือ M ของมันไว้สักครู่ แล้ว runtime จะส่ง P ของ M นั้นให้ thread อื่นเพื่อไม่ให้ goroutine อื่นอดตาย cgo call เดินผ่านกลไก entersyscall/exitsyscall เดียวกันนี้เป๊ะ — ในมุมมองของ scheduler การอยู่ข้างใน C function นาน ๆ ดูเหมือนอยู่ข้างใน syscall ที่ช้าทุกประการ verify แล้วบนเครื่องนี้ด้วย GOMAXPROCS(1) (P เดียวทั้งโปรแกรม) และ goroutine ที่ block อยู่ 300ms ข้างใน C.slowCCall() (C function ที่เรียก usleep(300000)):

pure-Go goroutine finished 6292139 loop iterations while the cgo call was blocked
cgo call finished after 310ms

ด้วย P เดียวทั้งโปรแกรม goroutine ที่เป็น pure-Go ตัวที่สองยังรัน loop ได้ 6.29 ล้านรอบในขณะที่ goroutine ตัวแรกอยู่ข้างใน C — พิสูจน์ว่า M ที่รัน cgo call ปล่อย P ของมันออกไปจริง แทนที่จะยึด P เดียวที่มีอยู่ไว้ทั้ง 300ms

กรณีเดียวที่เปลี่ยนเรื่องนี้: โค้ด C ที่พึ่งพา thread-local state (graphics context, library ที่เรียก pthread_self() ข้างในแล้วคาดหวังความสม่ำเสมอข้าม call) ต้องการ OS thread เดิมทุก call runtime.LockOSThread() ตรึง goroutine ผู้เรียกไว้กับ M ปัจจุบันของมันเพื่อเหตุผลนี้พอดี — ถ้าไม่มีมัน ไม่มีอะไรหยุด Go scheduler จากการรัน goroutine นั้นต่อบน M ตัวอื่นหลังจากจุด block ใดจุดหนึ่ง แล้วนามธรรม “goroutine ไม่ใช่ thread” ตามปกติของ Go ก็จะไม่เป็นจริงอีกต่อไปสำหรับสมมติฐานของ C library นั้น

ทุกไฟล์ที่มี import "C" มี build constraint แฝงคือ //go:build cgo (go doc cmd/cgo: “The special import “C” implies the “cgo” build constraint”) เมื่อ CGO_ENABLED=0 constraint นั้นไม่มีทางเป็นจริงเลย ไฟล์เลยถูกตัดออกจาก build ทั้งหมด — ไม่ใช่ compile error ข้างในไฟล์ แต่เป็น error แบบไฟล์หายไปถ้ามันเป็นไฟล์เดียวที่มี:

go: no Go source files

มีผลจริงสามเรื่องที่ตามมาจากนี้ verify แล้วทั้งหมด:

  • Cross-compile ต้องมี C cross-compiler จริง หรือไม่ต้องใช้เลย CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build บนเครื่อง darwin/arm64 นี้สำเร็จและได้ ELF binary แบบ static link (ยืนยันด้วย file: ELF 64-bit LSB executable, x86-64 ... statically linked) build เดียวกันด้วย CGO_ENABLED=1 ล้มเหลว เพราะ clang ของ macOS ไม่ใช่ C compiler ของ Linux: linux_syscall.c:73:13: error: call to undeclared function 'setresuid' — output จริงจากการลองทำ
  • net กับ os/user เปลี่ยนไปใช้ implementation แบบ pure Go เงียบ ๆ go doc net บอกตรง ๆ ว่า “the pure Go resolver is preferred over the cgo resolver, because a blocked DNS request consumes only a goroutine, while a blocked C call consumes an operating system thread” — แต่ cgo resolver ก็ยังถูกใช้ภายใต้เงื่อนไขบางอย่าง (macOS เป็นหนึ่งในนั้น ตาม doc เดียวกัน เพราะ “OS X” ทำ direct DNS request เองไม่ได้) เมื่อไหร่ที่ cgo ใช้ได้ CGO_ENABLED=0 บังคับให้ใช้ทาง pure-Go เสมอ; GODEBUG=netdns=go หรือ =cgo และ build tag netgo/netcgo บังคับได้ตรง ๆ ทั้งสองทาง go doc os/user อธิบายรูปแบบเดียวกัน: ทาง cgo-based ใช้ getpwuid_r/getgrnam_r ของ libc เมื่อมีให้ใช้ ทาง pure-Go parse /etc/passwd เป็น fallback บังคับได้ด้วย build tag osusergo
  • ไม่มี C compiler ไม่มี cgo ไม่มีปัญหา — สำหรับบาง library purego (รันในระบบของคอร์สนี้ไม่ได้) โหลด C shared library แล้วเรียกเข้าไปตอน runtime ผ่าน dlopen/dlsym ไม่มี import "C", ไม่มี cgo และไม่ต้องมี C compiler ตอน build เลย ข้อแลกเปลี่ยนคือต้องยอมเสียการเช็ก type ของ signature C ตอน compile-time เพื่อแลกกับ build ที่ไม่ต้องมีอะไรนอกจาก Go toolchain

ทั้งหมดนี้ไม่ใช่ข้ออ้างให้หยิบ cgo มาใช้เป็น default เลือกใช้ pure-Go port ของ C library ก่อนถ้ามีและมีคนดูแล (modernc.org/sqlite แทน mattn/go-sqlite3 ด้วยเหตุผลเดียวกับที่ default ของ net เองก็เลี่ยง cgo) เลือกใช้ package syscall เมื่อเป้าหมายจริง ๆ คือ “OS call เดียว” หรือ WASI เมื่อเป้าหมายคือ binary แบบ sandboxed ที่ portable cgo คุ้มค่าต้นทุนของมัน — การเสีย build แบบ CGO_ENABLED=0, ความยุ่งยากตอน cross-compile, รอยต่อของ scheduler และกฎ pointer ข้างบน — เมื่อไม่มีทางเลือกอื่นสำหรับ C library ตัวนั้นจริง ๆ

flowchart LR
  Go["Go code: C.fn(args)"] -->|"compiler-generated cgo stub"| Stub["_cgo_gotypes.go / _cgox.c: convert + check args"]
  Stub -->|"entersyscall: M keeps running C, hands its P to another M"| C["C function runs on this M's OS thread"]
  C -->|"exitsyscall: M must reacquire a P before running Go code again"| Stub2["stub converts the return value"]
  Stub2 --> Go2["Go code resumes"]
  Sched["Scheduler: other Ps/Ms keep running other goroutines the whole time"] -.-> C
cgo call หนึ่งครั้ง: โค้ด Go ผ่าน stub ที่ generate ให้ เข้าไปใน C พร้อมการส่งต่อ M/P แบบเดียวกับ blocking syscall
สิ่งที่ได้ประโยชน์ต้นทุน
เรียก C library ที่มีอยู่แล้วไม่ต้อง reimplement ใน Gooverhead ต่อการข้ามประมาณ 9 เท่า; ต้องมี C toolchain จริงตอน build
cgo.Handle สำหรับ callbackปลอดภัยตามกฎ Go pointer, GC มองเห็นเพิ่ม lookup อีกขั้น และต้องจำ Delete() (เหมือน C.free)
CGO_ENABLED=0binary แบบ static, cross-compile ง่าย ไม่ต้องมี C toolchainเสีย library ตัวไหนก็ตามที่มีแค่ C binding เท่านั้น
runtime.Pinner / cgo.Handle แทน raw pointerผ่านกฎ pinning และ pointer ที่ go vet/cgocheck คาดหวังโค้ดเยอะกว่า “ส่ง pointer ไปเฉย ๆ”
purego แทน cgoไม่ต้องมี C compiler เลยแม้แต่ตอน buildไม่มีการเช็ก signature ของ C function ตอน compile-time
  • “ถ้า go vet สะอาด แปลว่าผ่านกฎ pointer ของ cgo แล้ว” — cgocall analyzer ของ go vet จับแค่ Go chan, map, func หรือ slice ที่ส่งให้ C; struct ที่มี pointer field ธรรมดา ส่งผ่าน unsafe.Pointer ผ่าน go vet ไปได้สบาย แต่ยัง panic ภายใต้การเช็กตอน runtime คือ GODEBUG=cgocheck=1
  • “GODEBUG=cgocheck=1 (ค่า default) จับได้ทุกการละเมิดกฎ pointer” — มันเช็กแค่ที่ขอบเขต Go-ไป-C เท่านั้น C function ที่เอา pointer ที่ได้รับไปเก็บใน global ของตัวเอง โดยที่ type ของ argument เองไม่มีอะไรผิดปกติ จะมองไม่เห็นจากมันเลย — verify แล้วบนเครื่องนี้ว่ารันแล้ว “สำเร็จ” ทุกครั้ง
  • “cgo call block ทั้งโปรแกรมเหมือน sync.Mutex block goroutine เดียว” — มัน block แค่ M ตัวที่รันมันอยู่ เหมือน syscall เป๊ะ ๆ runtime จะส่ง P ของ M นั้นให้ thread อื่นเพื่อให้ goroutine ทุกตัวยังรันต่อได้ ตามที่การวัดการส่งต่อ M/P ข้างบนแสดงให้เห็นตรง ๆ
  • “CGO_ENABLED=0 แค่ปิด optimization ตัวหนึ่ง” — มันเปลี่ยน build constraint ของทุกไฟล์ที่มี import "C" ตัดไฟล์เหล่านั้นออกจาก build ไปเลย แล้วสลับ net/os/user ไปใช้ implementation แบบ pure-Go ที่มีพฤติกรรม resolution ต่างออกไป ไม่ใช่แค่ binary format ที่ต่างกัน

bug ที่มาจาก cgo เงียบมาก: compile ผ่าน, go vet มักไม่พูดอะไร และโปรแกรมรันถูกต้องไปได้สักพัก ตรวจสิ่งเหล่านี้:

  • มี C.CString / C.CBytes / C.malloc ในทุก path แต่ C.free (หรือ defer C.free) หายไปในอย่างน้อยหนึ่ง path — รวมถึง early error return ด้วยหรือเปล่า?
  • ค่า Go ที่มี Go pointer อยู่ข้างใน (struct field, slice, string) ถูกส่งให้ C เป็น raw unsafe.Pointer ที่ C อาจเก็บไว้เกินกว่า call เดียวที่มันถูกส่งให้หรือเปล่า?
  • cgo call ถูกเรียกครั้งละหนึ่ง element ข้างใน loop ทั้งที่ฝั่ง C รับ pointer กับความยาวแล้ววน loop ข้างในเองได้ง่าย ๆ หรือเปล่า?
  • โค้ดอ่านผ่าน C pointer (C.GoString, struct field) หลังจากเรียก C.free บน memory ที่มันชี้ไปแล้วหรือเปล่า?

🐛 หา bug ในโค้ดนี้

func label(id int) string {
cs := C.CString(fmt.Sprintf("item-%d", id))
C.free(unsafe.Pointer(cs))
return C.GoString(cs) // reading through a pointer that was just freed
}
เฉลย

C.free คืน memory นั้นให้ allocator ของ C ทันที — ไม่มีอะไรหยุด allocator จากการเอา byte ชุดเดียวกันไปให้ malloc ครั้งถัดไปในอีกไม่กี่บรรทัดถัดมา C.GoString(cs) จะ copy byte ที่อยู่ตรง address นั้น ณ ตอนนี้ ไม่ใช่ string เดิมที่เคยอยู่ตรงนั้น รันสามครั้งติดกันบนเครื่องนี้ function เดียวกันพิมพ์ "item-42" หนึ่งครั้ง แล้วพิมพ์ byte ขยะตัวเดียว ("A" แล้วก็ "@") อีกสองครั้ง — ไม่ crash ไม่ panic ไม่มีอะไรที่ go vet หรือ -race จับได้ (นี่คือ use-after-free ใน goroutine เดียว ไม่ใช่ data race) มีแค่ output ที่ผิดเงียบ ๆ ขึ้นอยู่กับว่า C allocator ทำอะไรแทรกเข้ามาระหว่างนั้นบ้าง

func label(id int) string {
cs := C.CString(fmt.Sprintf("item-%d", id))
defer C.free(unsafe.Pointer(cs)) // free only after the string is copied out
return C.GoString(cs) // GoString runs before the deferred free
}

💡 ตัวอย่างจากของจริง

doc comment ของ runtime/cgo.Handle เอง (runtime/cgo/handle.go ใน standard library) มีอยู่ก็เพราะเหตุผลเดียวกับที่ go doc cmd/cgo บอกว่า “programs that break these [pointer-passing] rules are likely to fail in unexpected and unpredictable ways” — handle type คือคำตอบของ standard library เองต่อปัญหาเดียวกับที่ตัวอย่าง register_callback ในบทนี้เจอ

อะไรตัดสินว่า comment ก่อน import "C" จะกลายเป็น cgo preamble หรือไม่?
โปรแกรมนี้ทำอะไรจริง ๆ ตอนรัน และเพราะอะไร?
func label(id int) string {
    cs := C.CString(fmt.Sprintf("item-%d", id))
    C.free(unsafe.Pointer(cs))
    return C.GoString(cs)
}
ทำไม go vet ไม่รายงานอะไรเลยบน Go struct ที่มี pointer field ส่งให้ C ผ่าน unsafe.Pointer ทั้งที่มัน panic ตอน runtime ภายใต้ GODEBUG=cgocheck=1 ค่า default?
benchmark บนเครื่องนี้วัดได้ว่า Go function call ธรรมดาใช้เวลาประมาณ 2.7 ns/op และ cgo call ที่เทียบเท่ากันใช้ประมาณ 24 ns/op ผลที่ตรงที่สุดสำหรับ hot loop คืออะไร?
ด้วย GOMAXPROCS(1) และ goroutine ตัวหนึ่ง block อยู่ข้างใน cgo call นาน 300ms goroutine pure-Go ตัวที่สองทำ loop ได้มากกว่า 6 ล้านรอบก่อนที่ cgo call จะคืนค่า สิ่งนี้แสดงอะไร?
ข้อความไหนเกี่ยวกับ CGO_ENABLED=0 ถูกต้อง ตามข้อมูลจาก go doc cmd/cgo และ go doc net?
runtime.LockOSThread รับประกันอะไรจริง ๆ ในบริบทของ cgo call?