6.1 Introduction to Concurrency

Concurrency is one of Go's most powerful and distinctive features. Understanding concurrency is essential for writing efficient, scalable Go programs. This section introduces the fundamental concepts that make Go's concurrency model unique.

What is Concurrency?

Concurrency is the ability of a program to manage multiple tasks at the same time. It's about dealing with lots of things at once, not necessarily doing them simultaneously. Think of it like a chef managing multiple dishes—they might start one dish, then switch to another while the first is cooking, then come back to check on it.

Modern applications face several challenges that make concurrency essential. Reading from files, network requests, and database queries often involve waiting, and while waiting for one operation, you can work on another. Applications need to remain responsive while performing background tasks—a web server must handle multiple requests simultaneously. Modern computers have multiple CPU cores, and concurrency allows you to utilize all available resources effectively. Systems that can handle concurrent operations can scale to serve more users and process more data.

Example of concurrent thinking

package main

import (
    "fmt"
    "time"
)

// Sequential execution
func sequentialTask() {
    fmt.Println("Task 1: Fetching data from database...")
    time.Sleep(1 * time.Second)  // Simulate database query

    fmt.Println("Task 2: Processing file...")
    time.Sleep(1 * time.Second)  // Simulate file processing

    fmt.Println("Task 3: Sending email...")
    time.Sleep(1 * time.Second)  // Simulate email sending

    fmt.Println("All tasks completed sequentially")
}

// Concurrent execution
func concurrentTask() {
    done := make(chan bool, 3)

    go func() {
        fmt.Println("Task 1: Fetching data from database...")
        time.Sleep(1 * time.Second)
        fmt.Println("Task 1: Complete")
        done <- true
    }()

    go func() {
        fmt.Println("Task 2: Processing file...")
        time.Sleep(1 * time.Second)
        fmt.Println("Task 2: Complete")
        done <- true
    }()

    go func() {
        fmt.Println("Task 3: Sending email...")
        time.Sleep(1 * time.Second)
        fmt.Println("Task 3: Complete")
        done <- true
    }()

    // Wait for all tasks
    for i := 0; i < 3; i++ {
        <-done
    }
    fmt.Println("All tasks completed concurrently")
}

func main() {
    fmt.Println("=== Sequential Execution ===")
    start := time.Now()
    sequentialTask()
    fmt.Printf("Time taken: %v\n\n", time.Since(start))

    fmt.Println("=== Concurrent Execution ===")
    start = time.Now()
    concurrentTask()
    fmt.Printf("Time taken: %v\n", time.Since(start))
}

Output

=== Sequential Execution ===
Task 1: Fetching data from database...
Task 2: Processing file...
Task 3: Sending email...
All tasks completed sequentially
Time taken: 3.001234567s

=== Concurrent Execution ===
Task 1: Fetching data from database...
Task 2: Processing file...
Task 3: Sending email...
Task 1: Complete
Task 2: Complete
Task 3: Complete
All tasks completed concurrently
Time taken: 1.001234567s

The concurrent version completes in approximately 1 second instead of 3 seconds because all three tasks run simultaneously.

Concurrency vs Parallelism

These terms are often confused, but they have distinct meanings:

Concurrency

Parallelism

The relationship

Concurrency is about structure, parallelism is about execution. You can have:

Understanding this distinction helps you design better systems by structuring code for concurrency and letting Go handle parallelism. It also helps you debug performance issues by identifying whether a bottleneck comes from lack of parallelism or poor concurrency design. This knowledge enables you to optimize effectively by focusing on the right level—concurrency design versus parallel execution.

Visual analogy

Think of a restaurant:

Go provides both: you write concurrent code (goroutines), and Go automatically uses parallelism when multiple CPU cores are available.

Why Go Excels at Concurrency

Go was designed from the ground up with concurrency in mind. The language provides several features that make concurrent programming both simple and efficient.

1. Lightweight Goroutines

Traditional threads in languages like Java or C++ are managed by the operating system. Each thread requires a large stack (typically 1-2 MB per thread), OS-level context switching (which is expensive), and has limited scalability—thousands of threads becomes problematic.

Goroutines are managed by Go's runtime, not the OS. This fundamental difference enables their lightweight nature. Each goroutine starts with a small stack of just 2 KB, which grows dynamically as needed—far smaller than the 1-2 MB typically allocated to OS threads. This small initial footprint means you can create millions of goroutines without exhausting system resources.

The efficiency comes from Go's runtime scheduler, which operates in user space rather than requiring expensive OS-level context switches. The scheduler uses a technique called "M:N scheduling," where M goroutines (potentially millions) are multiplexed onto N OS threads (typically matching the number of CPU cores). This multiplexing means the Go runtime maintains control over when and how goroutines execute, allowing it to make intelligent scheduling decisions.

