Intro to Cispy Channels for Async Functions
Here are a few basic examples to show how to use channels with async functions. This page is in large part a port of the examples at http://swannodette.github.io/2013/07/12/communicating-sequential-processes, which is written using Clojure's core.async but which translates nicely.
A note on terminology
Processes in the CSP world are the lightweight processes created by the go
function. (This shouldn't be confused with operating system processes — like the ones you might see in your Task Manager in Windows — which have carry a tremendous amount of overhead to both create and switch between.) With Cispy, async functions serve as those lightweight processes. Cispy is primarily about channels to easily communicate between those processes, along with some utility to make it a bit easier to run the processes; most of the legwork is done by the JavaScript engine.
Despite async functions being a thing with another name, when discussing them in terms of channels and CSP I will refer to them as processes as well.
Basic async functions and channels
To get things started, here's a basic example to illustrate how to communicate between processes. Of course, you can communicate between async functions by simply updating the values of shared variables, but this is a less than ideal way to do it because a lot of thought and care needs to be put into making sure that those shared variables aren't updated in a way that breaks things for another process. Channels are a safe way to communicate between processes.
This example coordinates three different processes, all of them running at different speeds, by using a fourth process which also displays the results of the coordination. Here's the end result:
Process 1 is running 4 times a second, Process 2 once per second, and Process 3 once every 1.5 seconds. This isn't anything that we couldn't do with regular JavaScript, using a function that updates the output div
and then calls setTimeout
with the right delay whose callback just re-calls the same function again. Let's see what this looks like with CSP.
const ch = chan();
go(async () => {
while (true) {
await sleep(250);
await put(ch, 1);
}
});
go(async () => {
while (true) {
await sleep(1000);
await put(ch, 2);
}
});
go(async () => {
while (true) {
await sleep(1500);
await put(ch, 3);
}
});
Functions with the async
keyword are a new feature to ES2017, though every modern browser already implements them (if the example isn't working for you, your browser may be out of date). They use the generators to do something that is otherwise not available in JavaScript: to create code that can be suspended and restarted.
The async
functions act as separate processes, and they allow the use of the await
keyword inside of them. await
causes the function to pause, waiting for the result of the function that comes right after it (in these cases, either sleep
or put
). In the meantime, it releases control of the engine to allow other functions to run while it waits. When the awaited function returns a value, the async
function is resumed from where it left off.
In our code, a channel is first created with chan
. Then three processes are created with go
, which takes one of those async functions and executes the code in it as a separate process. Each of these processes calls await sleep
, which blocks the process and does not allow it to unblock until some number of milliseconds have elapsed. After that time passes, the process will unblock and call await put
to put a value onto the already-created channel. (await put
also blocks until the value that was put onto a channel is taken off by some other process, but in this example that will be happening instantly.)
There are two ways this is very different from setTimeout
-based code. The first is that there isn't anything that looks like a callback at all. In their place, there are local event loops (while (true)
) which run continually, blocking for some amount of time before putting a value onto a channel and starting over again.
Secondly, there is no rendering code here whatsoever. The only interaction that any of these processes have with the outside world is through putting a value onto the channel. What happens then? The processes don't care. They have no knowledge of what's done with the value that they put onto that channel. This is as close to complete decoupling between event code and rendering code that you can get — decoupling that isn't often seen in JavaScript code because it's very difficult to do.
So what does happen to those values placed on the channel?
go(async () => {
const processDiv = document.querySelector('#processes');
const lines = [];
while (true) {
const index = await take(ch);
lines.unshift(`<div class="proc-${index}">Process ${index}</div>`);
if (lines.length > 10) {
lines.pop();
}
processDiv.innerHTML = lines.join('');
}
});
Here we create a fourth process, also with the local event loop running continuously. It blocks with await take
as it waits for the other three processes to put a value on the channel. When one of those values appears, the process unblocks and the value of the await take
expression becomes that value (assigned here to index
). It processes that value into a line of HTML. It also keeps track of the prior HTML lines to make sure that there isn't ever more than ten of them, and then it pushes all of those lines to the output div
.
Again, there isn't a whiff of a callback anywhere, and the rendering process has no knowledge or interest in what created the values on the channel or how they got there.
Taking from multiple channels
Our first example saw a channel being shared among four different processes. Sometimes it's useful to do the opposite, having a single channel that deals with multiple processes.
In this example, we're going to put DOM events onto channels. To facilitate that, here's a function that listens for those events and puts them onto a channel.
function listen(el, type, ch = chan()) {
el.addEventListener(type, event => putAsync(ch, event));
return ch;
}
This is an interesting function that can be used almost as-is in any event-related code you could ever write, so it's worth going through in some detail.
It takes three parameters: a DOM element, an event type, and a channel. The channel is optional; if it is not passed, then the default value for the parameter creates a new channel and assigns it to the same ch
variable.
The element's addEventListener
function property is invoked, sending the event type, to add a handler for any event of that type on that element. When such an element fires, the callback function (which takes the event object generated by the event) is fired. This uses a new cispy function, putAsync
, to put the event object onto the same channel that was passed in/created. putAsync
is just like put
except that it can be called outside an async function. In place of blocking (which can't be done outside of an async function), it accepts a callback as a third parameter that is invoked when some process takes the value from the channel. (In our case, our only purpose is to put the event on the channel; since we don't care to have anything else happen, we don't even pass a callback to putAsync
.)
At the end, the channel, whether passed in or created, will be returned. The event handler attached to the listened-to DOM element will ensure that this channel will continue to receive event objects for any events that might be triggered, up until the channel is closed (or the program ends).
This time we have a single process that creates two of these event channels and takes from them both.
go(async () => {
const el = document.querySelector('#events');
const mouseCh = listen(el, 'mousemove');
const clickCh = listen(el, 'click');
let mousePos = [0, 0];
let clickPos = [0, 0];
while (true) {
const v = await alts([mouseCh, clickCh]);
const event = v.value;
if (v.channel === mouseCh) {
mousePos = [event.layerX || event.clientX, event.layerY || event.clientY];
} else {
clickPos = [event.layerX || event.clientX, event.layerY || event.clientY];
}
el.innerHTML = `${mousePos[0]}, ${mousePos[1]} : ${clickPos[0]}, ${clickPos[1]}`;
}
});
Before the familiar continuous loop begins, some setup is done. Two event channels are created, both on the target div
, one for the mousemove
event and one for the click
event. A pair of variables to track the coordinates of the mouse when these events happen is also initialized.
That leaves us with two channels to pay attention to, and we do that with await alts
. This function takes an array of channels to take from, and it blocks until the first of these channels produces a value (alts
can also perform puts, but that's beyond this simple demo). When it unblocks, it returns an object instead of a value, with the properties value
(containing the value taken from a channel) and channel
(containing the channel that the value was ultimately taken from). We can then use the channel
property to figure out which coordinates we want to update, based on which event channel produced a value. The coordinates are then displayed in the target div
.
The result is below. Mouse over the gray box and see how the mouse coordinates (the numbers before the colon) update. Click on the box and see the numbers on the right of the colon update with the coordinates of the last click.
Channels and transducers
In the last example, the coordinates displayed were relative to the example's div
itself. What if we wanted to make the coordinates relative to the top left of the page itself? We could write code into our event loop to calculate the offset, but there's a better way.
Transducers are generic transformation functions that work on a wide variety of collection types — the same function is able to handle arrays, strings, and any other object that supports (or can be made to support) transducers. Cispy channels support transducers, which can be passed to the chan
function to create a channel that performs transformations on the values put onto the channel before they're made available to be taken off. We can use these transformations to automatically calculate the offset, so that the value is already calculated relative to the top left of the page before it even gets taken off the channel.
We're going to use a map
transducer to change what's coming into the channel to what we actually want on the channel. The particular transducer that we'll use is in my xduce library, though the map
transducer from any transducer library should work just fine. This transducer, like the regular functional map
, requires a function that takes an input and maps it to the output we want. Since our input is going to be the object put onto the channel - an event - we can create a function like this.
function coordinates(el) {
const top = el.offsetTop;
const left = el.offsetLeft;
return event => {
const x = (event.layerX || event.clientX) + left;
const y = (event.layerY || event.clientY) + top;
return { x, y };
};
}
coordinates
takes a DOM element, calculates its offset from the top and left of the page, and returns a function that takes an event and calculates the offset coordinates as an object. This means that when our event loop takes a value from the channel, rather than getting an event object, it's going to get this coordinates object. This isn't necessary - our event loop can still just calculate the coordinates like we did in our last example - but it is a little nicer and makes for a good demonstration.
This pattern uses a nifty trick. We don't pass coordinates
itself to map
, but instead pass a function that coordinates
creates. This is a good way to be able to specify some values at runtime. In this case, we're passing the element that's being used to calculate the offset. If we were to write a function to pass directly to map
, we'd have to write a different function for each element we wanted to use, each with their own values of el
. This way we can write one function and use it to generate any function we might want.
Here is our new event loop process, modified to take advantage of the map
transducer.
go(async () => {
const el = document.querySelector('#transducers');
const mouseCh = listen(el, 'mousemove', chan(1, { transducer: map(coordinates(el)) }));
const clickCh = listen(el, 'click', chan(1, { transducer: map(coordinates(el)) }));
let mousePos = { x: 0, y: 0 };
let clickPos = { x: 0, y: 0 };
while (true) {
const v = await alts([mouseCh, clickCh])
if (v.channel === mouseCh) {
mousePos = v.value;
} else {
clickPos = v.value;
}
el.innerHTML = `${mousePos.x}, ${mousePos.y} : ${clickPos.x}, ${clickPos.y}`;
}
});
There are three basic differences between this loop and the one we used in our last example: we're storing our coordinates as objects instead of arrays, await alts
is returning a plain object instead of an event object, and we're passing a third parameter to listen
.
The first two are just nice-to-haves. Our transducer, using the function generated by coordinates
, will be causing the values taken from the event channels to be simple objects instead of event objects. Since we can make these objects look however we want by adjusting coordinates
, we've chosen to make them coordinates objects, with x
and y
as their properties. This lends itself very well to an application that is designed to display coordinates, and it means we can write the slightly less cryptic mousePos.x
instead of mousePos[0]
.
This is possible because of the third parameter to listen
, which we didn't use in the last example. Passing a channel as this parameter means that listen
doesn't create a new channel, it just uses the one we send. And that means we can customize that channel any way we want.
In this case, we customize it by passing a couple of parameters to chan
, something we have not done before. The map
transducer, fueled by a function coming from coordinates
, is the second parameter. So what is the first parameter, the 1
? That's the specification for a buffer which stores values in the channel. By default, channels are unbuffered. Passing the 1
means that the channel will be backed by a buffer that can store one value. Buffers are useful and can have some desirable effects, but the real reason we're using one here is because you cannot put a transducer on an unbuffered channel.
The result is in the gray box below. It works exactly the same as the last example, except that the coordinates are much larger because they're in relation to the top and left of the page instead of the top and left of the div
.
Channel operations
One very noticeable issue with both of the last two examples is the sheer number of mousemove
events that get fired. As long as the mouse is moving, the events are constantly streaming forth. We might find that there are situations where this can be an issue, particularly if that event (or, say, a window scrolling event) is driving some expensive UI update process. Maybe we only want to see an update to mouse position every 100 milliseconds, for example, or we don't want to know about the window scrolling until it's finished.
Cispy has a number of operation functions that operate on channels, and debounce
and throttle
are two that work well for these situations.
Debouncing combines several closely-spaced inputs into one input. This would be the perfect operation to use if we didn't want to know about window scroll events until they were all over - all of the scroll events would be ignored except for the last one, which is what we probably want.
In this example, we apply debouncing to the same mousemove
channel that we've used in the last couple of examples. This is really easy to accomplish: just wrap that channel with the debounce
function, adding the desired debounce delay to the call.
const el = document.querySelector('#debounce');
const mouseCh = debounce(listen(el, 'mousemove', chan(1, { transducer: map(coordinates(el)) })), 500);
All other code remains the same as the transducers example.
The end effect of this is to make it so that our mouse channel doesn't produce a value until mousemove
events have stopped for 500 milliseconds (half a second). This is an unnaturally long delay in practice, but for this demonstration it makes the debounce delay visible and clear.
Move your mouse around the gray box above, and notice that nothing happens until the mouse has been stopped (or has moved out of the gray box, which also causes events on that box to stop) for a half second. Since the debounce
function is only applied to the mousemove
channel, mouse clicks still happen exactly as they had before.
Throttling is another option. While debouncing combines inputs together into a single input, throttling leaves the multiple inputs but makes sure that they can only come every so often. This is useful for events that you want to know about while they're happening, but when you don't need to know about all of them. Mouse move events are actually a good example, because there are a large number of them generated, but perhaps you only need to know about them every so often.
This is accomplished in code in a very similar manner to debouncing.
const el = document.querySelector('#throttle');
const mouseCh = throttle(listen(el, 'mousemove', chan(1, { transducer: map(coordinates(el)) })), 500);
The rest of the code remains the same as the last example.
In this example, we're replacing the debounce behavior of the last example with throttling behavior. This makes the difference between the two operations very clear. While moving the mouse continually prevents anything new from being displayed in the debouncing example, the same action in the throttling example below continues to produce events, but at a slower rate.
What happened to the old processes?
Cispy was started before async functions were even really known about, much less as common as they are now. It used generator functions as processes, and it controlled every aspect of them, even down to not being able to pass anything with yield
(the generator process analogue to await
) except for process functions like put
and take
. Eventually, async functions made an appearance, and as time went on, it became clear that they were just better.
The mechanism for running async functions is already a part of the JavaScript engine. Generators required their own engine and all of the attendent code that went with it. It was fun, but these days it's far less practical, has a worse syntax, and makes for a larger library.
I had for a time planned to continue forward with both mechanisms available, but then it occurred to me that probably I was the only one who cared much. End users shouldn't care so much what is going on behind the scenes. The extra effort in maintaining a second parallel code base is simply not worth it. Async functions it is.