Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

worker: implement Web Locks API #36502

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open

Conversation

addaleax
Copy link
Member

This is based upon #22719 and exposes the same API. Instead of a C++-backed approach, this variant implements the API mostly in JS.


Please feel free to push any changes that you think are reasonably uncontroversial to this branch yourself. 🙂 Tests are not going to pass until #36501 is merged.

/cc @devsnek @benjamingr @nodejs/workers

Checklist
  • make -j4 test (UNIX), or vcbuild test (Windows) passes
  • tests and/or benchmarks are included
  • documentation is changed or added
  • commit message follows commit guidelines

@addaleax addaleax added semver-minor PRs that contain new features and should be released in the next minor version. worker Issues and PRs related to Worker support. labels Dec 13, 2020
@nodejs-github-bot nodejs-github-bot added the lib / src Issues and PRs related to general changes in the lib or src directory. label Dec 13, 2020
@addaleax addaleax removed the lib / src Issues and PRs related to general changes in the lib or src directory. label Dec 13, 2020
doc/api/worker_threads.md Outdated Show resolved Hide resolved
@addaleax addaleax added the request-ci Add this label to start a Jenkins CI on a PR. label Dec 14, 2020
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Dec 14, 2020
@nodejs-github-bot
Copy link
Collaborator

@jasnell
Copy link
Member

jasnell commented Dec 14, 2020

Nice. Did you happen to benchmark this relative to the native addon alternative, in particular for the cross-worker cases? I'd be interested in seeing how the lock acquisition time compares and see if the use of MessagePort/BroadcastChannel have an impact on the performance.

Left a few comments on the test but otherwise it's looking good!

@devsnek
Copy link
Member

devsnek commented Dec 14, 2020

would it be possible to pull in the wpt tests?

@targos
Copy link
Member

targos commented Dec 14, 2020

would it be possible to pull in the wpt tests?

I'm trying it

Copy link
Member

@benjamingr benjamingr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few nits, ran a few simulations in my head to see that it works and ran code a bunch.

I am sure there are edge cases I haven't considered and there are probably edge cases and bugs in the impl - but I think we should land this (as experimental) and iterate on it since this sort of concurrency code is hard to review but good to iterate on.

locksInitialized = true;
// Make sure that locks are initialized before active
// multithreading starts.
require('worker_threads').locks.query();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit: This is kind of implicit - I would prefer it if this was more explicit (like .locks.initialize that calls query internally).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well … I can add another method, but I don’t think .locks.initialize() as a name would work since that’s public, and we could use a symbol, but at that point we’re probably introducing more complexity than the comment here provides?

// on the main thread makes sure that locks will be initialized.
assert(isMainThread);
const sab = new SharedArrayBuffer(16);
this.state = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of confusing but I can't think of a nicer way to maintain this state.

