Skip to content

Commit

Permalink
async_hooks: add AsyncLocal
Browse files Browse the repository at this point in the history
Co-Authored-By: Andrey Pechkurov <apechkurov@gmail.com>
Co-authored-by: Gerhard Stoebich <18708370+Flarna@users.noreply.github.com>
  • Loading branch information
3 people committed Feb 22, 2020
1 parent 9fdb6e6 commit d849256
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 1 deletion.
27 changes: 26 additions & 1 deletion benchmark/async_hooks/async-resource-vs-destroy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const sleep = promisify(setTimeout);
const read = promisify(readFile);
const common = require('../common.js');
const {
AsyncLocal,
createHook,
executionAsyncResource,
executionAsyncId
Expand All @@ -18,11 +19,34 @@ const connections = 500;
const path = '/';

const bench = common.createBenchmark(main, {
type: ['async-resource', 'destroy'],
type: ['async-local', 'async-resource', 'destroy'],
asyncMethod: ['callbacks', 'async'],
n: [1e6]
});

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

return {
server,
close
};

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

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

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

function buildCurrentResource(getServe) {
const server = createServer(getServe(getCLS, setCLS));
const hook = createHook({ init });
Expand Down Expand Up @@ -125,6 +149,7 @@ function getServeCallbacks(getCLS, setCLS) {
}

const types = {
'async-local': buildAsyncLocal,
'async-resource': buildCurrentResource,
'destroy': buildDestroy
};
Expand Down
99 changes: 99 additions & 0 deletions doc/api/async_hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,105 @@ 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.unwrap();
console.log(`${id !== undefined ? id : '-'}:`, msg);
}
let idSeq = 0;
http.createServer((req, res) => {
asyncLocal.store(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.unwrap()`

* Returns: {any}

Returns the value stored within the `AsyncLocal` in current execution context,
or `undefined` if no value has been stored yet.

### `asyncLocal.store(value)`

* `value` {any}

Stores a value for the `AsyncLocal` within current execution context.

Once stored, the value will be kept through the subsequent asynchronous calls,
unless replaced with a new call of `asyncLocal.store(value)`:

```js
const asyncLocal = new AsyncLocal();
setImmediate(() => {
asyncLocal.store('A');
setImmediate(() => {
console.log(asyncLocal.unwrap());
// Prints: A
asyncLocal.store('B');
console.log(asyncLocal.unwrap());
// Prints: B
});
console.log(asyncLocal.unwrap());
// Prints: A
});
```

### `asyncLocal.clear()`

When called, removes any value stored in the `AsyncLocal`.

### `asyncLocal.enable()`

When called, enables propagating the value stored within the `AsyncLocal`
throughout the asynchronous call graph. Calling `asyncLocal.enable()`
multiple times will have no effect.

### `asyncLocal.disable()`

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

## Promise execution tracking

By default, promise executions are not assigned `asyncId`s due to the relatively
Expand Down
64 changes: 64 additions & 0 deletions lib/async_hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,69 @@ function createHook(fns) {
}


// AsyncLocal API //

const locals = [];
const localsHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
const execRes = executionAsyncResource();
// Using var here instead of let because "for (var ...)" is faster than let.
// Refs: https://github.com/nodejs/node/pull/30380#issuecomment-552948364
for (var i = 0; i < locals.length; i++) {
locals[i][kPropagateSymbol](execRes, resource);
}
}
});

const kPropagateSymbol = Symbol('propagate');

class AsyncLocal {
constructor() {
this.symbol = Symbol('async-local');
this.enable();
}

[kPropagateSymbol](execRes, initRes) {
initRes[this.symbol] = execRes[this.symbol];
}

unwrap() {
const resource = executionAsyncResource();
return resource[this.symbol];
}

store(value) {
const resource = executionAsyncResource();
resource[this.symbol] = value;
}

clear() {
const resource = executionAsyncResource();
delete resource[this.symbol];
}

enable() {
const index = locals.indexOf(this);
if (index === -1) {
locals.push(this);
localsHook.enable();
}
}

disable() {
const index = locals.indexOf(this);
if (index === -1)
return;

this.clear();
locals.splice(index, 1);
if (locals.length === 0) {
localsHook.disable();
}
}
}


// Embedder API //

const destroyedSymbol = Symbol('destroyed');
Expand Down Expand Up @@ -213,6 +276,7 @@ module.exports = {
executionAsyncId,
triggerAsyncId,
executionAsyncResource,
AsyncLocal,
// Embedder API
AsyncResource,
};
29 changes: 29 additions & 0 deletions test/async-hooks/test-async-local-isolation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

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

// This test ensures isolation of `AsyncLocal`s
// from each other in terms of stored values

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

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

asyncLocalOne.store('foo');
asyncLocalTwo.store('bar');
assert.strictEqual(asyncLocalOne.unwrap(), 'foo');
assert.strictEqual(asyncLocalTwo.unwrap(), 'bar');

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

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

// This test ensures correct work of the global hook
// that serves for propagation of all `AsyncLocal`s
// in the context of `.unwrap()`/`.store(value)` calls

const asyncLocal = new AsyncLocal();

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

asyncLocal.store('A');
setTimeout(() => {
assert.strictEqual(asyncLocal.unwrap(), 'A');

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

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

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

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

// This test ensures correct work of the global hook
// that serves for propagation of all `AsyncLocal`s
// in the context of `.disable()` call

const asyncLocalOne = new AsyncLocal();
asyncLocalOne.store(1);
const asyncLocalTwo = new AsyncLocal();
asyncLocalTwo.store(2);

setImmediate(() => {
// Removal of one local should not affect others
asyncLocalTwo.disable();
assert.strictEqual(asyncLocalOne.unwrap(), 1);
assert.strictEqual(asyncLocalTwo.unwrap(), undefined);

// Removal of the last active local should not
// prevent propagation of locals created later
asyncLocalOne.disable();
const asyncLocalThree = new AsyncLocal();
asyncLocalThree.store(3);
setImmediate(() => {
assert.strictEqual(asyncLocalThree.unwrap(), 3);
});
});
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.store('foo');
await asyncFunc();
assert.strictEqual(asyncLocal.unwrap(), 'foo');
}

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

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

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

const asyncLocal = new AsyncLocal();

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

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

asyncLocal.disable();
assert.strictEqual(asyncLocal.unwrap(), undefined);

// Does not throw when disabled
asyncLocal.store('bar');

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

0 comments on commit d849256

Please sign in to comment.