From b191b818d490e7c7380415599f58265dae190e35 Mon Sep 17 00:00:00 2001 From: Andrey Pechkurov Date: Wed, 18 Dec 2019 07:57:47 +0300 Subject: [PATCH] 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. --- .../async_hooks/async-resource-vs-destroy.js | 31 +++++- doc/api/async_hooks.md | 105 ++++++++++++++++++ doc/api/errors.md | 5 + lib/async_hooks.js | 45 +++++++- lib/internal/errors.js | 2 + .../async-hooks/test-async-local-isolation.js | 26 +++++ .../test-async-local-propagation.js | 26 +++++ .../test-async-local.async-await.js | 24 ++++ test/async-hooks/test-async-local.js | 33 ++++++ 9 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 test/async-hooks/test-async-local-isolation.js create mode 100644 test/async-hooks/test-async-local-propagation.js create mode 100644 test/async-hooks/test-async-local.async-await.js create mode 100644 test/async-hooks/test-async-local.js diff --git a/benchmark/async_hooks/async-resource-vs-destroy.js b/benchmark/async_hooks/async-resource-vs-destroy.js index 32212df0574bf3..9b85a4c20a493c 100644 --- a/benchmark/async_hooks/async-resource-vs-destroy.js +++ b/benchmark/async_hooks/async-resource-vs-destroy.js @@ -8,7 +8,8 @@ const common = require('../common.js'); const { createHook, executionAsyncResource, - executionAsyncId + executionAsyncId, + AsyncLocal } = require('async_hooks'); const { createServer } = require('http'); @@ -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] }); @@ -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()); @@ -126,7 +150,8 @@ function getServeCallbacks(getCLS, setCLS) { const types = { 'async-resource': buildCurrentResource, - 'destroy': buildDestroy + 'destroy': buildDestroy, + 'async-local': buildAsyncLocal, }; const asyncMethod = { diff --git a/doc/api/async_hooks.md b/doc/api/async_hooks.md index 303c3b58f76832..052191093883f7 100644 --- a/doc/api/async_hooks.md +++ b/doc/api/async_hooks.md @@ -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 + + + +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 @@ -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 diff --git a/doc/api/errors.md b/doc/api/errors.md index cf21c142d6dd97..99d3f348b288de 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -643,6 +643,11 @@ by the `assert` module. An attempt was made to register something that is not a function as an `AsyncHooks` callback. + +### ERR_ASYNC_LOCAL_CANNOT_SET_VALUE + +An attempt was made to set value for a `AsyncLocal` after it was removed. + ### ERR_ASYNC_TYPE diff --git a/lib/async_hooks.js b/lib/async_hooks.js index bc30b555e0f246..6a3e1247687e27 100644 --- a/lib/async_hooks.js +++ b/lib/async_hooks.js @@ -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'); @@ -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 // @@ -207,6 +249,7 @@ module.exports = { executionAsyncId, triggerAsyncId, executionAsyncResource, + AsyncLocal, // Embedder API AsyncResource, }; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index ad12d99c7cc49c..464bf211cff00c 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -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', diff --git a/test/async-hooks/test-async-local-isolation.js b/test/async-hooks/test-async-local-isolation.js new file mode 100644 index 00000000000000..7799f5b2f20769 --- /dev/null +++ b/test/async-hooks/test-async-local-isolation.js @@ -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); diff --git a/test/async-hooks/test-async-local-propagation.js b/test/async-hooks/test-async-local-propagation.js new file mode 100644 index 00000000000000..d8aae1f530e245 --- /dev/null +++ b/test/async-hooks/test-async-local-propagation.js @@ -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); diff --git a/test/async-hooks/test-async-local.async-await.js b/test/async-hooks/test-async-local.async-await.js new file mode 100644 index 00000000000000..fe2daba3832d26 --- /dev/null +++ b/test/async-hooks/test-async-local.async-await.js @@ -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') +)); diff --git a/test/async-hooks/test-async-local.js b/test/async-hooks/test-async-local.js new file mode 100644 index 00000000000000..1ae85c868cda10 --- /dev/null +++ b/test/async-hooks/test-async-local.js @@ -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();