CS 134

Communicating Sequential Processes (CSP)

  • Hedgehog speaking

    My head is spinning from all this concurrency stuff! It's all so tricky; needing to protect memory from multiple threads and making sure everything happens in the right order. Is there a better way to handle concurrency?

  • PinkRobot speaking

    That's exactly what Tony Hoare wondered back in 1978.

  • Duck speaking

    Who is Tony Hoare?

  • PinkRobot speaking

    We mentioned him in the lesson on Monitors. He's a British computer scientist who made many important contributions to computer science. He's probably best known for inventing the Quicksort algorithm, but he's also known for his work on formal methods and concurrent programming.

  • Goat speaking

    1978? Is this going to be another history lesson?

  • PinkRobot speaking

    A bit…

Background and Context

Tony Hoare made the following observations about concurrent systems:

  • Having multiple processes share the same state is problematic because it is
    • Prone to race conditions, requiring careful synchronization, which is
    • Challenging to code correctly or reason about, and
    • Potentially inefficient due to contention.
  • Writing code with explicit lock-based synchronization is
    • Unnatural, and
    • Error-prone.

He came up with a new vision, Communicating Sequential Processes (CSP), to address these issues. The key principles of CSP include

  1. Processes are independent and do not share state.
  2. Communication between processes occurs via messages sent over channels.
  3. Synchronization is achieved through message passing, not locks.
  • Horse speaking

    Hay! Isn't this like the way we can make pipelines in the Unix shell? You know, grep error log.txt | sort | uniq -c?

  • Cat speaking

    You have a point there—each command is a separate process, and they communicate through pipes, which we could see as a kind of channel.

  • PinkRobot speaking

    We can see them as a simplified form of CSP. Hoare envisioned a more general and powerful model allowing more complex topologies and interactions than pipelines.

  • BlueRobot speaking

    The key thing is that Hoare's ideas have influenced many modern programming languages and systems.

In fact, when Hoare spoke of “processes” he meant independent threads of execution, not necessarily separate programs. These processes can be lightweight and managed by a single program, as in Go's “goroutines”.

The key thing is that CSP represented a significant shift in thinking about concurrency, moving away from shared memory and lock-based synchronization towards a model of independent processes that communicate through channels.

Historical Context

Technically, CSP is a formal language (“process algrbra”) for describing patterns of interaction in concurrent systems. It was first described by Tony Hoare in a 1978 paper and later expanded into a book in 1985.

CSP emerged during a period of intense research into concurrency and parallelism in the 1970s and 1980s. Other notable developments from this era include

  • Dijkstra's semaphores (1965)
  • Brinch Hansen's monitors (1973)
  • Lamport's work on logical clocks and distributed systems (1978)

CSP was particularly influential in the development of Occam, a programming language used in transputer microprocessors. However, its most significant impact has been conceptual, influencing the design of many modern concurrent-programming models.

CSP in Modern Programming

While pure CSP implementations are relatively rare, its ideas have been hugely influential. Some notable examples include

  • Erlang's Actor model (1986); also adopted by Scala and Swift.
  • Occam-π, a modern variant of occam (2005)
  • Clojure's core.async library (2013)
  • Go's concurrency model (2009)

Go, in particular, has brought CSP-style concurrency to mainstream programming. While not a pure implementation of CSP, Go's goroutines and channels provide a practical and efficient way to apply CSP principles in a general-purpose programming language.

Producer–Consumer Problem in Go

Let's implement the producer–consumer problem using Go's concurrency primitives. Skim the code below and then give it a try in Online GDB.

  • Hedgehog speaking

    Uh, I don't know Go. Is that a problem?

  • PinkRobot speaking

    No—we're not going to be writing code in Go, and your knowledge of C-like languages will be enough to understand the code. Just focus on the high-level concepts.

  • BlueRobot speaking

    In fact, the things that are unfamiliar are the things to notice.

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

const (
    bufferSize          = 32
    numProducers        = 2
    numConsumers        = 2
    messagesPerProducer = 10
)

type message struct {
    content string
}

func producer(id int, messages chan<- message, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < messagesPerProducer; i++ {
        msg := message{fmt.Sprintf("Message %d from Producer %d", i, id)}
        messages <- msg
        time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
    }
    messages <- message{"Bye"}
}

func consumer(id int, messages <-chan message, wg *sync.WaitGroup) {
    defer wg.Done()
    for msg := range messages {
        fmt.Printf("Consumer %d: %s\n", id, msg.content)
        if msg.content == "Bye" {
            return
        }
        time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
    }
}

func main() {
    messages := make(chan message, bufferSize)
    var wg sync.WaitGroup

    // Start producers
    for i := 0; i < numProducers; i++ {
        wg.Add(1)
        go producer(i, messages, &wg)
    }

    // Start consumers
    for i := 0; i < numConsumers; i++ {
        wg.Add(1)
        go consumer(i, messages, &wg)
    }

    // Wait for all goroutines to finish
    wg.Wait()
    close(messages)
}

