Synchronous Channels using MVars
An MVar in Haskell is a shared variable that is either full, or empty. Trying to write to a full one, or read from an empty one, will cause you to block. It can be used as a one-place buffered asynchronous channel. Consider if you didn’t want choice, or conjunction or any of the fancy features of CHP, but you do want to build a synchronous channel using MVars. How would you do it?
MVars — The Obvious Way
There is a very straightforward way to turn asynchronous channels into synchronous channels: you form a pair of channels, and use one for the writer to send the reader the data, and the other for the reader to send the writer an acknowledgement:
data SimpleChannel a = SimpleChannel (MVar a) (MVar ()) newSimpleChannel :: IO (SimpleChannel a) newSimpleChannel = liftM2 SimpleChannel newEmptyMVar newEmptyMVar writeSimpleChannel :: SimpleChannel a -> a -> IO () writeSimpleChannel (SimpleChannel msg ack) x = putMVar msg x >> takeMVar ack readSimpleChannel :: SimpleChannel a -> IO a readSimpleChannel (SimpleChannel msg ack) = takeMVar msg <* putMVar ack ()
Let’s assume that context-switching is the major cost in these algorithms, and examine how many times a process must block in the above algorithm. We know that this must be at least one; whoever arrives first will have to block to wait for the second participant.
We’ll start with what happens if the writer arrives first. The writer arrives, puts the value into the data MVar, then blocks waiting for the ack. The reader arrives, takes the data and sends the ack, at which point the writer wakes up. So in this case: one block.
If the reader arrives first, it will block waiting for the data MVar. The writer will arrive, put the value into the data MVar, then block waiting for the ack. The reader will wake up, take the data and send the ack; then the writer will wake up. So here we had two blocks. The writer blocking is unnecessary; if the reader was already there waiting, there is no need for the writer to wait for an ack, it could just deposit the value and go — if it knew the reader was there.
MVars — The Faster Way
There are several ways to remove that second block. One way is to have an MVar as a sort of status MVar. When the reader or writer arrives, they try to put into this MVar. If they succeed, they are first and they wait on a second MVar. If they fail, they are second and act accordingly, emptying the status variable and waking up the first party:
data FastChannel a = FastChannel (MVar (Maybe a)) (MVar ()) (MVar a) newFastChannel :: IO (FastChannel a) newFastChannel = liftM3 FastChannel newEmptyMVar newEmptyMVar newEmptyMVar writeFastChannel :: FastChannel a -> a -> IO () writeFastChannel (FastChannel sts ack msg) x = do first <- tryPutMVar sts (Just x) if first then takeMVar ack -- will block else takeMVar sts >> putMVar msg x readFastChannel :: FastChannel a -> IO a readFastChannel (FastChannel sts ack msg) = do first <- tryPutMVar sts Nothing if first then takeMVar msg -- will block else (fromJust <$> takeMVar sts) <* putMVar ack ()
This version is, in my benchmarks, twice as fast as the first version, which suggests that context-switching really is the expensive part of these algorithms. In fact, I started out with the first version in thi spost, but CHP’s more featured and complex algorithms were coming out faster because I only ever block once. It was only when I improved the MVar version to the second one above that the results were as I expected.