Skip to content

Commit b191b81

Browse files
committed
async_hooks: add AsyncLocal class
Introduces new AsyncLocal API to provide capabilities for building continuation local storage on top of it. The implementation is based on async hooks. Public API is inspired by ThreadLocal class in Java.
1 parent cd2b24e commit b191b81

File tree

9 files changed

+293
-4
lines changed

9 files changed

+293
-4
lines changed

benchmark/async_hooks/async-resource-vs-destroy.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ const common = require('../common.js');
88
const {
99
createHook,
1010
executionAsyncResource,
11-
executionAsyncId
11+
executionAsyncId,
12+
AsyncLocal
1213
} = require('async_hooks');
1314
const { createServer } = require('http');
1415

@@ -18,7 +19,7 @@ const connections = 500;
1819
const path = '/';
1920

2021
const bench = common.createBenchmark(main, {
21-
type: ['async-resource', 'destroy'],
22+
type: ['async-resource', 'destroy', 'async-local'],
2223
method: ['callbacks', 'async'],
2324
n: [1e6]
2425
});
@@ -102,6 +103,29 @@ function buildDestroy(getServe) {
102103
}
103104
}
104105

106+
function buildAsyncLocal(getServe) {
107+
const server = createServer(getServe(getCLS, setCLS));
108+
const asyncLocal = new AsyncLocal();
109+
110+
return {
111+
server,
112+
close
113+
};
114+
115+
function getCLS() {
116+
return asyncLocal.get();
117+
}
118+
119+
function setCLS(state) {
120+
asyncLocal.set(state);
121+
}
122+
123+
function close() {
124+
asyncLocal.remove();
125+
server.close();
126+
}
127+
}
128+
105129
function getServeAwait(getCLS, setCLS) {
106130
return async function serve(req, res) {
107131
setCLS(Math.random());
@@ -126,7 +150,8 @@ function getServeCallbacks(getCLS, setCLS) {
126150

127151
const types = {
128152
'async-resource': buildCurrentResource,
129-
'destroy': buildDestroy
153+
'destroy': buildDestroy,
154+
'async-local': buildAsyncLocal,
130155
};
131156

132157
const asyncMethod = {

doc/api/async_hooks.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,110 @@ const server = net.createServer((conn) => {
580580
Promise contexts may not get valid `triggerAsyncId`s by default. See
581581
the section on [promise execution tracking][].
582582

583+
### Class: AsyncLocal
584+
585+
<!-- YAML
586+
added: REPLACEME
587+
-->
588+
589+
This class can be used to store a value which follows asynchronous execution
590+
flow. Any value set on an `AsyncLocal` instance is propagated to any callback
591+
or promise executed within the flow. Because of that, a continuation local
592+
storage can be build with an `AsyncLocal` instance. This API is similar to
593+
thread local storage in other runtimes and languages.
594+
595+
The implementation relies on async hooks to follow the execution flow.
596+
So, if an application or a library does not play nicely with async hooks,
597+
the same problems will be seen with the `AsyncLocal` API. In order to fix
598+
such issues the `AsyncResource` API should be used.
599+
600+
The following example shows how to use `AsyncLocal` to build a simple logger
601+
that assignes ids to HTTP requests and includes them into messages logged
602+
within each request.
603+
604+
```js
605+
const http = require('http');
606+
const { AsyncLocal } = require('async_hooks');
607+
608+
const asyncLocal = new AsyncLocal();
609+
610+
function print(msg) {
611+
const id = asyncLocal.get();
612+
console.log(`${id !== undefined ? id : '-'}:`, msg);
613+
}
614+
615+
let idSeq = 0;
616+
http.createServer((req, res) => {
617+
asyncLocal.set(idSeq++);
618+
print('start');
619+
setImmediate(() => {
620+
print('finish');
621+
res.end();
622+
});
623+
}).listen(8080);
624+
625+
http.get('http://localhost:8080');
626+
http.get('http://localhost:8080');
627+
// Prints:
628+
// 0: start
629+
// 1: start
630+
// 0: finish
631+
// 1: finish
632+
```
633+
634+
#### new AsyncLocal()
635+
636+
Creates a new instance of `AsyncLocal`.
637+
638+
### asyncLocal.get()
639+
640+
* Returns: {any}
641+
642+
Returns the value of the `AsyncLocal` in current execution context,
643+
or `undefined` if the value is not set or the `AsyncLocal` was removed.
644+
645+
### asyncLocal.set(value)
646+
647+
* `value` {any}
648+
649+
Sets the value for the `AsyncLocal` within current execution context.
650+
651+
Once set, the value will be kept through the subsequent asynchronous calls,
652+
unless overridden by calling `asyncLocal.set(value)`:
653+
654+
```js
655+
const asyncLocal = new AsyncLocal();
656+
657+
setImmediate(() => {
658+
asyncLocal.set('A');
659+
660+
setImmediate(() => {
661+
console.log(asyncLocal.get());
662+
// Prints: A
663+
664+
asyncLocal.set('B');
665+
console.log(asyncLocal.get());
666+
// Prints: B
667+
});
668+
669+
console.log(asyncLocal.get());
670+
// Prints: A
671+
});
672+
```
673+
674+
If the `AsyncLocal` was removed before this call is made,
675+
[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][] is thrown.
676+
677+
### asyncLocal.remove()
678+
679+
When called, removes all values stored in the `AsyncLocal` and disables
680+
callbacks for the internal `AsyncHook` instance. Calling `asyncLocal.remove()`
681+
multiple times will have no effect.
682+
683+
Any subsequent `asyncLocal.get()` calls will return `undefined`.
684+
Any subsequent `asyncLocal.set(value)` calls will throw
685+
[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][].
686+
583687
## Promise execution tracking
584688

585689
By default, promise executions are not assigned `asyncId`s due to the relatively
@@ -747,3 +851,4 @@ never be called.
747851
[PromiseHooks]: https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit
748852
[`Worker`]: worker_threads.html#worker_threads_class_worker
749853
[promise execution tracking]: #async_hooks_promise_execution_tracking
854+
[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`]: errors.html#ERR_ASYNC_LOCAL_CANNOT_SET_VALUE

doc/api/errors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,11 @@ by the `assert` module.
643643
An attempt was made to register something that is not a function as an
644644
`AsyncHooks` callback.
645645

646+
<a id="ERR_ASYNC_LOCAL_CANNOT_SET_VALUE"></a>
647+
### ERR_ASYNC_LOCAL_CANNOT_SET_VALUE
648+
649+
An attempt was made to set value for a `AsyncLocal` after it was removed.
650+
646651
<a id="ERR_ASYNC_TYPE"></a>
647652
### ERR_ASYNC_TYPE
648653

lib/async_hooks.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ const {
88

99
const {
1010
ERR_ASYNC_CALLBACK,
11-
ERR_INVALID_ASYNC_ID
11+
ERR_INVALID_ASYNC_ID,
12+
ERR_ASYNC_LOCAL_CANNOT_SET_VALUE
1213
} = require('internal/errors').codes;
1314
const { validateString } = require('internal/validators');
1415
const internal_async_hooks = require('internal/async_hooks');
@@ -130,6 +131,47 @@ function createHook(fns) {
130131
return new AsyncHook(fns);
131132
}
132133

134+
// AsyncLocal API //
135+
136+
const kResToValSymbol = Symbol('resToVal');
137+
const kHookSymbol = Symbol('hook');
138+
139+
class AsyncLocal {
140+
constructor() {
141+
const resToVals = new WeakMap();
142+
const init = (asyncId, type, triggerAsyncId, resource) => {
143+
const value = resToVals.get(executionAsyncResource());
144+
if (value !== undefined) {
145+
resToVals.set(resource, value);
146+
}
147+
};
148+
this[kHookSymbol] = createHook({ init }).enable();
149+
this[kResToValSymbol] = resToVals;
150+
}
151+
152+
get() {
153+
if (this[kResToValSymbol]) {
154+
return this[kResToValSymbol].get(executionAsyncResource());
155+
}
156+
return undefined;
157+
}
158+
159+
set(value) {
160+
if (!this[kResToValSymbol]) {
161+
throw new ERR_ASYNC_LOCAL_CANNOT_SET_VALUE();
162+
}
163+
this[kResToValSymbol].set(executionAsyncResource(), value);
164+
}
165+
166+
remove() {
167+
if (this[kResToValSymbol]) {
168+
delete this[kResToValSymbol];
169+
this[kHookSymbol].disable();
170+
delete this[kHookSymbol];
171+
}
172+
}
173+
}
174+
133175

134176
// Embedder API //
135177

@@ -207,6 +249,7 @@ module.exports = {
207249
executionAsyncId,
208250
triggerAsyncId,
209251
executionAsyncResource,
252+
AsyncLocal,
210253
// Embedder API
211254
AsyncResource,
212255
};

lib/internal/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,8 @@ E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
722722
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
723723
E('ERR_ASSERTION', '%s', Error);
724724
E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError);
725+
E('ERR_ASYNC_LOCAL_CANNOT_SET_VALUE', 'Cannot set value for removed AsyncLocal',
726+
Error);
725727
E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError);
726728
E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError);
727729
E('ERR_BUFFER_OUT_OF_BOUNDS',
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use strict';
2+
3+
require('../common');
4+
const assert = require('assert');
5+
const async_hooks = require('async_hooks');
6+
const { AsyncLocal } = async_hooks;
7+
8+
const asyncLocalOne = new AsyncLocal();
9+
const asyncLocalTwo = new AsyncLocal();
10+
11+
setTimeout(() => {
12+
assert.strictEqual(asyncLocalOne.get(), undefined);
13+
assert.strictEqual(asyncLocalTwo.get(), undefined);
14+
15+
asyncLocalOne.set('foo');
16+
asyncLocalTwo.set('bar');
17+
assert.strictEqual(asyncLocalOne.get(), 'foo');
18+
assert.strictEqual(asyncLocalTwo.get(), 'bar');
19+
20+
asyncLocalOne.set('baz');
21+
asyncLocalTwo.set(42);
22+
setTimeout(() => {
23+
assert.strictEqual(asyncLocalOne.get(), 'baz');
24+
assert.strictEqual(asyncLocalTwo.get(), 42);
25+
}, 0);
26+
}, 0);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use strict';
2+
3+
require('../common');
4+
const assert = require('assert');
5+
const async_hooks = require('async_hooks');
6+
const { AsyncLocal } = async_hooks;
7+
8+
const asyncLocal = new AsyncLocal();
9+
10+
setTimeout(() => {
11+
assert.strictEqual(asyncLocal.get(), undefined);
12+
13+
asyncLocal.set('A');
14+
setTimeout(() => {
15+
assert.strictEqual(asyncLocal.get(), 'A');
16+
17+
asyncLocal.set('B');
18+
setTimeout(() => {
19+
assert.strictEqual(asyncLocal.get(), 'B');
20+
}, 0);
21+
22+
assert.strictEqual(asyncLocal.get(), 'B');
23+
}, 0);
24+
25+
assert.strictEqual(asyncLocal.get(), 'A');
26+
}, 0);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const async_hooks = require('async_hooks');
6+
const { AsyncLocal } = async_hooks;
7+
8+
const asyncLocal = new AsyncLocal();
9+
10+
async function asyncFunc() {
11+
return new Promise((resolve) => {
12+
setTimeout(resolve, 0);
13+
});
14+
}
15+
16+
async function testAwait() {
17+
asyncLocal.set('foo');
18+
await asyncFunc();
19+
assert.strictEqual(asyncLocal.get(), 'foo');
20+
}
21+
22+
testAwait().then(common.mustCall(() =>
23+
assert.strictEqual(asyncLocal.get(), 'foo')
24+
));
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const async_hooks = require('async_hooks');
6+
const { AsyncLocal } = async_hooks;
7+
8+
assert.strictEqual(new AsyncLocal().get(), undefined);
9+
10+
const asyncLocal = new AsyncLocal();
11+
12+
assert.strictEqual(asyncLocal.get(), undefined);
13+
14+
asyncLocal.set(42);
15+
assert.strictEqual(asyncLocal.get(), 42);
16+
asyncLocal.set('foo');
17+
assert.strictEqual(asyncLocal.get(), 'foo');
18+
const obj = {};
19+
asyncLocal.set(obj);
20+
assert.strictEqual(asyncLocal.get(), obj);
21+
22+
asyncLocal.remove();
23+
assert.strictEqual(asyncLocal.get(), undefined);
24+
25+
// Throws on modification after removal
26+
common.expectsError(
27+
() => asyncLocal.set('bar'), {
28+
code: 'ERR_ASYNC_LOCAL_CANNOT_SET_VALUE',
29+
type: Error,
30+
});
31+
32+
// Subsequent .remove() does not throw
33+
asyncLocal.remove();

0 commit comments

Comments
 (0)