Skip to content

Commit

Permalink
async_hooks: add AsyncLocal class
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
puzpuzpuz committed Dec 21, 2019
1 parent cd2b24e commit b191b81
Show file tree
Hide file tree
Showing 9 changed files with 293 additions and 4 deletions.
31 changes: 28 additions & 3 deletions benchmark/async_hooks/async-resource-vs-destroy.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const common = require('../common.js');
const {
createHook,
executionAsyncResource,
executionAsyncId
executionAsyncId,
AsyncLocal
} = require('async_hooks');
const { createServer } = require('http');

Expand All @@ -18,7 +19,7 @@ const connections = 500;
const path = '/';

const bench = common.createBenchmark(main, {
type: ['async-resource', 'destroy'],
type: ['async-resource', 'destroy', 'async-local'],
method: ['callbacks', 'async'],
n: [1e6]
});
Expand Down Expand Up @@ -102,6 +103,29 @@ function buildDestroy(getServe) {
}
}

function buildAsyncLocal(getServe) {
const server = createServer(getServe(getCLS, setCLS));
const asyncLocal = new AsyncLocal();

return {
server,
close
};

function getCLS() {
return asyncLocal.get();
}

function setCLS(state) {
asyncLocal.set(state);
}

function close() {
asyncLocal.remove();
server.close();
}
}