// internal state.
const mutex = this.state.mutexOwner;
let owner;
while ((owner = Atomics.compareExchange(mutex, 0, -1, threadId)) !== -1) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally there would be less "magic numbers" and more constants here (they're codified in comments atm) but I'm not sure that'd actually make things clearer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve replaced the -1s with MUTEX_UNOWNED, that should be a bit more expressive. Are there other constants that you’d like to see gone? :)

lib/internal/worker/locks.js Outdated Show resolved Hide resolved
lib/internal/worker/locks.js Show resolved Hide resolved
@targos
Copy link
Member

targos commented Dec 14, 2020

would it be possible to pull in the wpt tests?

I'm trying it

There are many failures. One that I see a lot is related to thrown errors:

"function() { throw e }" threw object "Error: NotSupportedError" that is not a DOMException NotSupportedError: property "code" is equal to 0, expected 9'

@targos
Copy link
Member

targos commented Dec 14, 2020

One of the failing tests has something like this, which results in an internal error:

const { locks } = require('worker_threads'); 
const controller = new AbortController();
const p = locks.request('name', {signal: controller.signal}, lock => { console.log('got lock') })
controller.abort();

addaleax and others added 3 commits December 14, 2020 21:21
This is based upon nodejs#22719
and exposes the same API. Instead of a C++-backed approach,
this variant implements the API mostly in JS.

Refs: nodejs#22719
Co-authored-by: Gus Caplan <[email protected]>
@targos
Copy link
Member

targos commented Dec 14, 2020

I added a commit with WPT (force-pushed to include #36501)

Current results:
$ ./node test/wpt/test-web-locks.js
---- acquire.tentative.https.any.js ----
[SKIPPED] function threw object "Error: NotSupportedError" that is not a DOMException
---- held.tentative.https.any.js ----
[SKIPPED] Error: this uncaught rejection is expected
---- idlharness.tentative.https.any.js ----
[SKIPPED] Unkexpected global object
---- non-secure-context.tentative.any.js ----
[SKIPPED] navigator.locks is only present in secure contexts
---- query.tentative.https.any.js ----
[SKIPPED] Uses Web Workers
---- resource-names.tentative.https.any.js ----
[SKIPPED] function threw object "Error: NotSupportedError" that is not a DOMException
---- signal.tentative.https.any.js ----
[SKIPPED] Various errors. Process hangs.
---- steal.tentative.https.any.js ----
[SKIPPED] AbortError: The operation was aborted
---- lock-attributes.tentative.https.any.js ----
[PASS] Lock attributes reflect requested properties (exclusive)
---- lock-attributes.tentative.https.any.js ----
[PASS] Lock attributes reflect requested properties (shared)
---- mode-exclusive.tentative.https.any.js ----
[PASS] Lock requests are granted in order
---- mode-exclusive.tentative.https.any.js ----
[PASS] Requests for distinct resources can be granted
---- mode-mixed.tentative.https.any.js ----
[PASS] Lock requests are granted in order
---- ifAvailable.tentative.https.any.js ----
[PASS] Lock request with ifAvailable - lock available
---- ifAvailable.tentative.https.any.js ----
[PASS] Lock request with ifAvailable - lock not available
---- ifAvailable.tentative.https.any.js ----
[PASS] Lock request with ifAvailable - lock not available, callback throws
---- ifAvailable.tentative.https.any.js ----
[PASS] Lock request with ifAvailable - unrelated lock held
---- ifAvailable.tentative.https.any.js ----
[PASS] Shared lock request with ifAvailable - shared lock held
---- ifAvailable.tentative.https.any.js ----
[PASS] Exclusive lock request with ifAvailable - shared lock held
---- ifAvailable.tentative.https.any.js ----
[PASS] Shared lock request with ifAvailable - exclusive lock held
---- ifAvailable.tentative.https.any.js ----
[PASS] Returned Promise rejects if callback throws synchronously
---- ifAvailable.tentative.https.any.js ----
[PASS] Returned Promise rejects if async callback yields rejected promise
---- ifAvailable.tentative.https.any.js ----
[PASS] Locks are available once previous release is processed
---- query-empty.tentative.https.any.js ----
[PASS] query() returns dictionary with empty arrays when no locks are held
---- secure-context.tentative.https.any.js ----
[PASS] API presence in secure contexts
---- mode-shared.tentative.https.any.js ----
[PASS] Lock requests are granted in order
---- mode-shared.tentative.https.any.js ----
[PASS] Shared locks are not exclusive
{
  'acquire.tentative.https.any.js': {
    skip: 'function threw object "Error: NotSupportedError" that is not a DOMException'
  },
  'held.tentative.https.any.js': { skip: 'Error: this uncaught rejection is expected' },
  'idlharness.tentative.https.any.js': { skip: 'Unkexpected global object' },
  'non-secure-context.tentative.any.js': { skip: 'navigator.locks is only present in secure contexts' },
  'query.tentative.https.any.js': { skip: 'Uses Web Workers' },
  'resource-names.tentative.https.any.js': {
    skip: 'function threw object "Error: NotSupportedError" that is not a DOMException'
  },
  'signal.tentative.https.any.js': { skip: 'Various errors. Process hangs.' },
  'steal.tentative.https.any.js': { skip: 'AbortError: The operation was aborted' }
}
Ran 7/15 tests, 8 skipped, 7 passed, 0 expected failures, 0 unexpected failures

@jasnell
Copy link
Member

jasnell commented Dec 14, 2020

@addaleax... just running a quick benchmark run here...

first scenario... time acuiring the lock over and over again

'use strict';

const common = require('../common');
const piscina_locks = require('../../../../nearform/piscina-locks');
const { Worker, isMainThread, parentPort, locks } = require('worker_threads');

const bench = common.createBenchmark(main, {
  n: [1e4],
  type: ['js', 'native']
});

async function testWithPiscina(n) {
  bench.start();
  for (let i = 0; i < n; i++) {
    await piscina_locks.request('foo', () => {});
  }
  bench.end(n);
}

async function testWithJS(n) {
  bench.start();
  for (let i = 0; i < n; i++) {
    await locks.request('foo', () => {});
  }
  bench.end(n);
}

async function main({ n, type }) {
  switch (type) {
    case 'js': await testWithJS(n); break;
    case 'native': await testWithPiscina(n); break;
  }
}

Benchmark results are:

util/checkout.js type="js" n=10000: 46,494.73518028555
util/checkout.js type="native" n=10000: 65,005.73334316654

second scenario... launch a worker, post a message, when the worker receives the message it acquires a lock a posts the message back to the main thread.

'use strict';

const common = require('../common');
const piscina_locks = require('../../../../nearform/piscina-locks');
const { Worker, isMainThread, parentPort, locks } = require('worker_threads');

if (isMainThread) {

const bench = common.createBenchmark(main, {
  n: [1e4],
  type: ['js', 'native']
});

async function testWithPiscina(w, n) {
  bench.start();
  for (let i = 0; i < n; i++) {
    let res;
    const p = new Promise((resolve) => {
      res = resolve;
    });
    w.once('message', (msg) => {
      if (msg === 'ok') res();
    });
    w.postMessage('native');
    await p;
  }
  bench.end(n);
}

async function testWithJS(w, n) {
  bench.start();
  for (let i = 0; i < n; i++) {
    let res;
    const p = new Promise((resolve) => {
      res = resolve;
    });
    w.once('message', (msg) => {
      if (msg === 'ok') res();
    });
    w.postMessage('js');
    await p;
  }
  bench.end(n);
}

async function main({ n, type }) {
  const w = new Worker(__filename);
  switch (type) {
    case 'js': await testWithJS(w, n); break;
    case 'native': await testWithPiscina(w, n); break;
  }
  w.terminate();
}
} else {
parentPort.on('message', (type) => {
  switch (type) {
    case 'js':
      locks.request('foo', () => {
        parentPort.postMessage('ok');
      });
      break;
    case 'native':
      piscina_locks.request('foo', () => {
        parentPort.postMessage('ok');
      });
  }
});
}

Benchmark results are:

util/checkout.js type="js" n=10000: 10,278.77744541762
util/checkout.js type="native" n=10000: 14,493.020126723723

The JS version results are good but definitely lag behind the native approach that piscina-locks takes. Not sure that there's room for much more improvement in the JS version.

It's also worth noting that the internals of the JS version are quite a bit more complicated than the native approach.

None of this is to block, of course, but just to add to the conversation.

// multithreading starts.
require('worker_threads').locks.query();
}

// Set up the C++ handle for the worker, as well as some internal wiring.
this[kHandle] = new WorkerImpl(url,
env === process.env ? null : env,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

                 env === process.env ? null : env,

@debadree25
Copy link
Member

debadree25 commented Apr 15, 2023

Hey @addaleax ! I came across this PR and am willing to give try to this, I managed to resolve all conflicts locally and updated the WPTs should I push the changes to this branch? or open a fresh PR?

Also when running the latest WPTs somehow the process completely hangs and tests dont proceed at all does anyone know why?

Also to look at what the present state looks like after all conflicts resolved you can checkout https://github.com/nodejs/node/compare/main...debadree25:node:web-locks-v3?expand=1
(I may have messed up the resolution in the docs part but i will fix that)

@debadree25
Copy link
Member

Ok weirdly seems like removing

"acquire.tentative.https.any.js": {
    "skip": "Unexpected global object"
  },

from test/wpt/status/web-locks.json makes the tests run and following is the present status

Path: wpt/test-web-locks
---- held ----
[SKIPPED] Error: this uncaught rejection is expected
---- idlharness ----
[SKIPPED] Unexpected global object
---- non-secure-context ----
[SKIPPED] navigator.locks is only present in secure contexts
---- query ----
[SKIPPED] Uses Web Workers
---- resource-names ----
[SKIPPED] Unexpected global object
---- signal ----
[SKIPPED] Various errors. Process hangs.
---- steal ----
[SKIPPED] AbortError: The operation was aborted
---- storage-buckets ----
[SKIPPED] Storage not in wpt
---- Lock request with ifAvailable - lock available ----
[PASS] Lock request with ifAvailable - lock available
---- Lock request with ifAvailable - lock not available ----
[PASS] Lock request with ifAvailable - lock not available
---- navigator.locks.request requires a name and a callback ----
[PASS] navigator.locks.request requires a name and a callback
---- Lock request with ifAvailable - lock not available, callback throws ----
[PASS] Lock request with ifAvailable - lock not available, callback throws
---- mode must be "shared" or "exclusive" ----
[PASS] mode must be "shared" or "exclusive"
---- Lock request with ifAvailable - unrelated lock held ----
[PASS] Lock request with ifAvailable - unrelated lock held
---- Shared lock request with ifAvailable - shared lock held ----
[PASS] Shared lock request with ifAvailable - shared lock held
---- The 'steal' and 'ifAvailable' options are mutually exclusive ----
[PASS] The 'steal' and 'ifAvailable' options are mutually exclusive
---- The 'steal' option must be used with exclusive locks ----
[PASS] The 'steal' option must be used with exclusive locks
---- Exclusive lock request with ifAvailable - shared lock held ----
[PASS] Exclusive lock request with ifAvailable - shared lock held
---- The 'signal' and 'steal' options are mutually exclusive ----
[PASS] The 'signal' and 'steal' options are mutually exclusive
---- Shared lock request with ifAvailable - exclusive lock held ----
[PASS] Shared lock request with ifAvailable - exclusive lock held
---- The 'signal' and 'ifAvailable' options are mutually exclusive ----
[PASS] The 'signal' and 'ifAvailable' options are mutually exclusive
---- Lock attributes reflect requested properties (exclusive) ----
[PASS] Lock attributes reflect requested properties (exclusive)
---- Lock requests are granted in order ----
[PASS] Lock requests are granted in order
---- Requests for distinct resources can be granted ----
[PASS] Requests for distinct resources can be granted
---- Returned Promise rejects if callback throws synchronously ----
[PASS] Returned Promise rejects if callback throws synchronously
---- Returned Promise rejects if async callback yields rejected promise ----
[PASS] Returned Promise rejects if async callback yields rejected promise
---- Locks are available once previous release is processed ----
[PASS] Locks are available once previous release is processed
---- callback must be a function ----
[PASS] callback must be a function
---- Lock attributes reflect requested properties (shared) ----
[PASS] Lock attributes reflect requested properties (shared)
---- navigator.locks.request's returned promise resolves after lock is released ----
[PASS] navigator.locks.request's returned promise resolves after lock is released
---- Returned Promise rejects if callback throws synchronously ----
[PASS] Returned Promise rejects if callback throws synchronously
---- Returned Promise rejects if callback throws asynchronously ----
[PASS] Returned Promise rejects if callback throws asynchronously
---- If callback throws a thenable, its then() should not be invoked ----
[PASS] If callback throws a thenable, its then() should not be invoked
---- query() returns dictionary with empty arrays when no locks are held ----
[PASS] query() returns dictionary with empty arrays when no locks are held
---- Lock requests are granted in order ----
[PASS] Lock requests are granted in order
---- Lock requests are granted in order ----
[PASS] Lock requests are granted in order
---- Releasing exclusive lock grants multiple shared locks ----
[UNEXPECTED_FAILURE][FAIL] Releasing exclusive lock grants multiple shared locks
assert_equals: An exclusive lock is held expected 1 but got 7
    at Test.<anonymous> (/Users/debadreechatterjee/Documents/Personal/node/test/fixtures/wpt/web-locks/mode-mixed.tentative.https.any.js:59:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
Command: /Users/debadreechatterjee/Documents/Personal/node/out/Release/node  /Users/debadreechatterjee/Documents/Personal/node/test/wpt/test-web-locks.js mode-mixed.tentative.https.any.js

---- Shared locks are not exclusive ----
[PASS] Shared locks are not exclusive
---- API presence in secure contexts ----
[PASS] API presence in secure contexts
---- An exclusive lock between shared locks ----
[UNEXPECTED_FAILURE][FAIL] An exclusive lock between shared locks
assert_equals: Shared locks are held expected 5 but got 6
    at Test.<anonymous> (/Users/debadreechatterjee/Documents/Personal/node/test/fixtures/wpt/web-locks/mode-mixed.tentative.https.any.js:84:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
Command: /Users/debadreechatterjee/Documents/Personal/node/out/Release/node  /Users/debadreechatterjee/Documents/Personal/node/test/wpt/test-web-locks.js mode-mixed.tentative.https.any.js

{
  "held.tentative.https.any.js": {
    "skip": "Error: this uncaught rejection is expected"
  },
  "idlharness.tentative.https.any.js": {
    "skip": "Unexpected global object"
  },
  "mode-mixed.tentative.https.any.js": {
    "fail": {
      "unexpected": [
        "Releasing exclusive lock grants multiple shared locks",
        "An exclusive lock between shared locks"
      ]
    }
  },
  "non-secure-context.tentative.any.js": {
    "skip": "navigator.locks is only present in secure contexts"
  },
  "query.tentative.https.any.js": {
    "skip": "Uses Web Workers"
  },
  "resource-names.tentative.https.any.js": {
    "skip": "Unexpected global object"
  },
  "signal.tentative.https.any.js": {
    "skip": "Various errors. Process hangs."
  },
  "steal.tentative.https.any.js": {
    "skip": "AbortError: The operation was aborted"
  },
  "storage-buckets.tentative.https.any.js": {
    "skip": "Storage not in wpt"
  }
}
Ran 8/16 tests, 8 skipped, 7 passed, 0 expected failures, 1 unexpected failures, 0 unexpected passes
/Users/debadreechatterjee/Documents/Personal/node/test/common/wpt.js:732
        throw new Error(
        ^

Error: Found 1 unexpected failures. Consider updating test/wpt/status/web-locks.json for these files:
mode-mixed.tentative.https.any.js
    at process.<anonymous> (/Users/debadreechatterjee/Documents/Personal/node/test/common/wpt.js:732:15)
    at process.emit (node:events:523:35)

Node.js v20.0.0-pre
Command: out/Release/node /Users/debadreechatterjee/Documents/Personal/node/test/wpt/test-web-locks.js


[00:00|% 100|+   0|-   1]: Done                  

Failed tests:
out/Release/node /Users/debadreechatterjee/Documents/Personal/node/test/wpt/test-web-locks.js

@aduh95
Copy link
Contributor

aduh95 commented Sep 18, 2023

This needs a rebase. @debadree25 if we want to open a new PR (make sure to mark Anna as a coauthor), I think that would be the easiest way to make this move forward.

@jasnell
Copy link
Member

jasnell commented Oct 28, 2023

I'd certainly like to see this move forward but I think a separate PR is definitely the way to go unless @addaleax is interested in getting this branch updated.

@debadree25
Copy link
Member

I will open a fresh one then, I have probably lost the fixes that I had done let me find that branch again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
semver-minor PRs that contain new features and should be released in the next minor version. worker Issues and PRs related to Worker support.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants