cgo Internals

cgo is the toolchain facility that lets Go packages call C — and lets C call back into Go. It is not a library; it is a source-to-source translator (go tool cgo) invoked automatically by the go command whenever a package contains the special import "C". As the cgo blog post puts it, “cgo lets Go packages call C code by processing special Go source files and generating Go and C files that combine into a single Go package” (go.dev/blog/cgo). The runtime price of that bridge is real: every Go→C call switches stacks, marks the goroutine as in a system call, and is invisible to the garbage collector and the preemption machinery. Go 1.26 (released 10 February 2026; latest patch 1.26.3 as of May 2026) cut that baseline overhead — its release notes state “the baseline runtime overhead of cgo calls has been reduced by ~30%” (go.dev/doc/go1.26) — and the mechanism is verifiable in the runtime source: the per-P _Psyscall status was eliminated, replaced by reading the goroutine’s own state, so each cgo call no longer pays an atomic store to flip the processor’s status (see Why the Boundary Is Expensive below).

Mental Model

cgo bolts together two runtimes that know nothing about each other. The Go side has goroutines, segmented growable stacks, a garbage collector, and a scheduler; the C side has fixed OS thread stacks, malloc/free, and no idea any of that exists. cgo’s job is to build a transition layer — generated glue code plus a handful of runtime functions — that translates calling conventions and, crucially, keeps each side’s invariants intact while control is on the other side.

flowchart TD
    subgraph GoWorld["Go world — goroutine g, growable stack, GC-scanned"]
        GC["your Go code: C.puts(s)"]
    end
    subgraph Glue["cgo-generated glue (_Cfunc_puts, _cgo_*)"]
        W["_Cfunc_puts wrapper"]
    end
    subgraph Runtime["runtime"]
        CC["runtime.cgocall<br/>entersyscall"]
        AC["runtime.asmcgocall<br/>switch to g0 stack"]
    end
    subgraph CWorld["C world — OS thread g0 stack, NOT GC-scanned"]
        CF["C function puts()"]
    end
    GC --> W --> CC --> AC --> CF
    CF -.return.-> AC -.->|exitsyscall| CC -.-> GC
    style GoWorld fill:#e8f5e9
    style CWorld fill:#ffebee
    style Runtime fill:#e3f2fd

Diagram: a Go→C call descends through cgo-generated glue into runtime.cgocall, which marks the goroutine as in a syscall (entersyscall) and hands off to runtime.asmcgocall, which switches from the goroutine’s growable stack to the thread’s fixed g0 stack before the C function runs. The insight: the cost of cgo is not the C code — it is this descent. The stack switch is mandatory because C cannot run on a Go segmented stack, and the entersyscall bookkeeping is mandatory so the scheduler and GC do not stall waiting on a goroutine that has left Go-world.

The C→Go direction is the mirror image: C calls a generated wrapper, the wrapper calls crosscall2, which calls runtime.cgocallback, which switches back from the g0 stack onto a real goroutine stack so the Go function — which may grow its stack, allocate, or be preempted — runs in a proper Go environment.

Mechanical Walk-through

What go tool cgo generates

When a package imports "C", the go command runs go tool cgo on its cgo source files before the compiler sees them. For input files x.go and y.go, cgo’s design doc (src/cmd/cgo/doc.go, verified at the go1.26.0 tag — cmd/cgo/doc.go) lists exactly these outputs: x.cgo1.go and y.cgo1.go — copies of the originals with import "C" removed and every C.xxx reference rewritten to a generated name like _Cfunc_xxx or _Ctype_xxx; _cgo_gotypes.go — Go declarations for the C types and the wrapper functions; and x.cgo2.c / y.cgo2.c / _cgo_export.c / _cgo_export.h / _cgo_main.c — the C side. So C.puts(s) in your source becomes a call to a generated Go function _Cfunc_puts in the post-cgo source the compiler actually compiles.

The Go→C wrapper

The cgo design doc shows the shape of a generated _Cfunc_ wrapper (this is the verbatim _Cfunc_puts example from doc.go at go1.26.0):

func _Cfunc_puts(p0 *_Ctype_char) (r1 _Ctype_int) {
	_cgo_runtime_cgocall(_cgo_be59f0f25121_Cfunc_puts, uintptr(unsafe.Pointer(&p0)))
	return
}

