33.5 CGo Performance Overhead and When to Avoid It
Let’s be blunt: calling C from Go via CGo is not free. It’s not even cheap. It feels like you’re getting the best of both worlds, but you’re paying a toll on every crossing. Think of it not as a seamless bridge, but a drawbridge that has to be raised and lowered with every single cart that crosses. It adds friction, and that friction has a real cost.
The overhead isn’t in the raw computation speed of your C function itself—once it’s running, it’s running at native C speed. The overhead is in the marshaling and the context switching. Every time you jump the boundary between the Go runtime and the C world, the Go scheduler has to put its drink down and deal with something it wasn’t designed for.
The Overhead Tax: What You’re Actually Paying
The cost comes from several places. First, the call itself involves a switch from Go’s calling convention to C’s. This isn’t a simple CALL instruction; it’s a wrapper function generated by CGo that handles the transition.
Second, and more importantly, is the Goroutine and Scheduler Overhead. The Go scheduler operates on a world of goroutines, which are incredibly lightweight userspace threads. When a goroutine calls into C code via CGo, the current thread executing that goroutine must be locked to an OS thread (an M in Go’s runtime parlance) for the duration of the call. The scheduler can’t preempt that thread to run another goroutine while it’s executing C code. It’s effectively a blocking operation from the scheduler’s perspective.
This means that if you have a heavily concurrent Go program making frequent CGo calls, you can starve your scheduler. You end up with a bunch of OS threads blocked in C land, while your other goroutines sit in a queue waiting for a free thread. It’s a concurrency killer.
Here’s a simplistic benchmark to illustrate the call overhead. We’ll compare a pure Go function adding two integers to a CGo wrapper doing the same trivial task.
// main.go
package main
// #include <stdlib.h>
// int add(int a, int b) { return a + b; }
import "C"
import "testing"
// Pure Go version for comparison
func addGo(a, b int) int {
return a + b
}
func BenchmarkGoAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = addGo(1, 2)
}
}
func BenchmarkCGoAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = int(C.add(C.int(1), C.int(2)))
}
}
Run this with go test -bench=. -benchmem. On my machine, the CGo call is hundreds of times slower for this trivial operation. The nanoseconds add up fast. The cost isn’t in the addition; it’s in the paperwork.
When to Swallow the Cost (And When to Spit It Out)
So when is this overhead acceptable? The rule of thumb is brutally simple: The work done inside the C function must be significant enough to dwarf the overhead of the call.
- Avoid CGo for: Tiny functions, getters/setters, frequent callbacks (like iterating over a slice or a map where you call C for each element). This is a classic rookie mistake that will murder your performance.
- Accept CGo for:
- Large, batch operations: Calling a single C function that processes a massive array or performs a complex mathematical calculation. The single call overhead is amortized over the huge amount of work.
- System calls: You’re already doing a context switch into the kernel; the CGo overhead is a rounding error in comparison.
- Using mature, battle-tested C libraries that would be foolish to rewrite, like complex image codecs (libjpeg, libpng) or linear algebra libraries (BLAS, LAPACK).
The Pointer Pitfall: Or, How to Invalidate the Entire GC
This is a big one. You cannot safely hold a Go pointer in C memory and vice-versa for long periods. Why? Because of Go’s moving garbage collector.
The Go heap is not a stable place; the GC compacts it and moves things around. If you store a Go pointer in a C struct and then let Go run its GC, that pointer becomes a dangling reference. It’s pointing to where the memory was, not where it is. This will cause a crash that is spectacularly difficult to debug.
The same goes the other way. A pointer to C memory (*C.some_type) is, from Go’s perspective, an opaque value. The Go GC cannot see inside it to trace what it points to. If that C memory itself holds a pointer to a Go object, you’ve just created a hidden reference that the GC knows nothing about. It might happily free the Go object, and your C code will be left holding a pointer to garbage.
The rules are strict for a reason. CGo’s //go comments (//go:noescape, //go:cbgo) exist to help the compiler navigate this minefield. When you see them, know that they’re there to prevent the universe from unraveling. Always ask yourself: “How long does this pointer live, and who owns it?” If the answer is fuzzy, you’re probably about to step on a rake.