Description
This is an issue covering quite a large idea, however I think it would be considerably more ergonomic that the much lower level shared struct idea, while still perfectly permitting high efficiency shared structs where no code sharing is used.
So the idea is as the title suggests, instead of having highly basic "shared structs" we expose the notion of serializable/transferable as a first-class concept within the JS language itself. This would allow authors to implement a rich class of objects rather than the rather painful status quo of marshalling such objects into lower-level serializable/transferable values.
As an example consider a host object like OffscreenCanvas
returned from a call to .transferControlToOffscreen()
, such an object will have an implicit shared memory buffer which the thread can write with the OffscreenCanvas
abstraction, but the renderer can read from an entirely different thread.
Now to give an example of how such an API could potentially look, I give below an example of a theoretical version of AbortSignal
that is transferable, involves shared state, but is otherwise compatible with the existing API:
// NOTE: In this example I am going to use ${Name} to indicate
// places where free variables are initialized based on the
// thread
// We declare the class as serializable struct, this gives it all
// the following super-powers that enable it to be cloned
// across threads, NOTE that we do require this class to also be a struct
// as we cannot dynamically add properties to the class
//
// One of the first things to note is that this serializable declaration
// applies to the WHOLE class, it causes the class, all prototype methods, all
// static methods, the constructor and beyond to inherit serializable semantics,
// the meaning of these semantics will become clearer as you follow the example
//
// Now by serializable extending to the whole class it means many things are
// well founded, for example suppose we received an AbortSignal as defined below:
// self.onmessage = (event) => {
// const abortSignal = event.data.signal;
// // This class is available and fully operational
// // because of the shared semantics the entire class
// // can be cloned into this thread
// const AbortSignal = abortSignal.constructor;
// const newSignal = new AbortSignal();
// }
//
// Now one of the first things to notice about this declaration itself is that
// we can subclass ANY objects, not just transferable ones, when the AbortSignal
// class is transferred into a thread (either directly, or indirectly via an instance)
// we lookup the free variable in the new thread and initialize it as such
// i.e. the ${EventTarget} acts as a kind've template, which is filled in by
// values on the thread this value has been received on
serializable struct class AbortSignal extends ${EventTarget} {
// Upon serialize to another thread, this property is also structured serialized
// in this case as it's just a shared memory, it becomes usable as normal
//
// Ideally we would be able to sugar this up somehow, like instead of
// explictly making a SharedArrayBuffer and manipulating it, we could just declare
// shared #aborted = false;
// however then we need to extend Atomics to private fields somehow, as this is just
// sugar it does not change the conceptual design of this example so I'm omitting it
#aborted = new Int32Array(new SharedArrayBuffer(4));
// No AbortController in this example, so we'll just use the revealing constructor
// pattern to provide abort capability, note that the callback we pass into
// the start function is not in anyway shared, in fact it lives only on
// the thread where we actually created the AbortSignal and is not serialized
// or transfered in anyway as basic closures are not transfered
//
// The constructor here is quite special in that on deserialization of such objects
// on another thread the constructor will be called on an ALREADY INITIALIZED instance
// of the class
constructor(start) {
// We have a new meta-property (or something) available in the constructor
// of seriazable classes, if we are deserializing this value from another thread
// then the value of "this" will already be partially defined, upon calling
// super() the "this" value in the constructor will become new.serializedInstance
// exactly, the reason this is available now is so that we can access fields with
// data that are needed to be passed up into any superclass
// in this case I'm just logging it for illustrative purposes as EventTarget
// accepts no arguments so we don't have anything to do here
console.log(new.serializedThis);
// super() behaves fairly specially here as well
// in particular if Superclass is also serializable
// it will ALSO be called in deserialization mode rather
// than being called as a constructor normally, when this happens
// field initializers DO NOT RUN, as the data is already available
// on the new.serializedThis
super();
// If we already had an instance from another thread, then the constructor
// has been called as a such we wouldn't need to initialize it
if (!new.serializedThis) {
// This function simply closes over the object in the usual way
// this is fine as this function isn't transfered over the thread
const abort = () => this.#abort();
start(abort);
}
// Regardless of thread, we need to observe the #aborted
this.#listenForAbort();
}
// This is an ordinary method, it is simply cloned by definition to other threads,
// note that it won't actually be called on other threads unless they create
// new abortSignals (i.e. using signal.constructor)
#abort() {
// This logic isn't overly defensive as writing this
// value is only done on a single thread
if (Atomics.load(this.#aborted, 0) === 1) {
// Already aborted so do nothing
return;
}
// We set the abort on the signal
Atomics.store(this.#aborted, 0, 1);
// Notify all threads that their abort signal needs to fire an event
Atomics.notify(this.#aborted, 0);
}
// This is on the whole, a regular method that simply returns
// the value stored in this.#aborted, it is cloned purely by
// it's definition so doesn't need any special treatment
get aborted() {
return Boolean(Atomics.load(this.#aborted, 0));
}
// Now this is the MOST IMPORTANT magic of what serializable enables, essentially
// this is how we are able to initialize our objects when they are received on
// other threads, essentially this "function"-block-thing is called when a thread
// deserializes an AbortSignal object
deserialize {
// On other threads, the constructor was never called for this object
// so our post deserialization steps are simply to register to listen
// out for abort's on our #aborted field
this.#listenForAbort();
}
// Again, just another bog-standard method, this is cloned simply by redefining
// this function at the destination
#listenForAbort() {
const { async, value } = Atomics.waitAsync(
this.#aborted,
0,
// If the abort signal has been aborted already then this will cause
// waitAsync to return synchronously
0,
);
// If the signal is not already aborted, we'll fire an event when it
// eventually does become
if (async === true) {
value.then(() => this.dispatchEvent(new Event("abort"));
}
}
}
// Nothing special about this really, the inner function creates a local
// closure to the local value of abortSignal
const abortSignal = new AbortSignal(abort => {
setTimeout(abort, 5000);
});
// We can send the signal to another thread, what this does is
worker.postMessage({ abortSignal });
self.addEventListener("message", (event) => {
// An abort signal from another thread, entirely up and ready to go, prior
// to firing this event the object was entirely deserialized into the current thread,
// future events won't need to repeatedly deserialize the whole AbortSignal class
// however as it can just be cached
const abortSignal = event.data.abortSignal;
// We can call all methods and such as per normal, as super() was called to initialize
// abortSignal as an EventTarget in this thread, it has become an EventTarget
// also in this thread
abortSignal.addEventListener("abort", () => {
console.log("Aborted!");
});
});