CS 134

A CSP Equivalence Demonstration

  • Duck speaking

    What if I love semaphores, but all I've got is CSP? Am I out of luck?

  • PinkRobot speaking

    Actually, no. And it's pretty neat to show that we can implement one scheme for synchronization using the other. It's a good way to demonstrate that the two models are equivalent in power.

A Classic Semaphores Problem

Suppose we have a “party room” that can hold at most five people (it's a small party!), and we have ten people who want to enter the room. We want to ensure that no more than five people are in the room at any time.

This is a classic problem where a semaphore is useful. We can use a semaphore to control access to the room. The semaphore is initialized to 5, and each person who wants to enter the room must acquire the semaphore. If the semaphore is 0, the person must wait until someone leaves the room and signals the semaphore.

We want to replicate this behavior using CSP.

Designing a Semaphore Equivalent in CSP

Sketch out a possible design for a CSP process that models the behavior of a semaphore. You don't have to write correct Go code, just sketch out an idea for how you'd support the following operations:

  • NewSemaphore — Create a new semaphore with a given initial value.
  • Acquire — a process that wants to acquire the semaphore (a.k.a., wait, or P)
  • Release — a process that wants to release the semaphore (a.k.a., signal, or V)

For our party-room example, remember that we begin with five spaces for people in the room (semaphore value is 5). When we run out of space (semaphore value is 0), we need to wait until someone leaves the room and signals the semaphore before anyone else can enter.

Hints:

There are two ways to solve this problem:

  • A simple way that fixes the maximum number of people in the room at 5, based on using a channel as our primary synchronization mechanism.
  • A more general way with an arbitrator process that manages the semaphore value and the people who want to enter the room.

There are three ways we can solve this problem.

1. Use a Channel that Blocks when Full

If we create a channel with a buffer of size five, that can be the analogue for our party.

  • When someone enters the room, we send to the channel.
  • If the channel is full, the sender will block until someone leaves the room. When someone leaves the room, we receive from the channel.

In Go, the fundamental code would look like

type Semaphore chan struct{}

func NewSemaphore(capacity int) Semaphore {
    return make(Semaphore, capacity)
}

func (s Semaphore) Acquire() {
    s <- struct{}{}
}

func (s Semaphore) Release() {
    <-s
}

Remember, you can fork the code and try different things! For example, you could add more print statements to see what's happening, or try changing some of the values.

  • Horse speaking

    Hay! What's the deal with struct{}{}?

  • PinkRobot speaking

    It's a way to signal without sending any data. It creates an empty struct, which takes up no space in memory. It's a common idiom in Go for signaling.

  • Duck speaking

    I don't like this one. If we decided we wanted to expand the room to hold more people, we'd be stuck! We have to decide on the room capacity at the start and then can't change it. That's not very flexible.

  • PinkRobot speaking

    True. Let's see if we can improve on that while still keeping it simple.

2. Use a Channel that Blocks when Empty

We can also create a channel with a size that is five (or more). This time, we can imagine our channel is full of tickets to get into the room. Initially, we fill the channel with five tickets.

  • When someone enters the room, they take a ticket from the channel. If there are no tickets left, they must wait until someone leaves the room and puts a ticket back in the channel.
  • When someone leaves the room, they put a ticket back in the channel.

In Go, the fundamental code would look like

type Semaphore chan struct{}

func NewSemaphore(capacity int) Semaphore {
    channel := make(Semaphore, capacity)
    for i := 0; i < capacity; i++ {
        channel <- struct{}{}
    }
    return channel
}

func (s Semaphore) Acquire() {
    <-s
}

func (s Semaphore) Release() {
    s <- struct{}{}
}

(Note that in this version, we expand the party room's capacity during the party, something we couldn't do in the previous version.)

  • Duck speaking

    This is a bit better. We get to decide how many tickets to issue and can issue more later.

    But there's still a problem that we have a fixed maximum number of unused tickets. If we issued lots of extra tickets, we'd let all those people in, but when they left, they'd try to return their tickets and hit the maximum buffer capacity and block.

  • PinkRobot speaking

    That's a good point.

3. Use an Arbitrator Process

We can create a more general solution by having an arbitrator process that manages the semaphore value and the people who want to enter the room.

In this design, we have two (unbuffered) channels to talk to the arbitrator:

  • One channel is for people who want to enter the room. The arbitrator only listens to this channel when there is space available in the room.
  • One for people who want to leave the room. The arbitrator always listens to this channel, even when it is also listening to the other channel.

In Go, the fundamental code would look like

func NewSemaphore(initialCapacity int) *Semaphore {
    s := &Semaphore{
        acquire: make(chan struct{}),
        release: make(chan struct{}),
    }
    go s.arbitrator(initialCapacity)
    return s
}

func (s *Semaphore) arbitrator(initialCapacity int) {
    capacity := initialCapacity

    for {
        if capacity > 0 {
            // Listen for both acquire and release
            select {
            case s.acquire <- struct{}{}:
                capacity--
            case <-s.release:
                capacity++
            }
        } else {
            // Only listen for release; aquires will block
            <-s.release
            capacity++
        }
    }
}

func (s *Semaphore) Acquire() {
    <-s.acquire
}

func (s *Semaphore) Release() {
    s.release <- struct{}{}
}
  • Goat speaking

    Meh. I thought everyone hated semaphores because they were so complicated.

  • PinkRobot speaking

    True, but the point is that we can implement semaphores using CSP. It's a good way to show that the two models are equivalent in power. And it's a good way to show that CSP can be used to implement other synchronization primitives.

  • BlueRobot speaking

    Our final strategy—using a separate arbitrator process—is the most general and could be used to implement almost any synchronization primitive. It's a good way to show that CSP is a powerful model for synchronization.

Takeaways

  • One synchronization model can be used to implement another synchronization model.
    • We could also have gone in the other direction, and shown how to implement CSP using semaphores (or locks and condition variables).
  • In CSP, we can always fall back to a coordinator process to manage the synchronization.

(When logged in, completion status appears here.)