We're on a quest to enforce modularity on a single machine.
Last time: Virtualize memory to prevent programs from accessing each other's memory.
This time: Virtualize communication links to allow programs to communicate.
Still assuming one program per CPU, and a correct kernel.
Bounded Buffers
Allow programs to communicate.
Another application of virtualization.
Stores N messages, to deal with bursts.
API: send(m), m <- receive()
Receivers and senders block if there are no messages (receiver) or no space (sender).
Concurrency causes problems in the implementation.
Need to decide when it's okay to write, when it's okay to read, and where to write to/read from.
Bounded Buffers for Single Senders
send(bb, message): while True: # Wait until it's okay to write if bb.in – bb.out < N: bb.buf[bb.in mod N] <- message bb.in <- bb.in + 1 return
receive(bb): while True: # Wait until it's okay to read if bb.out < bb.in: message <- bb.buf[bb.out mod N] bb.out <- bb.out + 1 return message
Can't swap the action and the increment; can cause reads of messages that don't exist.
Bounded Buffer for Multiple Senders
With two senders, different orders of executions will lead to unexpected output in the previous implementation (empty slots in the buffer, too few elements in the buffer).
Need locks.
Locks
Allow only one CPU to be in a piece of code at a time.
API: acquire(lock), release(lock)
*Not* acquire(variable I want to lock)
If two CPUs try to acquire the same lock at the same time, one will succeed and the other will block.
Bounded Buffers with Locks
Attempt 1 (using pseudocode): Locks around every line.
send(int x) { acquire(&lck); buf[in] = x; release(&lck); acquire(&lck); in = in + 1; release(&lck); }
Result: Correct number of elements, but some slots have no messages (A and B write to same slot, and both increment).
Attempt 2:
send(int x) { acquire(&lck); buf[in] = x; in = in + 1; release(&lck); }
Correct: We want write and increment to be atomic (happen together).
Back to original code. Attempt 1:
send(bb, message): while True: if bb.in — bb.out < N: acquire(bb.lock) bb.buf[bb.in mod N] <- message bb.in <- bb.in + 1 release(bb.lock) return
No: Concurrent senders will both think they can write, the first to acquire the lock might fill up the buffer (and so the second shouldn't write).
Attempt 2:
send(bb, message): acquire(bb.lock) while True: if bb.in — bb.out < N: bb.buf[bb.in mod N] <- message bb.in <- bb.in + 1 release(bb.lock) return
If the receiver is also trying to acquire lock, this attempt will prevent the receiver from ever receiving (so the sender will keep blocking when the buffer is full). If the receiver is using a different lock we will face issues with concurrently editing the same data structure.
Attempt 3 (correct):
send(bb, message): acquire(bb.lock) while bb.in - bb.out = N: release(bb.lock) // repeatedly release and acquire, to allow acquire(bb.lock) // processes calling receive() to jump in bb.buf[bb.in mod N] <- message bb.in <- bb.in + 1 release(bb.lock) return
Atomic Actions
How to decide what should make up an atomic action?
Too much code in locks: Performance suffers.
Too little code in locks: Unexpected behavior.
Think of locks as protecting an invariant. Don't release the lock when the invariant is false.