The reality of concurrent workloads in production
When you're building an API that needs to handle thousands of concurrent requests, the threading model you choose can make or break your system. Go's goroutines have become the go-to solution for developers who need lightweight concurrency, but understanding how they differ from traditional OS threads is essential for writing reliable production code. This guide explains goroutines vs threads: a practical guide for developers who want to unlock Go's concurrency power without running into the traps that catch most teams.
What makes goroutines so cheap
The fundamental difference between goroutines and OS threads comes down to memory overhead. When you spawn an OS thread, the kernel allocates a fixed amount of stack memory—typically 1-2 MB on modern systems. That's a hard floor. If you tried to run 10,000 OS threads on a typical server, you'd burn through gigabytes of memory before any real work even happened.
Goroutines take a different approach. A new goroutine starts with just 2-4 KB of stack space, and here's the key: it grows dynamically as needed. The runtime manages this growth transparently, so you don't have to think about stack depth when you launch one. In practice, you can comfortably run hundreds of thousands of goroutines on a single machine. We've deployed services with over a million concurrent goroutines handling everything from WebSocket connections to background job processing.
This memory efficiency isn't theoretical—it's what makes Go particularly suited for high-concurrency workloads like API servers, message brokers, and real-time systems. When you're building an e-commerce platform that needs to push notifications to thousands of customers simultaneously, or a ride-hailing service tracking live driver locations, goroutines let you do it without provisioning massive machines.
How the Go runtime schedules goroutines onto OS threads
Here's where the magic happens: Go doesn't create one OS thread per goroutine. Instead, the runtime uses a work-stealing scheduler that multiplexes many goroutines onto a smaller pool of OS threads. By default, Go creates roughly as many OS threads as you have CPU cores (controlled by GOMAXPROCS). The scheduler then distributes goroutines across these threads, automatically load-balancing the work.
This is radically different from traditional thread-per-request models. In Java or Python with threads, spawning 10,000 concurrent operations meant creating 10,000 OS threads—which the OS kernel then had to context-switch between constantly. Context switching is expensive: the CPU has to save register state, flush caches, and restore everything for the next thread. With goroutines, you're doing far less switching because far fewer OS threads are involved.
The scheduler also handles blocking operations intelligently. If a goroutine blocks on network I/O or a channel operation, the runtime can park it and schedule a different goroutine on that thread. This keeps cores busy even when individual goroutines are waiting for external resources. That's why a typical Go HTTP server can handle thousands of in-flight requests without breaking a sweat.
Let's look at a concrete example: an order processing service that needs to send confirmation emails.
type OrderService struct {
emailQueue chan *Order
db *sql.DB
}
func (s *OrderService) ProcessOrder(ctx context.Context, order *Order) error {
// Save order to database
if err := s.db.WithContext(ctx).Create(order).Error; err != nil {
return fmt.Errorf("failed to save order: %w", err)
}
// Send email asynchronously using a goroutine
go sendEmail(order)
return nil
}
func sendEmail(order *Order) {
// This could take 1-5 seconds, but doesn't block the API handler
resp, err := http.Post("https://mail-service/send", "application/json", bytes.NewBuffer(orderJSON))
if err != nil {
log.Printf("email send failed for order %s: %v", order.ID, err)
return
}
defer resp.Body.Close()
}In this example, launching a goroutine with go sendEmail(order) is nearly free. The handler returns immediately while the email sends in the background. Under load with thousands of concurrent orders, you're not creating thousands of OS threads—you're distributing the work across a handful of threads that the runtime manages for you.
Starting a goroutine: the simplest concurrent pattern
Launching a goroutine is trivial—just prefix a function call with the go keyword. No setup, no configuration, no thread pools to maintain.
// Synchronous—blocks until done
result := expensiveCalculation()
// Asynchronous—launches in a goroutine
go expensiveCalculation()The simplicity is deceptive, though. Just because it's easy to start a goroutine doesn't mean you should fire them off carelessly. We often see production incidents caused by developers who launch goroutines without thinking about their lifecycle or error handling.
Here's a more realistic scenario: a notification service that needs to broadcast updates to subscribed clients over WebSocket connections. Using a goroutine per connection is the standard pattern:
type Notifier struct {
subscribers map[string]chan *Update
mu sync.RWMutex
}
func (n *Notifier) Subscribe(userID string, ws *websocket.Conn) {
updateChan := make(chan *Update, 10)
n.mu.Lock()
n.subscribers[userID] = updateChan
n.mu.Unlock()
// One goroutine per subscription
go n.broadcastToClient(userID, ws, updateChan)
}
func (n *Notifier) broadcastToClient(userID string, ws *websocket.Conn, updates chan *Update) {
for update := range updates {
if err := ws.WriteJSON(update); err != nil {
log.Printf("failed to send update to %s: %v", userID, err)
break
}
}
// Cleanup when subscription ends
n.mu.Lock()
delete(n.subscribers, userID)
n.mu.Unlock()
}
func (n *Notifier) Publish(userID string, update *Update) {
n.mu.RLock()
ch, exists := n.subscribers[userID]
n.mu.RUnlock()
if exists {
select {
case ch <- update:
default:
log.Printf("notification channel full for %s", userID)
}
}
}Each active connection gets its own goroutine, but they're so cheap that this model scales to thousands of concurrent connections easily.
Planning the exit: preventing goroutine leaks
This is where most teams stumble. A goroutine that never exits is a memory leak—it holds onto its stack, any variables it references, and any resources it's waiting for. Over time, leaked goroutines can consume all available memory and crash your service.
The most common leak pattern is launching a goroutine that waits for an event that never comes. Here's what NOT to do:
// BAD: This goroutine may never exit
go func() {
result := <-someChannel
processResult(result)
}()If someChannel never receives a value, that goroutine sits there forever, holding memory. When you multiply that by thousands of requests, you've got a serious problem.
The fix is to always provide an exit path. Use context cancellation, timeout channels, or explicit cleanup:
func (s *Service) FetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
resultChan := make(chan []byte, 1)
errChan := make(chan error, 1)
// Goroutine exits when it completes or context cancels
go func() {
resp, err := http.Get(url)
if err != nil {
errChan <- err
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
errChan <- err
return
}
resultChan <- body
}()
select {
case result := <-resultChan:
return result, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, ctx.Err() // Goroutine exits when main flow returns
}
}Notice the buffered channels (make(chan T, 1)). This ensures the goroutine can send its result even if the caller has already returned. Without the buffer, the goroutine would block forever trying to write to an unreceived channel.
For long-lived goroutines, use a sync.WaitGroup to track them and ensure they all finish cleanly:
type Worker struct {
jobs chan Job
wg sync.WaitGroup
stopCh chan struct{}
}
func (w *Worker) Start(numWorkers int) {
for i := 0; i < numWorkers; i++ {
w.wg.Add(1)
go w.workerLoop()
}
}
func (w *Worker) workerLoop() {
defer w.wg.Done()
for {
select {
case job := <-w.jobs:
w.processJob(job)
case <-w.stopCh:
return // Clean exit
}
}
}
func (w *Worker) Shutdown() {
close(w.stopCh)
w.wg.Wait() // Block until all workers have exited
}This pattern is used throughout production systems: worker pools, background job processors, and server shutdown handlers all rely on graceful goroutine cleanup.
Common mistakes
- Fire-and-forget goroutines without error handling: When you launch a goroutine, errors that occur inside it are invisible to the caller. Always log failures or use channels to communicate errors back to the main flow.
- Unbounded goroutine spawning: If you launch a goroutine for every external API call without any limits, a spike in traffic can create millions of goroutines, exhausting memory. Use worker pools or semaphores to cap concurrency.
- Ignoring context cancellation: When your request context cancels (user disconnects, timeout expires), any goroutines launched by that request should exit too. Propagate context through all function calls.
- Closing shared channels from multiple goroutines: Only the sender should close a channel. If multiple goroutines try to close the same channel, your program will panic. Use sync.Once or a dedicated closer goroutine.
- Assuming goroutines run in any particular order: Goroutine scheduling is non-deterministic. Never rely on ordering or timing guarantees unless you explicitly synchronize with channels or mutexes.
Key takeaways
- Goroutines consume kilobytes of memory with dynamic stack growth, while OS threads consume megabytes fixed. This enables Go to handle massive concurrency efficiently.
- The Go runtime multiplexes many goroutines onto fewer OS threads, reducing context-switch overhead and keeping CPUs busy even when individual goroutines block.
- Launching a goroutine is one line of code:
go functionCall(). The simplicity tempts misuse—always plan how a goroutine exits. - Every goroutine must have a clear exit condition: a channel close, context cancellation, or a return statement. Otherwise, you leak memory over time.
- Use buffered channels to avoid goroutines blocking on send, and use sync.WaitGroup for coordinated shutdown of multiple goroutines.
- In production, protect against unbounded goroutine creation by using worker pools, semaphores, or explicit connection limits.






Comments
Loading comments…