cgo Performance and Pitfalls
A call from Go into C via cgo is not a function call — it is a controlled boundary crossing that switches stacks, changes the scheduler’s accounting of the calling goroutine, and disables a swathe of runtime services for the duration of the C code. Each crossing therefore carries a fixed overhead far larger than a Go-to-Go call, and the boundary imposes hard rules on what memory may cross it. As of Go 1.26 (released 10 February 2026; 1.26.3 is the current patch as of May 2026) the baseline cgo call overhead has been reduced by roughly 30% — achieved by eliminating the per-
Psyscall status so each call no longer atomically stamps the processor as “in a syscall” (Go 1.26 release notes; runtime2.go @ go1.26.0) — but cgo remains the slowest and most dangerous interface in the language. This note explains why the crossing is expensive, what the pointer-passing rules are and why they exist, and the recurring ways cgo programs break.
Mental Model
The right way to think about a cgo call is inter-thread coordination, not a jump. Go code runs on a small, segmented goroutine stack that the runtime can grow, shrink, and move; C code expects a large, fixed, contiguous stack and may call setjmp, longjmp, alloca, or block indefinitely in a syscall. These two execution environments are incompatible on the same stack, so every cgo call must switch from the goroutine stack to a separate system stack (g0’s stack) — or, more precisely, to a per-call C stack that is large and fixed-size — before the C function runs.
flowchart TD A[Goroutine running Go code on movable g-stack] --> B[C.fn called] B --> C[cgocall: save g state, mark goroutine as in syscall] C --> D[switch to system stack g0] D --> E[asmcgocall: switch to large fixed C stack] E --> F[C function executes — no GC scan, no preemption of this thread] F --> G[return: switch back to g0, then to g-stack] G --> H[exitsyscall: re-acquire a P, resume Go scheduling] H --> A
Diagram: the round trip of one C.fn() call. The insight is that the crossing touches the scheduler twice (entry marks the goroutine as syscall-blocked so its P can be handed to another goroutine; exit re-acquires a P) and switches stacks twice. None of these steps exist for a plain Go call — they are the irreducible cost of cgo.
Mechanical Walk-through
When the compiler sees C.fn(args) it does not emit a direct call. It emits a call to runtime.cgocall, passing a pointer to a generated C-ABI wrapper and the marshalled arguments (runtime/cgocall.go @ go1.26.0). cgocall performs the following sequence.
First it calls entersyscall (the same routine a real blocking syscall uses; “cgocall calls entersyscall so as not to block other goroutines or the garbage collector,” per runtime/cgocall.go @ go1.26.0). This records the goroutine’s program counter and stack pointer and transitions the goroutine into the _Gsyscall state. The goroutine’s P is left in a state where, if the C call blocks for a long time, sysmon can hand that P to another OS thread and keep the rest of the program scheduling. This is why a blocking C call does not stall the whole program, and why the entry path touches the scheduler’s casgstatus (compare-and-swap goroutine status) machinery — a known contention point under heavy concurrent cgo load (shane.ai benchmark).
A historical note that matters for reading older write-ups: through Go 1.25 the processor itself also carried a dedicated _Psyscall status, set on entry and reclaimed with a CAS by work-stealing/sysmon (runtime2.go @ go1.25.0). Go 1.26 removed that: the constant is now _Psyscall_unused, “a now-defunct state for a P. A P is identified as ‘in a system call’ by looking at the goroutine’s state” (runtime2.go @ go1.26.0). Deleting the per-P atomic status store — which shane.ai measured at roughly 9% of a cgo call’s cost — is the documented source of the ~30% baseline reduction. This is a narrower change than the still-open, unplanned proposal to abolish the syscall state entirely by running syscalls on a dedicated thread (golang/go#58492); the two should not be conflated.
Next it switches to the system stack and calls asmcgocall, the architecture-specific assembly that switches to a real C stack and invokes the wrapper. While the C function runs, the garbage collector cannot scan this goroutine’s stack (it is parked in _Gsyscall) and the runtime cannot asynchronously preempt this thread. C code is opaque to the runtime.
On return, cgocall switches stacks back and calls exitsyscall, which tries to re-acquire a P. If the goroutine’s old P is still free it grabs it cheaply; otherwise it must find another idle P or, failing that, park the goroutine on the global run queue — a potentially expensive path. Then Go execution resumes.
Measured end to end, a single empty cgo call on Go 1.21 cost 38.93 ns single-threaded (shane.ai 2023) — against 1.665 ns for a plain non-inlined Go call on the same machine, a ~23× gap. The author’s vivid framing: a cgo call costs “about the same time encoding/json takes to parse a single-digit integer.” Go 1.26’s ~30% reduction off the baseline implies an empty-call cost in the high-twenties of nanoseconds on comparable hardware, but the release notes publish a percentage, not an absolute ns/op, and the absolute number is hardware- and workload-dependent — so treat ~25–30 ns as an estimate, not a measured figure. The order-of-magnitude gap to a native call (still ~1–2 ns) is unchanged: cgo is cheaper in 1.26, not cheap.
Uncertain
Verify: the precise absolute post-1.26 per-call ns/op figure. Reason: the 38.93 ns datum is a primary Go 1.21-era benchmark on specific hardware; Go 1.26 publishes only a “~30% reduction off baseline overhead” percentage, never an absolute, and per-call cost varies by CPU and call shape. The ~25–30 ns estimate above is arithmetic on the 1.21 number, not a fresh measurement. To resolve: run
go test -benchof an empty cgo call (and an empty Go call) on the target hardware under Go 1.26.3.
The cost is per-call, not amortizable by argument size
A subtle consequence: cgo overhead is fixed per call, so the cure for a hot cgo loop is fewer, fatter calls, not faster ones. Calling a C function a million times in a Go loop pays the crossing a million times. Restructuring so that one C call processes a million elements pays it once. This “batch at the boundary” rule is the single most important cgo performance technique.
Consider the difference concretely. The naive shape pays the crossing per element:
// SLOW: one cgo call per element — N crossings
total := 0.0
for _, x := range data { // data is a Go []float64
total += float64(C.transform(C.double(x)))
}The batched shape pins the slice once and pays the crossing once:
// FAST: one cgo call for the whole slice — 1 crossing
var pinner runtime.Pinner
pinner.Pin(&data[0]) // pin the backing array
C.transform_batch(
(*C.double)(unsafe.Pointer(&data[0])),
C.size_t(len(data)),
)
pinner.Unpin()The C side iterates internally. For a million-element slice the second form does roughly one ten-thousandth of the boundary work. The lesson generalizes: design the C API around buffers and counts, not around scalar getters and setters. A C library that only exposes per-element accessors is a poor cgo citizen, and the right fix is often a thin C wrapper that loops.
What asynchronous preemption cannot do across cgo
A second-order effect worth knowing: while a goroutine is inside C, the runtime cannot asynchronously preempt the OS thread running it. The Go 1.14+ signal-based preemption mechanism delivers SIGURG to a thread to interrupt a tight Go loop; a thread executing C code cannot be safely interrupted that way, because the signal handler would run on a C stack the runtime does not understand. A long-running C call therefore occupies an OS thread for its entire duration, and if such calls are frequent the runtime may grow GOMAXPROCS-plus-extra threads to keep Go goroutines scheduling. This is benign for occasional calls and pathological for a program that fans out thousands of concurrent long C calls — each one is, in effect, a blocked thread.
Pointer-Passing Rules — and Why They Exist
The deepest pitfall in cgo is memory. Go’s heap is managed by a moving-aware, concurrent collector; C’s memory is not. The cgo pointer-passing rules (cmd/cgo docs) exist to keep the two worlds from corrupting each other.
The central rule for Go → C, quoted exactly from the cmd/cgo docs, is: “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” (pkg.go.dev/cmd/cgo). Concretely, you may pass *C.int or *int to C, but you may not pass a *struct{ p *int } whose p field is a live unpinned Go pointer. The reason is reachability: while C holds the outer pointer, the runtime cannot see the inner pointer (C memory is not scanned), so the GC could free or move whatever p referenced. By forbidding nested unpinned Go pointers, cgo guarantees that everything C can reach from a passed pointer is either non-pointer data or already known to the runtime as pinned. The docs also pin down which memory counts: “When passing a pointer to a field in a struct, the Go memory in question is the memory occupied by the field, not the entire struct. When passing a pointer to an element in an array or slice, the Go memory in question is the entire array or the entire backing array of the slice.”
A pointer passed as a function argument is implicitly pinned for the duration of that single call — the docs state “Go pointers passed as function arguments to C functions have the memory they point to implicitly pinned for the duration of the call,” and the runtime will not move or collect it until the C call returns. If C must keep a Go pointer after the call returns, the program must explicitly pin it with runtime.Pinner:
var pinner runtime.Pinner // 1
defer pinner.Unpin() // 2
buf := make([]byte, 4096) // 3
pinner.Pin(&buf[0]) // 4
C.register_callback_buffer( // 5
(*C.char)(unsafe.Pointer(&buf[0])),
C.size_t(len(buf)),
)Line 1 creates a Pinner. Line 2 ensures the pin is released even on panic. Line 3 allocates a Go-managed slice. Line 4 pins the address of element zero; for a slice element, pinning one element pins the entire backing array — the runtime cannot move part of an array. Line 5 hands the pinned pointer to C, which may now legally retain it until Unpin runs. Without the pin, retaining the pointer past the call is undefined behavior: the GC may move the backing array out from under C. The spec is explicit that some Go values can never be retained by C this way — “C code may not keep a copy of a string, slice, channel, and so forth, because they cannot be pinned with runtime.Pinner” (pkg.go.dev/cmd/cgo). For passing a Go value’s identity (not its address) across the boundary and back, the GC-safe tool is cgo.Handle, which hands C an opaque integer rather than a Go pointer at all.
The reverse rule, C → Go: C memory passed into Go is fine and unrestricted — Go code can hold C pointers indefinitely; the GC simply ignores them. The danger is the other direction.
The runtime enforces a subset of these rules dynamically, and the knobs are exactly as the cmd/cgo docs state (verified May 2026): “The default setting is GODEBUG=cgocheck=1, which implements reasonably cheap dynamic checks. These checks may be disabled entirely using GODEBUG=cgocheck=0. Complete checking of pointer handling, at some cost in run time, is available by setting GOEXPERIMENT=cgocheck2 at build time” (pkg.go.dev/cmd/cgo). Note the asymmetry: cgocheck=0/1 are runtime GODEBUG values, whereas full checking is cgocheck2 — a build-time GOEXPERIMENT, not a GODEBUG=cgocheck=2 runtime setting. As of Go 1.26 it has not graduated to such a setting. A program that violates the rules may pass cgocheck=1 and still crash mysteriously later — the dynamic check is a safety net, not a proof.
runtime.LockOSThread and Thread-Affinity Pitfalls
Many C libraries assume thread-local state: they store data in OS thread-local storage, or require that initialization and use happen on the same thread (OpenGL contexts, some GUI toolkits, libraries built on pthread_setspecific). Go goroutines are not OS threads — the scheduler freely migrates a goroutine between threads at any preemption point. A goroutine that calls a thread-affine C function, yields, and resumes may now be running on a different OS thread, and the C library’s thread-local state is gone.
runtime.LockOSThread() “wires the calling goroutine to its current operating system thread. The calling goroutine will always execute in that thread, and no other goroutine will execute in it, until the calling goroutine has made as many calls to UnlockOSThread as to LockOSThread” (pkg.go.dev/runtime#LockOSThread). If the goroutine exits while still locked, the docs note the thread itself is terminated rather than recycled. This is mandatory when using thread-affine C libraries: lock the thread, do all the affine C work, unlock. The classic correct shape:
func init() { runtime.LockOSThread() } // 1 — main goroutine pinned for GL
func renderLoop() {
runtime.LockOSThread() // 2 — this goroutine owns its thread
defer runtime.UnlockOSThread()
C.glInit() // 3 — context bound to this thread
for { C.glDrawFrame() } // 4 — guaranteed same thread
}Line 1 pins main early — many GUI frameworks demand the main thread. Lines 2–4 show a dedicated render goroutine that locks before touching OpenGL so the context created at line 3 is still valid at line 4. Forget the lock and the program will work intermittently — until the scheduler migrates the goroutine mid-loop and the GL calls start failing or crashing.
A related cost: a goroutine making a cgo call onto a locked thread that blocks cannot have its P stolen as cheaply, and locked threads are never reused for other goroutines, so heavy LockOSThread use can inflate the thread count.
Failure Modes and Common Misunderstandings
“cgo is fast enough, I’ll call it in a loop.” The most common performance bug. Per-call overhead dominates; profiles show time vanishing into runtime.cgocall, runtime.casgstatus, and syscall enter/exit. Fix: batch.
Passing a Go struct containing pointers. C.fn(&goStruct) where goStruct has a string, []T, map, or pointer field violates the rules. The compiler often cannot catch this; cgocheck sometimes does at runtime; sometimes it is a silent heap corruption surfacing as an unrelated crash later.
Leaking C memory. C.CString and C.CBytes allocate with malloc and are invisible to the Go GC. Every C.CString needs a matching C.free(unsafe.Pointer(cs)) — the idiomatic defer C.free(...) immediately after allocation (go.dev/blog/cgo). Forgetting it is a classic slow leak that no Go profiler attributes correctly, because the Go heap profiler does not see C allocations.
Stack growth disabled across the boundary. C code runs on a fixed C stack; deep C recursion or large alloca can overflow it with no friendly Go stack-overflow message — just a hard crash.
Build-time and cross-compilation surprises. Setting CGO_ENABLED=0 produces a pure-Go, statically linkable binary; enabling cgo links against libc and complicates cross-compilation, static linking, and minimal-container images. Many teams keep CGO_ENABLED=0 precisely to avoid this.
Slow builds and lost tooling. cgo invokes a C compiler per package, serializes parts of the build, and degrades the experience of the escape analyzer, inliner, and race detector across the boundary. Dave Cheney’s well-known summary — “cgo is not Go” — captures the broader cost: it forfeits fast builds, easy cross-compilation, and the runtime’s safety guarantees (dave.cheney.net).
The errno multiple-return trap. Any C function can be called in a two-value assignment context to capture errno: n, err := C.somecall(...). The trap is ordering — errno is only meaningful when the call signalled failure through its return value, and C functions do not reliably clear errno on success. The documented discipline (cmd/cgo) is to inspect the return value first and only then trust err:
n, err := C.setenv(key, value, 1)
if n != 0 { // C signalled failure via the return value
return err // only NOW is err meaningful
}Reading err unconditionally yields stale errno values left over from an earlier, unrelated failed call.
Variadic and function-pointer limits. cgo cannot call C variadic functions (printf-style) directly — you must write a fixed-arity C wrapper in the preamble and call that. It also cannot call a C function pointer directly, though it can pass one between Go and C; again the workaround is a small C bridge function. These are not performance issues but they routinely surprise people wrapping a C API for the first time.
The “C memory looks like a Go pointer” bug. A documented known bug: if a block of C memory you intend to write into happens to contain bit patterns that look like Go pointers, the runtime’s pointer checks can trigger a spurious crash. The workaround in the cgo docs is to zero the C buffer before handing it across the boundary.
Alternatives and When to Choose Them
When cgo’s costs bite, the alternatives are: pure-Go reimplementation (best when feasible — keeps the toolchain’s guarantees; many “needs C” libraries now have pure-Go equivalents, e.g. crypto, SQLite ports); a separate process speaking over a pipe, socket, or gRPC (isolates crashes and memory, at the cost of IPC latency — appropriate for coarse-grained, infrequent calls); the syscall/golang.org/x/sys packages for OS calls that do not actually need a C library; and WebAssembly or plugin boundaries for sandboxed extension. cgo earns its keep when a large, mature, performance-critical C library (a video codec, a database engine, a hardware SDK) simply has no Go equivalent and the call granularity is coarse enough to amortize the crossing.
Production Notes
Real-world Go services that depend on cgo (the canonical example being mattn/go-sqlite3) routinely hit two issues: build-pipeline complexity from the C toolchain dependency, and the inability to produce a fully static scratch-based container without extra linker care. Teams that need static, cross-compiled binaries gravitate to pure-Go alternatives (e.g. modernc.org/sqlite, a machine-translated pure-Go SQLite) specifically to delete cgo from the dependency graph. The Go 1.26 ~30% overhead cut and the new internal-linking support for windows/arm64 cgo programs (Go 1.26 release notes) ease the pain at the margins but do not change the strategic calculus: prefer pure Go; reach for cgo deliberately; batch at the boundary; pin explicitly; free malloc’d memory; and LockOSThread around anything thread-affine.
See Also
- cgo Internals — the code-generation and toolchain mechanics of the
import "C"bridge - Calling Go from C — the reverse direction:
//export,c-archive,c-shared - The unsafe Package —
unsafe.Pointer, used pervasively at the cgo boundary - Goroutine Stacks — why Go and C stacks are incompatible
- GMP Scheduler Model — the P that gets released and re-acquired on every cgo call
- System Calls and the Scheduler — cgo reuses the syscall enter/exit machinery
- Go Internals MOC — parent map (§14)