serpentiel

cgo will not let you hand C a Go function, and the refusal is structural, not bureaucratic. A C function pointer is a bare code address. A Go func value is a pointer to a closure — code plus captured environment, owned by a runtime C has never heard of. No conversion exists, and unsafe.Pointer can’t smuggle it either: the pointer-passing rules ban giving C any Go pointer whose memory contains other Go pointers, and a captured environment is exactly that. You hit this the first time C needs to call back into Go — an event fires, a queue drains, a completion completes — and the fix is always the same shape: send an integer across, keep the function in a table at home, and export one fixed entry point that does the lookup.

betterglobekey runs this pattern in its mainthread package, which funnels work onto the AppKit main thread. The Go side files the function under an ID and sends only the ID:

func Run(fn func()) {
	mu.Lock()
	lastID++
	id := lastID
	pending[id] = fn
	mu.Unlock()

	C.bgkDispatchMain(C.ulonglong(id))
	// ...cleanup after the call returns
}

The C side never sees a function. It ferries the number to the main dispatch queue and calls the one exported Go entry point:

//export bgkRunOnMain
func bgkRunOnMain(handle C.ulonglong) {
	mu.Lock()
	fn := pending[uint64(handle)]
	mu.Unlock()

	if fn != nil {
		fn()
	}
}

//export is the loophole that makes this legal, and it’s a narrow one: it produces C-callable functions, but only fixed, top-level ones — nothing captured, nothing dynamic. All the dynamism lives in the table. The same repo also shows the degenerate form: the keyboard event tap allows one listener at a time, so its “table” is a single package-level variable and the exported callback just invokes it. One slot or a map — same pattern, different capacity.

Since Go 1.17 the standard library ships this exact move as runtime/cgo.Handle: NewHandle mints the integer, Value resolves it, Delete retires it. A hand-rolled map behind a mutex, like the one above, is the same shape with the same guarantees — use whichever reads better next to your code, but use one of them; a uintptr cast of anything else is a bug with a delay on it.

The mental model that survives contact with cgo: code cannot emigrate, data can. Give C a number, and keep your functions where the garbage collector can see them.

comments