You can contrast this code against the C implementation we saw in Week 3, Lesson 1.

What are the key differences between the Go implementation and the C implementation of the producer–consumer problem we saw in Week 3, Lesson 1? What are some positives that you notice?

Understanding the Go Code

Let's look at a few key lines from the code.

messages := make(chan message, bufferSize)

This line creates a channel named messages that can hold up to bufferSize messages. Channels are typed, thread-safe queues that allow goroutines to communicate. This channel is known as a buffered channel as it receive messages (up to a limit) even when no one is ready to read them.

Go also allows unbuffered channels, which block until both a sender and receiver are ready to communicate. We would make such a channel by saying messages := make(chan message).

go producer(i, messages, &wg)

The go keyword launches the producer function as a goroutine. Goroutines are lightweight threads managed by the Go runtime.

msg := message{fmt.Sprintf("Message %d from Producer %d", i, id)}
messages <- msg

This code creates a new message struct and sends it to the messages channel. The <- operator is used to send and receive messages on a channel.

for msg := range messages {
    // Process messages
}

The range keyword is used to iterate over messages received from the messages channel. The loop continues until the channel is closed. We could also receive an individual message using msg := <-messages.

close(messages)

This line closes the messages channel, signaling that no more messages will be sent. It's essential to close channels to avoid goroutines blocking indefinitely.

  • Dog speaking

    I see that the Go code is much simpler than the C code. There are no explicit locks or condition variables, and the code is easier to read.

  • Hedgehog speaking

    I was a bit worried, but I do think it's easier to see what's going on in the Go code.

  • Goat speaking

    I think you cheated. You haven't really implemented a bounded buffer at all. Go is doing it all for you behind the scenes. The channel is the buffer.

  • PinkRobot speaking

    All good points. The Go code is simpler because the channel abstracts away the complexity of managing a buffer and synchronization. Goat is right that Go is doing more for us than C did, but that's not necessarily a bad thing.

  • Dog speaking

    What about wg? What's the whole sync.WaitGroup thing?

  • PinkRobot speaking

    Ah, yes, let's talk about that next.

The sync.WaitGroup type is used to wait for a collection of goroutines to finish. It acts as a counter tracking the number of tasks in progress. The Add method increments the counter, Done decrements it, and Wait blocks until the counter reaches zero. Thus, in the code, we see

wg.Add(numProducers)

This line increments the WaitGroup counter by the number of producers. Each producer will call wg.Done() when it finishes, decrementing the counter.

wg.Wait()

This line blocks until all producers have finished, ensuring that the program doesn't exit prematurely.

  • Dog speaking

    So it's like a semaphore, it counts up and down and makes people wait?

  • PinkRobot speaking

    Actually, it's kind of the opposite-land version of a semaphore. In a semaphore, you wait while the count is zero, and signal when you increment it. In a WaitGroup, you wait while the count isn't zero, and call wg.Done() to decrement it, signaling that you're done.

This concept is sometimes called a barrier.

Implementation

  • Horse speaking

    Hay! Goat's point earlier set me thinking. Yes, Go provides these nice facilities, but it needs to actually implement them. And I have a suspicion that it's using locks and condition variables under the hood.

  • PinkRobot speaking

    We don't know for sure that it's using locks and condition variables, but it's a reasonable guess. The Go runtime needs to manage the goroutines and channels somehow, and locks are a common way to implement synchronization primitives. Possibly it uses atomic instructions for some operations as well.

  • Hedgehog speaking

    So we can never escape locks and condition variables?

  • PinkRobot speaking

    If you're implementing the core technology, you'll likely need to deal with locks and condition variables at some level. However, higher-level abstractions like channels can simplify your code and reduce the risk of errors.

  • BlueRobot speaking

    But that's one reason we have you focus on the lower-level primitives in an OS class. This lower-level code what systems programmers end up using when writing operating systems and programming language runtimes.

  • Dog speaking

    Wait, what's a runtime?

  • PinkRobot speaking

    A runtime is a piece of software that provides services to the programs in a particular language running on a computer. In the case of Go, the runtime manages memory allocation, garbage collection, and concurrency. It's responsible for creating and scheduling goroutines, managing channels, and ensuring safe communication between them.

Although we can implement communications channels in shared memory using traditional locks and condition variables, they aren't the only option. CSP scales to distributed systems, where the communication is literally over a network. The principles of CSP can be applied at many levels of abstraction.

