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.