Skip to content

Commit 67a1890

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 3eba33e commit 67a1890

File tree

10 files changed

+347
-4
lines changed

10 files changed

+347
-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
asyncMethod: ['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 asyncMethods = {

doc/api/async_hooks.md

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

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

584688
By default, promise executions are not assigned `asyncId`s due to the relatively
@@ -868,3 +972,4 @@ for (let i = 0; i < 10; i++) {
868972
[PromiseHooks]: https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit
869973
[`Worker`]: worker_threads.html#worker_threads_class_worker
870974
[promise execution tracking]: #async_hooks_promise_execution_tracking
975+
[`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: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ const {
44
NumberIsSafeInteger,
55
ReflectApply,
66
Symbol,
7+
WeakMap,
78
} = primordials;
89

910
const {
1011
ERR_ASYNC_CALLBACK,
1112
ERR_ASYNC_TYPE,
12-
ERR_INVALID_ASYNC_ID
13+
ERR_INVALID_ASYNC_ID,
14+
ERR_ASYNC_LOCAL_CANNOT_SET_VALUE,
1315
} = require('internal/errors').codes;
1416
const { validateString } = require('internal/validators');
1517
const internal_async_hooks = require('internal/async_hooks');
@@ -132,6 +134,63 @@ function createHook(fns) {
132134
return new AsyncHook(fns);
133135
}
134136

137+
// AsyncLocal API //
138+
139+
const locals = [];
140+
const localsHook = createHook({
141+
init(asyncId, type, triggerAsyncId, resource) {
142+
const execRes = executionAsyncResource();
143+
// Using var here instead of let because "for (var ...)" is faster than let.
144+
// Refs: https://github.com/nodejs/node/pull/30380#issuecomment-552948364
145+
for (var i = 0; i < locals.length; i++) {
146+
locals[i][kPropagateSymbol](execRes, resource);
147+
}
148+
}
149+
});
150+
151+
const kResToValSymbol = Symbol('resToVal');
152+
const kPropagateSymbol = Symbol('propagate');
153+
154+
class AsyncLocal {
155+
constructor() {
156+
this[kResToValSymbol] = new WeakMap();
157+
locals.push(this);
158+
localsHook.enable();
159+
}
160+
161+
[kPropagateSymbol](execRes, initRes) {
162+
const value = this[kResToValSymbol].get(execRes);
163+
// Always overwrite value to prevent issues with reused resources.
164+
this[kResToValSymbol].set(initRes, value);
165+
}
166+
167+
get() {
168+
if (this[kResToValSymbol]) {
169+
return this[kResToValSymbol].get(executionAsyncResource());
170+
}
171+
return undefined;
172+
}
173+
174+
set(value) {
175+
if (!this[kResToValSymbol]) {
176+
throw new ERR_ASYNC_LOCAL_CANNOT_SET_VALUE();
177+
}
178+
this[kResToValSymbol].set(executionAsyncResource(), value);
179+
}
180+
181+
remove() {
182+
const index = locals.indexOf(this);
183+
if (index === -1)
184+
return;
185+
186+
delete this[kResToValSymbol];
187+
locals.splice(index, 1);
188+
if (locals.size === 0) {
189+
localsHook.disable();
190+
}
191+
}
192+
}
193+
135194

136195
// Embedder API //
137196

@@ -213,6 +272,7 @@ module.exports = {
213272
executionAsyncId,
214273
triggerAsyncId,
215274
executionAsyncResource,
275+
AsyncLocal,
216276
// Embedder API
217277
AsyncResource,
218278
};

lib/internal/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,8 @@ E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
730730
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
731731
E('ERR_ASSERTION', '%s', Error);
732732
E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError);
733+
E('ERR_ASYNC_LOCAL_CANNOT_SET_VALUE', 'Cannot set value for removed AsyncLocal',
734+
Error);
733735
E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError);
734736
E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError);
735737
E('ERR_BUFFER_OUT_OF_BOUNDS',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
// This test ensures isolation of `AsyncLocal`s
9+
// from each other in terms of stored values
10+
11+
const asyncLocalOne = new AsyncLocal();
12+
const asyncLocalTwo = new AsyncLocal();
13+
14+
setTimeout(() => {
15+
assert.strictEqual(asyncLocalOne.get(), undefined);
16+
assert.strictEqual(asyncLocalTwo.get(), undefined);
17+
18+
asyncLocalOne.set('foo');
19+
asyncLocalTwo.set('bar');
20+
assert.strictEqual(asyncLocalOne.get(), 'foo');
21+
assert.strictEqual(asyncLocalTwo.get(), 'bar');
22+
23+
asyncLocalOne.set('baz');
24+
asyncLocalTwo.set(42);
25+
setTimeout(() => {
26+
assert.strictEqual(asyncLocalOne.get(), 'baz');
27+
assert.strictEqual(asyncLocalTwo.get(), 42);
28+
}, 0);
29+
}, 0);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
// This test ensures correct work of the global hook
9+
// that serves for propagation of all `AsyncLocal`s
10+
// in the context of `.get()`/`.set(value)` calls
11+
12+
const asyncLocal = new AsyncLocal();
13+
14+
setTimeout(() => {
15+
assert.strictEqual(asyncLocal.get(), undefined);
16+
17+
asyncLocal.set('A');
18+
setTimeout(() => {
19+
assert.strictEqual(asyncLocal.get(), 'A');
20+
21+
asyncLocal.set('B');
22+
setTimeout(() => {
23+
assert.strictEqual(asyncLocal.get(), 'B');
24+
}, 0);
25+
26+
assert.strictEqual(asyncLocal.get(), 'B');
27+
}, 0);
28+
29+
assert.strictEqual(asyncLocal.get(), 'A');
30+
}, 0);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
// This test ensures correct work of the global hook
9+
// that serves for propagation of all `AsyncLocal`s
10+
// in the context of `.remove()` call
11+
12+
const asyncLocalOne = new AsyncLocal();
13+
asyncLocalOne.set(1);
14+
const asyncLocalTwo = new AsyncLocal();
15+
asyncLocalTwo.set(2);
16+
17+
setImmediate(() => {
18+
// Removal of one local should not affect others
19+
asyncLocalTwo.remove();
20+
assert.strictEqual(asyncLocalOne.get(), 1);
21+
22+
// Removal of the last active local should not
23+
// prevent propagation of locals created later
24+
asyncLocalOne.remove();
25+
const asyncLocalThree = new AsyncLocal();
26+
asyncLocalThree.set(3);
27+
setImmediate(() => {
28+
assert.strictEqual(asyncLocalThree.get(), 3);
29+
});
30+
});

0 commit comments

Comments
 (0)