You could have invented asynchronous channels!
Your intuition is right: channels tend to block, but asynchronous channels usually don’t. Why? What are the exact semantics? Well, first, let's talk about ordinary channels.
Channels
A channel is effectively a primitive in Racket, implemented alongside threads as a way of cross-thread communication. They are also synchronous, to quote the Racket reference:
Channels are synchronous; both the sender and the receiver must block until the (atomic) transaction is complete. Multiple senders and receivers can access a channel at once, but a single sender and receiver is selected for each transaction.
This means channels are rather primitive features—reading and writing from a channel is a single action in which both threads need to coordinate so that they can send and receive simultaneously.
To use a metaphor, channels represent a transfer of some item between two people, Alice and Bob. Both agree upon a meeting location. If Alice arrives first, she waits for Bob to get there, then gives the item to Bob. If Bob arrives first, he waits for Alice to give him the item. After the transfer has taken place, both people leave at the same time.
People are threads, the item is some Racket value, and the meeting place is a channel. Arriving at the meeting place is either reading or writing from the channel, and having to wait is a thread blocking. Leaving is a thread resuming.
Example
Consider a simple exchange between two threads:
#lang racket
(define channel (make-channel))
(define alice
(thread (lambda ()
(channel-put channel 'something)
(displayln "Done!"))))
(define bob
(thread (lambda ()
(sleep 5)
(let ([item (channel-get channel)])
(sleep 5)
(displayln item)))))
In the above example, Done!
will only be printed after five seconds, even though the Alice thread puts 'something
into the channel immediately, without waiting. Since both channels need to coordinate, channel-put
waits for another thread, in this case Bob, to call channel-get
so that the transaction can occur.
Why do we need to block?
At this point, you might ask yourself: why does Alice need to wait? It would be a lot better if Alice could go to the meeting place, drop the item into a bin, and immediately leave. Then Bob could just pick the item out of the bin when he arrived, and Alice could get on with her business. If the bin was big enough, Alice could even put multiple items into it before Bob took any out!
This is the idea of buffered asynchronous channels.
Buffered Asynchronous Channels
Async channels are a simple, derived concept in Racket. They can be implemented on top of channels using an internal, mutable buffer as the “bin”.
Asynchronous channels are composed of three parts, internally:
- The “input” or “enqueue” channel, for threads putting values into the buffer.
- The “output” or “dequeue” channel, for threads taking values out of the buffer.
- The “buffer” or “queue”, a mutable value that holds all the values that have been put into the async channel but haven’t yet been taken out.
The channels are obviously just Racket channels, but what should we use for the buffer? Well, Racket actually provides an imperative queue implementation in the data/queue
module, which could be used, but the Racket implementation just builds its own queue on top of mutable pairs.
To manage the interaction between these three components, we just need a manager thread that coordinates reads and writes together. The implementation of this turns out to be fairly trivial, but I will not reproduce it here. If you would like, take a look at async-channel.rkt
, the module that implements async channels in Racket. It has a number of extra goodies that I didn’t mention, but the whole implementation is still less than 300 lines.
Example
Let’s revisit the original example, but let’s use async channels instead of plain channels:
#lang racket
(require racket/async-channel)
(define channel (make-async-channel))
(define alice
(thread (lambda ()
(async-channel-put channel 'something)
(displayln "Done!"))))
(define bob
(thread (lambda ()
(sleep 5)
(let ([item (async-channel-get channel)])
(sleep 5)
(displayln item)))))
Now, Done!
prints immediately because the thread that put
s doesn’t need to block. It just sticks it on the internal queue and doesn’t need to care when the value is fetched.
Caveats
By default, putting a value into an async channel will never block (you can set a limit on buffer size, but this is optional). However, reading from an async channel can absolutely block if the internal buffer is empty. This is usually the behavior you want, in my experience, but you can always check if a value is ready using async-channel-try-get
, if you need it.
Async channels are also, of course, mutable state, and all the general warnings about mutation apply here, too. Notably, you cannot have multiple receivers for a single channel because once a read operation has been performed, the value is removed from the queue. If you want pub/sub style event dispatching, consider using the Multicast Asynchronous Channels package.
Still, pitfalls aside, async channels are almost always what you want, in my experience. Channels are an important primitive to have, but they are tricky to use. Async channels pretty much just work, and they make cooperating between multiple threads extremely simple. Just be careful to understand how they work so you don't shoot yourself in the foot.