threads icon indicating copy to clipboard operation
threads copied to clipboard

How to cancel a pending Atomics.waitAsync?

Open juj opened this issue 3 years ago • 40 comments

In several scenarios, after having issued a Atomics.waitAsync(...).value.then((value) => { /* do something */ }); operation, it can happen that some kind of resource/application/page deinitialization type of activity occurs, which deinitializes parts of the Wasm app that contain e.g. the data structures for, say, a lock, semaphore or other multithreading synchronization primitive that resided at that address.

How would one ensure that if such application deinitialization does take place before the waitAsync.then() handler has been invoked, that the promise handler would never be fired (and /* do something */ should never get executed)?

At application level, I can use a separate JavaScript variable

var lockStillExists = true;

void deleteLock() {
  lockStillExists = false;
}

...

Atomics.waitAsync(myInt32Array, ...).value.then((value) => { if (lockStillExists) { /* do something */ } })

to ensure that /* do something */ will never erroneously execute in case the wasm app deinitializes in between, but what happens if I want to deinitialize and let myInt32Array or other JS variables garbage collect altogether? I.e. the promise callback function (value) => { ... } will capture a scope, that would surely pin down myInt32Array and other JS vars in the scope to remain alive indefinitely (since the .then() will never resolve)?

Is there a way to cancel an async wait and/or guarantee that JS garbage collection will safely occur?

juj avatar Jan 17 '21 13:01 juj

I don't think there's a way of cancelling a waitAsync. ISTR there's generally some concern around promises not having a cancellation facility. May be worthwhile pinging TC39 around that.

