Concurrency - Goroutines and Channels

The Microservice That Processed 10,000 Records in 12 Minutes

It was Thursday afternoon when my teammate pinged me: "The data sync job is taking forever. Can you take a look?"

I pulled up the logs. Our microservice was processing 10,000 user records from an external API, updating our database for each one. Elapsed time: 12 minutes, 34 seconds.

The Python code looked innocent enough:

def sync_users():
    user_ids = get_all_user_ids()  # 10,000 IDs
    
    for user_id in user_ids:
        user_data = fetch_user_from_api(user_id)  # ~70ms per call
        update_database(user_data)  # ~5ms per call
    
    print(f"Synced {len(user_ids)} users")

The math: 10,000 users × 75ms per user = 750,000ms = 12.5 minutes.

We'd tried multiprocessing, but the overhead of spawning processes was eating into our gains. Threading helped a bit, but the GIL limited true parallelism.

That weekend, I rewrote it in Go:

func syncUsers() error {
    userIDs, err := getAllUserIDs()
    if err != nil {
        return err
    }
    
    var wg sync.WaitGroup
    results := make(chan error, len(userIDs))
    
    // Process 50 users concurrently
    sem := make(chan struct{}, 50)
    
    for _, id := range userIDs {
        wg.Add(1)
        go func(userID string) {
            defer wg.Done()
            sem <- struct{}{}        // Acquire semaphore
            defer func() { <-sem }() // Release semaphore
            
            userData, err := fetchUserFromAPI(userID)
            if err != nil {
                results <- err
                return
            }
            
            if err := updateDatabase(userData); err != nil {
                results <- err
            }
        }(id)
    }
    
    wg.Wait()
    close(results)
    
    return nil
}

The result: 10,000 users synced in 15 seconds. A 50x improvement.

I'd used goroutines for the first time, and they changed how I thought about concurrency. This article covers everything I learned.


What Are Goroutines?

A goroutine is a lightweight thread managed by the Go runtime. Unlike OS threads:

  • Lightweight: Start with 2KB of stack (vs 1-2MB for OS threads)

  • Cheap: Can run millions of goroutines on a modest machine

  • Multiplexed: Go runtime schedules goroutines across OS threads

Starting a Goroutine

Critical: The main function is itself a goroutine. If it exits, all other goroutines are terminated.

Basic Example

Output (order may vary):


Channels: Communicating Between Goroutines

Channels are Go's way of letting goroutines communicate safely. They're typed and synchronized.

Creating and Using Channels

Simple Example

Real Example: Concurrent URL Checker


Buffered vs Unbuffered Channels

Unbuffered Channels

Default behavior - sends block until someone receives:

Buffered Channels

Can hold a limited number of values without blocking:

When to use buffered channels:

  • Producer/consumer with different speeds

  • Batch processing

  • Preventing goroutine blocking


Channel Directions

You can restrict channels to send-only or receive-only:

This enforces correct usage at compile time.


Closing Channels

Checking if Channel is Closed

Ranging Over Channels

Critical: If you don't close the channel, the range loop will deadlock waiting for more values.


The Select Statement

select lets you wait on multiple channel operations:

Select with Default

Non-blocking select:

Select with Timeout

Real Example: Worker with Timeout


Real Example: Concurrent Data Processor

This is similar to what I built for the user sync service:


Common Pitfalls and How to Avoid Them

1. Goroutine Leaks

Problem: Goroutines waiting on channels that never get data:

Solution: Always ensure goroutines have an exit path:

2. Channel Deadlocks

Problem: All goroutines are blocked waiting:

Solution: Use buffered channel or goroutine:

3. Closing Channels Multiple Times

Problem: Closing an already-closed channel panics:

Solution: Only the sender should close:

4. Sending to Closed Channel

Problem: Sending to a closed channel panics:

Solution: Don't send after closing, or use recover:

5. Data Races

Problem: Accessing shared data without synchronization:

Solution: Use channels or sync primitives:


Your Challenge

Build a concurrent web scraper:


Key Takeaways

  1. Goroutines are cheap: Can run thousands concurrently

  2. Channels synchronize: Safe communication between goroutines

  3. Buffered vs unbuffered: Buffered channels prevent blocking

  4. Channel directions: Enforce send-only or receive-only at compile time

  5. Close channels: Sender closes to signal completion

  6. Select statement: Wait on multiple channels

  7. Avoid leaks: Always provide exit paths for goroutines

  8. Detect races: Use go run -race to find data races


What I Learned

That user sync rewrite taught me that concurrency doesn't have to be hard:

  • Goroutines made parallelism trivial - no thread pools, no complexity

  • Channels eliminated shared state bugs - data races disappeared

  • 15-second sync time saved hours of processing weekly

  • Go's runtime handled the hard parts - scheduling, multiplexing

Coming from Python's threading/multiprocessing pain, Go's concurrency felt magical. But it's not magic - it's thoughtful design. The sync service has been running for 2 years now, processing millions of records without a single concurrency bug.

The 50x speedup was nice. The zero-bug record was better.


Next: Concurrency Patterns

In the next article, we'll explore advanced concurrency patterns: worker pools, fan-out/fan-in, pipelines, and the context package. You'll learn the patterns that turned Go into the language of choice for high-performance systems.

Last updated