function getServeAwait(getCLS, setCLS) {
return async function serve(req, res) {
setCLS(Math.random());
Expand All @@ -126,7 +150,8 @@ function getServeCallbacks(getCLS, setCLS) {

const types = {
'async-resource': buildCurrentResource,
'destroy': buildDestroy
'destroy': buildDestroy,
'async-local': buildAsyncLocal,
};

const asyncMethod = {
Expand Down
105 changes: 105 additions & 0 deletions doc/api/async_hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,110 @@ const server = net.createServer((conn) => {
Promise contexts may not get valid `triggerAsyncId`s by default. See
the section on [promise execution tracking][].

### Class: AsyncLocal

<!-- YAML
added: REPLACEME
-->

This class can be used to store a value which follows asynchronous execution
flow. Any value set on an `AsyncLocal` instance is propagated to any callback
or promise executed within the flow. Because of that, a continuation local
storage can be build with an `AsyncLocal` instance. This API is similar to
thread local storage in other runtimes and languages.

The implementation relies on async hooks to follow the execution flow.
So, if an application or a library does not play nicely with async hooks,
the same problems will be seen with the `AsyncLocal` API. In order to fix
such issues the `AsyncResource` API should be used.

The following example shows how to use `AsyncLocal` to build a simple logger
that assignes ids to HTTP requests and includes them into messages logged
within each request.

```js
const http = require('http');
const { AsyncLocal } = require('async_hooks');

const asyncLocal = new AsyncLocal();

function print(msg) {
const id = asyncLocal.get();
console.log(`${id !== undefined ? id : '-'}:`, msg);
}

let idSeq = 0;
http.createServer((req, res) => {
asyncLocal.set(idSeq++);
print('start');
setImmediate(() => {
print('finish');
res.end();
});
}).listen(8080);

http.get('http://localhost:8080');
http.get('http://localhost:8080');
// Prints:
// 0: start
// 1: start
// 0: finish
// 1: finish
```

#### new AsyncLocal()

Creates a new instance of `AsyncLocal`.

### asyncLocal.get()

* Returns: {any}

Returns the value of the `AsyncLocal` in current execution context,
or `undefined` if the value is not set or the `AsyncLocal` was removed.

### asyncLocal.set(value)

* `value` {any}

Sets the value for the `AsyncLocal` within current execution context.

Once set, the value will be kept through the subsequent asynchronous calls,
unless overridden by calling `asyncLocal.set(value)`:

```js
const asyncLocal = new AsyncLocal();

setImmediate(() => {
asyncLocal.set('A');

setImmediate(() => {
console.log(asyncLocal.get());
// Prints: A

asyncLocal.set('B');
console.log(asyncLocal.get());
// Prints: B
});

console.log(asyncLocal.get());
// Prints: A
});
```

If the `AsyncLocal` was removed before this call is made,
[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][] is thrown.

### asyncLocal.remove()

When called, removes all values stored in the `AsyncLocal` and disables
callbacks for the internal `AsyncHook` instance. Calling `asyncLocal.remove()`
multiple times will have no effect.

Any subsequent `asyncLocal.get()` calls will return `undefined`.
Any subsequent `asyncLocal.set(value)` calls will throw
[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`][].

## Promise execution tracking

By default, promise executions are not assigned `asyncId`s due to the relatively
Expand Down Expand Up @@ -747,3 +851,4 @@ never be called.
[PromiseHooks]: https://docs.google.com/document/d/1rda3yKGHimKIhg5YeoAmCOtyURgsbTH_qaYR79FELlk/edit
[`Worker`]: worker_threads.html#worker_threads_class_worker
[promise execution tracking]: #async_hooks_promise_execution_tracking
[`ERR_ASYNC_LOCAL_CANNOT_SET_VALUE`]: errors.html#ERR_ASYNC_LOCAL_CANNOT_SET_VALUE
5 changes: 5 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,11 @@ by the `assert` module.
An attempt was made to register something that is not a function as an
`AsyncHooks` callback.

<a id="ERR_ASYNC_LOCAL_CANNOT_SET_VALUE"></a>
### ERR_ASYNC_LOCAL_CANNOT_SET_VALUE

An attempt was made to set value for a `AsyncLocal` after it was removed.

<a id="ERR_ASYNC_TYPE"></a>
### ERR_ASYNC_TYPE

Expand Down
45 changes: 44 additions & 1 deletion lib/async_hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const {

const {
ERR_ASYNC_CALLBACK,
ERR_INVALID_ASYNC_ID
ERR_INVALID_ASYNC_ID,
ERR_ASYNC_LOCAL_CANNOT_SET_VALUE
} = require('internal/errors').codes;
const { validateString } = require('internal/validators');
const internal_async_hooks = require('internal/async_hooks');
Expand Down Expand Up @@ -130,6 +131,47 @@ function createHook(fns) {
return new AsyncHook(fns);
}

// AsyncLocal API //

const kResToValSymbol = Symbol('resToVal');
const kHookSymbol = Symbol('hook');

class AsyncLocal {
constructor() {
const resToVals = new WeakMap();
const init = (asyncId, type, triggerAsyncId, resource) => {
const value = resToVals.get(executionAsyncResource());
if (value !== undefined) {
resToVals.set(resource, value);
}
};
this[kHookSymbol] = createHook({ init }).enable();
this[kResToValSymbol] = resToVals;
}

get() {
if (this[kResToValSymbol]) {
return this[kResToValSymbol].get(executionAsyncResource());
}
return undefined;
}

set(value) {
if (!this[kResToValSymbol]) {
throw new ERR_ASYNC_LOCAL_CANNOT_SET_VALUE();
}
this[kResToValSymbol].set(executionAsyncResource(), value);
}

remove() {
if (this[kResToValSymbol]) {
delete this[kResToValSymbol];
this[kHookSymbol].disable();
delete this[kHookSymbol];
}
}
}


// Embedder API //

Expand Down Expand Up @@ -207,6 +249,7 @@ module.exports = {
executionAsyncId,
triggerAsyncId,
executionAsyncResource,
AsyncLocal,
// Embedder API
AsyncResource,
};
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,8 @@ E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
E('ERR_ASSERTION', '%s', Error);
E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError);
E('ERR_ASYNC_LOCAL_CANNOT_SET_VALUE', 'Cannot set value for removed AsyncLocal',
Error);
E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError);
E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError);
E('ERR_BUFFER_OUT_OF_BOUNDS',
Expand Down
26 changes: 26 additions & 0 deletions test/async-hooks/test-async-local-isolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

require('../common');
const assert = require('assert');
const async_hooks = require('async_hooks');
const { AsyncLocal } = async_hooks;

const asyncLocalOne = new AsyncLocal();
const asyncLocalTwo = new AsyncLocal();

setTimeout(() => {
assert.strictEqual(asyncLocalOne.get(), undefined);
assert.strictEqual(asyncLocalTwo.get(), undefined);

asyncLocalOne.set('foo');
asyncLocalTwo.set('bar');
assert.strictEqual(asyncLocalOne.get(), 'foo');
assert.strictEqual(asyncLocalTwo.get(), 'bar');

asyncLocalOne.set('baz');
asyncLocalTwo.set(42);
setTimeout(() => {
assert.strictEqual(asyncLocalOne.get(), 'baz');
assert.strictEqual(asyncLocalTwo.get(), 42);
}, 0);
}, 0);
26 changes: 26 additions & 0 deletions test/async-hooks/test-async-local-propagation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

require('../common');
const assert = require('assert');
const async_hooks = require('async_hooks');
const { AsyncLocal } = async_hooks;

const asyncLocal = new AsyncLocal();

setTimeout(() => {
assert.strictEqual(asyncLocal.get(), undefined);

asyncLocal.set('A');
setTimeout(() => {
assert.strictEqual(asyncLocal.get(), 'A');

asyncLocal.set('B');
setTimeout(() => {
assert.strictEqual(asyncLocal.get(), 'B');
}, 0);

assert.strictEqual(asyncLocal.get(), 'B');
}, 0);

assert.strictEqual(asyncLocal.get(), 'A');
}, 0);
24 changes: 24 additions & 0 deletions test/async-hooks/test-async-local.async-await.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const async_hooks = require('async_hooks');
const { AsyncLocal } = async_hooks;

const asyncLocal = new AsyncLocal();

async function asyncFunc() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

async function testAwait() {
asyncLocal.set('foo');
await asyncFunc();
assert.strictEqual(asyncLocal.get(), 'foo');
}

testAwait().then(common.mustCall(() =>
assert.strictEqual(asyncLocal.get(), 'foo')
));
33 changes: 33 additions & 0 deletions test/async-hooks/test-async-local.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const async_hooks = require('async_hooks');
const { AsyncLocal } = async_hooks;

assert.strictEqual(new AsyncLocal().get(), undefined);

const asyncLocal = new AsyncLocal();

assert.strictEqual(asyncLocal.get(), undefined);

asyncLocal.set(42);
assert.strictEqual(asyncLocal.get(), 42);
asyncLocal.set('foo');
assert.strictEqual(asyncLocal.get(), 'foo');
const obj = {};
asyncLocal.set(obj);
assert.strictEqual(asyncLocal.get(), obj);

asyncLocal.remove();
assert.strictEqual(asyncLocal.get(), undefined);

// Throws on modification after removal
common.expectsError(
() => asyncLocal.set('bar'), {
code: 'ERR_ASYNC_LOCAL_CANNOT_SET_VALUE',
type: Error,
});

// Subsequent .remove() does not throw
asyncLocal.remove();

0 comments on commit b191b81

Please sign in to comment.