CSP: Communicating Sequential Processes

In 1978, Tony Hoare published 'Communicating Sequential Processes,' a paper that would fundamentally shape how we think about concurrent programming. While the industry spent decades wrestling with...

Key Insights

  • CSP eliminates shared-state concurrency bugs by forcing all communication through typed channels, making race conditions structurally impossible rather than just discouraged.
  • Synchronous channels provide natural backpressure and make system behavior predictable, but require careful design to avoid deadlocks—buffered channels trade predictability for flexibility.
  • The select statement is CSP’s killer feature, enabling clean handling of timeouts, cancellation, and multiplexed I/O without callback hell or complex state machines.

Introduction to CSP

In 1978, Tony Hoare published “Communicating Sequential Processes,” a paper that would fundamentally shape how we think about concurrent programming. While the industry spent decades wrestling with locks, mutexes, and the endless parade of race conditions they produce, Hoare proposed something radical: what if concurrent processes never shared memory at all?

CSP’s core insight is simple. Instead of multiple threads fighting over shared state (protected by locks you’ll inevitably forget), you have independent processes that communicate exclusively by passing messages through channels. No shared memory means no data races. No locks means no deadlocks from lock ordering bugs.

The contrast with traditional shared-memory concurrency is stark. With locks, you’re constantly asking: “Did I acquire locks in the right order? Did I remember to release this lock on all error paths? Is there a window where another thread could see inconsistent state?” With CSP, you ask: “What message should I send, and who should receive it?”

Go brought CSP to mainstream programming in 2009, and its influence has spread to Clojure’s core.async, Kotlin’s channels, and Rust’s crossbeam. If you’re building concurrent systems today, understanding CSP isn’t optional—it’s foundational.

Core Concepts: Processes and Channels

CSP has two fundamental building blocks: processes and channels.

A process is an independent unit of execution with its own state. It doesn’t share memory with other processes. In Go, these are goroutines. In Clojure, they’re go blocks. The key property is isolation—a process can only affect the outside world by sending messages.

A channel is a typed conduit for communication between processes. Channels have a direction (send or receive) and carry values of a specific type. When a process sends a value on a channel, another process must receive it.

package main

import "fmt"

func main() {
    // Create an unbuffered channel of strings
    messages := make(chan string)

    // Spawn a goroutine (process) that sends a message
    go func() {
        messages <- "hello from another process"
    }()

    // Receive the message in the main process
    msg := <-messages
    fmt.Println(msg)
}

This example demonstrates the core mechanic. The anonymous goroutine and main function are separate processes. They share no state—the only connection is the messages channel. The sender blocks until the receiver is ready, ensuring synchronization without explicit locks.

The type system helps here too. A chan string can only carry strings. You can’t accidentally send an integer or a struct—the compiler catches it. This is a significant improvement over untyped message-passing systems.

Synchronous vs Buffered Communication

By default, CSP channels are synchronous (unbuffered). A send operation blocks until another process receives the value. This creates a “rendezvous” point where both processes must be ready simultaneously.

Synchronous channels provide natural backpressure. If your producer is faster than your consumer, the producer automatically slows down—it can’t send until the consumer receives. This prevents unbounded queue growth and makes system behavior predictable.

But synchronous channels can deadlock if you’re not careful:

func deadlock() {
    ch := make(chan int)
    
    // This blocks forever—no one is receiving
    ch <- 42
    
    // We never reach here
    fmt.Println(<-ch)
}

The send blocks waiting for a receiver, but the receive is after the send in the same goroutine. Classic deadlock.

Buffered channels decouple send and receive timing:

func noDeadlock() {
    ch := make(chan int, 1) // Buffer size of 1
    
    // Send doesn't block—value goes into buffer
    ch <- 42
    
    // Receive pulls from buffer
    fmt.Println(<-ch)
}

Buffered channels are appropriate when you need to decouple producers and consumers temporarily, or when you know the maximum number of in-flight messages. But they’re not a magic fix for deadlocks—they just delay them. A buffer of size 10 still deadlocks when you try to send the 11th message without a receiver.

My rule: start with unbuffered channels. They make synchronization explicit and bugs obvious. Add buffering only when you have a specific reason and understand the implications for backpressure.

Select and Non-Determinism

Real concurrent systems need to handle multiple channels simultaneously. What if you’re waiting for either user input or a timeout? What if you have multiple workers and want to receive from whichever finishes first?

The select statement handles this elegantly:

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
    result := make(chan string)
    errors := make(chan error)

    go func() {
        resp, err := http.Get(url)
        if err != nil {
            errors <- err
            return
        }
        defer resp.Body.Close()
        body, _ := io.ReadAll(resp.Body)
        result <- string(body)
    }()

    select {
    case body := <-result:
        return body, nil
    case err := <-errors:
        return "", err
    case <-time.After(timeout):
        return "", fmt.Errorf("request timed out after %v", timeout)
    }
}