The Power of select

  • Goat speaking

    Meh. Looks like Go's channels are a nothingburger. It's just a buffer we can read from and write to. Big deal.

  • PinkRobot speaking

    Well, one of the things that Hoare emphasized was the importance of choice in communication. Go's select statement allows us to choose between multiple communication operations, which can be used to implement complex communication patterns, such as timeouts, retries, or load balancing.

  • Duck speaking

    But we could do that with locks and condition variables, too, right?

  • PinkRobot speaking

    Yes, and as we've said, that's probably what Go is doing behind the scenes. But the select statement provides a more elegant and expressive way to handle these patterns. It's a powerful tool for building robust and flexible concurrent systems.

  • BlueRobot speaking

    Let's take a look at an example…

In this version we've added one new goroutine, a printer that handles all printing. It has three input channels: one for messages from the producers, one for messages from the consumers, and one for a quit signal. It uses a select statement to choose between these channels, but also complains if it hasn't gotten a message in a while.

Let's take a look at the code:

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

const (
    bufferSize          = 32
    numProducers        = 2
    numConsumers        = 2
    messagesPerProducer = 10
)

type message struct {
    content string
}

type printMessage struct {
    source  string
    content string
}

func producer(id int, messages chan<- message, printer chan<- printMessage, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < messagesPerProducer; i++ {
        msg := message{fmt.Sprintf("Message %d from Producer %d", i, id)}
        messages <- msg
        printer <- printMessage{"producer", fmt.Sprintf("Producer %d sent: %s", id, msg.content)}
        time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
    }
    messages <- message{"Bye"}
}

func consumer(id int, messages <-chan message, printer chan<- printMessage, wg *sync.WaitGroup) {
    defer wg.Done()
    for msg := range messages {
        printer <- printMessage{"consumer", fmt.Sprintf("Consumer %d received: %s", id, msg.content)}
        if msg.content == "Bye" {
            return
        }
        time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
    }
}

func printer(producerMsgs <-chan printMessage, consumerMsgs <-chan printMessage, done <-chan bool) {
    const TIMEOUT_MS = 2000

    for {
        select {
        case msg := <-producerMsgs:
            fmt.Printf("[PRODUCER] %s\n", msg.content)
        case msg := <-consumerMsgs:
            fmt.Printf("[CONSUMER] %s\n", msg.content)
        case <-done:
            fmt.Println("Printer: Shutting down...")
            return
        case <-time.After(TIMEOUT_MS * time.Millisecond):
            fmt.Println("Printer: No activity for", TIMEOUT_MS, "ms. Feeling lonely...")
        }
    }
}

func main() {
    rand.Seed(time.Now().UnixNano())
    messages := make(chan message, bufferSize)
    producerMsgs := make(chan printMessage, bufferSize)
    consumerMsgs := make(chan printMessage, bufferSize)
    done := make(chan bool)

    var wg sync.WaitGroup

    // Start printer
    go printer(producerMsgs, consumerMsgs, done)

    // Start producers
    for i := 0; i < numProducers; i++ {
        wg.Add(1)
        go producer(i, messages, producerMsgs, &wg)
    }

    // Start consumers
    for i := 0; i < numConsumers; i++ {
        wg.Add(1)
        go consumer(i, messages, consumerMsgs, &wg)
    }

    // Wait for all goroutines to finish
    wg.Wait()
    close(messages)

    // Signal printer to shut down
    done <- true

    // Allow some time for final messages to be printed
    time.Sleep(100 * time.Millisecond)
}

As provided, the printer goroutine will complain if it hasn't received a message for 2 seconds, but that's too long. Tweak the code so that at least occasionally it will print a message saying that it's lonely.

(If that change seems too easy, feel free to explore making other changes to the code.)

Describe what you did and what you found…

Key Points

  1. Channels: The messages channel replaces the circular buffer. It's a typed, thread-safe queue provided by Go.
  2. Goroutines: producer and consumer functions run as goroutines, lightweight threads managed by the Go runtime.
  3. No Explicit Locking: There are no mutexes or condition variables. Synchronization is handled implicitly by channel operations.
  4. Simplicity: The Go code is significantly simpler than the C version, with no need for explicit buffer management or lock handling.

Comparing CSP (Go) and Lock-based (C) Approaches

  1. Abstraction: Go's channels provide a higher level of abstraction, hiding the complexities of synchronization.
  2. Safety: The CSP approach reduces the risk of deadlocks and race conditions by design.
  3. Scalability: Go's runtime can efficiently manage many goroutines, making it easier to scale to many producers and consumers.
  4. Readability: The Go code is generally more straightforward and easier to reason about.
  5. Flexibility: While this example is similar in structure to the C version, CSP allows for more flexible communication patterns that would be complicated to implement with locks.

Conclusion

CSP and Go's implementation of its principles offer a powerful alternative to traditional lock-based concurrency. By focusing on communication rather than shared state, CSP can lead to more robust, scalable, and understandable concurrent programs. However, it's important to note that both approaches have their place, and understanding both is valuable for any programmer working with concurrent systems.

(When logged in, completion status appears here.)