Skip to content
gohacks.dev
Concurrency

Cgo Calls and Blocked Threads in Go Programs Explained

Why a blocking cgo call holds an OS thread, a run where 100 blocked C calls created 104 threads, the 10000 thread limit, and a semaphore that caps the damage.

6 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Cgo is how a Go program calls into C libraries: SQLite, image codecs, GPU drivers, vendor SDKs. It works, and it breaks the scheduling story that makes Go services cheap. A goroutine that calls a C function leaves the world where the runtime can pause and move it. The C code runs on the goroutine's thread, the runtime cannot interrupt it, and the thread is unavailable to anyone else until the call returns. For a quick call that is nothing. For a call that blocks (a network query in a vendor library, a lock inside a database driver, a sleep), it is one OS thread per blocked goroutine.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64), cgo enabled

This article measures that, shows the failure mode, and gives the standard mitigation. It continues from goroutines and OS threads and LockOSThread, sits next to blocking syscalls, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.

Why a C call is different

When Go code blocks, say on a channel, the runtime knows. It parks the goroutine, hands the thread to another goroutine, and the thread count stays near the number of cores. C code is opaque to the runtime. There is no point at which it can park a goroutine in the middle of a C function, no safe place to switch stacks, and no way to preempt it. So a cgo call keeps the thread for as long as it runs, and the runtime has two ways to cope.

First, before the call, the goroutine marks its processor (P) as available. If the call is short, the P comes back and nothing is lost. If it runs longer, the runtime's monitor notices the thread is stuck in C and hands the P to a different thread, which may need to be created. The goroutines that were queued on the P keep running; only the blocked thread is lost. Second, when the C function finally returns, the goroutine has to reacquire a P before it can run Go code again, and waits if none is free.

The consequence is that the number of threads in a cgo-heavy program is not tied to GOMAXPROCS. GOMAXPROCS limits the number of threads executing Go code. Threads inside C calls do not count against it.

The measurement

The program below starts a hundred goroutines that each call a C function that sleeps for 300 ms with usleep. It reports the thread count from the runtime's threadcreate profile before and after:

main.goGo
package main

/*
#include <unistd.h>
static void block(void) { usleep(300000); }
*/
import "C"

import (
	"fmt"
	"runtime/pprof"
	"sync"
	"time"
)

func threads() int { return pprof.Lookup("threadcreate").Count() }

func main() {
	fmt.Println("threads at start:", threads())

	var wg sync.WaitGroup
	for range 100 {
		wg.Go(func() { C.block() })
	}
	wg.Wait()
	fmt.Println("threads after 100 blocked C calls:", threads())

	before := threads()
	for range 100 {
		wg.Go(func() { time.Sleep(300 * time.Millisecond) })
	}
	wg.Wait()
	fmt.Println("extra threads after 100 time.Sleep goroutines:", threads()-before)
}
Text
threads at start: 7
threads after 100 blocked C calls: 104
extra threads after 100 time.Sleep goroutines: 0

The first number is the baseline of a few runtime threads. After a hundred goroutines blocked in C for 300 ms, the process had created roughly a hundred more: one per simultaneous call. The control at the end uses the same shape with time.Sleep, which the runtime understands, and creates no further threads. The sleep example shows what the Go runtime does well, and the first shows where that stops.

Threads are not free. Each has a stack (with a default of 8 MiB of virtual address space on Linux, typically a fraction of that resident), a kernel scheduling entity, and the cost of creation. A hundred is tolerable. A few thousand begin to hurt, and the Go runtime sets a hard stop. debug.SetMaxThreads defaults to 10,000, and a program that exceeds it dies with fatal error: thread exhaustion. A service that makes one blocking C call per request, behind a slow dependency, can reach that limit during an incident, when every call blocks at once.

Capping the damage

The fix is to bound how many goroutines can be inside C at the same time. A buffered channel makes a counting semaphore, and the C call goes inside it:

main.goGo
package main

/*
#include <unistd.h>
static void block(void) { usleep(300000); }
*/
import "C"

import (
	"fmt"
	"runtime/pprof"
	"sync"
)

func threads() int { return pprof.Lookup("threadcreate").Count() }

// slots limits concurrent calls into C. Size it by what the library can use, not by request volume.
var slots = make(chan struct{}, 8)

func blockCapped() {
	slots <- struct{}{} // acquire; waits in Go, where waiting is cheap
	defer func() { <-slots }()
	C.block()
}

func main() {
	fmt.Println("threads at start:", threads())

	var wg sync.WaitGroup
	for range 100 {
		wg.Go(blockCapped)
	}
	wg.Wait()
	fmt.Println("threads after 100 capped C calls:", threads())
}
Text
threads at start: 7
threads after 100 capped C calls: 15

Eight at a time pass the semaphore; the other ninety-two wait in a channel receive, where a waiting goroutine costs a few kilobytes of stack and no thread. The thread count stays close to the cap plus the runtime's baseline. The calls take longer in total, since they run in waves of eight, which is exactly the trade you are choosing: bounded resources against latency. Pick the cap from what the C library can really do in parallel (a single-connection client wants a cap of one, a CPU-bound codec wants about the number of cores), not from the number of requests. Pair it with a context so a goroutine waiting for a slot gives up when the request does (a semaphore with cancellation).

The per-call cost

Even a C function that returns immediately costs more than a Go function call. The runtime has to switch from the goroutine's small stack to a system stack, record that the goroutine is in a call it cannot preempt, and undo it on return. Measured on this class of hardware, a cgo call is typically on the order of tens of nanoseconds, against about a nanosecond for a Go call the compiler may inline. That is irrelevant for a call that does milliseconds of work and decisive for a call placed in an inner loop. The remedy is to batch: send the whole array to C once and let it loop, instead of calling per element. Cgo's pointer-passing rules apply, and the cgo documentation spells out what may point where.

When to avoid it

The cleanest way to remove these problems is to not use cgo. Cgo disables easy cross-compilation, needs a C toolchain in the build image, makes builds slower, prevents fully static binaries without extra flags, and brings the thread behavior above. Before reaching for a C library, check for a pure-Go alternative; SQLite has one in the modernc.org/sqlite ecosystem, and many codecs have Go ports. When there is no choice, isolate the cgo behind a small package, give it a bounded concurrency, and test it under load with a deliberately slow C call. The cgo documentation covers the rules for passing pointers and for callbacks, and the runtime/debug.SetMaxThreads documentation describes the thread limit. For the same effect through plain system calls without C, the next article is blocking syscalls and the Go scheduler.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.