Walking it: _Cfunc_puts is the Go-callable stand-in for C’s puts. Its body calls _cgo_runtime_cgocall (which is runtime.cgocall), passing two things — a function pointer to a C trampoline (_cgo_be59f0f25121_Cfunc_puts, a gcc-compiled function generated into the x.cgo2.c file; the hex segment is a per-build hash) and the address of the argument frame, here &p0. The C trampoline, per the design doc, “extracts the arguments from the pointer to _Cfunc_puts’s argument frame, invokes the system C function (in this case, puts), stores the result in the frame, and returns.” The return value travels back through the frame into r1. The cgocall.go comment names the same call site abstractly: “the cgo-generated code calls runtime.cgocall(_cgo_Cfunc_f, frame), where _cgo_Cfunc_f is a gcc-compiled function written by cgo.”

runtime.cgocallruntime.asmcgocall

The authoritative description is the file comment of src/runtime/cgocall.go (quoted here from the go1.26.0 tag — runtime/cgocall.go). The sequence for a Go→C call:

  1. The _Cfunc_ wrapper calls runtime.cgocall.
  2. cgocall calls entersyscall — “so as not to block other goroutines or the garbage collector.” This marks the goroutine’s P as available for other goroutines and tells the GC and scheduler that this goroutine has, in effect, left the Go runtime. Without it, a long-running C call would pin a P and could stall a stop-the-world GC phase.
  3. cgocall calls runtime.asmcgocall (in architecture-specific assembly, asm_$GOARCH.s). asmcgocall switches to the m.g0 stack — the OS-allocated stack of the thread’s scheduling goroutine, which (unlike a goroutine stack) is a normal, large, fixed C-safe stack. The C function runs there.
  4. The C function returns; asmcgocall switches back to the original goroutine stack and returns to cgocall.
  5. cgocall calls exitsyscall, which — in the words of the comment — “blocks until this m can run Go code without violating the $GOMAXPROCS limit,” reacquiring a P before Go execution resumes.

Every Go→C call pays for that whole descent: the entersyscall/exitsyscall pair, the stack switch, and the loss-and-reacquire of a P. This is why cgo calls are orders of magnitude costlier than a Go function call, and why Go 1.26’s ~30% reduction of the baseline overhead — achieved by deleting the per-P syscall status (below) — is a meaningful win for cgo-heavy workloads (go.dev/doc/go1.26).

The C→Go callback path

