willworth.dev
View RSS feed

Concurrency in Go

Published on

Concurrency in Go: A Beginner's Guide to Thinking in Systems

When developers first approach Go, they often come from languages where concurrent programming is either an afterthought or requires heavy-duty frameworks. Go takes a different approach: concurrency is baked into its DNA. But with this power comes the responsibility to think differently about how we structure our programs.

The Foundation: Understanding Go's Philosophy

Let's start with Go's famous concurrency mantra:

"Don't communicate by sharing memory; share memory by communicating"

This might sound cryptic at first, but it's actually a profound shift in how we think about concurrent programming. Let's break it down:

The Old Way: Sharing Memory

In traditional concurrent programming, different threads might share the same memory space:

var counter int

// Thread 1
counter++

// Thread 2
counter++

This seems simple, but it's fraught with dangers:

  • What if both threads try to increment at the same time?
  • How do we protect this shared resource?
  • What if one thread reads while another writes?

We end up needing locks, mutexes, and complex synchronization mechanisms. It's like having multiple people try to write in the same notebook at once – chaos unless carefully managed.

The Go Way: Communicating Instead

Go encourages a different approach: instead of sharing memory and protecting it with locks, we pass messages between independent processes (goroutines):

// Create a channel for communication
counter := make(chan int)

// Goroutine that owns the counter
go func() {
value := 0
for {
value++
counter <- value // Send new value
}
}()

// Other goroutines request the value when needed
newValue := <-counter

This is like having people pass notes instead of fighting over a notebook. Each piece of data has a clear owner, and we communicate by sending messages.

Goroutines: Your Building Blocks

Think of goroutines as incredibly lightweight threads. When I say lightweight, I mean it:

  • A typical thread might cost megabytes of memory
  • A goroutine starts with just 2KB
  • You can easily run thousands or even millions of goroutines

Here's a simple example:

func printNumbers() {
for i := 0; i < 5; i++ {
fmt.Println(i)
time.Sleep(100 * time.Millisecond)
}
}

func main() {
// Start multiple goroutines
for i := 0; i < 3; i++ {
go printNumbers()
}

// Wait so we can see the output
time.Sleep(1 * time.Second)
}

Channels: The Communication Highways

Channels are Go's built-in way for goroutines to communicate. Think of them as pipes where you can send and receive messages:

// Unbuffered channel
messages := make(chan string)

// Buffered channel with room for 3 messages
bufferedMessages := make(chan string, 3)

Unbuffered vs Buffered Channels

This is where things get interesting:

Unbuffered Channels:

  • Like a direct handoff
  • Sender waits until receiver is ready
  • Great for synchronization
  • Can cause deadlocks if not careful
messages := make(chan string)

go func() {
messages <- "Hello!" // Will wait here until someone receives
}()

msg := <-messages // Receives when ready

Buffered Channels:

  • Like a small mailbox
  • Sender can drop off messages (up to buffer size) without waiting
  • Less synchronization, more flexibility
  • Can hide timing bugs
messages := make(chan string, 2)
messages <- "First" // Won't block
messages <- "Second" // Won't block
// messages <- "Third" // Would block until someone receives

Real-World Pattern: Worker Pools

Let's look at a common pattern: processing a bunch of tasks concurrently with a fixed number of workers:

type Task struct {
ID int
Data string
Result chan string
}

func worker(id int, tasks <-chan Task) {
for task := range tasks {
// Process the task
result := fmt.Sprintf("Worker %d processed task %d: %s",
id, task.ID, task.Data)

// Send back result
task.Result <- result
}
}

func main() {
// Create task channel
tasks := make(chan Task)

// Start workers
numWorkers := 3
for i := 1; i <= numWorkers; i++ {
go worker(i, tasks)
}

// Send some tasks
for i := 1; i <= 5; i++ {
resultChan := make(chan string)
tasks <- Task{
ID: i,
Data: fmt.Sprintf("Task %d data", i),
Result: resultChan,
}

// Wait for and print result
fmt.Println(<-resultChan)
}
}

This pattern is incredibly powerful because:

  1. It naturally load-balances work across workers
  2. It controls resource usage (fixed number of workers)
  3. It's easy to scale by adjusting the number of workers

Advanced Pattern: Fan-Out, Fan-In

Sometimes you need to split work across multiple goroutines and then combine their results. This pattern is called fan-out, fan-in:

func fanOut(input <-chan int, numWorkers int) []<-chan int {
channels := make([]<-chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
ch := make(chan int)
go func(ch chan int) {
for num := range input {
result := num * 2 // Some processing
ch <- result
}
close(ch)
}(ch)
channels[i] = ch
}
return channels
}

func fanIn(channels []<-chan int) <-chan int {
combined := make(chan int)
var wg sync.WaitGroup

for _, ch := range channels {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for num := range ch {
combined <- num
}
}(ch)
}

// Close combined channel when all input channels are done
go func() {
wg.Wait()
close(combined)
}()

return combined
}

Error Handling and Timeouts

In real systems, things go wrong. Go provides excellent tools for handling these cases:

func doWork(ctx context.Context) (string, error) {
// Create channel for result
resultCh := make(chan string)
errCh := make(chan error)

go func() {
// Simulate some work
time.Sleep(100 * time.Millisecond)
resultCh <- "Success!"
}()

// Wait for result or timeout
select {
case result := <-resultCh:
return result, nil
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(2 * time.Second):
return "", fmt.Errorf("operation timed out")
}
}

Best Practices and Gotchas

  1. Always Clean Up

    • Close channels when you're done with them
    • Use defer for cleanup operations
    • Consider using context for cancellation
  2. Avoid Goroutine Leaks

    // BAD:
    go func() {
    for {
    // This goroutine never exits
    }
    }()

    // GOOD:
    go func(ctx context.Context) {
    for {
    select {
    case <-ctx.Done():
    return
    default:
    // Do work
    }
    }
    }(ctx)
  3. Handle Channel Closure

    // Sender should close
    close(ch)

    // Receiver can check for closure
    val, ok := <-ch
    if !ok {
    // Channel is closed
    }

Conclusion

Concurrent programming in Go is not just about running things in parallel – it's about thinking in systems. By embracing Go's philosophy of communication over shared memory, you can build robust, scalable systems that are easier to reason about and maintain.

Remember:

  • Start with channels and goroutines for simple cases
  • Use worker pools for parallel task processing
  • Implement fan-out, fan-in for complex workflows
  • Always handle errors and cleanup
  • Use context for timeouts and cancellation

The power of Go's concurrency model lies in its simplicity and composability. As you build more complex systems, these basic patterns combine in powerful ways to solve real-world problems.

Happy coding!