When a goroutine performs a blocking operation—such as waiting for I/O, sleeping, or communicating through a channel—the Go scheduler automatically detects this and switches to another ready goroutine. This cooperative scheduling happens entirely within the Go runtime, avoiding the overhead of OS thread context switches. The scheduler can preemptively switch goroutines as well, ensuring that CPU-bound goroutines don't starve others. This combination of small stacks, user-space scheduling, and intelligent multiplexing gives goroutines their massive scalability advantage.

Example demonstrating scalability

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup
    numGoroutines := 100000  // 100,000 goroutines!

    start := time.Now()

    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // Simulate some work
            time.Sleep(10 * time.Millisecond)
        }(i)
    }

    wg.Wait()
    elapsed := time.Since(start)

    fmt.Printf("Created and completed %d goroutines\n", numGoroutines)
    fmt.Printf("Time taken: %v\n", elapsed)
    fmt.Printf("Goroutines per second: %.0f\n", float64(numGoroutines)/elapsed.Seconds())
}

This demonstrates that Go can easily handle hundreds of thousands of goroutines, something that would be impractical with traditional threads.

2. Built-in Communication: Channels

Most languages rely on shared memory and locks for communication between threads, which leads to race conditions (data accessed by multiple threads simultaneously), deadlocks (threads waiting for each other indefinitely), and complex synchronization code that's hard to reason about. Go's philosophy is different: "Don't communicate by sharing memory; share memory by communicating."

Channels provide a safe, built-in way for goroutines to communicate. They enable synchronous communication where data is safely passed between goroutines. Unlike shared memory approaches, each goroutine has its own copy of data, making it obvious which goroutine owns data at any moment. This clear ownership model prevents many common concurrency bugs.

Example comparing approaches

// Traditional approach (shared memory with locks)
var counter int
var mu sync.Mutex

func increment() {
    mu.Lock()
    counter++  // Potential for bugs if lock is forgotten
    mu.Unlock()
}

// Go's approach (communication via channels)
func incrementChannel(counterChan chan int) {
    current := <-counterChan  // Receive current value
    counterChan <- current + 1  // Send incremented value
}

The channel approach makes data flow explicit and prevents many common concurrency bugs.

3. Simple Syntax

Go's concurrency primitives (go keyword, channels, select) are part of the language, not a library. This design means everyone uses the same concurrency primitives, creating a consistent API. The minimal set of concepts makes concurrency easy to learn, while these simple primitives combine to create complex patterns. Here's how simple the syntax is:

// Starting a goroutine is as simple as:
go doSomething()

// Creating a channel:
ch := make(chan int)

// Sending/receiving:
ch <- value    // Send
value := <-ch  // Receive

// Waiting for multiple channels:
select {
case msg := <-ch1:
    // Handle message from ch1
case msg := <-ch2:
    // Handle message from ch2
}

Compare this to other languages where you might need to:

4. Automatic Parallelism

Go's runtime automatically uses multiple CPU cores when available. You don't need to manually create thread pools, configure thread affinity, or manage CPU core assignment. The runtime handles all of this for you, making parallelism transparent.

The Go runtime's scheduler operates as follows:

  1. Detects available CPU cores
  2. Creates OS threads (typically one per CPU core)
  3. Distributes goroutines across threads
  4. Automatically balances load

You write concurrent code, and Go handles the parallel execution.

Go's Concurrency Model

Go's concurrency model is based on CSP (Communicating Sequential Processes), a mathematical model developed by Tony Hoare in 1978.

Core principles of CSP

  1. Sequential Processes: Each goroutine is a sequential process that executes independently
  2. Communication: Processes communicate through channels (synchronous message passing)
  3. No Shared State: Processes don't share memory directly; they communicate by passing messages

Go implements CSP through goroutines and channels. Each goroutine is a sequential process that executes independently. Processes communicate through channels using synchronous message passing, and they don't share memory directly—they communicate by passing messages. Here's an example:

// Each goroutine is a sequential process
func worker(id int, jobs <-chan int, results chan<- int) {
    // This function executes sequentially
    for job := range jobs {
        // Process job
        result := job * 2
        // Send result via channel (communication)
        results <- result
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // Start worker processes
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect results
    for r := 1; r <= 5; r++ {
        fmt.Println(<-results)
    }
}

CSP provides several advantages: each goroutine is independent and sequential, making code easier to understand. The absence of shared memory means fewer race conditions and bugs. Simple goroutines can be composed into complex systems, and each goroutine can be tested independently.

While Go is inspired by CSP, it adds practical features that make it more suitable for real-world use. Buffered channels allow asynchronous communication when needed. The select statement handles multiple channels elegantly. Goroutines are lightweight compared to CSP's processes, and the runtime handles process scheduling automatically.

This combination makes Go's concurrency model both theoretically sound (based on proven CSP principles) and practically powerful (with real-world performance and ease of use).