When C calls a Go function (one marked //export, or a Go function pointer handed to C), the path reverses. The exported Go function is reachable from C via a declaration in the generated _cgo_export.h; calling it enters a gcc-compiled wrapper, which calls crosscall2 — described in the cgocall.go comment as “a four-argument adapter from the gcc function call ABI to the gc function call ABI.” crosscall2 calls runtime.cgocallback, which “switches from m.g0’s stack to the original g (m.curg)‘s stack” — i.e. off the g0 stack back onto a real goroutine stack. Now running on that goroutine stack, runtime.cgocallbackg first calls runtime.exitsyscall (which “will block until the $GOMAXPROCS limit allows running this goroutine”) and only then invokes the actual Go function via the generated _cgoexp_GoF. A goroutine stack is required because the Go function may grow its stack, allocate (triggering GC), or be asynchronously preempted — none of which the g0 stack supports. The comment also documents a GC subtlety: from the collector’s perspective “time can move backwards” across a callback (GC may see the call site roll back from asmcgocall to entersyscall, making arguments look dead-then-live), so cgocall calls KeepAlive(fn), KeepAlive(arg), and KeepAlive(mp) to “prevent these undead arguments from crashing GC” (runtime/cgocall.go @ go1.26.0).

Crossing the boundary with data

C and Go have separate memory worlds, so cgo provides explicit conversion functions (cmd/cgo docs): C.CString(goString) *C.char and C.CBytes([]byte) unsafe.Pointer allocate with C malloc and must be freed by the caller with C.free; C.GoString, C.GoStringN, C.GoBytes copy C memory into Go-managed values. Numeric C types map to Go types — C.int, C.char, C.long, C.double, and so on — and C.void* maps to unsafe.Pointer.

Code — Line-by-Line

The fputs example from the cgo blog, annotated:

package print
 
// #include <stdio.h>
// #include <stdlib.h>
import "C"
import "unsafe"
 
func Print(s string) {
	cs := C.CString(s)                       // 1
	defer C.free(unsafe.Pointer(cs))         // 2
	C.fputs(cs, (*C.FILE)(C.stdout))         // 3
}
  1. C.CString(s) copies the Go string s into a freshly malloc’d, NUL-terminated C char array, returning *C.char. The copy is mandatory: a Go string’s bytes are Go-managed and may move/be collected; C needs stable memory it owns.
  2. defer C.free(unsafe.Pointer(cs)) — because step 1 used malloc, the memory is invisible to the Go GC and must be freed explicitly. The unsafe.Pointer conversion is needed because C.free takes void*. Pairing CString with a defer C.free is the canonical idiom; forgetting it leaks C memory.
  3. C.fputs(cs, (*C.FILE)(C.stdout)) — the actual cgo call, which descends through _Cfunc_fputsruntime.cgocallasmcgocall as walked above. C.stdout is cast to *C.FILE.

#cgo directives configure the C toolchain (cmd/cgo docs):

// #cgo CFLAGS: -I${SRCDIR}/include -DNDEBUG
// #cgo LDFLAGS: -L${SRCDIR}/lib -lfoo
// #cgo pkg-config: zlib
// #include "foo.h"
import "C"

CFLAGS are passed to the C compiler; LDFLAGS to the linker; pkg-config resolves flags via the pkg-config tool; ${SRCDIR} expands to the source directory. By default only -D -U -I -l are permitted in these directives for security; broader flags require CGO_CFLAGS_ALLOW.

Two performance directives from the cmd/cgo docs let the compiler skip pessimistic assumptions: // #cgo noescape cFunc promises a Go pointer passed to cFunc does not escape (so the argument need not be heap-allocated), and // #cgo nocallback cFunc promises cFunc will not call back into Go. Both are unchecked promises — violating noescape corrupts memory; violating nocallback panics.

Failure Modes and Common Misunderstandings

“cgo is free / cheap.” It is not. Each call pays the entersyscall/exitsyscall + stack-switch descent — far more than a Go call. Calling C in a tight loop is a classic performance trap; batch the work into one C call instead. (Covered in depth in cgo Performance and Pitfalls.)

Passing Go pointers to C incorrectly. The cmd/cgo “Passing pointers” rules are strict: a Go pointer passed to C must point to pinned memory, the pointed-to memory must contain no unpinned Go pointers, and C must not retain the pointer past the call unless the memory is pinned with runtime.Pinner. Violations are caught by GODEBUG=cgocheck (default cgocheck=1; GOEXPERIMENT=cgocheck2 for full checking). To pass a Go value’s identity through C safely, use runtime/cgo.Handle — it never exposes a Go pointer at all.

Leaking C memory. C.CString/C.CBytes/C.malloc allocate memory the Go GC cannot see or reclaim. Every such allocation needs a matching C.free. The GC will never collect it for you.

//export and preamble definitions. A file using //export may only put C declarations, not definitions, in the preamble — the preamble is copied into two generated C files, so a definition would be duplicated and fail to link. Put definitions in a separate .c file.

Uninitialized C memory passed to Go. The cmd/cgo docs flag a known bug: if uninitialized C memory’s bytes happen to look like a Go pointer, the runtime may error. Zero C memory in C before handing it to Go.

Cross-compilation breakage. cgo needs a C toolchain for the target platform. CGO_ENABLED=0 disables cgo entirely (and is the easy way to get a fully static, portable binary); cross-compiling with cgo requires setting CC_FOR_TARGET and a cross C compiler.

Why the Boundary Is Expensive — The Cost Anatomy

It is worth quantifying where the cost of a cgo call goes, because the instinct to “just call C” usually underestimates it by an order of magnitude. A plain Go function call is a few instructions — push arguments, CALL, return. A cgo call, by contrast, pays for a sequence of distinct operations, each documented in the runtime/cgocall.go comment and walked above:

The entersyscall bookkeeping records that the goroutine is leaving the Go runtime and detaches its P (processor) so other goroutines can use it. The stack switch in asmcgocall saves the goroutine’s stack pointer and loads the g0 (thread) stack pointer — the C function must run on a normal fixed OS stack because C has no concept of Go’s small, growable, GC-scanned segmented stacks. The C function itself runs. Then exitsyscall tries to reacquire a P; if every P is busy (because GOMAXPROCS goroutines are already running Go code), the returning goroutine must wait — so a cgo call’s tail latency depends on scheduler contention, not just on the C code. On top of all that, any argument marshallingC.CString copying a string into malloc’d memory, C.GoString copying back — is additional, separately-paid cost.

A careful single-machine benchmark on Go 1.21 put the empty-cgo-call cost at 38.93 ns single-threaded against 1.665 ns for a non-inlined Go call — roughly a 23× gap — and identified runtime.casgstatus (the goroutine-status compare-and-swap, and the per-P atomic.Store(&pp.status, _Psyscall)) as the dominant runtime hot spot, contended past ~16 cores (shane.ai 2023). That measurement is hardware- and version-specific, but the order of magnitude — tens of nanoseconds versus ~1 ns, i.e. roughly 20–40× — is robust across reports, and it pinpoints why Go 1.26’s change matters: deleting the _Psyscall P-status (now _Psyscall_unused in the runtime, see below) removes precisely the atomic store shane.ai measured as ~9% of the call. The design implication is blunt: make C APIs coarse. One cgo call that processes a megabyte amortizes the descent to nothing; a million cgo calls that each process a byte are dominated by it. This cost model is treated in full in cgo Performance and Pitfalls.

What Go 1.26 actually changed

The ~30% baseline reduction is not a black box. Comparing the runtime source across releases makes the mechanism concrete. In Go 1.25 and earlier, a processor (P) carried a dedicated status _Psyscall that was set every time its goroutine entered a syscall or cgo call: the P comment in src/runtime/runtime2.go at the go1.25.0 tag describes _Psyscall as a live state a P enters “when entering a syscall,” left only “with a CAS, either to steal” it (runtime2.go @ go1.25.0). Maintaining that status meant every cgo/syscall entry performed an atomic store on the P’s status word, and sysmon/work-stealing performed CAS operations to retake it — the very casgstatus/atomic.Store(&pp.status, _Psyscall) traffic shane.ai isolated.

At the go1.26.0 tag the same constant is renamed and demoted: “_Psyscall_unused is 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). In other words, Go 1.26 stopped tracking “in a syscall/cgo call” as a processor status and now derives it from the goroutine’s status, which the runtime already maintains — so the per-call atomic store on the P and its associated steal-CAS simply disappear. That is the bulk of the ~30% the release notes advertise. This is a narrower, shipped change than the broader proposal to “eliminate the notion of a ‘syscall state’” entirely by running syscalls on a dedicated thread, which as of May 2026 remains an open, unplanned proposal (golang/go#58492) — do not conflate the two.

Alternatives and When to Choose Them

  • Pure Go. The strongest option: no cgo means fast builds, trivial cross-compilation, static binaries, no FFI overhead, and full GC/race-detector/preemption support. Reimplementing a C dependency in Go is often worth it. The Go team’s own guidance is that “cgo is not Go.”
  • os/exec — call a separate process. For coarse-grained, infrequent C functionality, shelling out to a separate executable avoids linking C into your binary entirely and isolates crashes.
  • A network/IPC service. For heavyweight C/C++ subsystems, running them as a separate service and talking over a socket trades latency for isolation and independent deployment.
  • syscall / golang.org/x/sys. For OS calls specifically, the syscall packages reach the kernel without cgo — no C compiler, no FFI cost.
  • WebAssembly / plugin boundaries. In some sandboxed designs a Wasm module replaces a cgo dependency.

Choose cgo only when you genuinely must reuse a substantial, mature C/C++ library (cryptography, codecs, hardware SDKs, large numerical libraries) for which no acceptable Go equivalent exists — and then minimize the number of boundary crossings.

Production Notes

cgo’s cost model shapes real systems: SQLite drivers (mattn/go-sqlite3), graphics and ML bindings, and OS-vendor SDK wrappers all live with the per-call overhead, and the standard mitigation is to design coarse C APIs — one call that does a lot — rather than chatty ones. Go 1.26’s ~30% baseline cgo-overhead reduction directly benefits these workloads with zero code changes (go.dev/doc/go1.26). The pointer-passing safety net is unchanged in 1.26: cmd/cgo still documents GODEBUG=cgocheck=1 as the default cheap dynamic check, GODEBUG=cgocheck=0 to disable, and GOEXPERIMENT=cgocheck2 (set at build time) for complete-but-slow checking (pkg.go.dev/cmd/cgo) — the cgocheck2 knob has not graduated to a GODEBUG=cgocheck=2 runtime setting. Operationally, two facts dominate: cgo makes builds slower and cross-compilation hard (many teams keep CGO_ENABLED=0 as the default and treat cgo as an opt-in exception), and cgo code is outside the reach of some Go tooling — the goroutine that is inside a C call cannot be asynchronously preempted, does not show normal Go stack frames in profiles, and its C-side memory is invisible to pprof heap profiling. The runtime/cgo.Handle API and runtime.Pinner are the modern, GC-correct way to move references across the boundary and should be preferred over hand-rolled unsafe.Pointer smuggling.

See Also