Go is celebrated for its lightweight goroutines and fast compilation, but many teams hit a wall when building high-throughput data pipelines. The bottleneck is rarely CPU—it's I/O. Disk reads, network calls, and system call overhead can turn a snappy service into a sluggish queue. This guide is for engineers who have a Go pipeline that should be fast but isn't, and who need practical, battle-tested solutions.
Recognizing the I/O Bottleneck: When Go's Concurrency Model Falters
Go's goroutines are cheap, but they don't eliminate the fundamental cost of I/O operations. Each goroutine that blocks on a read or write still consumes a thread from Go's scheduler, and if that thread is waiting on a system call, it can't run other goroutines. The result: under heavy I/O, your pipeline may show high goroutine counts, low CPU utilization, and rising latency.
We often see teams assume that simply adding more goroutines will fix throughput. The reality is that I/O-bound goroutines can pile up, each holding a stack and competing for scheduler time. The operating system's I/O subsystem becomes the real bottleneck. For example, a log ingestion service reading from multiple TCP connections may spawn one goroutine per connection. That works at low scale, but when connections reach thousands, the scheduler spends more time context-switching than doing actual work.
A classic symptom is the "thundering herd" of goroutines all waking up after a select or epoll event, only to find the I/O resource already taken. The scheduler then parks most of them again, wasting cycles. This is the I/O bottleneck in action: not the hardware, but the coordination overhead.
Signs Your Pipeline Is I/O-Bound
- CPU usage stays below 30% while latency climbs.
- Number of goroutines in your pprof output is in the tens of thousands.
- Adding more goroutines does not improve throughput.
- System calls (strace shows read/write) dominate wall-clock time.
If these sound familiar, it's time to rethink your I/O strategy.
Three Approaches to I/O Concurrency in Go
There is no one-size-fits-all solution. The best approach depends on your workload: is it network I/O, disk I/O, or mixed? We'll compare three common patterns: the simple goroutine-per-connection model, bounded worker pools, and async I/O with epoll/kqueue wrappers.
1. Goroutine-per-Connection
This is the default pattern for many Go network services. Each incoming connection or read task gets its own goroutine. It's simple to write and reason about. The downside: at scale, the scheduler overhead becomes significant. For a pipeline handling thousands of concurrent connections, the goroutine stack memory (initially 2 KB, but can grow) adds up, and the scheduler's O(1) per-goroutine cost still matters when there are 50,000 of them.
When to use: Low concurrency (<500 connections), or when each connection does significant CPU work between I/O operations. Not ideal for high-throughput, I/O-only pipelines.
2. Bounded Worker Pools
Worker pools limit the number of goroutines doing I/O simultaneously. You create a fixed number of workers (e.g., 100) and feed tasks through a channel. This controls memory and reduces scheduler contention. The challenge is choosing the pool size: too small and you underutilize I/O bandwidth; too large and you recreate the original problem.
When to use: Disk I/O or network I/O where latency is uniform and you can tune the pool size experimentally. Good for batch processing pipelines.
3. Async I/O with epoll/kqueue
Go's netpoller already uses epoll/kqueue under the hood for network I/O, but for custom file descriptors (like pipes or special devices) you may need to implement your own event loop. Libraries like gnet or evio provide this, but they bypass Go's standard net package and introduce complexity. This approach gives the highest throughput for pure I/O workloads by reducing goroutine count to near zero.
When to use: Extremely high connection counts (10k+) where each connection does minimal work, or when you need fine-grained control over I/O scheduling.
How to Choose: Decision Criteria for Your Pipeline
Picking the right I/O concurrency model requires weighing throughput, latency, memory, and development complexity. We recommend a structured evaluation.
Throughput vs. Latency Trade-off
If your pipeline must maximize requests per second (throughput), async I/O or a well-tuned worker pool usually wins. If you need low and predictable latency (e.g., sub-millisecond per request), goroutine-per-connection can be simpler to tune because you avoid the queueing delay of a worker pool. Measure both metrics under realistic load.
Memory Footprint
Each goroutine has a minimum stack of 2 KB, but that can grow to megabytes if it blocks on a large buffer. For 10,000 goroutines, the memory overhead alone can be 20 MB or more. Worker pools cap this. Async I/O reduces it further because only a few goroutines manage the event loop.
Development and Debugging Cost
Goroutine-per-connection is the easiest to write and debug. Worker pools add channel logic and backpressure handling. Async I/O libraries require learning a new API and often break compatibility with standard Go tools like net/http. Factor in your team's experience and the cost of maintenance.
Checklist for Decision
- What is the peak concurrent I/O count? (<100, 100–5000, >5000)
- Is the I/O mostly network or disk? (Network benefits more from Go's netpoller.)
- What is the acceptable latency percentile (p99)?
- How much memory can you allocate to goroutine stacks?
- Can you use standard Go libraries, or do you need custom I/O?
Answer these before committing to a pattern.
Trade-offs in Practice: A Composite Scenario
Let's examine a realistic scenario: a team builds a log ingestion service that receives JSON logs over TCP, parses them, and writes to a local file. Initially, they use goroutine-per-connection. At 500 connections, it works fine. At 5,000 connections, latency spikes and the service starts dropping connections.
What Went Wrong
The goroutine-per-connection model caused thousands of goroutines to block on TCP reads simultaneously. Go's scheduler could handle the load, but each read invoked a system call, and the kernel spent more time context-switching between threads than reading data. Additionally, the file writes were unbuffered, causing each write to hit the disk synchronously.
The Fix: Worker Pool + Buffered Writes
The team switched to a worker pool of 200 goroutines. Incoming connections were accepted by a single goroutine, which dispatched read tasks to the pool via a buffered channel. They also added a bufio.Writer for file output, flushing every 4 KB. The result: throughput increased 3x, p99 latency dropped from 200 ms to 30 ms, and memory usage fell by 40%.
This scenario illustrates a common pattern: the simplest design works until it doesn't, and the fix often involves limiting concurrency and batching I/O.
Another Angle: Async I/O for Extreme Scale
If the same service needed to handle 50,000 connections, the worker pool would still struggle because each worker goroutine blocks on a single connection's read. In that case, an async event loop (using gnet or raw epoll) could handle all connections with a handful of goroutines. The trade-off is higher code complexity and less compatibility with standard Go middleware.
Implementation Path: From Diagnosis to Deployment
Once you've chosen an approach, follow these steps to implement it safely.
Step 1: Profile the Current System
Use pprof to capture CPU and goroutine profiles under load. Look for high time in syscall.Read or syscall.Write. Also check block profile for mutex contention. This tells you where the bottleneck is.
Step 2: Prototype the New Concurrency Model
Write a small benchmark that simulates your I/O pattern. Test the goroutine-per-connection baseline against your chosen alternative. Measure throughput, latency percentiles, and memory. Use testing.B with realistic data sizes.
Step 3: Add Backpressure
Whether you use worker pools or async I/O, ensure that when the system is overloaded, it rejects or queues work gracefully. For worker pools, use a buffered channel and a select with a default case to drop or return errors. For async I/O, implement a token bucket or circuit breaker.
Step 4: Tune I/O Buffering
For disk writes, always use buffered writers. For network reads, consider using bufio.Reader to reduce system calls. The optimal buffer size depends on your message size; 4 KB to 64 KB is typical.
Step 5: Monitor and Iterate
Deploy the change behind a feature flag or to a canary. Monitor goroutine count, system call rate, and latency. Adjust pool size or buffer sizes based on real traffic. Remember that the optimal configuration may change as your traffic patterns evolve.
Risks of Getting It Wrong
Choosing the wrong I/O model can lead to performance degradation, resource exhaustion, or hard-to-debug failures. Here are the most common pitfalls.
Overloading the Scheduler
Using too many goroutines for I/O can starve the scheduler. Go's scheduler uses a work-stealing algorithm, but when thousands of goroutines are all blocked on I/O, the run queues become empty and the scheduler spends time parking and unparking threads. This manifests as low CPU usage and high latency.
Ignoring File Descriptor Limits
Each open connection or file uses a file descriptor. If your goroutine-per-connection model hits the system's ulimit (often 1024 or 4096), connections will be refused. Always check and raise limits, but also design to stay below them.
Backpressure Neglect
Without backpressure, a slow consumer can cause memory to balloon as unprocessed data accumulates in channels or buffers. This can lead to OOM crashes. Always implement a strategy: drop, throttle, or reject.
Premature Optimization
Async I/O libraries add complexity. If your pipeline handles fewer than 1,000 concurrent I/O operations, the goroutine-per-connection model is likely sufficient. Don't adopt a complex solution before you measure the actual bottleneck.
Misaligned GOMAXPROCS
Setting GOMAXPROCS too high can increase contention on the scheduler's global run queue. For I/O-bound workloads, a value equal to the number of CPU cores (or slightly higher) is usually best. Experiment with values between 1 and 2x cores.
Frequently Asked Questions
When should I use io.Copy vs. bufio.Reader/Writer?
Use io.Copy when you're moving data from one stream to another without processing, like copying a file to a network connection. It internally uses a 32 KB buffer. If you need to read line by line or parse data, use bufio.Reader for its buffering and convenience methods. For writing, bufio.Writer lets you control flush frequency.
How do I tune GOMAXPROCS for an I/O-heavy pipeline?
Start with the number of physical CPU cores. Increase it if you have blocking system calls that cause threads to wait. A common heuristic is GOMAXPROCS = number of cores + 1 or 2. Monitor context switches with perf or sar; if they are high, reduce GOMAXPROCS.
Are netpoll libraries like gnet worth the complexity?
They are worth it if you need to handle 10,000+ concurrent connections with minimal per-connection work. For typical web services or APIs, Go's built-in netpoller is sufficient. Libraries like gnet bypass Go's runtime and can give 2-3x throughput in extreme cases, but they require significant changes to your codebase and often lack support for TLS or HTTP/2.
What about using io.Pipe for in-memory I/O?
io.Pipe creates a synchronous in-memory pipe. It's useful for connecting a reader and writer in the same process, but it blocks the goroutine until both ends are ready. Use it sparingly; for high throughput, consider a buffered channel or shared buffer with synchronization.
How do I debug a mysterious I/O bottleneck?
Start with pprof goroutine profile: look for goroutines stuck in syscall.Read or syscall.Write. Then use strace -c -p to count system calls. If you see many read calls returning small amounts, you need larger buffers. Also check network latency with tcpdump or iperf.
Next steps: run a load test with your current design, profile it, then apply one of the three concurrency models. Measure again. Iterate. The goal is not to eliminate all I/O overhead—that's impossible—but to align your concurrency model with the actual I/O pattern your pipeline serves.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!