Asynchrony: Under the Hood - Shelley Vohr - JSConf EU
Summary
TLDRIn this talk, Shelley, a software engineer at GitHub, delves into asynchronous programming in JavaScript. She begins with basic principles and progresses to methodologies, highlighting the event loop, call stack, and run-to-completion semantics. Shelley contrasts callbacks, promises, generators, and async/await, explaining their roles and best use cases. She emphasizes the cognitive challenges of callbacks, the error handling in promises, and the intuitive, synchronous-like code structure of async/await. The presentation aims to equip developers with a deeper understanding of asynchronous tools in JavaScript.
Takeaways
- 🕒 Asynchronous programming in JavaScript deals with the 'now' and 'later' execution of code, managing tasks that will complete at an uncertain time.
- 🔁 The event loop is a central concept, running as an endless loop that processes chunks of code and manages the execution queue.
- 🆕 ES6 introduced microtasks, a queue that precedes the traditional task queue and helps in handling asynchronous operations more promptly.
- 💡 JavaScript's run-to-completion semantics ensure that a task finishes before the next begins, providing a safe execution context without concurrent modifications.
- 📈 Understanding the call stack is crucial for tracking function execution and returns, which is vital for managing asynchronous callbacks.
- 🔀 Callbacks, while simple, can lead to 'callback hell' due to their non-linear and complex nature, making code hard to follow and error-prone.
- 🔐 Promises introduced a new level of abstraction with placeholders for future values, offering better control and error handling for asynchronous operations.
- 🔁 Generators allow for a pause and resume pattern, making asynchronous code appear synchronous and simplifying error handling with traditional try/catch.
- 🔄 Async/await syntax, built on promises, makes asynchronous code more readable and manageable, using synchronous-style error handling with try/catch.
- 🛠️ Choosing the right asynchronous pattern depends on the specific requirements of the task, with each method having its strengths and appropriate use cases.
Q & A
What is the main focus of Shelley's talk?
-Shelley's talk focuses on the mechanics of asynchronous programming in JavaScript, including the basic principles, methodologies, and nuances that differentiate them.
What does the term 'asynch' refer to in the context of programming?
-In the context of programming, 'asynch' refers to asynchronous operations, which are operations that do not occur in a linear, sequential order but are managed over time with respect to 'now' and 'later'.
How is the event loop described in the talk?
-The event loop is described as an endlessly running, singly threaded loop that processes tasks in the order they are added to the queue, with each iteration running a small chunk of code.
What is the significance of JavaScript's run-to-completion semantics?
-JavaScript's run-to-completion semantics mean that the current task always finishes before the next task begins, ensuring that each task has complete control over all current states without interference from other tasks.
What is the role of the call stack in JavaScript's execution model?
-The call stack manages the execution of functions by keeping track of the location each function needs to return to once it's done executing, allowing for proper sequencing and memory management of function calls.
Why can callbacks lead to difficulties in understanding and managing asynchronous code?
-Callbacks can lead to difficulties because they require managing multiple asynchronous operations that may not execute in a linear, sequential order, which can result in complex and hard-to-follow code structures known as 'callback hell'.
What is the 'inversion of control' in the context of callbacks?
-Inversion of control in callbacks refers to the practice where the flow of the program is dictated by callback functions rather than the sequential order of the code, which can lead to trust issues and complexity.
How do promises change the way asynchronous operations are handled in JavaScript?
-Promises introduce a new way of handling asynchronous operations by providing a placeholder for future values, allowing developers to work with these values as if they were already available, and managing the eventual success or failure of these operations.
What is the microtask queue and how does it relate to promises?
-The microtask queue is a queue introduced in ES6 that allows certain asynchronous actions to be added to the end of the current microtask queue instead of waiting for the next event loop tick, which is particularly useful for promises to ensure their callbacks are executed immediately after the promise settles.
How do generators provide a different approach to asynchronous programming?
-Generators allow for a function to be paused and resumed multiple times, which enables a synchronous-looking code style while hiding the asynchronous operations as implementation details, making the code easier to reason about.
What is the main benefit of using async/await over traditional promises?
-Async/await simplifies the process of working with promises by making asynchronous code look and behave more like synchronous code, improving error handling and readability, and reducing the amount of boilerplate code needed.
Outlines
💻 Introduction to Asynchronous Programming in JavaScript
Shelley, a software engineer at GitHub, introduces the topic of asynchronous programming in JavaScript. She emphasizes the importance of understanding the relationship between 'now' and 'later' in code execution and the challenges it presents for developers. Shelley outlines the event loop as a central concept, describing it as a continuously running, single-threaded loop that processes chunks of code. She also introduces the idea of JavaScript's run-to-completion semantics, where tasks execute in full before yielding control back to the event loop. The summary also touches on the mechanics of the call stack and how functions are managed within it.
🔁 Understanding Callbacks and Their Challenges
This section delves into callbacks, a common asynchronous pattern. Shelley illustrates how callbacks can lead to complex code structures, known as 'callback hell,' which are difficult to follow and maintain. She discusses the inversion of control, where the flow of the program is handed off to callbacks, creating trust issues. Shelley also addresses error handling in callbacks, emphasizing the need to handle errors both through callbacks and exceptions. The introduction of Promises with ES6 is highlighted as a significant innovation that changed how asynchronous programming is approached in JavaScript, offering a new way to handle tasks and their outcomes.
🔗 Promises: Enhancing Asynchronous Code Management
Shelley explains how Promises revolutionized JavaScript's approach to asynchronous operations. Promises act as placeholders for future values, which can either be fulfilled or rejected. They allow for a more manageable way to handle asynchronous operations by providing a clearer sequence of operations with the 'then' and 'catch' methods. She discusses the microtask queue, which is part of the event loop and allows for immediate execution of tasks associated with Promises. The section also covers how Promises help avoid the callback 'black box' issue and improve error handling with catch handlers, making asynchronous code more predictable and easier to debug.
🔄 Generators: A Synchronous Approach to Asynchronous Code
Generators are introduced as a way to write asynchronous code that looks synchronous. Shelley describes generators as functions that can be paused and resumed, allowing other code to run in between. This feature makes generators particularly useful for creating a clear and linear code structure while handling asynchronous operations. She demonstrates how the 'yield' keyword is used within generator functions to send and receive values, and how this mechanism can be used to iterate over asynchronous operations in a more controlled manner. The summary also touches on the error-handling capabilities of generators, which can use traditional try-catch blocks, simplifying the process compared to other asynchronous patterns.
🚀 Async/Await: Simplified Asynchronous Code with Promises
The final section focuses on async/await, a syntactic sugar built on top of Promises that simplifies writing asynchronous code. Shelley explains that async/await allows developers to write code that looks synchronous but operates asynchronously under the hood. She points out the benefits of using async/await for better error handling with a single try-catch block and for writing more readable and maintainable code. The section also discusses the potential pitfall of forgetting the asynchronous nature of the code when using async/await and the importance of understanding the underlying mechanisms for effective usage.
🎉 Conclusion and Q&A
Shelley concludes the presentation with a summary of the key points discussed, emphasizing the importance of choosing the right asynchronous pattern for the job at hand. She highlights the strengths and weaknesses of callbacks, Promises, generators, and async/await, and encourages a deeper understanding of these tools to write more effective and maintainable JavaScript code. The section ends with applause from the audience, indicating the successful delivery of the presentation.
Mindmap
Keywords
💡Asynchronous Programming
💡Event Loop
💡Microtasks Queue
💡Run-to-Completion Semantics
💡Call Stack
💡Callbacks
💡Promises
💡Generators
💡Async/Await
💡Error Handling
Highlights
Introduction to asynchronous programming in JavaScript by Shelley, a software engineer at GitHub.
Explaining the basic principles of asynchronous programming and its importance in managing the 'now and later' relationship in code.
The event loop as an endlessly running single-threaded loop that manages code execution in JavaScript.
Introduction of the microtasks queue with ES6 and its role in asynchronous programming.
JavaScript's run-to-completion semantics and their impact on task execution.
Explanation of the call stack and how it manages function execution and return addresses.
The concept of inversion of control and its challenges in asynchronous programming with callbacks.
The issue of error handling in callbacks and the need for proper error management.
Promises as a new concept in ES6 that changed the JavaScript engine's approach to asynchronous operations.
How promises act as placeholders for future values and their role in error handling.
The microtask queue's function in handling promises and its impact on the event loop.
Generators as a way to write asynchronous code with a synchronous-looking style.
The use of the 'yield' keyword in generator functions to pause and resume execution.
Async/await as a syntactical approach to simplify working with promises and writing asynchronous code.
How async/await functions return promises implicitly and their impact on error handling.
The importance of choosing the right asynchronous programming tool for the job based on its specific requirements.
Conclusion and summary of the key takeaways from the talk on asynchronous programming in JavaScript.
Transcripts
Hi everybody ! Thank you again for the introduction
I'm Shelley, a software engineer at GitHub working on open source.
Today, I would like to do a deep dive into the mechanics of asynchronous programming
in JavaScript.
To make sure we're all on the same page, before I get started, I will go over basic principles
of asynch, and then we will launch into the different methodologies and nuances of what
exactly differentiates them, and, finally, we will compare over the spectrum of options
to discuss which situations require which approaches.
Let's say you have a programme detained within a contained within a final file.
It's mate up of functions.
At any given time, only one of these is going to be executing now.
The rest will execute later at some point in the future.
That, there, is the crux of asynch.
It's the relationship between now and later, and the way in which you manage this relationship
with your code.
What is key here, however, and what causes some of the most difficulties for developers
when they're just starting out, is that later does not mean strictly and immediately after
now.
It could be at any point in the future, and you don't necessarily know when that will
be.
Now, let's move on to some of the technical underpinnings of how code is executed in the
JavaScript environment.
In pursuit of this understanding, it's best to start with the event loop.
The event loop can best be conceptualised as an endlessly running singly threaded loop
where each iteration runs a small chunk of code in the programme.
If you want to run a chunk of code at a later time, that chunk would simply be added to
the queue for the event loop.
When the time came that you desired it to execute, it would be dequeued and executed.
With ES6 came a new concept called the microtasks queue, but we will save that for a little
bit later.
It is also important to mention that JavaScript has what is known as run-to-completion semantics.
This means that the current task always finishes before the next task begins.
Or until it explicitly yields control back to the event loop.
As a result of this, each task has complete control over all current states, and does
not need to worry about another task modifying things at the exact same time.
Looking at the code here, running this will always print first and then second, because
the function, starting in setTimeout is added to the task queue immediately but only executed
after the current piece of code is down since it yielded control.
Now let's look at the call stack.
When a function foo calls a function bar, bar needs to know where inside foo to return
to after it's done.
This information is managed with the call stack.
Take a look at the set of three functions on the left-hand side of the slide.
Let's walk through how the stack will look as the programme executes.
Initially, when the programme above has started, the call stack is empty.
After foo is called, the stack has one entry: location and global scope.
As I mentioned before, foo knows where to return to when it has finished.
Next, after bar is called, the stack has two entries.
It's now stored at the location that bar needs to return to when it has finished executing
in addition to global scope.
The trend continues as bas is called, containing the previous return address and the previous
return addresses from previous calls.
When console.log is called in bas, the call stack is... the console.
Next, each of the functions terminates, and, each time, the top entry is removed from the
stack.
After foo is done, we're back in global scope and the call stack is empty.
At the end, we return, and the programme is terminated.
In looking at this, you can see a handful of the things I previously mentioned.
The event loop, the task queue, and the stack.
The current task comes off of the event queue and its location is stored in memory while
irrelevant variables populate the heap.
The task queue is populated by task sources, any one of which would be a particular chunk
of code in an executing programme.
This snapshot would represent the moment after the all functions in the previous slide had
executed and before locations had begun to pop off of the stack.
Most of you have likely encountered callbacks, so I'm going to focus more on how they fit
into the asynch landscape as a whole.
Let's start by looking at some functions and discussing them in context.
Here, you can see I have two programmes side by side.
On the left, the function names are alphabetical from top down, and, on the right side, I've
mapped the functions to the order in which they run.
Most likely, your eyes have to do a significant amount of jumping around in order to discern
the order in which the functions are executing for the programme on the left.
Since they're not running in the top-down sequential order you might expect.
By following the Cardinal number of words on the right side, you probably had an easier
time.
Let's talk about why that is.
Your brain operates sequentially.
It places it at odds with the inherent functionality of callbacks.
This is a comparatively simpler example, but when callbacks start to next significantly,
you enter what is known colloquially as callback hell which becomes more difficult for your
brain to reason of through.
What is happening here in terms of the call stack, and, by extension, the event loop?
Assuming all these functions are asynch, first we will begin, and then immediately yield
control, and, then second, execute.
A callback was registered by first so the next available task will be that callback.
Next, third will execute, followed by fourth.
Fourth registers a callback, at then returns control, so that fifth executes.
The callback registered by fourth will come off the Q and execute, and — the queue and
execute, and finally, six will execute.
For each of these functions taking a callback, the callback itself is a black box for the
function.
The continuation of the programme is dependent on our handing that callback off to another
part of the code and then essentially just praying that it will do the correct thing
whether the callback is invoked.
This paradigm is known as inversion of control, and can create significant trust issues.
In the previous code snippet I had up, you could see that your eyes had to skip around,
even though they most likely wanted to read the code in a top-down fashion.
When you sometimes struggle to understand how and when each part of a piece of code
is working, it's undoubtedly hard to deal with errors in that code been within the callback
landscape, there are two ways in which errors are reported: via callbacks and via exceptions.
You need to combine both properly.
This is because the most obvious, but occasionally overlooked aspect of dealing with errors in
callbacks, is to make sure that they've actually all been handled.
However, there's a caveat to this advice, and it lies specifically in how errors are
caught: can you see how, in the snippet above, I returned and didn't throw an error.
That is intentional.
In an asynchronous environment, the exception could be thrown, and, when the lock is out
of scope, the error would be rendered meaningless.
The next big innovation in asynchronous programming promises hit JavaScript with ES6.
Before this, there was no direct notion of asynch built directly into the JavaScript
engine.
All it ever did was execute a single chunk of your programme at any given moment when
asked to.
You, the developer, asked it to by means of callbacks or timeouts, in order to shuffle
its place in the event loop.
With promises came a change to the JS engine which took a form of the queue that I mentioned
briefly at the beginning — the microtask queue.
Before I delve into how exactly this changes the form of the stack queue event loop diagram
I showed you a few slides ago, let's look at an example of a programme utilising promises
and then discuss what is happening and why.
Promises act as placeholders.
They allow us to reason about future values without necessarily knowing their outcomes,
making them functionally extemporaneous.
When the future value is settled, it might succeed or fail.
Then, at that point, the promise is no longer a placeholder and becomes an immutable value.
A few slides ago, I referenced the paradigm, the version of control.
Promises confer to us a capability to know when a given task finishes.
Promises may seem like an entirely different paradigm, but they don't get rid of callbacks
at all.
They just change with the callback has passed to, and remove the black box effect we saw
previously.
We get back from a function when it is completed, and then perform new passes from there, as
I said.
Promises are not just the mechanism for single-step operations that follow with this then that
flow.
[Sound distorted].
From the — calls for
the second then would create then another promise.
So, how does this look under the hood?
Promises utilise the microtask queue by allows us to say, "Here is this thing that I need
to do later but I need to ensure it happens immediately later from the — the microtask
queue can best be thought of as attached to each tech in the event loop.
Some asynch actions will be added to the end of the current microtask queue instead of
creating a whole new tick as a whole.
When a promise is resolved or rejected, the associated handlers will be called asynchronously
as microtasks, and then added to the queue for the current tick.
This diagram looks nearly identical to the diagram I showed you a few slides back.
One key difference from the microtask queue.
Once a promise settles or already settling, it queues a microtask.
This ensures promise callbacks are asynch even if the promise has already settled, so
calling it then would resolve a reject against the settled promise immediately queues a microtask.
Any additional microtasks queued during a given microtask are added to the end of the
queue and also processed.
So, in looking at the diagram, you see that the current task can come off the task queue
or the microtask queue.
All promised callbacks are queued as microtasks.
A microtask can also cause one or more microtasks to be added to the end of the same queue.
So it's theoretically possible that a microtask loop could spin indefinitely thus starving
the programme of its ability to move on to the next event loop tick.
This would conceptually be almost the same as expressing an infinite loop in your code.
What happens if something goes wrong in a promise.
How do we deal with those errors?
By default, it turns out, promises are swallowed if unhandled.
More specifically, any exception which is thrown inside a dot.then handler, or within
a function path, a new promise will be soundly disposed of unless manually handled.
The standard way to handle errors from promises is to add a catch handler at the end of your
promise chain.
You can also chain these handlers so that you can throw errors into progressively more
outer level scopes.
Catching and rethrowing errors in this way will tell you what led to the error more effectively
so that you can later debug the issue more efficiently.
If exceptions are thrown inside the callbacks of dot then and dot catch, it's not a method
because these two can revert them to rejections.
Let's look at an example and talk about what would work and what wouldn't in more concrete
terms.
This is a simple promise example.
From looking, you can see that from is going to reject with an error, no matter what.
So, what happens in the first example versus the second?
The first has a catch.
So it's going to print the error followed by the error message which in this case is
rejected.
You can also see the associated call stack by printing error.stack.
The second one doesn't have a catch.
So even though we threw an error, it will simply fail silently.
This next type of asynch approach — generators — sits a little on the edge of the overall
asynch landscape but is nevertheless important to touch on.
The first thing to observe as we talk about generators, is how they differ from normal
functions.
There are several notable differences but key to generators is their behaviour with
respect to the run to completion expectation.
With generators, we have a different kind of function which can be paused in the middle
either once or several times.
It is resumed later which allows other code to run during these pause periods between
runs.
The main strength of generators is that they provide a single threaded synchronous-looking
code style but allow us to hide the asynchrony away as an implementation detail.
This lets us express naturally what a programme step and statement flow is without navigating
the asynchronous gotchas and syntax at the same time.
To get an idea of how this looks in practice, let's see a function.
Here, you will see immediately that there's a little star next to the function in the
signature.
This is the syntactical indicator that you're looking at a generator function.
The key word "yield" will send out a value whenever the function is run.
So, here, the yield index plus plus expression will send the index value out when pausing
the generator function at that point.
If ever the generator is restarted, whatever value it was sent in will be the result of
that expression.
It will then get added to one and assigned to the index variable.
When we start, index is zero.
So this will be yielded and then 1 will be added to the index as a result of the plus
plus.
This will occur each time as the value of index is passed out and then in again, and
so that it increments by one every time.
To see this a little more clearly, let's look at a diagrammatical representation.
On the left, you will see a traditional non-generator function.
It adheres to the run to completion behaviour we expect from functions with no interruptions
from beginning to end.
On the right is the generator function.
With multiple stops and starts between beginning and end.
It's important to note that there is no real official end per se in the generator function.
The end in this case will be the last time that that yield is called.
When the generator function is initialised, an iterator is returned, and then the generator
starts with the first call to next on the function.
This function pauses when yield is called, and then would restart with the next call
to next, and so on, and so forth.
To recap, with normal functions, you get parameters at the beginning, and a return value at the
end, but generator functions, you send messages out with yield, and you send messages back
in with each restart.
One of the most powerful parts of ES6 generator design is that the semantics of the code inside
the generator are synchronous.
Even if the external iteration control proceeds asynchronously.
That's a fancy way of saying that you can use a very simple error-handling technique
you're probably familiar with — the tri catch mechanism.
If even if the function will pause at the yield expression, the tri catch with catch
it.
With normal asynch capabilities like callbacks, that's almost impossible to do.
The generator itself has a throw function to throw an error into the generator at its
pause position which of course can also be caught by a try catch inside the generator.
Asynch await is a new way to write asynchronous code.
Previous options are callbacks and promises.
Asynch await was created to simplify the process of working with and writing chained promises.
And so asynch await functions themselves return promises.
They cannot be used with plain callbacks.
Asynch await is thus like promises non-blocking, and makes asynchronous code look and behave
more like synchronous code.
This is where all the power lies.
Since any asynch await function returns a promise implicitly, the resolve value of the
promise will be whatever you return from the function to ill straight, let's look at.
Here, we are returning a string that returns several components of a street address.
The function has the asynch key word before it and the await key word can only be used
in function s defined with asynch.
All four variables must be resolved before the function will return the desired string.
Also important to note here is that if we did not use the await key cord before each
of the address component functions, this would not necessarily fail.
It would just mean that the variables would be set to promises and from the values returned
from them.
There is it also an oft overlooked gotcha.
Await calls operate sequentially.
What this means is that the call to get.city won't kick off until we have a value for get.street
address.
In some, this depends on a call from results of a previous call.
Here, we want to get the address components simultaneously, so we should not be blocking
each on the previous one.
Instead, we should rewrite it like this.
Under the hood, asynch await works almost exactly the same as promises, utilising the
new microtask queue introduced to handle asynch operations.
If you're familiar with promises, you know that, if a promise is rejected, you need to
handle that error inside a catch.
If a handle be errors for synchronous and asynchronous code, you will likely have to
duplicate your error-handler.
In the above snippet, we can see there is duplicate code on lines 6 and 8.
The catch statement on line 7 will handle any errors that the synchronous function do
synchronous things may throw, but it won't handle any errors thrown by do something since
it's asynchronous.
This example may seem palatable, since all it's doing is printing the error to console,
but if there is any kind of complex error-handle be logic, we want to avoid duplicating it.
Asynch await let's us do exactly that.
But looking at the code snippet on the top right, you will see that we only catch once.
When we use asynch await, we can catch errors in a single handler.
Here, we can both minimise the total amount of code that we write and catch errors in
a more readable and clear way.
So, wrapping up: our brains plan things out in sequential blocking singly threaded semantic
ways.
But callbacks express asynchronous flow in a rather non-linear and non-sequential way.
Which makes reasoning properly about such code much more difficult.
Callbacks suffer from lack of sequentiality and lack of trustability, but they're good
in situations where you may just be performing a simple request that is always asynchronous.
They're just plain functions, so they also don't require any additional understanding
beyond knowing how asynchronous operations work.
They also at the end to be more verbose, so co-ordinating multiple asynchronous requests
with them concurrently can lead to callback hell if you're not actively modularising your
functions.
Dealing with errors also tends to be more confusing, since there could be many error
objects that all go back to a single error further down the call stack.
Promises are easier to reason of about.
Although they still have their downsides.
There are they are especially useful for co-ordinating multiple asynchronous operations, propagating
errors from nested asynch operations, and dynamically chaining asynchronous operation
s, i.e., those where you would do something asynch, examine output, and then do something
else asynch, based on the intermediate values.
Errors and error stacks in promises can also be a challenge, because they behave unintuitively
in the way that they print errors when those errors are thrown.
They brought new changes to the JavaScript engine and paved the way for later innovations
like asynch await.
This latest asynch pattern is basically syntactical sugar on promises, but it allows for more
intuitive reeled of asynchronous code.
It improves error handling compared to traditional promises and can be handled with simple try
catch blocks.
It is so easy to use asynch await that one of its slight dangers that you forget you're
writing asynchronous code at all.
Ultimately, the best tool to use for a job is going to be the one that is most specific
to the job that you're doing.
Now, I hope you'll have a firmer grasp on what exactly makes each of these tools a best
fit for certain jobs.
And be able to use them with the deeper understanding of what is going on at a granular level.
Thank you very much.
[Applause]
浏览更多相关视频
Asynchronous JavaScript in ~10 Minutes - Callbacks, Promises, and Async/Await
Javascript Promises vs Async Await EXPLAINED (in 5 minutes)
JavaScript Visualized - Promise Execution
Top 9 JavaScript topics to know before learning React JS in 2024
What the heck is the event loop anyway? | Philip Roberts | JSConf EU
The Hidden Cost Of GraphQL And NodeJS
5.0 / 5 (0 votes)