n4nAI

Benchmarking goroutine overhead in concurrent LLM calls

A practical go goroutine overhead benchmark llm study: measuring scheduler cost vs network latency, and why bounded concurrency beats per-request goroutines at scale.

n4n Team4 min read950 words

Audio narration

Coming soon — every post will get a voice note here.

Most Go services that call LLM APIs default to launching a goroutine per request and move on. A go goroutine overhead benchmark llm exercise shows that the scheduler cost is negligible compared to network latency, but unbounded concurrency still sinks workloads through connection exhaustion and provider rate limits.

The baseline: how cheap is a goroutine?

Go’s goroutine is a lightweight, stack-allocated thread of execution managed by the runtime. The initial stack is 2KB (as of Go 1.22), growing and shrinking as needed. Spawning one is a few dozen nanoseconds of allocation and scheduler bookkeeping.

To ground this, here is a minimal benchmark that isolates spawn cost from any I/O:

func BenchmarkGoroutineSpawn(b *testing.B) {
    for i := 0; i < b.N; i++ {
        done := make(chan struct{})
        go func() {
            close(done)
        }()
        <-done
    }
}

Running this with go test -bench=. on any modern machine reports numbers in the low hundreds of nanoseconds per operation. That includes channel creation and synchronization. The takeaway: the runtime can create millions of goroutines per second if they do nothing.

Measurement approach

The above benchmark is intentionally trivial. It does not measure stack growth, syscalls, or context switches under load. For a realistic go goroutine overhead benchmark llm scenario, we must add the weight of an actual HTTP client call to a model endpoint.

func BenchmarkLLMCallSequential(b *testing.B) {
    ctx := context.Background()
    for i := 0; i < b.N; i++ {
        _, _ = callLLM(ctx, "ping")
    }
}

A single call to a hosted LLM over TLS, with a small prompt, typically takes hundreds of milliseconds to seconds depending on model size and provider load. Compared to ~300ns for a goroutine spawn, the spawn is six orders of magnitude cheaper.

LLM calls are dominated by I/O, not scheduling

The core finding of any go goroutine overhead benchmark llm project is that the network round trip dwarfs runtime overhead. Even if you could eliminate goroutine cost entirely, you would not speed up a batch of 1,000 calls by more than a fraction of a percent.

Typical latency profile

An OpenAI-compatible chat completion request involves:

  • DNS resolution (cached after first)
  • TCP + TLS handshake (reused via http.KeepAlive)
  • Request serialization (tiny)
  • Server-side queue and inference (dominant)
  • Response streaming or full-body return

The server-side inference is the bottleneck. A 7B model might return first token in 100–300ms; a 70B model or constrained provider might take multiple seconds. No client-side concurrency trick changes that.

File descriptors and connection pools

Each goroutine that makes an outbound HTTP call uses a socket. The Go net/http transport maintains a pool, but unbounded goroutines will open unbounded connections if you do not cap MaxIdleConnsPerHost and MaxConnsPerHost. The default MaxIdleConnsPerHost is 100, but active connections can exceed that under concurrency spikes.

t := &http.Transport{
    MaxIdleConns:        1000,
    MaxIdleConnsPerHost: 100,
    MaxConnsPerHost:     100, // hard cap
}
client := &http.Client{Transport: t}

Without such caps, a loop like for _, p := range prompts { go callLLM(p) } will, at 10k prompts, attempt 10k simultaneous sockets. The OS will refuse with too many open files long before the goroutine scheduler complains.

Unbounded goroutines will still kill you

The thesis here is nuanced: goroutine spawn cost is not your problem, but goroutine lifetime and resource holdings are.

Memory and FD limits

A goroutine blocked on a slow LLM response holds its 2KB+ stack, any local variables, and a file descriptor. 50,000 blocked goroutines consume ~100MB of stack alone, plus kernel socket buffers. That is manageable until you add retries.

The retry storm anti-pattern

A common bug: on rate-limit error, spawn another goroutine to retry immediately. If the provider is degraded, you now have N original goroutines plus N retry goroutines, then N*2, exponentially. This is where a go goroutine overhead benchmark llm misses the point—the overhead is trivial, but the resulting connection flood is fatal.

Use bounded retries with backoff and a shared context:

func callWithRetry(ctx context.Context, p string) (string, error) {
    var lastErr error
    for attempt := 0; attempt < 3; attempt++ {
        if err := ctx.Err(); err != nil {
            return "", err
        }
        if resp, err := callLLM(ctx, p); err == nil {
            return resp, nil
        } else {
            lastErr = err
            time.Sleep(time.Second << attempt)
        }
    }
    return "", lastErr
}

Bounded concurrency with worker pools

The decisive pattern for production LLM batch work is a semaphore or worker pool. It caps simultaneous goroutines to a number matching your provider’s rate limit and your FD budget.

Semaphore pattern

A buffered channel acts as a counting semaphore:

func BatchCall(ctx context.Context, prompts []string, concurrency int) {
    sem := make(chan struct{}, concurrency)
    var wg sync.WaitGroup
    for _, p := range prompts {
        wg.Add(1)
        go func(p string) {
            defer wg.Done()
            sem <- struct{}{}
            defer func() { <-sem }()
            _, _ = callWithRetry(ctx, p)
        }(p)
    }
    wg.Wait()
}

Here, concurrency might be 20 if your API key allows 20 RPM, or 200 if you have higher quota. The goroutines beyond the cap block on sem <- struct{}{} without consuming extra sockets.

Using client-side cancellation

Always pass a context.Context with a timeout. LLM providers can hang. A blocked goroutine behind a semaphore prevents others from starting.

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
BatchCall(ctx, prompts, 50)

Where a gateway changes the equation

If you front your inference traffic with a gateway such as n4n.ai, which provides automatic fallback when a provider is rate-limited or degraded, you can keep client-side concurrency bounded and push retry logic to the edge. The gateway honors client routing directives and forwards cache-control hints, so a single OpenAI-compatible call from Go need not implement cross-provider failover goroutines. That removes one class of retry-storm risk entirely.

This does not eliminate the need for a semaphore—your local FD count is still finite—but it simplifies the goroutine’s job to a single HTTP request per logical attempt.

Tradeoffs: when to reach for more complex orchestration

A plain worker pool is enough for batch jobs. Streaming responses or fan-out pipelines need more.

Fan-out vs pipeline

If you need to call an LLM, then post-process, then call again, a channel-based pipeline avoids holding a goroutine per stage for the whole duration. But each network stage still respects the same concurrency cap.

// stage 1: generate
gen := make(chan string, 10)
// stage 2: summarize
go func() {
    for p := range gen {
        sum, _ := callLLM(ctx, "summarize: "+p)
        // ...
    }
}()

Streaming considerations

When streaming tokens, the goroutine lives for the entire generation but yields data incrementally. You should still limit concurrent streams; each holds a connection. Use http.Flusher or the github.com/sashabaranov/go-openai streaming API, but cap with the same semaphore.

Takeaway

The go goroutine overhead benchmark llm result is clear: spawning a goroutine per LLM call is not what hurts you. The runtime handles millions of lightweight threads effortlessly. What hurts is unbounded concurrent network connections, provider rate limits, and retry amplification. Default to a bounded worker pool with a context timeout, cap your transport’s connections, and if you use a fallback-capable gateway, let it absorb cross-provider retries. Reach for more elaborate orchestration only when you have multi-stage pipelines or strict latency budgets, not because you fear goroutine cost.

Tagsgolangbenchmarkinggoroutinesperformance

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All go streaming with goroutines & channels posts →