Skip to content

Instantly share code, notes, and snippets.

@xkizer
Last active April 28, 2019 15:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save xkizer/d63ac72ef48720c2066fbc9d3580ea90 to your computer and use it in GitHub Desktop.
Save xkizer/d63ac72ef48720c2066fbc9d3580ea90 to your computer and use it in GitHub Desktop.
let globalVar = 10;
function globalFunction(param1: Record<string, unknown>) {
const innerObj = {
key: 'value',
obj: {} as Record<string, unknown>
};
// Threaded functions will run in a new thread every time they're called. They behave like
// async functions, in that they return a Promise which is resolved when the thread exits
// gracefully, and rejected when the thread exits unexpectedly. Additionally, it supports
// the use of await keyword to wait for promises to resolve.
//
// This function takes a message port as the second argument. This is used to send messages
// between a thread and another. An async function can use as many ports as it needs, and these
// can be passed to the thread in many ways, including through another message port.
//
// The behaviour of message ports is not defined here
//
// The "threaded" keyword defines this function as threaded, much as the "async" keyword defines
// an async function as async.
threaded function threadSafeFn(obj1: Record<string, any>, msgPort: Port<any>) {
// Read-only access, all are fine
let newNum = globalVar + 90;
const newObj = {...innerObj, key2: 'value 2'};
// Direct modification
newObj.key = 'new value'; // Fine, we're modifying our local copy
newObj.obj.key = 'value'; // TypeError. Even though newObj is in the local context, newObj.obj was created outside the local context
obj1.modified = true; // Works fine, obj1 was passed directly to function
obj1.prop.modified = true; // TypeError. obj1 was passed directly to function, but not obj1.prop
param1.modified = true; // TypeError. param1 belongs to higher context
innerObj.key = 'another value'; // TypeError. param1 belongs to higher context
globalVar = 30; // TypeError. globalVar belongs to higher context
// Indirect modification
setGlobalVar(90); // TypeError. this will cause the modification of an external variable
setProp(param1, 'key', 'new value'); // TypeError. This will modify an external object
// Exiting unexpectedly
throw new Error('Something terrible happened'); // uncaught error will terminate the thread with error
Promise.reject(new Error('Another thing happened')); // Uncaught promise rejection will terminate the thread with error
const users = await getUsers(); // Just like an await in a normal async function
// Even though the users object is returned by a function defined outside the local context,
// it was created within that function or within a function that that function called.
// This means it was created in the context of this thread, making this thread the owner.
// So we can modify it.
users.push({id: 'random', name: 'User'});
// Listen for messages from any another thread. msg can be anything, including
// objects. Same modification rules apply as above. Message can be anything, inclusing another
// message port!
msgPort.onMessage(msg => {
// Do something with msg
});
// Send a message to all other threads listening on this message port. No other thread can modify
// the object sent by this thread. Message can be anything, including another message port!
msgPort.postMessage({
a: 1,
b: 2
});
// Performing async
setTimeout(() => {
// This will never be executed, as the function returns (and kills this thread)
// before this has a chance to execute
newNum = 120;
});
getUsers().then((newUsers) => {
// This will never happen, as the thread would have died before the promise has
// a chance to resolve. If you want to make sure this happens, await it.
users.concat(newUsers);
}).catch((e) => {
// This will also never happen.
console.error(e);
});
const users$ = getUsers();
// Returning a threaded function signifies the end of life for the thread. This will
// terminate the thread, and transfer the context to the calling thread. In this case
// we have returned a function. Anything can be returned (strings, undefined, object, etc).
// Unless this returned function is also defined as threaded, calling it will run it in the
// thread of the caller, not in this thread.
//
// Returning kills the thread. All pending asynchrnous actions will be killed. Any unresolved
// promise will be cancelled. If the promise, or a reference to it, is returned, it will be
// rejected on the next tick.
//
// Any thread explicitly started by this function or another function that it called (and so on)
// will be killed
return function () {
// Behavioir depends on who would be calling this returned function
// If this function is called on the same thread as the creator of innerObj,
// everything is fine. Else, it will be TypeError.
innerObj.key = 'yet another value';
// This will succeed in the thread that originally called the "threadSafeFn"
// because the context of "threadSafeFn" is transferred to the calling thread
// after it exits. This will fail in any other thread, except in a few exceptions.
// One such exceptions is if the calling thread (thread B) is also the child thread
// of another thread (thread A), and thread B returned, thereby transferring its
// context (which now includes the context of this thread) to the parent.
newObj.key2 = 'a new value';
// A reference to a promise whose execution started in this thread. This promise
// has been cancelled when the thread exited. Therefore, this promise will be
// rejected immediately
return users$;
}
}
}
function setGlobalVar(value: number) {
globalVar = value;
}
function setProp(obj: Record<string, unknown>, key: string, value: unknown) {
obj[key] = value;
}
declare class Port<T> {
onMessage(cb: (msg: T) => void): void;
postMessage(msg: T): void;
};
async function getUsers(): Promise<User[]> {
const usersStr = await fetch('/users');
const users: User[] = await usersStr.json();
return users;
}
type User = {
name: string,
id: string
};
@xkizer
Copy link
Author

