JavaScript Concurrency: Event Loops
Welcome to the Event Loops lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
Real-world browsers include complex event loops that schedule
setTimeouttimers, promises, and some more esoteric types of asynchronous code. To get an idea of how they work, we can build a basic version of the event loop that queues up simple functions. Our event loop will have two components:- A
queueEventfunction we can call to queue (or schedule) code to run later. This is similar to scheduling code to run later by setting a timer or creating a promise. - A
runLoopfunction that runs the queued events.
- A
Here's the code to implement that:
>
/* Each event in the events array is a function. Functions take no arguments* and their return values are ignored. */const events = [];function runLoop() {while (events.length > 0) {/* .shift() on an array removes and returns the 0th element. This* works like .pop(), but for the beginning of the array instead of* the end. */const nextEvent = events.shift();nextEvent();}}function queueEvent(event) {events.push(event);}- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
queueEvent(() => console.log('Event 1 is running.'));queueEvent(() => console.log('Event 2 is running.'));runLoop();console output Note that calling
queueEventdoesn't call our function; it only stores it in the array to be run later. Nothing happens until we actually start the loop withrunLoop(). Once the loop starts, it calls the functions stored in the array one by one.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
queueEvent(() => console.log('Event 1 is running.'));queueEvent(() => console.log('Event 2 is running.'));console.log('The example ran, but neither event ran!');console output In our manual example, we have to start the event loop ourselves. If we don't start the loop, nothing gets executed.
In browsers or in Node, we don't have to do this manually; those systems run the event loop for us. More on this later.
Our event loop might seem trivial, but it's perfectly functional! We can use it to build complex asynchronous systems, where events schedule other events to happen in the future, just like we do with promises.
The key thing to realize is that our event functions can call
queueEventto queue even more events to run in the future. When this happens, our event loop will continue runningwhile (events.length > 0), until the event array is empty. If one event queues another event, that second event will keep the loop going!- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
queueEvent(() => {console.log('Event 1 is running.');queueEvent(() => {console.log('Event 2 is running.');queueEvent(() => {console.log('Event 3 is running.');});});});runLoop();console output What about timeouts, like the ones supported by
setTimeout? Adding them is surprisingly straightforward.In the code above, our queue elements were functions that we called one by one. Now that we're working with timed events, we need a way to track when the function should execute. The new version of
queueEventwill take two arguments: the function to call and the number of milliseconds to wait before calling the function.We can use an object to accomplish this, storing events like
{fn, runAt}. Here,fnis the function to call andrunAtis a timestamp when the function should be run.There's one more complication. We can't simply grab the 0th element of the array with
shift(), like we did before. What if the event is scheduled to run in the future?To address this, we'll continuously loop over our event queue array, checking the
runAtargument for every element. Ifnow >= event.runAt, we can go ahead and run the function, then remove it from the queue. Otherwise, we'll continue onto the next event, looping as long as any events remain in the queue.>
/* Each event in the events array is an object {fn, runAt}.* fn: the function to call.* runAt: the timestamp when we should call the function. */const events = [];function runLoop() {while (events.length > 0) {const now = new Date().getTime();// Find the next event and run it if it's ready.for (let i=0; i<events.length; i++) {const event = events[i];if (now >= event.runAt) {events.splice(i, 1); // Remove the event at index i.event.fn();/* We changed the array that we're looping over, so abort the* loop. The outer loop will restart it anyway. */break;}}}}function queueEvent(fn, ms) {const now = new Date().getTime();const runAt = now + ms;events.push({fn, runAt});}Our
queueEventfunction now works like the built-insetTimeoutfunction!- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
queueEvent(() => {console.log('Event 1 is running.');}, 1000);runLoop();console output The next example schedules an event for 1000 ms in the future. That event schedules another event for 500 ms in the future. As with nested
setTimeouts, the result is that the second event runs 1500 ms after execution begins.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
queueEvent(() => {console.log('Event 1 is running.');queueEvent(() => {console.log('Event 2 is running.');}, 500);}, 1000);runLoop();console output Here's a similar example instrumented with an array.
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const array = [];const user = {name: 'Amir'};queueEvent(() => {array.push('before');if (user !== undefined) {queueEvent(() => array.push('user exists'), 500);}array.push('after');}, 1000);runLoop();array;Result:
['before', 'after', 'user exists']
And the same example again, except that this time
userisundefined.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const array = [];const user = undefined;queueEvent(() => {array.push('before');if (user !== undefined) {queueEvent(() => array.push('user exists'), 500);}array.push('after');}, 1000);runLoop();array;Result:
['before', 'after']
Our event loop is crude in many ways. For example, it's not very efficient. Consider the events above, delayed by 500 or 1000 ms. When those events are pending, the outer
whilein our event loop runs millions of times, waiting for the event to reach itsrunAttime. We can see that by instrumenting the loop.>
const events = [];let iterationCount = 0;function runLoop() {while (events.length > 0) {iterationCount += 1;const now = new Date().getTime();// Find the next event and run it, if any are ready.for (let i=0; i<events.length; i++) {const event = events[i];if (now >= event.runAt) {events.splice(i, 1); // Remove the event at index i.event.fn();/* We changed the array that we're looping over, so abort the* loop. The outer loop will restart it anyway. */break;}}}}function queueEvent(fn, ms) {const now = new Date().getTime();const runAt = now + ms;events.push({fn, runAt});}queueEvent(() => {console.log('event ran');}, 1000);runLoop();iterationCount;Result:
Unlike most examples, we didn't actually run that one in your browser because the number would vary unpredictably. However, on our machine we got 10,528,649. The event loop ran through over ten million cycles while waiting one second for the scheduled time to arrive!
Fortunately, browsers don't waste CPU in that way. But our event loop is still an event loop even if it makes our CPU get hotter than it should!
In this lesson, we had to call
runLoopto begin the event loop, and thatrunLoopfunction blocked until all of the events were finished. Does that mean that this wasn't real concurrency, or that we didn't build a real event loop? No; we're just seeing that detail because we built the event loop ourselves, rather than using one built in to the browser.When we write concurrent code in JavaScript, it seems like the browser magically calls our function later. But there's nothing magical from the browser's perspective. It has an event loop similar to the one that we wrote here, looping over queued events, running the corresponding code.
(Another important difference is that the browser's event loop is more efficient than ours. For example, it won't endlessly loop while nothing is happening, like our code did.)
Event loops are common in computing. So far, we've been talking about the browser event loops. NodeJS is a JavaScript runtime combined with an asynchronous event loop, so you use an event loop every time you write a server in Node. Most graphical toolkits, like those used in Windows, macOS, iOS, and Android, also have event loops at their cores. (Windows calls it a "message loop" and macOS/iOS call it the "main event loop".)
A final note about terminology. "Event loop" refers to the code that actually processes events, calling their callbacks. An "event queue" is the list of events that are waiting to run, like the
eventsarray in our examples here. You may see event loops called "message loops" or "message pumps", but these mean roughly the same thing.