Callback based approaches – How Programming Languages Model Asynchronous Program Flow

Note!

This is another example of M:N threading. Many tasks can run concurrently on one OS thread. Each task consists of a chain of callbacks.

You probably already know what we’re going to talk about in the next paragraphs from JavaScript, which I assume most know.

The whole idea behind a callback-based approach is to save a pointer to a set of instructions we want to run later together with whatever state is needed. In Rust, this would be a closure.

Implementing callbacks is relatively easy in most languages. They don’t require any context switching or pre-allocated memory for each task.

However, representing concurrent operations using callbacks requires you to write the program in a radically different way from the start. Re-writing a program that uses a normal sequential program flow to one using callbacks represents a substantial rewrite, and the same goes the other way.

Callback-based concurrency can be hard to reason about and can become very complicated to understand. It’s no coincidence that the term “callback hell” is something most JavaScript developers are familiar with.

Since each sub-task must save all the state it needs for later, the memory usage will grow linearly with the number of callbacks in a task.

Advantages
• Easy to implement in most languages
• No context switching
• Relatively low memory overhead (in most cases)
Drawbacks
• Memory usage grows linearly with the number of callbacks.
• Programs and code can be hard to reason about.
• It’s a very different way of writing programs and it will affect almost all aspects of the program since all yielding operations require one callback.
• Ownership can be hard to reason about. The consequence is that writing callback-based programs without a garbage collector can become very difficult.
• Sharing state between tasks is difficult due to the complexity of ownership rules.
• Debugging callbacks can be difficult.

Coroutines: promises and futures

Note!

This is another example of M:N threading. Many tasks can run concurrently on one OS thread. Each task is represented as a state machine.

Promises in JavaScript and futures in Rust are two different implementations that are based on the same idea.

There are differences between different implementations, but we’ll not focus on those here. It’s worth explaining promises a bit since they’re widely known due to their use in JavaScript. Promises also have a lot in common with Rust’s futures.

First of all, many languages have a concept of promises, but I’ll use the one from JavaScript in the following examples.

Promises are one way to deal with the complexity that comes with a callback-based approach.

Instead of:
setTimer(200, () => {
  setTimer(100, () => {
    setTimer(50, () => {
      console.log(“I’m the last one”);
    });
  });
});

We can do:
function timer(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
timer(200)
.then(() => timer(100))
.then(() => timer(50))
.then(() => console.log(“I’m the last one”));

The latter approach is also referred to as the continuation-passing style. Each subtask calls a new one once it’s finished.

The difference between callbacks and promises is even more substantial under the hood. You see, promises return a state machine that can be in one of three states: pending, fulfilled, or rejected.

When we call timer(200) in the previous example, we get back a promise in the pending state.

Now, the continuation-passing style does fix some of the issues related to callbacks, but it still retains a lot of them when it comes to complexity and the different ways of writing programs. However, they enable us to leverage the compiler to solve a lot of these problems, which we’ll discuss in the next paragraph.

Leave a Reply

Your email address will not be published. Required fields are marked *