async/await
is a new feature of the EcmaScript programming language that
combines generators and promises to improve the language-level model for
writing asynchronous code.
It is currently in "stage 3" of the TC39 process, which means it's a "candidate" that needs more implementation experience.
It is currently implemented only by transpilers such as Babel, Traceur and TypeScript, and by only one VM: Microsoft Edge 13, behind a flag.
The async
keyword is used to declare a function as being able to use the
await
keyword to "wait" on asynchronous operations:
// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';
function someAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(42);
});
});
}
function someOtherAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(43);
});
});
}
async function someAsyncWorkflow() {
var foo = await someAsyncOperation();
var bar = await someOtherAsyncOperation();
console.log('foo:', foo);
console.log('bar:', bar);
}
someAsyncWorkflow();
Note that in the code above, the someAsyncWorkflow
function outputs the
results from the two asynchronous operations that it await
s on the console,
when a more idiomatic way to write this code would be to simply return foo
and bar
and use another async function
to await on someAsyncWorkflow
,
but that would have made the generated code that the rest of this document
uses as a reference non-minimal.
Most current async/await implementations implement a variant of the spawn
function mentioned in the async/await
specification:
function spawn(genF, self) {
return new Promise(function(resolve, reject) {
var gen = genF.call(self);
function step(nextF) {
var next;
try {
next = nextF();
} catch(e) {
// finished with failure, reject the promise
reject(e);
return;
}
if(next.done) {
// finished with success, resolve the promise
resolve(next.value);
return;
}
// not finished, chain off the yielded promise and `step` again
Promise.resolve(next.value).then(function(v) {
step(function() { return gen.next(v); });
}, function(e) {
step(function() { return gen.throw(e); });
});
}
step(function() { return gen.next(undefined); });
});
}
Basically, async functions are turned into a generator that yields values that are (potentially) asynchronously generated by promises and that returns a promise.
Internally, here's how a typical JavaScript runtime runs the function
someAsyncWorkflow
:
-
It turns the function into a generator.
-
It returns a new promise that represents its eventual result.
-
That new promise's resolver starts the generator.
-
await
expressions are implemented byyield
statements that are executed within the generated generator. -
When an "awaited" promise resolves,
next
is called on the generator by passing the value produced by the awaited promise. When it rejects, it callsthrow
on the generator. -
If the generator throws, the promise that was returned as a result of the async function rejects. If the generator completes, that promise resolves.
More specifically, here is the code that Babel, a JavaScript transpiler, generates for the above-mentioned sample code:
'use strict';
let someAsyncWorkflow = function () {
var ref = _asyncToGenerator(function* () {
var foo = yield someAsyncOperation();
var bar = yield someOtherAsyncOperation();
console.log('foo:', foo);
console.log('bar:', bar);
});
return function someAsyncWorkflow() {
return ref.apply(this, arguments);
};
}();
function _asyncToGenerator(fn) {
return function () {
var gen = fn.apply(this, arguments);
return new Promise(function (resolve, reject) {
function step(key, arg) {
try {
var info = gen[key](arg);
var value = info.value;
} catch (error) {
reject(error);
return;
}
if (info.done) {
resolve(value);
} else {
return Promise.resolve(value).then(function (value) {
return step("next", value);
}, function (err) {
return step("throw", err);
});
}
}
return step("next");
});
};
}
function someAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(42);
});
});
}
function someOtherAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(43);
});
});
}
someAsyncWorkflow();
The _asyncToGenerator
generator function is the equivalent of the spawn
function described in the async/await specification. ChakraCore emits similar
code.
The rest of this document uses the code generated by Babel as reference, since it's much easier to reason about than ChakraCore's implementation.
From the perspective of a user of post-mortem debugging tools, the main issue
is that all errors thrown synchronously and not explicitly caught do not
make the process exit, or abort when the --abort-on-uncaught-exception
command line option is passed to node.
For instance, any throw
statement that doesn't have an explicit
corresponding catch
clause added to the sample code mentioned in the
introduction will not make node abort when running with --abort-on- uncaught-exception
, as a user not familiar with async/await's error handling
might expect.
Let's consider the following instance of this problem:
// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';
function someAsyncOperation(someParam) {
return new Promise(function (resolve, reject) {
if (typeof someParam !== 'string') {
throw new Error('someParam must be a string');
}
setImmediate(function _asyncDone() {
return resolve(42);
});
});
}
function someOtherAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(43);
});
});
}
async function someAsyncWorkflow() {
var foo = await someAsyncOperation();
var bar = await someOtherAsyncOperation();
console.log('foo:', foo);
console.log('bar:', bar);
}
someAsyncWorkflow();
When running this code with --abort-on-uncaught-exception
, node doesn't
abort, and exits successfully, even though someAsyncOperation
throws an
error synchronously, and that error is not explicitly caught in the rest of
the code:
$ node --version
v4.3.0
$ node --abort-on-uncaught-exception test-async-throw-param-out.js
$ echo $?
0
$
Async/await builds on top of promises. The challenges in using promises with
post-mortem debugging and differences with the pre-promises error handling
model are presented in details in a separate document and boils down
to the fact that promises implicitly turn uncaught errors thrown synchronously
into rejections, which don't automatically make a node process exit (or abort
when using --abort-on-uncaught-exception
).
Furthermore, from looking at Babel's generated code for the
_asyncToGenerator
function, the reasons why uncaught errors do not result in
the process exiting or aborting can be summarized as follows:
-
The generator is run by a promise's resolver, which turn errors thrown synchronously and not caught into rejections with implicit try/catch blocks.
-
Awaited promises, by definition, have any of their uncaught synchronous exceptions turned into a rejection, that itself triggers a call to
Generator.prototype.throw
. -
Moreover, calls to
Generator.prototype.next
andGenerator.prototype.throw
in thestep
function are implicitly wrapped in a try/catch block. Thus, all errors thrown from any async function are implicitly caught.
Because async/await builds on top of two different abstractions, namely generators and promises, both of which have various imcompatibilities with post-mortem debugging, this section first explores if some of the potential solutions to fix their respective issues can improve async/await's support for post-mortem debugging.
In the document entitled "Challenges in using promises with post-mortem debugging", a few potential solutions are explored to:
-
Prevent promises from implicitly catching uncaught errors thrown synchronously.
-
Making node abort at the exact time an uncaught error is thrown when
--abort- on-uncaught-exception
is used.
The most promissing
solution
for these two problems was suggested by @vkurchatkin and works by adding a
special case in v8::internal::Isolate::Throw
to check if the exception is
thrown from within a promise that doesn't have a catch handler.
So what happens for async/await when this potential solution is used to run the same sample code used to describe the original problem:
// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';
function someAsyncOperation(someParam) {
return new Promise(function (resolve, reject) {
if (typeof someParam !== 'string') {
throw new Error('someParam must be a string');
}
setImmediate(function _asyncDone() {
return resolve(42);
});
});
}
function someOtherAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(43);
});
});
}
async function someAsyncWorkflow() {
var foo = await someAsyncOperation();
var bar = await someOtherAsyncOperation();
console.log('foo:', foo);
console.log('bar:', bar);
}
someAsyncWorkflow();
The source code is first transpiled with Babel:
[jgilli@dev ~/node]$ ./node_modules/.bin/babel test-async-throw-param.js > test-async-throw-param-out.js
and then run with --abort-on-uncaught-exceptin
:
[jgilli@dev ~/node]$ ./node --abort-on-uncaught-exception ~/generators-js/test-async-throw-param-out.js
Uncaught Error: someParam must be a string
FROM
/home/jgilli/generators-js/test-async-throw-param-out.js:22:4
someAsyncOperation (/home/jgilli/generators-js/test-async-throw-param-out.js:20:9)
/home/jgilli/generators-js/test-async-throw-param-out.js:5:19
step (/home/jgilli/generators-js/test-async-throw-param-out.js:17:191)
/home/jgilli/generators-js/test-async-throw-param-out.js:17:451
/home/jgilli/generators-js/test-async-throw-param-out.js:17:99
someAsyncWorkflow (/home/jgilli/generators-js/test-async-throw-param-out.js:13:14)
Object.<anonymous> (/home/jgilli/generators-js/test-async-throw-param-out.js:38:1)
Module._compile (module.js:417:34)
Object.Module._extensions..js (module.js:426:10)
Module.load (module.js:357:32)
Function.Module._load (module.js:314:12)
Function.Module.runMain (module.js:451:10)
startup (node.js:149:18)
node.js:1013:3
Illegal Instruction (core dumped)
The node process aborted, which is the expected behavior when throwing
synchronously and using --abort-on-uncaught-exception
. Moreover, the process
aborted at the exact time the error threw:
[root@dev ~]# mdb /home/jgilli/cores/core.node.34907
Loading modules: [ libumem.so.1 libc.so.1 ld.so.1 ]
> ::load /home/jgilli/mdb_v8/build/amd64/mdb_v8.so
mdb_v8 version: 1.1.2 (dev)
V8 version: 4.8.271.17
Autoconfigured V8 support from target
C++ symbol demangling enabled
> ::jsstack
native: v8::base::OS::Abort+9
native: v8::internal::Runtime_Throw+0x30
(1 internal frame elided)
js: <anonymous> (as <anon>)
js: Promise
(1 internal frame elided)
js: someAsyncOperation
(1 internal frame elided)
js: <anonymous> (as <anon>)
js: next
js: step
(1 internal frame elided)
js: <anonymous> (as <anon>)
js: Promise
(1 internal frame elided)
js: <anonymous> (as <anon>)
(1 internal frame elided)
js: someAsyncWorkflow
js: <anonymous> (as <anon>)
(1 internal frame elided)
js: <anonymous> (as Module._compile)
js: <anonymous> (as Module._extensions..js)
js: <anonymous> (as Module.load)
js: <anonymous> (as Module._load)
js: <anonymous> (as Module.runMain)
js: startup
js: <anonymous> (as <anon>)
(1 internal frame elided)
(1 internal frame elided)
native: v8::internal::_GLOBAL__N_1::Invoke+0xb3
native: v8::internal::Execution::Call+0x62
native: v8::Function::Call+0xf6
native: v8::Function::Call+0x41
native: node::LoadEnvironment+0x1e8
native: node::Start+0x508
native: _start+0x6c
>
So it seems that this use case is fixed by @vkurchatkin proposed change, with the caveats mentioned in the document that describes it in further details: it might not be an acceptable changefor V8 due to its differences with the default behavior of rejected promises in node.
For the sake of exploring the solutions space, let's consider it's an acceptable solution, and let's move on to testing if this change fixes other use cases.
Now let's consider the following slightly different sample code:
// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';
function someAsyncOperation(someParam) {
if (typeof someParam !== 'string') {
throw new Error('someParam must be a string');
}
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(42);
});
});
}
function someOtherAsyncOperation() {
return new Promise(function (resolve, reject) {
setImmediate(function _asyncDone() {
return resolve(43);
});
});
}
async function someAsyncWorkflow() {
var foo = await someAsyncOperation();
var bar = await someOtherAsyncOperation();
console.log('foo:', foo);
console.log('bar:', bar);
}
someAsyncWorkflow();
Note how the error is now thrown from someAsyncOperation
, and not from the
resolver of the promise it creates.
When running this code with --abort-on-uncaught-exception
after having
transpiled it with Babel, it outputs the following:
$ ./node_modules/.bin/babel test-async-throw-param.js > test-async-throw-param-out.js
$ ./node --abort-on-uncaught-exception ~/generators-js/test-async-throw-param-out.js
$ echo $?
0
$
The process doesn't abort and it exits sucessfully. The reason is that
_asyncToGenerator
by default wraps calls to the generator's next
and
throw
calls in a try/catch
block, thus catching any error thrown as part
of the evaluation of the await
expressions, such as when
someAsyncOperation
is called.
So it seems the _asyncGenerator
code itself would need to not add that
implicit try/catch
block when using --abort-on-uncaught-exception
. Let's
explore that solution in the next section.