xkizer commented Apr 23, 2019

Motivation

JavaScript is often touted as single-threaded. While this is true on a very high level, this is not true when you come down a bit lower. Many JS implementations use multi-threading for a lot of asynchronous tasks. JavaScript is just good at hiding the multi-threading from the programmer.

There has been some "experiments" with multi-threading, or things that look like it, in different implementations of JS. This include Node.js's child_process.fork, Node.js's cluster, and, more recently, the web worker API (and Node.js's experimental worker_thread). While these have their applications and solve a dew problems, we have to admit that at some point we have to stop treating JavaScript like a language for children, and introduce some kind of real multi-threading support.

This draft is my (mostly unrefined) thoughts on how this problem could be solved, while maintaining a degree of safety.

Use-cases

Trying to fit the processing of blocking calculations within 16.66ms is a common theme in both user code and a number of popular libraries. This is necessary to avoid dropped frames. Usually measures, such as breaking tasks into smaller subtasks and processing them in different processing frames, are adopted.

These could be simplified a lot by running these time-consuming functions in a separate thread and not worrying about blocking the screen repaint.

  • WebGL
  • Long-running event handlers
  • Virtual DOM diffing

Thread-safe functions in JavaScript

  • Has read-only access to the variables in the definition context, like every other function in JavaScript
  • Cannot modify the higher context:
    • Cannot reassign variables outside the local context: all variables defined outside the local context of the function behave as if they had been defined using the “const” keyword
    • Cannot modify objects outside the local context: all objects defined outside the local context behave as if Object.freeze had been called on them, except that trying to modify the object throws an error instead of failing silently
    • In general, a threaded function cannot modify any memory located outside its own memory block, unless a pointer to that memory block has been passed to it as argument (in the form of an object)
  • Can modify objects passed to it directly as call parameters. These behave as if they had been defined in the local context of the function.
    • Can only modify this object, not other objects linked to this object through properties
  • Each function call will not create a new physical thread, but something more like a goroutine in golang.

Known issues:

  • Non-atomic operations will always be a risk. A non-atomic read may end up reading parts of two versions of an object.
    • Could be mitigated by discipline and following best-practices. For example by using atomic updates (create a new object, and then reassign)
  • … add more ...

@d3x0r
Copy link

d3x0r commented Apr 25, 2019

Even read only variables may be garbage collected by the another thread.

There are no locks on the heaps which allocate objects, and are designed for speed as a single threaded process. This requires each 'thread' be given its own full heap/context/isolate. Specifying a script to fill the new thread content with some code entirely separates the heaps, and doesn't have to figure out how to serialize functions in the current module to ship to the new context.

The separation of heaps requires, then, serializing messages through some sort of communication protocol.... which, for many things just adds overhead. But some things, like vertex maps, large goemetry buffers, etc can be shared with SharedArrayBuffer (on chrome, and behind a user enablable flag on firefox I guess)

Note that SharedArrayBuffer was disabled by default in all major browsers on 5 January, 2018 in response to Spectre. Chrome re-enabled it in v67 on platforms where its site-isolation feature is enabled to protect against Spectre-style vulnerabilities.

I don't know what else to mention; maybe something will come to me later.

I understand the desire for threads. Just give me a simple 'ThreadTo( function )' and some sort of 'Relinquish()' (Sleep/sched_yield/...);
But, I've come to understand, for 25 years JS engines have not had to implement threads. It's actually been really nice in some cases to know that it IS single threaded, and things won't just change async to the JS code. I have a node addon which has lots of threads for various reasons, but having a single threaded usage of them is nice; although sometimes I do wish I could just return from the current point in the JS stack and run other JS to return to here... Generator functions sort of do that; but then you have complicated calling of those.

'cluster' and fork are processes, not threads (yes, arguably because a process has a thread, it is also a thread but not in the same memory... ). YOu can also start a node

worker-threads can just take a code snippet... and doesn't HAVE To be an external resource.

new Worker(filename[, options])
If options.eval is true, this is a string containing JavaScript code rather than a path.

I'm not a fan of service workers in the browser; just really feels like someone spent a long time on how to make it the most convoluted possible. And they keep presisting even though there's no sw.js on the server... it just activates whatever old version it had laying around.

@jokeyrhyme
Copy link

You may be interested in experimental work in WebKit / JavaScriptCore to support threads: https://webkit.org/blog/7846/concurrent-javascript-it-can-work/

@xkizer
Copy link
Author

xkizer commented Apr 28, 2019

Thanks @jokeyrhyme, that's enlightening.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment