Creating tasks using timers
So far in this chapter, we've had a look at all the inner workers of the web browser environment, and where the JavaScript interpreter fits in this environment. What does all this have to do with applying concurrency principles to our code? With the knowledge of what's happening under the hood, we have a greater insight into what's happening when a given chunk of our code is run. Particularly, we know what's happening relative to other code chunks; time ordering is a crucial concurrency property.
This being said, let's actually write some code. In this section, we'll use timers to explicitly add tasks to the task queue. We'll also learn when and where the JavaScript interpreter jumps in and starts executing our code.
Using setTimeout()
The setTimeout()
function is staple in any JavaScript code. It's used to execute code at some point in the future. New JavaScript programmers often trip over the setTimeout()
function because it's a timer. At a set point in the future, say 3 seconds from now, a callback function will be invoked. When we call setTimeout()
, we will get the atimer
ID in return, which can be cleared later on using clearTimeout()
. Here's what the basic usage of setTimeout()
looks like:
// Creates a timer that calls our function in no less // than 300MS. We can use the "console.time()" and the // "console.timeEnd()" functions to see how long it actually // takes. // // This is typically around 301MS, which isn't at all // noticeable by the user, but is unreliable for // accurately scheduling function calls. var timer = setTimeout(() => { console.timeEnd('setTimeout'); }, 300); console.time('setTimeout');
Here's the part that's misunderstood by JavaScript newcomers; it's a best effort timer. The only guarantee we have when using setTimeout()
is that our callback function will never be called sooner than the allotted time that we pass it. So if we said call this function in 300 milliseconds, it'll never call it in 275 milliseconds. Once the 300 milliseconds have elapsed, a new task is queued. If there's nothing waiting in line before this task, the callback is run right on time. Even if there are a few things in the queue in front of it, the effects are hardly noticeable—it appears to run at the correct time.
But as we've seen, JavaScript is single threaded and run-to-completion. This means that once the JavaScript interpreter starts, it doesn't stop until it's finished; even if there's a task waiting for a timer event callback. So, it's entirely possible that even though we asked the timer to execute the callback in 300 milliseconds, it executes it in 500 milliseconds. Let's take a look at an example to see how this is possible:
// Be careful, this function hogs the CPU... function expensive(n = 25000) { var i = 0; while (++i < n * n) {} return i; } // Creates a timer, the callback uses // "console.timeEnd()" to see how long we // really waited, compared to the 300MS // we were expecting. var timer = setTimeout(() => { console.timeEnd('setTimeout'); }, 300); console.time('setTimeout'); // This takes a number of seconds to // complete on most CPUs. All the while, a // task has been queued to run our callback // function. But the event loop can't get // to that task until "expensive()" completes. expensive();
Using setInterval()
The cousin of setTimeout()
is the setInterval()
function. As the name suggests, it accepts a callback function that's to be called at a regular interval. In fact, setInterval()
takes the exact same arguments as setTimeout()
. The only difference is that it will keep calling the function every x milliseconds until the timer is cleared using clearInterval()
.
This function is handy when we want to keep calling the same function, over and over. For example, if we poll an API endpoint, setInterval()
is a good candidate solution. However, keep in mind that the scheduling of the callbacks is fixed. That is, once we call setInterval()
with, say, 1000 milliseconds, there's no changing that 1000 milliseconds without first clearing the timer. For cases where the interval needs to be dynamic, using setTimeout()
works better. The callback schedules the next interval, which allows the interval to be dynamic. For example, backing off from polling an API too frequently by increasing the interval.
In the setTimeout()
example that we last looked at, we saw how running JavaScript code can mess with the event loop. That is, it prevents the event loop from consuming the task that invokes the JavaScript interpreter with our callback function. This allows us to defer code execution till some point in the future, but with no promises of accuracy. Let's see what happens when we schedule tasks using setInterval()
. There's also some blocking JavaScript code that runs afterward:
// A counter for keeping track of which // interval we're on. var cnt = 0; // Set up an interval timer. The callback will // log which interval scheduled the callback. var timer = setInterval(() => { console.log('Interval', ++cnt); }, 3000); // Block the CPU for a while. When we're no longer // blocking the CPU, the first interval is called, // as expected. Then the second, when expected. And // so on. So while we block the callback tasks, we're // also blocking tasks that schedule the next interval. expensive(50000);