AboutReadingsNotes

Time-slicing With Continuation-passing Style

CPSnon-blocking
Photo by Jake Blucker on Unsplash

Background story

In January this year, I applied for an overseas dev job. I was asked to finish a project as homework. The project required a ton of computations on the front end, which posed a challenge as I must ensure no operations should block the main thread. I didn’t want to move the computation to a worker thread because the data was consumed in the main thread. Serializing and de-serializing a large set of data also has a performance cost. Furthermore, to improve indexing performance, I used Map to store the results, which is not serializable.

The task involved is as following:

Process a long list of news items, and index each item according to its tags. It looks like this in code:

JavaScript
const newsList = [
{
uid: '8be34939-bc25-4b9e-999d-2daf19fbea7b',
title:
'Adipisicing do eu magna ex non est eu labore nisi duis enim elit.',
tags: ['reprehenderit', 'cupidatat', 'ad', 'ea', 'labore'],
},
{
uid: '488399fc-e474-4c52-a36c-625e2218fabe',
title: 'Elit aute tempor dolore do sunt.',
tags: ['sint', 'ea', 'Lorem', 'consectetur', 'officia'],
},
{
uid: '0340b317-bd55-4c43-9982-94bf9d08b977',
title: 'Aliquip qui est sint veniam consectetur.',
tags: ['sit', 'ullamco', 'consectetur'],
},
// ... omit remaining tens of thousands of items
];
const newsMap = new Map();
newsList.forEach((news) => {
news.tags.forEach((tag) => {
if (!newsMap.has(tag)) {
newsMap.set(tag, [news]);
} else {
newsMap.get(tag).push(news);
}
});
});

In practice, I’d never done massive computations on the front-end, as JavaScript is not very efficient in handling CPU-intensive work. However, after some google search, I found a way to get around this limitation. The idea is to slice a big task to many small ones and put them to different callstacks.

Time-slicing to the rescue

If you’ve been working with React long enough, you’ve probably heard of React Fiber and time-slicing. React Fiber is a new reconciliation algorithm inside of the React core. It was designed to enable React to handle computations(mostly diffing caused by setState) in a non-blocking way. When high priority tasks occur due to user inputs, React can pause low priority tasks and handle them immediately.

The fiber architecture is very complicated. The implementation of it is quite cryptic and daunting. Implementing a fiber algorithm in my little project would be an overkill, if not infeasible. However, I can still do time-slicing in JavaScript in an easier way.

The solution lies in JavaScript’s power in asynchronicity and callback functions. Encountered with asynchronous tasks, we tend to shun away from callbacks because of the dreaded “callback hell.” However, when handled properly, callbacks and asynchronicity are a good fit. The callback soup I’m about to introduce is very powerful and irreplaceable.

If you used to pass callbacks in Ajax programming or node.js event handlers, you already know the technique I’m going to show you. I just find a fancier name for it: CPS(Continuation-passing Style).

What is CPS

Here’s an identity function you would write normally:

JavaScript
function id(x) {
return x;
}

and here is a CPS version:

JavaScript
function id(x, cb) {
cb(x);
}

In continuation-passing style, instead of “returning” the procedure to its caller, you invoke the “current continuation” callback (provided by the caller) on the return value.

The foundation of CPS is simple:

Procedures can take a callback to invoke upon their return value.

Slice it up!

The atomic operation we need to do in processing the list is as following:

JavaScript
function indexNews(item, tag) {
if (!newsMap.has(tag)) {
newsMap.set(tag, [item]);
} else {
newsMap.get(tag).push(item);
}
}

So we need to slice our previous one big task to many small tasks like the above one. Here’s how to do it:

JavaScript
function indexNews(item, tag, continuation) {
if (!newsMap.has(tag)) {
newsMap.set(tag, [item]);
} else {
newsMap.get(tag).push(item);
}
continuation();
}
function iterate(list, processTask, continuation) {
function handleOne(i, j, _continuation) {
if (i < list.length) {
const item = list[i];
if (j < item.tags.length) {
processTask(item, item.tags[j], function handleNext() {
handleOne(i, j + 1, _continuation);
});
} else {
handleOne(i + 1, 0, _continuation);
}
} else {
_continuation();
}
}
handleOne(0, 0, continuation);
}
iterate(newsList, indexNews, () => {
// Indexing is done,
// we can enable features that depend on indexing safely here.
console.log('done');
});

This introduces too many recursions and makes our code way more complicated without improving performance. But we are very close!

Notice a very important feature of the indexNews function. It takes a continuation callback and calls the continuation after finishing the current task. We’ve achieved “Inversion of Control” here. We can tell indexNews what to do in the next iteration.

In our case, we want it to move subsequent tasks to a new callstack so that we don’t put too much pressure on the current callstack.

However, we don’t want to move every task to its own callstack, which is unnecessary.

So, here’s the rule:

Each callstack can take 500 tasks, after that limit, we put the next 500 tasks in the next callstack. Repeat until all tasks are done.

We just need a few modifications to our previous code to achive this:

JavaScript
function indexNews(item, tag, continuation) {
if (!newsMap.has(tag)) {
newsMap.set(tag, [item]);
} else {
newsMap.get(tag).push(item);
}
if (indexNews.skip++ % 500 === 0) {
setTimeout(continuation, 0);
} else {
continuation();
}
}
indexNews.skip = 0;

Because of how event loop works in JavaScript, the setTimeout method will move its callback to the next execution callstack.

Summary

We introduced a technique we already knew and rediscovered the hidden power of it. Continuation-passing Style enables us to have control over what happens next when the current operation is completed. Relying on this feature, we first sliced a very big task to many small ones, and then chunked them into different callstacks.

Check out the project here.

Why I Quit Social Media and News AppsOn Doing Less
Follow Levi on Twitter (@levibit), GitHub, or send him an email
Back to Home
Design and </> with ☕ by Levi Wong
© 2018 - 2020