Note: The tasklets proposal spawned the Comlink library. It’s an RPC library that extracts the ergonomics feature from tasklets.
Problem
Most modern development platforms favor a multi-threaded approach by default. Typically, the split for work is:
- Main thread: UI manipulation, event/input routing
- Background thread(s): All other work
iOS and Android native platforms, for example, restrict (by default) the usage of any APIs not critical to UI manipulation on the main thread.
The web has support for this model via WebWorkers
. However:
postMessage()
is clunky and difficult to use- WebWorkers can be expensive (e.g: ~5MB per thread in Chrome)
As a result, worker adoption has been minimal at best and the default model remains to put all work on the main thread. In order to encourage developers to move work off the main thread, we propose a more ergonomic solution with Tasklets.
Tasklets API
Note: APIs described below are just strawperson proposals. We think they're pretty cool but there's always room for improvement.
TL;DR:
// fetcher.js
export async function fetchDataObject() {
const resp = await fetch(/*...*/);
const json = await resp.json();
return doSomeExpensiveProcessing(json);
}
// app.js
const fetcher = await tasklets.addModule('fetcher.js');
const json = await fetcher.fetchDataObject();
// ...
Problem
Today, many uses of WebWorkers follow a structure similar to:
const worker = new Worker('worker.js');
worker.postMessage({'cmd': 'fetch', 'url': 'example.com'});
// worker.js
self.addEventListener('message', (evt) => {
switch (evt.data.cmd) {
case 'fetch':
performFetch(evt.data.url);
break;
default:
throw Error(`Invalid command: ${evt.data.cmd}`);
}
});
A switch statement in the worker then typically routes messages to the correct API. The Tasklets API exposes this behavior natively, by allowing a class within one context to expose methods to other contexts.
Exported classes and functions
The code below shows a basic example of the Tasklets API.
// speaker.js
export class Speaker {
sayHello(message) {
return `Hello ${message}`;
}
}
export function add(a, b) {
return a + b;
}
const module = await tasklets.addModule('speaker.js');
const speaker = await new module.Speaker();
console.log(await speaker.sayHello('world!')); // Logs "Hello world!".
console.log(await module.add(2, 3)); // Logs '5'.
A few things are happening here, so let's step through them individually.
const module = await tasklets.addModule('speaker.js');
This loads the module into the tasklet's JavaScript global scope. This is similar to invoking
new Worker('speaker.js')
. Also similar to WebWorkers, the tasklet would be around for the lifetime
of the page.
However, when this module is loaded, the browser will look into the script you imported and find all
of the exported classes and functions. In the above example we only exported the Speaker
class and the add
function.
addModule
returns a "namespace" object, for which the browser creates "proxy" constructors and
functions:
module.Speaker.toString() == 'function Speaker() { [native code] }';
module.Speaker.prototype.sayHello.toString() == 'function sayHello() { [native code] }';
Note: `[native code]`
It doesn't have to be `[native code]` above, this is just to give people the idea that this class on the main thread side, is synthesized by the browser.
All of the functions now return promises, for example:
const p = speaker.sayHello('world!');
Supported parameter and return values
All arguments and return values go through the structured clone algorithm, which means that functions can only accept certain kinds of objects, for example:
speaker.sayHello(document.body); // Causes a DOMException as HTMLBodyElement cannot be cloned.
As for transferrables, we think that every parameter and return value should be transferred by default, e.g:
const arr = new Int8Array(100);
api.someFunction(arr);
// arr has now been transferred, you can't access it.
If web developers need copying behavior instead, they are able to make a copy in the call, e.g:
api.someFunction(new Int8Array(arr));
Into the weeds
We'll quickly go through some more detailed cases here. We haven't fully formed everything here yet.
APIs Exposed
We believe that all asynchronous APIs which are exposed in workers should be exposed in the
TaskWorkletGlobalScope
(that means Sync XHR for example would not be exposed). Additionally
Atomics.wait
would throw a TypeError
.
We want this characteristic as we'd like to potentially run multiple tasklets in the same thread. Some implementations have a high overhead per thread, but a smaller cost per JavaScript environment.
Events
We think it's very compelling to have classes inside the TaskWorkletGlobalScope
to be able to
extend from EventTarget
, for example:
// api.js
export class FetchManager extends EventTarget {
performFetch(url) {
const response = await fetch(url);
if (response.get('Content-Type').startsWith('image')) {
this.dispatchEvent('image-fetched');
}
}
}
const api = await tasklets.addModule('api.js');
const fetchManager = await new api.FetchManager();
fetchManager.addEventListener('image-fetched', () => {
// maybe update some UI?
});
fetchManager.performFetch('cats.png');
The data provided with the event is structured cloned, similar to arguments and return values.
Things not fully thought out yet
This is a very early stage proposal, so it has a few problems that we'll need to sort out.
Returning references
It will undoubtedly be useful to return instances of objects created in the tasklet. The complete async nature of the proxies, however, make reasoning harder and handling a bit awkward.
// calendarTasklet.js
export class Calendar {
constructor(credentials) { /* ... */ }
nextEvents(limit = 10) {
/* ... */
return arrayOfCalendarEntries;
}
generateShareLink(id) { /* ... */ }
/* ... */
}
export class CalendarEntry {
constructor(calender) { /* ... */ }
get id() { /* ... */ }
/* ... */
}
// main.js
const {Calendar} = await tasklets.addModule('calendarTasklet.js');
const myCalendar = await new Calendar(myCredentials);
const events = await myCalendar.nextEvents();
events.map(event => myCalender.generateShareLink(event.id)); // !!!
The last line is potentially problematic. event.id
has been promisified. This line would create a
lot of message passing under the hood: Every invocation of the map()
callback would have to wait
for event.id
to resolve just pass a message back to the tasklet to invoke generateShareLink
.
This can be solved by the author by architecting their tasklet appropriately. A method like
myCalender.generateShareLinks(events)
for example would be much more efficient.
What gets exported?
Consider this tasklet code:
import {A} from 'a.js';
export class B extends A {}
What gets exported?
¯\_(ツ)_/¯
We aren't sure. Options:
- Export everything down to the
Object
prototype. - Only export things declared in the class (i.e. everything from
B
, but notA
) - Don't do magic – rely on explicit listing of things to expose (á la
static get exportedProperties() { return [/*...*/]; }
) - WebIDL