(I don't know the job semantics well enough to say what happens with pending promises at page shutdown, which is probably the only sense of "deinitialization" that is known to the web.)

lars-t-hansen avatar Jan 18 '21 07:01 lars-t-hansen

Running the following test

<html><body><script>
function test() {
	var heap = new Int32Array(new SharedArrayBuffer(128*1024*1024));
	var waitIndex = 0;
	Atomics.waitAsync(heap, waitIndex, 0, Infinity).value.then((value) => {
		console.log(heap[waitIndex]);
	});
}
for(var i = 0; i < 10; ++i) {
	test();
}
</script></body></html>

in Chrome does indeed result in the created SABs being pinned down, and never freed.

In e.g. Unity use, it is somewhat common for pages to need to unload a Unity engine build from the page, and maybe open another Wasm build. An example usage is in a 3D asset store web store page, with a preview carousel, where the preview carousel may be a photo, a video, or a live 3D page (written in Unity game engine to wasm) of the product in question. When one navigates the carousel, the page will dynamically populate to show the active selected preview item index. An example page: https://assetstore.unity.com/packages/3d/environments/landscapes/polydesert-107196

Another example usage is a game portal, where game instances are loaded into a child element.

This limitation of being unable to gracefully deinitialize does effectively mean that one is mandated to use iframes in order to get JavaScript garbage collection of Wasm heaps to work.

juj avatar Jan 18 '21 14:01 juj

Trying out a similar test in Chrome with iframes does suggest that Chrome is able to reclaim the memory if one does use iframes:

iframe_parent.html

<html><body><script>
var numLoaded = 0;
function open() {
	if (numLoaded++ >= 20) return;
	console.log(numLoaded);
	var iframe = document.getElementById('container');
	if (iframe) {
		console.log('unload');
		iframe.parentElement.removeChild(iframe);
	}
	else {
		console.log('load');
		iframe = document.createElement('iframe');
		iframe.id = 'container';
		document.body.appendChild(iframe);
		iframe.src = 'iframe_child.html';
	}
	console.log(iframe.src);
}
setInterval(open, 1000);
</script></body></html>

iframe_child.html

<html><body><script>
var heap = new Int32Array(new SharedArrayBuffer(128*1024*1024));
var waitIndex = 0;
console.log('waitAsync');
Atomics.waitAsync(heap, waitIndex, 0, Infinity).value.then((value) => {
	console.log(heap[waitIndex]);
});
</script></body></html>

However not sure if this is adequate - requiring wrapping content in an iframe for garbage collection to work can be quite a footgun, so certainly in search of a better way here.

juj avatar Jan 18 '21 14:01 juj

/cc @syg

dschuff avatar Jan 19 '21 21:01 dschuff

Interesting example, Jukka, thanks!

As @lars-t-hansen says, yeah, there's not really a way to cancel, and the question has baggage around cancellation of Promises. Before I pull on threads to see about a Promise cancellation mechanism again, which, given AbortController, is probably not going to get anywhere, let me ask:

For the Unity use case, would either of the following be sufficient? And do you have a preference?

  1. Upon the SAB being finalized, at some future time all pending waitAsync Promises are either resolved with a new sentinel like "cancelled" or rejected?
  2. A new SAB.cancelAllAsyncWaiters() API or something ugly like that?

Also cc @marjakh

syg avatar Jan 19 '21 22:01 syg

The resolution proposal 1. would likely be sufficient for the GC reclaim scenario - such 'cancelled' or rejected resolves could easily be filtered out in the application.

The resolution proposal 2. may be tricky, in particular the question is whether SAB.cancelAllAsyncWaiters() would cancel all .waitAsync()s in all Workers waiting on any address on the given SAB, or just all .waitAsync()s that were issued in the Worker that calls SAB.cancelAllAsyncWaiters(). If it canceled all Workers, then I think it would work. However, not needing to implement any code would certainly be preferable here - then the GC would "just work" without a danger that JS code could write any piece of code that generates essentially unreachable objects that can never free (in the page lifetime).

There is also another scenario that becomes complicated from the lack of cancellation: deleting locks.

To make the example concrete, let's say your wasm application has some kind of database of records that are stored in the wasm heap. The records are large enough that SAB/pthread-based Workers are used to download them in the background (think e.g. image files or .json data or something else), and/or maybe on the main thread also.

Whenever a download finishes (e.g. via a XHR or Fetch Promise), the code will issue an Atomics.waitAsync into the wasm heap to lock a mutex and access a central shared data structure that contains this database of records, to insert the data in the database. Atomics.waitAsync is needed since the main thread could not do sync Atomics.waits. Maybe due to simplicity and ease of sharing the code flow, the Workers also use the same Atomics.waitAsync code (saves having to write two copies of mutex locking, one for .waitAsync and another for .wait)

So essentially we have N Workers that are each Atomics.waitAsyncing a lock address in the heap at unexpected times.

Then at some point, e.g. based on user input, it is decided that the database should close (but the wasm application still lives on, maybe opening a new database, or deciding to do something else). If implemented in common OOP fashion, the database lock address will likely have been dynamically allocated uint32 on the heap.

In order to deinitialize the lock, safely free() its uint32 address and reclaim the memory address for malloc() to reuse, the application would need to cancel all pending Atomics.waitAsyncs in both the main thread, but also in all Workers in the wasm application in an atomic/watertight manner. Or otherwise the main thread/Workers may race to resolve Atomics.waitAsyncs to an essentially freed memory address (or in worst case, the app may have allocated a new lock structure in exactly same memory address, leading to random behavior).

At first I thought all of this could simply be avoided by using a helper uint32 next to the wait address as a field to denote whether the uint32 lock is valid, and when deinitializing, mark the lock invalid first, then Atomics.notify() to wake up all waiters, and then proceed to free() all the memory. The woken up waiters would then be set to fizzle the event when they see the lock having been deallocated. And waiters would never be allowed to register a new Atomics.waitAsync() unless first checking the "is the lock valid" uint32 field.

However that does not quite work, but it has a problem that freeing a lock becomes an asynchronous operation! That is, the "is the lock valid" uint32 helper field needs to be kept alive until the last Atomics.waitAsync() event has resolved, making it hard to deal with in C code: deleting locks and free()ing is generally expected to be a synchronous operation.

I can't think of a great way to resolve this issue, except to maybe start making some kind of refcounting things in wasm heap, where whenever a Worker issues a Atomics.waitAsync() on a memory address, it increments a refcount, and when a lock is deleted, it is marked deleted, and all pending .waitAsync()s finally resolve, then the last one to resolve will actually do the free(). However that will still have a big issue if one calls Worker.terminate(), since that would leak refcounts from that Worker, unless one keeps a global registry in wasm heap of all addresses that each Worker has .waitAsync()ed.

I thought about imposing a restriction to Emscripten that pthread Workers are not allowed to do any .waitAsync()s, but only main thread is, and then only main thread would be allowed to delete any locks. Then it would be able to locally track in JS side state the set of alive locks, and clear out the .waitAsync()s. But this kind of deinitialization restriction seems a bit limiting from a C programmers viewpoint.

If there existed a function SAB.cancelAllAsyncWaitersAtAddress(address) that cleared all waiters on all Workers on a given address, then I think this kind of lock deinitialization would be possible to implement synchronously without complications, and one could delete a lock by first issueing a SAB.cancelAllAsyncWaitersAtAddress(address) then followed by free(address).

So I wonder, would we be looking at two functions, SAB.cancelAllAsyncWaiters() and SAB.cancelAllAsyncWaitersAtAddress(address), or might there be a simpler way to address both use cases?

juj avatar Jan 19 '21 23:01 juj

Moi Jukka (&others),

I'm mostly worrying about this from the "how do we release memory" point of view. In the example in OP, if you never call notify(), the code in the then-func will never be executed, so that's fine. But if you create waitAsyncs and never notify() them, in the current impl in V8, memory will grow without bound. (Even if the SAB is GCd!)

Relevant doc where this use case is discussed:

https://docs.google.com/document/d/1aeEGDm1XSqoJkQQKz9F75WqnuAa2caktxGy_O_KpO9Y/edit#heading=h.2cmggae83oqv (that link should link to the "Open question: Problem with location based lists" section).

Re: SAB not being released (your example in here: https://github.com/WebAssembly/threads/issues/176#issuecomment-762274936 ). I think what happens here is that the closure you pass to then() keeps "heap" alive which keeps the SAB alive :) So it's not the waitAsync that keeps stuff alive. If you didn't refer to "heap" in your function, the SAB should get GCd just fine. (But we still keep internal data structures alive and leak memory that way.)

Re: SAB.cancelAllAsyncWaiters APIs, just to confirm, the intent would be that it's a synchronous operation which does not call any callbacks (e.g., promise rejected callbacks), right? Otherwise it wouldn't help the "freeing a lock must be sync" use case.

marjakh avatar Jan 20 '21 10:01 marjakh

Moi Jukka (&others),

👋

Re: SAB not being released (your example in here: #176 (comment) ). I think what happens here is that the closure you pass to then() keeps "heap" alive which keeps the SAB alive :) So it's not the waitAsync that keeps stuff alive. If you didn't refer to "heap" in your function, the SAB should get GCd just fine. (But we still keep internal data structures alive and leak memory that way.)

That is exactly correct. It is the closure that keeps the heap alive. If the closure did not reference the heap, then the heap will be freed (as one can test by removing console.log(heap[waitIndex]); from the test code). But then again, it is the Atomics.waitAsync that is keeping that closure alive.

The rationale I now realize I missed explaining in that code is that it is extremely common to need to reference the heap index that one just waited on from within the Promise, so having to access heap[waitIndex] inside the Promise .then() is in some sense the "canonical" usage of the Atomics.waitAsync() API. (because that memory location generally stores the state of the lock primitive one is implementing)

Re: SAB.cancelAllAsyncWaiters APIs, just to confirm, the intent would be that it's a synchronous operation which does not call any callbacks (e.g., promise rejected callbacks), right? Otherwise it wouldn't help the "freeing a lock must be sync" use case.

I presume it could work either by resolving with a special 'cancelled', or rejecting, or not calling the callbacks at all. As long as one would know inside the callback that the wait was rejected so as not to access any heap memory in the callback. (since that heap memory has now already been free()d prior)

juj avatar Jan 20 '21 13:01 juj

I think there are (at least) 2 separate problems here:

  1. The "SAB stays alive" problem can be solved with a suitable indirection object. You could make the closure point to the indirection object and the indirection object point to the SAB. When you want to free the memory, kill the indirection_object -> SAB pointer.

When you want to free memory, could you actually notify() everything at that point or is that cumbersome? You can make the closure no-op in that case, so it wouldn't access memory, so it shouldn't matter that you first free memory and only after that notify.

my_sab = new SharedArrayBuffer(...);
my_sab.indirection_object = {'buffer': my_sab };

function createWaiter(indirection_object) {
  waitAsync(indirection_object.buffer, ...).value.then(() => {
    if (indirection_object.buffer) { // when this is null, do nothing
      // do stuff with indirection_object.buffer
    }});
}

// now we want to free memory:
my_sab.indirection_object.buffer = null;
// maybe notify everything here? the then-funcs will be noop since buffer is null

// and let SAB die
my_sab = null;

(I hope I didn't mess up that example :) )

  1. If you don't wan't to / can't notify everything, we have a "user creates waitAsyncs and doesn't notify them -> we leak memory" problem on our side (we don't leak the SAB but our internal data). We can either provide an API the user must call, or just do the right thing without an API (when a SAB dies, detect that we have waitAsyncs which will never finish and release them).

That'd require you let the SAB die on your side.

I might be grossly oversimplifying things here, feel free to point out what exactly :)

Not adding an API would be simpler in a sense; we should just do the right thing anyway :-P But it requires the user to jump through hoops to let the SAB die (as it's indeed canonical use case to refer to it in the closure). So... not sure which is better.

marjakh avatar Jan 20 '21 14:01 marjakh

1. The "SAB stays alive" problem can be solved with a suitable indirection object.

That would certainly help break the link. One difficulty maintaining such an indirection is due to WebAssembly Memory.grow. When memory growth occurs, one must reinitialize all views to the newly grown buffer, so if there existed multiple copies to these views, then it would have to be coordinated with the heap memory growth code to make sure they don't fall out of sync. This would practically limit the shape of any given codebase to be able to have at most one place where they Memory.grow, and at most one place where they Atomics.waitAsync so that those two tightly coordinate the heap views.

When you want to free memory, could you actually notify() everything at that point or is that cumbersome?
// maybe notify everything here? the then-funcs will be noop since buffer is null

A difficulty here is that we are dealing with multiple Workers - so they each would have their own copy of the my_sab.indirection_object, and they would somehow have to coordinate setting that to null so that none of the Workers will accidentally resolve the SAB. In the "application is terminating, we are GCing" scenario that would not be a problem, all Workers have been long .terminate()d by then, but in solving the sync free() problem, coordinating to resolve all Workers is difficult.

1. We can either provide an API the user must call, or just do the right thing without an API (when a SAB dies, detect that we have waitAsyncs which will never finish and release them).

It would probably be best if this could automatically be detected, also like what @syg pondered above. If there is a manual procedure one must follow, it can probably appear arcane for developers who are not experts on SAB+Atomics development, and they might not immediately realize there is an extra procedure to follow (and thus cause leaks).

juj avatar Jan 20 '21 15:01 juj

Ah, I see where I was oversimplifying this :) Thanks for the added info.

It might be that coordinating workers wrt "letting the SAB die" (what must die is the backing store shared by all workers) is equally complex as what the cancelAllAsyncWaiters would do internally. The coordination would require some kind of a "drop all your references to the SAB" command sent to the workers. So maybe we want to add the explicit API to save the user from this complexity. I'll need to think about this a bit more.

marjakh avatar Jan 20 '21 15:01 marjakh

It might be that coordinating workers wrt "letting the SAB die" (what must die is the backing store shared by all workers) is equally complex as what the cancelAllAsyncWaiters would do internally.

This is my current understanding as well after the discussion. My two initial proposed solutions and @marjakh's two problem statements in https://github.com/WebAssembly/threads/issues/176#issuecomment-763629579 are in a cycle of sorts. Ideally, as @juj says, we want cancellation of waitAsync Promises to require no extra code and be tied to the lifetime of the SAB, but it is precisely those waiters' Promise handlers that are keeping the SAB alive.

The ideal, "no user intervention needed" solution then seems to me that waitAsync Promise handlers' references to the SAB they are waiting on to be automatically weak. This is much too specialized, however, since waitAsync Promise are just regular Promises, and most likely a non-starter.

I wonder if the canonical use of waitAsync should refer to the SAB in a WeakRef might work before going down the explicit API proposal route?

syg avatar Jan 21 '21 01:01 syg

I don't think WeakRefs would help here.

Either the worker has an additional strong pointer to the SAB or not (in addition to the closure ptr which would now be weak). The worker's SAB is a normal object on the worker's side, independent of the SAB in the main thread, except that they refer to the same backing store.

Has a strong pointer -> making the pointer in the closure weak doesn't change anything; the worker still needs to be told to nullify the strong pointer.

Doesn't have a strong pointer -> the SAB will die too early and the worker won't be able to access it when notify() is called.

Having a SAB in a different thread, referring to the same backing store, doesn't keep the worker's SAB alive. If that was the case, then maybe a weak pointer would help. Was that the scenario you were thinking about?

marjakh avatar Jan 21 '21 14:01 marjakh

Trying out WeakRefs with the original example, GC does occur properly:

<html><body><script>
function test() {
	var heap = new Int32Array(new SharedArrayBuffer(128*1024*1024));
	var weakheap = new WeakRef(heap);
	var waitIndex = 0;
	Atomics.waitAsync(heap, waitIndex, 0, Infinity).value.then((value) => {
		var heap = weakheap.deref();
		if (heap) console.log(heap[waitIndex]);
	});
}
for(var i = 0; i < 10; ++i) {
	test();
}
</script></body></html>

That certainly is workable to solve the first problem of wasm program deinitialization (to my understanding), but it does have two awkward concerns:

  1. When the Wasm app grows its heap size via WebAssembly.Memory.grow, the existing references to SAB views will all be cleared, and reallocated to new grown views. Thus it can happen that the WeakRef pointing to an existing view will get nulled already while the application is still running, causing the Promise to resolve incorrectly during app lifetime.

The only way I can think of resolving this is to capture the underlying SAB itself, rather than the view, but that has the awkward result that then each .waitAsync resolve will require allocating a new SAB view to process the resolve. I.e. the code would have to look as follows to be correct (compare to the above):

<html><body><script>
function test() {
	var heap = new SharedArrayBuffer(128*1024*1024);
	var view = new Int32Array(heap);
	var weakheap = new WeakRef(heap);
	var waitIndex = 0;
	Atomics.waitAsync(view, waitIndex, 0, Infinity).value.then((value) => {
		var heap = weakheap.deref();
		if (heap) {
			var view = new Int32Array(heap);
			console.log(view[waitIndex]);
		}
	});
}
for(var i = 0; i < 10; ++i) {
	test();
}
</script></body></html>
  1. A second concern is that in a larger application, the closure of .waitAsync() may need to capture multiple JS objects and variables. Application developers would hence need to be able to refer to all of those other objects as well via a WeakRef, or otherwise anything else will leak as well. There is exactly no diagnostics that one would get about this situation either, one "just has to know" this behavior, or silently will get memory leaks. That will make the API more brittle, harder to use correctly. Though in a pinch, I suppose that could work.

I wonder if it would be feasible&sensible to have a new API Atomics.cancel(sabInt32Array[, index]); (Atomics.reject, Atomics.notifyCancel, Atomics.notifyReject?) symmetric to Atomics.notify(), but instead of notifying the waiters, it would make the synchronous waiters return with a 'cancelled' or 'rejected' string, and it would make asynchronous waiters reject the wait Promise (or also resolve with a 'cancelled' or 'rejected'?). I believe that would allow "kicking the waiters out" from a given index when freeing up a lock in that index. Specifying the index param would be optional, in which case all waiters on all addresses would be rejected.

juj avatar Jan 21 '21 17:01 juj

One issue I note is that Safari does not support WeakRefs at all (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef), so it would require Apple to add support for that to Safari in tandem for the SAB support, if that was made a required part of the spec.

juj avatar Jan 21 '21 17:01 juj

In your latest example, if you force a GC, and then try to notify(), will the WeakRef be valid or invalid? I'd guess invalid. If valid, what is keeping the SAB alive?

marjakh avatar Jan 21 '21 18:01 marjakh

In your latest example, if you force a GC, and then try to notify(), will the WeakRef be valid or invalid? I'd guess invalid. If valid, what is keeping the SAB alive?

The WeakRef will be invalid, but that would be the intent in this example scenario.

In the real application/runtime, the code structure looks something like this:

function Module() {
  var heap = new SharedArrayBuffer(128*1024*1024);
  var weakheap = new WeakRef(heap);
  // ...more global state

  function waitAsync(waitIndex) {
    Atomics.waitAsync(heap, waitIndex, 0, Infinity).value.then((value) => {
      var heap = weakheap.deref();
      if (heap) {
        var view = new Int32Array(heap);
        console.log(view[waitIndex]);
      }
    });
  }
  // ... more global functions

  // main app entry point
  function main() {
    // ...start executing application, sets up all sorts of event handlers that can call Atomics.waitAsync()
  }
  // return exports
  return {
    main: main,
    // ...other exports
  }
}

// Creating an app
var app = Module();

// Deinitializing an app, this should cause the whole app contents to GC
app = null;

In this case, what keeps the SAB alive is the app object in global scope, which references the function exports from the Module, which reference the local scope of the Module where the SAB lives, so my understanding here is that the SAB would stay alive as long until app = null; is run.

juj avatar Jan 21 '21 18:01 juj

I wonder if it would be feasible&sensible to have a new API Atomics.cancel(sabInt32Array[, index]); (Atomics.reject, Atomics.notifyCancel, Atomics.notifyReject?) symmetric to Atomics.notify(), but instead of notifying the waiters, it would make the synchronous waiters return with a 'cancelled' or 'rejected' string, and it would make asynchronous waiters reject the wait Promise (or also resolve with a 'cancelled' or 'rejected'?).

Atomics.cancel(sab, index, N) seems reasonable to me as an analog to notify for the async waiters. It seems the case is a little weaker for the synchronous wait use case, but I also see no harm in supporting sync waiters.

Shall I make a proposal?

syg avatar Jan 21 '21 21:01 syg

The API seems useful (though it's quite subtle why that is :) ).

The thought process I used for convincing myself: I wonder whether Atomics.cancel(sab, index, N) enables something that's not doable byAtomics.notify + distinguishing between the two different kinds of notifications on the receiver's side. The trivial way for distinguishing would involve setting a value in the SAB which the notified thread then reads. But we don't want that, since we want to release the memory before the notified thread wakes up. There are probably other ways to do the distinguishing but meh. With adding a new "cancel" notification, the notified thread can honor the invariant that it won't read the memory if it receives the cancel notification.

Is Atomics.cancel(sab, index, N) ergonomic enough for the users, or do we need something more generic like Atomics.cancel(sab)? (The latter is more difficult to implement so I'd be happy with the former of course.)

marjakh avatar Jan 25 '21 07:01 marjakh

I suppose our semantics are already deeply tied into having a critical region for the wait/notify system inside which all ambiguities about racing wait/cancel pairs boil away, so this seems no worse than what we're already doing.

My gut feeling says to restrict this to only async waiters, and to call it cancelAsync. Sync waiters will always have access to the memory and can always read a dedicated location to look for eg a cancellation code. I think there would need to be additional justification for making cancel work on these waiters.

lars-t-hansen avatar Jan 25 '21 08:01 lars-t-hansen

Sorry for probably missing it but: Can someone explain to me why .cancel(sab, index, N) can't be accomplished by passing a signal parameter to waitAsync?

Is it because the canceller needs to be able to do this independently from whomever calls waitAsync and cancel another operation? Is that behaviour desireable?

benjamingr avatar Jan 27 '21 18:01 benjamingr

Sorry for probably missing it but: Can someone explain to me why .cancel(sab, index, N) can't be accomplished by passing a signal parameter to waitAsync?

Could you spell out some more what you had in mind for the extra parameter?

syg avatar Jan 28 '21 00:01 syg

async function doSomething({ signal } = {}) {  
  const { value } = await Atomics.waitAsync(view, waitIndex, 0, Infinity, { signal })
  // do more stuff 
}
const ac = new AbortController();
doSomething({ signal: ac.signal }); //

ac.abort(); // stops waiting, throws AbortError, just like fetch and other web APIs

benjamingr avatar Jan 28 '21 16:01 benjamingr

Thank you for the illustrative example.

The uncomfortable but real political answer here is that TC39, where JS is developed, is a different standards body than WHATWG, where AbortController is developed. To extend Atomics.waitAsync to use AbortController wouldn't work.

On technical grounds, the JS spec features also strive to be cross-platform, and AbortController is web platform-specific.

syg avatar Jan 28 '21 23:01 syg

On technical grounds, the JS spec features also strive to be cross-platform, and AbortController is web platform-specific.

Some of the non-browser platforms like Node.js and Deno have added support for AbortController, we've shipped it in Node.js pretty recently. This was mostly done to reduce the cognitive overhead of developers so there is one (obvious) way to cancel things in APIs that support it (things like http.request and fs.readFile).

I am wondering if the spec can be phrased in such a way that ECMAScript will support cancellation with AbortController without requiring implementing or speccing the full API.

Alternatively if that is impossible to propose a simpler signal-like API that Node can implement on its AbortControllers (or even better: be made part of the DOM's) - Atomics could use that.

benjamingr avatar Jan 29 '21 13:01 benjamingr

If it were only down to this "political"/layering issue, I would be motivated to work out some kind of general solution. However, when discussing it earlier with @marjakh , I got the impression that it's important for the cancellation to come from a different Worker (compared to where waitAsync is called), where there's no particular way to share the AbortController with a Worker to trigger it, and this motivates an index-based API described above. Is this accurate?

littledan avatar Jan 29 '21 15:01 littledan

I got the impression that it's important for the cancellation to come from a different Worker (compared to where waitAsync is called), where there's no particular way to share the AbortController with a Worker to trigger it

Yes the cancellation can certainly come from any Worker or the main thread. I wasn't aware there wasn't a way to share an AbortController across workers, thanks for pointing this out.

syg avatar Jan 29 '21 16:01 syg

Thinking about this a little more: I suspect we could define AbortController and AbortSignal as serializable objects (assuming they are restricted to only in-memory), but they aren't defined that way right now. But maybe such an interface would be overkill for this application, and index-based cancellation is more manageable and efficient.

littledan avatar Jan 29 '21 16:01 littledan

@syg

Yes the cancellation can certainly come from any Worker or the main thread. I wasn't aware there wasn't a way to share an AbortController across workers, thanks for pointing this out.

Why? Can you elaborate on this perhaps with a use case where one thread needs the capability of aborting a waitAsync of another thread.

I suspect we could define AbortController and AbortSignal as serializable objects (assuming they are restricted to only in-memory), but they aren't defined that way right now.

I suspect that's the case - though we can also provide a method on atomics to obtain an AbortController for a given index. That kind of bypasses the "the capability to abort something and the capability to listen to cancellation are separate" thing.

and index-based cancellation is more manageable and efficient.

I don't understand the problem domain well enough to know if that's a real limitation though waitAsync already seems pretty "heavy" returning a promise compared to .wait.

benjamingr avatar Jan 29 '21 16:01 benjamingr

Why? Can you elaborate on this perhaps with a use case where one thread needs the capability of aborting a waitAsync of another thread.

@juj lays out the use case in the first half of this thread: deleting mutexes.

Taking a step back, the primary use case for Atomics.wait and Atomics.waitAsync is to implement higher-level synchronization mechanisms such as mutexes. The most popular example is emscripten-compiled pthreads programs. Since Atomics.wait blocks and the main thread cannot block, Atomics.waitAsync was introduced so that those programs can also use mutexes on the main thread, asynchronously.

Since these are C/C++ pthreads programs compiled via emscripten (perhaps interacting with wasm), these waiters, async or otherwise, are always going to be notified from another thread.

The fact that same-thread notifications are possible is a result of JS asynchrony, and indeed have use cases in JS itself. But given how low-level the API is (it's futexes), I imagine the majority use case will remain as an implementation building block for multithreaded synchronization mechanisms.

syg avatar Jan 29 '21 17:01 syg