The select blocks until one of the cases is ready, then executes that case. If multiple cases are ready simultaneously, Go picks one at random—this is the “non-determinism” in CSP.

You can also use default for non-blocking operations:

select {
case msg := <-messages:
    process(msg)
default:
    // No message available, do something else
    doOtherWork()
}

This pattern is essential for building responsive systems that don’t block indefinitely on any single operation.

Common CSP Patterns

Pipeline

Pipelines chain processes together, each transforming data and passing it downstream:

func pipeline() {
    numbers := make(chan int)
    squared := make(chan int)
    printed := make(chan bool)

    // Stage 1: Generate numbers
    go func() {
        for i := 1; i <= 5; i++ {
            numbers <- i
        }
        close(numbers)
    }()

    // Stage 2: Square each number
    go func() {
        for n := range numbers {
            squared <- n * n
        }
        close(squared)
    }()

    // Stage 3: Print results
    go func() {
        for sq := range squared {
            fmt.Println(sq)
        }
        printed <- true
    }()

    <-printed
}

Each stage is independent and can run concurrently. The channels handle synchronization automatically.

Worker Pool

For CPU-bound or I/O-bound work, distribute jobs across multiple workers:

func workerPool(numWorkers int, jobs []Job) []Result {
    jobChan := make(chan Job, len(jobs))
    resultChan := make(chan Result, len(jobs))

    // Start workers
    for i := 0; i < numWorkers; i++ {
        go func(id int) {
            for job := range jobChan {
                result := process(job)
                resultChan <- result
            }
        }(i)
    }

    // Send all jobs
    for _, job := range jobs {
        jobChan <- job
    }
    close(jobChan)

    // Collect results
    results := make([]Result, 0, len(jobs))
    for i := 0; i < len(jobs); i++ {
        results = append(results, <-resultChan)
    }
    return results
}

Workers pull jobs from a shared channel and push results to another. The channel handles work distribution automatically—no need for explicit load balancing.

CSP in Practice: Language Implementations

Go’s implementation is the most direct mapping of CSP concepts. Goroutines are cheap (a few KB of stack), channels are first-class, and the runtime handles scheduling.

Clojure’s core.async brings CSP to the JVM with a macro-based approach:

(require '[clojure.core.async :refer [chan go >! <! timeout]])

(defn producer-consumer []
  (let [ch (chan)]
    ;; Producer
    (go
      (doseq [i (range 5)]
        (>! ch i))
      (close! ch))
    
    ;; Consumer
    (go
      (loop []
        (when-let [v (<! ch)]
          (println "Received:" v)
          (recur))))))

The go macro transforms the code into a state machine that parks on channel operations instead of blocking threads. This lets you have thousands of logical processes on a small thread pool.

Rust’s crossbeam provides CSP-style channels with Rust’s ownership guarantees:

use crossbeam_channel::{unbounded, select};

fn main() {
    let (sender, receiver) = unbounded();
    
    std::thread::spawn(move || {
        sender.send("hello").unwrap();
    });
    
    println!("{}", receiver.recv().unwrap());
}

Kotlin’s coroutines offer channels integrated with structured concurrency, making cancellation and error handling more predictable than in Go.

Trade-offs and When to Use CSP

CSP shines when you have naturally independent processes that need to coordinate. Request handlers, data pipelines, and event-driven systems map cleanly to the model.

The debugging story is mixed. Channel operations are explicit, making data flow visible. But when something goes wrong, you’re often staring at a goroutine blocked on a channel with no obvious indication of what should have sent to it. Go’s race detector helps, but it can’t catch all channel-related bugs.

Performance is generally good but not free. Channel operations involve synchronization overhead. For tight loops over shared data, a well-implemented lock might outperform channel-based solutions. Profile before optimizing.

CSP works poorly when you need fine-grained sharing of large data structures. Sending a copy of a large struct through a channel is expensive. Sending a pointer works but reintroduces shared-state concerns.

Compared to the actor model (Erlang, Akka), CSP channels are anonymous—any process can send to any channel it has a reference to. Actors have identity and receive messages at their own mailbox. This makes actors better for systems where you need to address specific entities, while CSP excels at pipeline-style processing.

Compared to async/await, CSP is more explicit about communication points. Async/await hides the concurrency machinery, which can be good or bad depending on whether you need to reason about it.

Use CSP when coordination between concurrent processes is your primary concern. Use something else when it isn’t.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.