What the Heck Is the Event Loop Anyway? A Guide to JavaScript Runtime
Introduction to the Runtime and Single-Threaded Nature
>> (Phillip Roberts) Hello, come in and sit down. So, for the last session before the afternoon break, we have Phillip Roberts, who works at Andela—and is here from London? Scotland? Edinburgh. >> (Audience member) Wow, ten-second memory. He's going to talk about the event loop. If everyone could give Phillip a big round of applause. >> Phillip Roberts: Okay, hello everyone. Thanks for coming to the side track; it's awesome to see it packed out in here. Can everyone give me a stretch? I needed to stretch, so I look less weird. I want to talk about the event loop, and what the heck is the event loop, as in the event loop inside JavaScript? So first up, as he said, I work for AndYet, which is an awesome little dev shop in the US. Look us up if you need help with real-time stuff. That's what we're good at. So, about 18 months ago—I'm a paid, professional JavaScript developer—I thought to myself, how does JavaScript actually work? And I wasn't entirely sure. I'd heard V8 as a term, Chrome's runtime, but didn't really know what that meant or what that did. I'd heard things like single-threaded, and you know, obviously I'm using callbacks. How do callbacks work? I started a journey of reading, research, and experimenting in the browser, which basically started like this: I was kind of like, "JavaScript, what are you?" "I'm a single-threaded, single-concurrent language, right." "Yeah, cool. I have a call stack, an event loop, a callback queue, and some other APIs and stuff." "Right." I did not do a computer science degree. I mean, these words—they're words. So I heard about V8 and the various runtimes in different browsers, and I looked to V8: "Do you have a call stack, an event loop, a callback queue, and some other APIs and stuff?" "I have a call stack and a heap. I don't know what those other things are." Okay, interesting. So basically, 18 months passed, and I think I get this. (Laughing) And so, this is what I want to share with you today. Hopefully this will be useful if you're relatively new to JavaScript, to help you understand why JavaScript is so weird when you compare it to other languages you might have used, why callbacks are a thing—they cause us hell, but are required. And if you're an experienced JavaScript developer, hopefully it will give you some fresh insights into how the runtime you're using works so you can think about it a little better. So if we look at the JavaScript runtime itself, like V8, which is the runtime inside Chrome, this is a simplified view of what a JavaScript runtime is: the heap, where memory allocation happens, and then there's the call stack, which is where your stack frames are and all that kind of stuff. But, if you, like, clone the V8 code base and grep for things like setTimeout, DOM, or HTTP request, they're not in there. They don't exist in V8, which was a surprise to me. It's the first thing you use when you start thinking about async stuff, and it's not in the V8 source. Hmm... interesting. So, over this 18 months of discovery, I've come to realize this is really, this is really the bigger picture. This is what I'm hoping to get you on board with today and help you understand what these pieces are: we have the V8 runtime, but then we have these things called web APIs, which are extra things that the browser provides. DOM, AJAX, timeouts, things like that. Then we have this mythical event loop and the callback queue. I'm sure you've heard some of these terms before, but maybe you don't quite understand how these pieces pull together. So, I'm going to start from the beginning. Some of this will be new—certain words might be new to some people, while other people will get this. We're going to quickly move on from here; bear with me if this is obvious, but I think for a lot of people it's not.
Understanding the Call Stack and Blocking Code
So, JavaScript is a single-threaded programming language, a single-threaded runtime. It has a single call stack. And it can do one thing at a time. That's what single-threaded means: the program can run one piece of code at a time. So, let's try and visualize that just to get our heads around what that means. If I have some code like this on your left, we've got a few functions: a function multiply which multiplies two numbers, square which calls multiply with the same number twice, a function printSquare which calls square and then calls console.log, and then at the bottom of our file we actually run printSquare. Is this code all good? Does it make sense? Cool. So, if we run this—well, I should back up a step. The call stack is basically a data structure which records where in the program we are. If we step into a function, we put something onto the stack. If we return from a function, we pop off the top of the stack. That's all the stack can do. So if you run this file, there's kind of a main function, right, like the file itself, so we push that onto the stack. Then we have some function definitions—they're just defining the state of the world—and finally we get to printSquare. printSquare is a function call, so we push that onto the stack, and immediately inside printSquare we call multiply, which we push onto the stack. Now we have a return statement: we multiply a and b and we return. When we return, we pop something off the stack. So, pop multiply off the stack, returning to square, return to printSquare, console.log. There's no return—it's implicit because we got to the end of the function, and we're done. So that's like a visualization of the call stack. Does that make sense? Yes, Phil. Even if you haven't thought about the call stack before, you've come across it when you've been doing browser-side development. So if we have code like this: a function baz which calls bar, which calls foo, which throws an error, if we run it in Chrome, we see this: and it prints the stack trace, right—the state of the stack when that error happened. Uncaught error: oops. foo, bar, baz, anonymous function, which is our main. Equally, if you've heard the term like "blowing the stack," this is an example of that: have a function foo which calls foo. So what's going to happen? We have a function main which calls foo, which calls foo, which calls foo, and ultimately Chrome says, "You probably didn't mean to call foo 16,000 times recursively; I'll just kill things for you and you can figure out where your bug lies." So although I may be presenting a new side of the call stack, you have some sense of it in your development practice already.
So, the big question then comes up: what happens when things are slow? So, we talk about blocking and blocking behavior. There's no strict definition of what is and isn't blocking; really, it's just code that's slow. So console.log isn't slow. Doing a while loop from one to ten billion is slow. Network requests are slow. Image requests are slow. Things which are slow and on that stack are what blocking means. So here's a little example. Let's say we have—this is like a fake bit of code—getSynchronous, right, like jQuery's old AJAX requests. What would happen if those were synchronous requests? Forget what we know about async callbacks; they're synchronous. If we go through it like that, we call getSync and then we wait, because we're doing a network request, and networks—relative to computers—are slow. Hopefully that network request completes, we can move on, wait, move on. Wait, and I mean, this network request might never finish, so... yeah, I guess I'll go home. Finally, those three blocking behaviors complete and we can clear the stack, right. So in a programming language that's single-threaded—you're not using threads like, say, Ruby—that's what happens, right? We make a network request, we have to just wait 'til it's done because we have no way of handling that. Why is this actually a problem? The problem is because we're running code in browsers. So, let's—here we go, okay. So this is just Chrome, this is the code I just ran. Browsers don't give us—well, they do give us synchronous AJAX requests. I'm faking this out with a big while loop because it's synchronous; I basically loop for five seconds before continuing. So if I open up the console here, we can see what happens. So we request foo.com. While this is happening, I can't do anything, right? Even the run button hasn't finished rerendering the fact that I clicked it. The browser is blocked, it's stuck, it can't do anything until those requests complete. And then all hell breaks loose because I did some stuff, it figured out I'd done it, it couldn't actually render it. Couldn't do anything. That's because if that call stack has things on it—and here it's got these, yeah, it's still going—we've got the synchronous request, the browser can't do anything else. It can't render, it can't run any other code. It's stuck. Not ideal, right? If we want people to have nice, fluid UIs, we can't block the stack.
Asynchronous Callbacks and the Event Loop Architecture
So, how do we handle this? Well, the simplest solution we're provided with is asynchronous callbacks. There's almost no blocking functions in the browser; equally in Node, they're all made asynchronous, which basically means we run some code, give it a callback, and run that later. If you've seen JavaScript, you've seen asynchronous callbacks. What does this actually look like? A simple example to remind people where we're at: Code like this: console.log('hi'). Right, we run the setTimeout, but that queues the console log for the future, so we skip on to JSConf and then, five seconds later, we log "there", right? Make sense? Happy? Basically, that's setTimeout doing something. So, asynchronous callbacks with regards to the stacks we saw before... how does this work? Let's run the code: console.log('hi'). setTimeout. We know it doesn't run immediately. We know it's going to run in five seconds' time. We can't push it onto the stack; somehow it just disappears. We don't have a way of describing this yet, but we'll come to it. We log JSConfEU, clear. Five seconds later, somehow magically "there" appears on the stack. How does that happen? And that's—this is basically where the event loop comes in on concurrency. Right, so I've been kind of partially lying to you and telling you that JavaScript can only do one thing at one time. That's true: the JavaScript runtime can only do one thing at one time. It can't make an AJAX request while you're doing other code. It can't do a setTimeout while you're doing other code. The reason we can do things concurrently is that the browser is more than just the runtime. So, remember this diagram: the JavaScript runtime can do one thing at a time, but the browser gives us these other things. It gives us these web APIs. These are effectively threads that you can just make calls to, and those pieces of the browser are where concurrency kicks in. If you're a back-end person, this diagram looks basically identical for Node: instead of web APIs we have C++ APIs, and the threading is being hidden from you by C++.
Now we have this picture, let's see how this code runs in a fuller picture of what a browser looks like. So, same as before: run code, console.log('hi'), logs hi to the console, simple. Now we can see what happens when we call setTimeout. We pass this callback function and a delay to the setTimeout call. Now, setTimeout is an API provided to us by the browser; it doesn't live in the V8 source. It's extra stuff we get in the environment where we're running the JavaScript runtime. The browser kicks off a timer for you. And now it's going to handle the countdown for you, right? So that means our setTimeout call itself is now complete, so we can pop it off the stack. JSConfEU, clear. So, now we've got this timer in the web API, which five seconds later is going to complete. Now, the web API can't just start modifying your code. It can't chuck stuff onto the stack when it's ready; if it did, it would appear randomly in the middle of your code. So this is where the task queue or callback queue kicks in. Any of the web APIs push the callback onto the task queue when it's done. Finally, we get to the event loop—the title of the talk! What the heck is the event loop? It's like the simplest little piece in this whole equation, and it has one very simple job: the event loop's job is to look at the stack and look at the task queue. If the stack is empty, it takes the first thing on the queue and pushes it onto the stack, which effectively runs it. So here we can see that now the stack is clear, there's a callback on the task queue, the event loop runs, it says, "Oh, I get to do something," and pushes the callback onto the stack. Remember, the stack is like JavaScript land, back inside V8. The callback appears on the stack, runs console.log('there'), and we're done. Does that make sense? Are you all with me? Awesome! Okay. So, now we can see how this works with probably one of the first encounters you would have...
had with async stuff, which for some weird reason, someone says you have to call setTimeout(0). Okay, you want me to run the function in zero time? Why would I wrap it in a setTimeout? Like, the first time you run across this, if you're like me, you see it doing something, but you don't know why.
The reason is, generally, if you're trying to defer something until the stack is clear. So we know looking at this, if you've written JavaScript, that we're going to see the same result — we're going to see “hi”, “JSConf”, and “there” is going to appear at the end. We can see how that happens.
The setTimeout(0) — now it's going to complete immediately and push it on to the queue. Remember what I said about the event loop: it has to wait till the stack is clear before it can push the callback on to the stack. So your stack is going to continue to run: console.log “hi”, “JSConfEU”, and clear. Now the event loop can kick in and call your callback. That's an example of setTimeout(0): deferring that execution of code, for whatever reason, to the end of the stack, or until the stack is clear. Okay.
So, all these web APIs work the same way. If we have an AJAX request, we make an AJAX request to a URL with a callback, it works the same way. Oops, sorry, console.log “hi”, make an AJAX request. The code for running that AJAX request does not live in the JavaScript runtime, but in the browser as a web API. So we spin it up with a callback in the URL; your code can continue to run. Until that XHR request completes — or it may never complete, that's okay — the stack can continue to run. Assuming it completes, it gets pushed to the queue, picked up by the event loop, and it's run. That's all that happens when an async call happens.
Visualizing Execution with the 'Loop' Tool and Practical Examples
Let's do a crazy complicated example, I hope this is going to work. If you haven't realized, all this is in Keynote; there's like, I don't know, 500 animation steps in this whole deck. (code blows up, flames animation) (Applause) Whew... no... so... interesting, we're given a link. Hmm... is this big enough? Can people see? Okay, so basically I wrote this talk for Scotland JS. After the talk I broke half of the slides and couldn't be bothered to redo all the slides, because it was a total pain in the ass in Keynote to do it. So I took the much easier route (Laughing) of writing a tool that can visualize the JavaScript runtime at runtime, and it's called loop.
So, let's just run this example, which was kind of the example that we had on the previous slide. I haven't shimmed XHR yet; it's doable, I just haven't done it. As you can see in the code, we're going to log something, this is a shim around addEventListener, setTimeout, and we're going to do a console.log. I'm going to run it and see what happens. So... add a DOM API, add a timeout, code is going to continue to run, pushes the callback into the queue, which runs, and we're done. If I click on here, then it's going to... trigger the web API, queue the callback for the click, and run it. If I click a hundred times, we can see what happens. I clicked; the click doesn't get processed immediately. It gets pushed to the queue. As the queue gets processed, eventually my click is going to get dealt with, right.
So I have a few more examples I'm going to run through here. Here we go. Okay, so, I'm just going to run through a few examples just to talk about a few things that you might have run into and not thought about with async APIs. In this example, we call setTimeout four times with a one-second delay, and console.log “hi”. By the time the callbacks get queued... that fourth callback, we asked for a one-second delay, and it's still waiting. The callback hasn't run, right? This illustrates what timeout is actually doing: it's not a guaranteed time to execution, it's a minimum time to execution. Just like setTimeout(0) doesn't run the code immediately, it runs the code next-ish, sometime, right?
So... in this example I want to talk about callbacks. So, depending on who you speak to and how they phrase things, callbacks can be one of two things:
- Callbacks can be any function that another function calls, or
- Callbacks can be more explicitly an asynchronous callback, meaning one that will get pushed back on the callback queue in the future.
This bit of code illustrates the difference, right. The forEach method on an array — it doesn't run asynchronously; it takes a function, which you could call a callback, but it's not running it asynchronously, it's running it within the current stack. We could define an asynchronous forEach so it can take an array, a callback, and for each item in the array it's going to do a setTimeout(0) with that callback. I guess this should pass in the value, but anyway. So, I'm going to run it and we can see what the difference is. For the first block of code that runs, it's going to sit and block the stack, right, until it's complete. Whereas in the async version, okay, it's slowed down, but we're basically going to queue a bunch of callbacks and they're going to clear, and then we can actually run through and do a console.log. In this example the console.log is fast, so the benefit of doing it asynchronously is not obvious, but let's say you're doing some slow processing on each element in the array. I think I have that shown somewhere... no, no, I don't. Okay.
How Rendering, Delays, and Event Floods Interact with the Loop
So let's say — oops. So I have a delay function which is just slow; it's just a slow thing. So... let's say processing async, and here processing sync. Okay, now, I'm going to turn on a thing I literally hacked together this morning, which is to simulate the repaint or the render in the browser. Something I haven't touched on is how all of this interacts with rendering — I've kind of touched on it, but not really explained it. So, basically the browser is kind of constrained by what you're doing in JavaScript. The browser would like to repaint the screen every 16.6 milliseconds; 60 frames a second is ideal, that's the fastest it will do repaints if it can. But it's constrained by what you're doing in JavaScript for various reasons, so it can't actually do a render if there is code on the stack, right. Like, the render call is almost like a callback in itself. It has to wait till the stack is clear. The difference is that the render is given a higher priority than your callback. Every 16 milliseconds it's going to queue a render, wait till the stack is clear before it can actually do that render. So this is — this render queue is just simulating a render. Every second: can I do a render? Yes. Can I do a render? Yes. Why? Because our code isn't doing anything now. If I run the code, you can see while we're doing this slow synchronous loop through the array, our render is blocked, right? If our render is blocked, you can't select text on the screen, you can't click things and see the response, right, like the example I showed earlier. In this example, okay, it's blocked while we queue up the async timeout, that's relatively quick, but we're giving — we're kind of giving the render a chance between each element, because we've queued it up asynchronously, to jump in there and do the render. Does that make sense? >> Yeah. >> Yeah, cool. So, that's just kind of — this is just like a simulation of how the rendering works, but it really shows you, when people say don't block the event loop, this is exactly what they're talking about. They're saying don't put shitty, slow code on the stack, because when you do that, the browser can't do what it needs to do to create a nice, fluid UI. This is why when you're doing things like image processing, or animating too many things, it gets sluggish if you're not careful about how you queue up that code.
An example of that we can see with scroll handlers. Scroll handles — like scroll events in the DOM trigger a lot, right? They trigger like — I presume they trigger on every frame, like every 16 milliseconds. If I have code like this, right: document.scroll, animate something, or do some work. If I have this code, as I scroll it's going to queue up like a ton of callbacks, right, and then it has to go through and process all of those. If each of the processing of those is slow, then, okay, you're not blocking the stack, you're flooding the queue with queued events. So, this is just helping visualize, I guess, what happens when you actually trigger all these callbacks. There are ways you can debounce that to basically say, okay, we're going to queue up all those events, but let's do the slow work every few seconds, or until the user stops scrolling for some amount of time. I think that's basically it.
There's a whole other talk in how the hell this works, because basically in running the code, like this code runs at runtime, right, and it's slowed down by — I run it through Esprima, a JavaScript parser, I insert a big while loop, that takes half a second, it just slow-motions the code. Ship it to a Web Worker and do a whole bunch of stuff to visualize what's happening while doing it at runtime, that makes sense. A whole other talk in that. I'm super excited about it and will talk to anyone about it after, because I think it's kind of neat. So with that, thanks very much! (Applause)