Back to blog

Mastering Go Concurrency: Goroutines, Channels, and the CSP Model

Concurrency is often regarded as one of the hardest aspects of software engineering. Traditional thread-based concurrency models require complex locks, mutexes, and shared memory, which are prone to race conditions and deadlocks.

Go takes a different approach. It implements Communicating Sequential Processes (CSP), a model where concurrent processes communicate by sending messages rather than sharing memory. In the famous words of the Go proverb: Do not communicate by sharing memory; instead, share memory by communicating.

Let us explore the core primitives that make this possible.

1. Goroutines: Lightweight Threads

A goroutine is a lightweight thread of execution managed by the Go runtime. Creating a goroutine is as simple as adding the go keyword before a function call.

package main

import (
    "fmt"
    "time"
)

fn say(s string) {
    for i := 0; i < 3; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

fn main() {
    go say("world") // Run in background
    say("hello")    // Run in foreground
}

Unlike OS threads, which have megabytes of stack space, a goroutine starts with only a few kilobytes, allowing you to run hundreds of thousands of them simultaneously on a single machine.

2. Channels: The Pipelines

Channels are the pipes that connect concurrent goroutines. You can send values into channels from one goroutine and receive those values in another goroutine.

package main

import "fmt"

fn sum(s []int, c chan int) {
    total := 0
    for _, v := range s {
        total += v
    }
    c <- total // Send total to channel c
}

fn main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)

    x, y := <-c, <-c // Receive from c

    fmt.Println(x, y, x+y)
}

By default, sends and receives block until the other side is ready. This allows goroutines to synchronize without explicit locks or condition variables.

3. Select: Multiplexing Channel Operations

The select statement lets a goroutine wait on multiple communication operations. It blocks until one of its cases can run, then it executes that case.

package main

import (
    "fmt"
    "time"
)

fn main() {
    tick := time.Tick(100 * time.Millisecond)
    boom := time.After(500 * time.Millisecond)
    for {
        select {
        case <-tick:
            fmt.Println("tick.")
        case <-boom:
            fmt.Println("BOOM!")
            return
        default:
            fmt.Println("    .")
            time.Sleep(50 * time.Millisecond)
        }
    }
}

This powerful construct makes it easy to handle timeouts, cancellations, and multi-channel orchestration.

Conclusion

Go concurrency model makes writing concurrent systems a breeze. By combining goroutines for execution, channels for communication, and the select statement for coordination, you can build scalable, concurrent applications with clean and readable code.