The saga of async JavaScript: CSP
Roman Sarder
Posted on December 1, 2022
Intro
In a previous series, we discovered that it is indeed possible to write our async code in a synchronous fashion using a combination of promises and generators. Our enhanced functions can run asynchronously and independently with the ability to pause and resume on demand. They are much like isolated mini-programs inside our main program! But when you start writing some real-world scenarios, you stumble upon a situation when those tiny independent bits need to communicate with each other and synchronize their execution. In this article we will introduce ourselves to a new pattern that will hopefully allow us to structure our async code with generators in a convenient and scalable way, solving the most common challenges that concurrency presents to us.
What is CSP
CSP stands for Communicating Sequential Processes and is yet another way to model concurrency in our application. CSP is about decomposing your program into a set of independent, self-contained, black-box processes which respond to various events and produce events themselves. To allow these processes to communicate we use channels - a middleman entity that acts like a pipe and distributes events between processes. The way interaction with channels works makes up most of the pattern. There is a strong mathematical theory behind CSP which defines a set of laws and abstractions to make it work but thankfully we don't have to be Ph.D. in Maths to start using it.
An overview
In the real world, things communicate with each other and respond to events. We even tend to think about many things in terms of how they react to events. How could we adopt the right mindset to view the real-world object as a process? We may try to think of something as a set of its reactions to events. Let's imagine a coffee machine. When you turn it on, it heats the water. When you click "Latte", it makes a latte. What about us? When we want a coffee, we go to the coffee machine and make a coffee. When coffee is ready, we drink it. You and a coffee machine are just two processes communicating with each other through an interface. It is the interface that synchronize your actions, and let's you know when the coffee machine finished heating and is ready for you to choose a coffee. Otherwise, without knowing how the mechanism works, your actions would be chaotic and out of order resulting in a broken coffee machine and a solid paycheck. In this particular case, an interface acts like an event channel for the actual coffee-making mechanism inside of our machine and the person. We surely don't need to know about what makes us a coffee behind the curtains as long as we know how to respond to events that it communicates to us.
What is a channel
You can think of a channel as an array of constantly updating pieces of data or events. It might be also similar to a queue in some sense. Anyway, channels represent asynchronous communication between your code. You can take it from the channel and you can put something into the channel. Channels can be buffered or unbuffered, and the buffers can be of different kinds. For example dropping buffer, when full, will drop all incoming values. The sliding buffer will accept an incoming value and drop the oldest element in the buffer. Why would you need buffers? Imagine yourself being a bartender and getting an order for Mojito. Once you are done with Mojito, you ring the bell and expect your order to be taken. Would you sit and wait for somebody to take it before processing other orders? Surely not, you can fit 10 Mojitos on your bar counter, so your "buffer" capacity is 10.
Guarded commands
As we said above, CSP enforces a set of rules when it comes to communication and channels. One of the most important ones is the notion of back pressure. Imagine two people standing on opposite sides of the pipe each having a tap. We cannot push the water to the person on the opposite side until he turns the tap and allows us to deliver it. We need to wait for it. Guarded commands in most of the CSP implementations feature the same concept. We have to wait for somebody to be ready to take out the event from the channel before completing our put operation. The sender process is essentially blocked at this point. When a process wants to receive a an event, it will be blocked as well until an event arrives at the channel. Guarded commands act like a synchronization mechanism that lets two otherwise mostly independent processes sync on particular events.
Show me an example
While learning a pattern, I attempted to create a bare-bones implementation of CSP. Besides getting a deeper understanding of the pattern, another important benefit is that I can show you examples with this library. The code illustrates a super simple coffee machine example I was talking about earlier:
import { makeChannel } from 'csp-coffee/channel';
import { take, put } from 'csp-coffee/operators';
import { go } from 'csp-coffee/go';
import { delay } from 'csp-coffee/utils'
const coffeeMachineInterface = makeChannel<string>();
function* person () {
yield put(coffeeMachineInterface, 'latte');
console.log('i want my coffee so much');
yield take(coffeeMachineInterface);
console.log('yummy');
}
function* coffeeMaker() {
const coffeeToMake: string = yield take(coffeeMachineInterface);
yield delay(1000);
yield put(coffeeMachineInterface, `have a coffee ${coffeeToMake}`);
}
const { cancellablePromise: personPromise } = go(person);
const { cancellablePromise: coffeeMakerPromise } = go(coffeeMaker);
Promise.all([personPromise, coffeeMakerPromise]).then(() => {
console.log('done');
})
Note that we expressed our actors as generator functions that are later passed to a special go
function. This is what turns our generators into processes. It just takes care of promises, library operators (put, take), and the correct order of their execution. It also returns a promise which resolves when the generator functions is done executing which is handy if we would try to incorporate this library into our code with heavy promise usage.
How does it make things better?
It is not, it is just a different way to organize your asynchronous code. Of course, you would not need such kind of abstraction in every single case. But at some point in developing pretty much any sizeable application, you have to deal with increasing numbers of concurrency problems. And when trying to solve these problems using promises, RxJS, or plain callbacks, you start putting your business code into event handlers. With a growing amount of such code, it is becoming harder and harder to reason about your business logic since you have to assemble a puzzle of callbacks spread all over your application. CSP tackles this issue by introducing channels in the middle, which separates concerns of communication and flow control. It takes all of the good parts of generators - synchronous-looking code, pausing, etc - and takes it to the next level by providing an abstraction for communication and a clear set of rules for it.
Outro
This article appeared to be much shorter than I had previously expected. The amount of methods and functions that CSP might provide to you is overwhelming for one article. What's important is that you are not required to know them all to understand the concept. We took what we learned from the previous article about generators and saw how it could evolve into a much more serious tool. I will leave a few links below for you to explore the API and get a better understanding of what an implementation might be capable of.
Resources
Posted on December 1, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.