multithreading
Version:
⚡ Multithreading functions in JavaScript to speedup heavy workloads, designed to feel like writing vanilla functions.
865 lines (838 loc) • 25.4 kB
JavaScript
var _Promise;
(_Promise = Promise).withResolvers ?? (_Promise.withResolvers = function withResolvers() {
var a,
b,
c = new this(function (resolve, reject) {
a = resolve;
b = reject;
});
return {
resolve: a,
reject: b,
promise: c
};
});
var _ref;
(_ref = import.meta).resolve ?? (_ref.resolve = async function (specifier, parentUrl) {
const {
Module: {
createRequire
}
} = await import('node:module');
const require = createRequire(import.meta.url);
return require.resolve(specifier, {
...(parentUrl ? {
paths: [parentUrl]
} : {})
});
});
const EVENTS = Symbol.for("events");
class EventTarget {
addEventListener(type, callback, options) {
let events = this[EVENTS].get(type);
if (!events) this[EVENTS].set(type, events = []);
events.push(callback);
}
dispatchEvent(event) {
event.target = event.currentTarget = this;
if (this["on" + event.type]) {
try {
this["on" + event.type](event);
} catch (err) {
console.error(err);
}
}
const list = this[EVENTS].get(event.type);
if (list == null) return false;
list.forEach(handler => {
try {
handler.call(this, event);
} catch (err) {
console.error(err);
}
});
return false;
}
removeEventListener(type, callback, options) {
let events = this[EVENTS].get(type);
if (events) {
const index = events.indexOf(callback);
if (index !== -1) events.splice(index, 1);
}
}
constructor() {
Object.defineProperty(this, EVENTS, {
value: new Map()
});
}
}
class Event {
composedPath() {
throw new Error("Method not implemented.");
}
initEvent(type, bubbles, cancelable) {
throw new Error("Method not implemented.");
}
preventDefault() {
// throw new Error("Method not implemented.");
}
stopImmediatePropagation() {
// throw new Error("Method not implemented.");
}
stopPropagation() {
// throw new Error("Method not implemented.");
}
constructor(type, target = null) {
this.bubbles = false;
this.cancelBubble = false;
this.cancelable = false;
this.composed = false;
this.currentTarget = null;
this.defaultPrevented = false;
this.eventPhase = 0;
this.isTrusted = false;
this.returnValue = false;
this.srcElement = null;
this.target = null;
this.NONE = 0;
this.CAPTURING_PHASE = 1;
this.AT_TARGET = 2;
this.BUBBLING_PHASE = 3;
this.type = type;
this.timeStamp = Date.now();
}
}
var _globalThis;
(_globalThis = globalThis).Worker ?? (_globalThis.Worker = (async () => {
const {
default: threads
} = await import('node:worker_threads');
const {
URL,
pathToFileURL,
fileURLToPath
} = await import('node:url');
const WORKER = Symbol.for("worker");
return class Worker extends EventTarget {
postMessage(message, options) {
this[WORKER].postMessage(message, options);
}
terminate() {
this[WORKER].terminate();
}
constructor(url, options = {}) {
super();
this.onmessage = null;
this.onmessageerror = null;
this.onerror = null;
const {
name,
type
} = options;
url += "";
let mod;
if (/^data:/.test(url)) {
mod = url;
} else {
const baseUrl = pathToFileURL(process.cwd() + "/");
mod = fileURLToPath(new URL(url, baseUrl));
}
const worker = new threads.Worker(new URL(import.meta.url), {
workerData: {
mod,
name,
type
}
});
Object.defineProperty(this, WORKER, {
value: worker
});
worker.on("message", data => {
const event = new Event("message");
event.data = data;
this.dispatchEvent(event);
});
worker.on("error", error => {
error.type = "error";
this.dispatchEvent(error);
});
worker.on("exit", () => {
this.dispatchEvent(new Event("close"));
});
}
};
})());
class ErrorEvent extends Event {
constructor(init) {
super("error");
this.colno = 0;
this.filename = "";
this.lineno = 0;
this.message = "";
Object.assign(this, init);
}
}
var WorkerGlobalScopePromise = Worker instanceof Promise ? (async () => {
const {
default: threads
} = await import('node:worker_threads');
return class WorkerGlobalScope extends EventTarget {
postMessage(data, transferList) {
threads.parentPort.postMessage(data, transferList);
}
// Emulates https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/close
close() {
process.exit();
}
};
})() : undefined;
class PromiseRejectionEvent extends Event {
constructor(init) {
super("unhandledrejection");
Object.assign(this, init);
}
}
Worker instanceof Promise && (async () => {
const {
default: threads
} = await import('node:worker_threads');
const {
default: VM
} = await import('node:vm');
const WorkerGlobalScope = await WorkerGlobalScopePromise;
return threads.isMainThread || workerThread();
async function workerThread() {
let {
mod,
name,
type
} = threads.workerData;
if (!mod) return await Worker;
// turn global into a mock WorkerGlobalScope
const self = global.self = global;
// enqueue messages to dispatch after modules are loaded
let queue = [];
function flushQueue() {
const buffered = queue;
queue = null;
buffered.forEach(event => {
self.dispatchEvent(event);
});
}
threads.parentPort.on("message", data => {
const event = new Event("message");
event.data = data;
if (queue == null) self.dispatchEvent(event);else queue.push(event);
});
threads.parentPort.on("error", error => {
error.type = "Error";
self.dispatchEvent(new ErrorEvent({
error
}));
});
process.on("unhandledRejection", (reason, promise) => {
self.dispatchEvent(new PromiseRejectionEvent({
reason,
promise
}));
});
process.on("uncaughtException", (error, origin) => {
self.dispatchEvent(new ErrorEvent({
error
}));
});
let proto = Object.getPrototypeOf(global);
delete proto.constructor;
Object.defineProperties(WorkerGlobalScope.prototype, proto);
proto = Object.setPrototypeOf(global, new WorkerGlobalScope());
["postMessage", "addEventListener", "removeEventListener", "dispatchEvent"].forEach(fn => {
proto[fn] = proto[fn].bind(global);
});
global.name = name;
const isDataUrl = /^data:/.test(mod);
if (type === "module") {
import(mod).catch(err => {
if (isDataUrl && err.message === "Not supported") {
console.warn("Worker(): Importing data: URLs requires Node 12.10+. Falling back to classic worker.");
return evaluateDataUrl(mod, name);
}
console.error(err);
}).then(flushQueue);
} else {
try {
if (isDataUrl) {
evaluateDataUrl(mod, name);
} else {
require(mod);
}
} catch (err) {
console.error(err);
}
Promise.resolve().then(flushQueue);
}
}
function evaluateDataUrl(url, name) {
const {
data
} = parseDataUrl(url);
return VM.runInThisContext(data, {
filename: "worker.<" + (name || "data:") + ">"
});
}
function parseDataUrl(url) {
let [m, type, encoding, data] = url.match(/^data: *([^;,]*)(?: *; *([^,]*))? *,(.*)$/) || [];
if (!m) throw Error("Invalid Data URL.");
if (encoding) switch (encoding.toLowerCase()) {
case "base64":
data = Buffer.from(data, "base64").toString();
break;
default:
throw Error('Unknown Data URL encoding "' + encoding + '"');
}
return {
type,
data
};
}
})();
const Function = 2;
const Other = 3;
const Init = 4;
const Claim = 5;
const Unclaim = 6;
const Return = 7;
const ClaimAcceptance = 8;
const ClaimRejection = 9;
const Invocation = 12;
const Synchronization = 13;
const Error$1 = 14;
const Variables = "a";
const Args = "b";
const EventType = "c";
const EventValue = "d";
const WasType = "e";
const Name = "f";
const YieldList = "g";
const InvocationId = "h";
const Value = "i";
const ProcessId = "j";
const DebugEnabled = "k";
const Type = "l";
const AbsolutePath = "m";
const Code = "n";
const Pid = "o";
const UserFunction = "p";
const Internal = "q";
const ShareableNameMap = "r";
const ValueClaimMap = "s";
const ValueInUseCount = "t";
const serialize = variables => {
const serializedVariables = {};
for (const [key, value] of Object.entries(variables)) {
if (typeof value === "function") {
serializedVariables[key] = {
[WasType]: Function,
value: value.toString()
};
} else {
serializedVariables[key] = value;
}
}
return serializedVariables;
};
const deserialize = variables => {
const deserializedVariables = {};
for (const [key, value] of Object.entries(variables)) {
if (typeof value === "object" && WasType in value) {
switch (value[WasType]) {
default:
deserializedVariables[key] = value;
break;
}
} else {
deserializedVariables[key] = value;
}
}
return deserializedVariables;
};
function replaceContents(originalObject, newValue) {
if (Array.isArray(originalObject)) {
// Clear the array and push new values
originalObject.length = 0;
newValue.forEach(item => originalObject.push(item));
} else if (originalObject instanceof Map) {
// Clear the map and set new key-value pairs
originalObject.clear();
newValue.forEach(([key, value]) => originalObject.set(key, value));
} else if (originalObject instanceof Set) {
// Clear the set and add new values
originalObject.clear();
newValue.forEach(item => originalObject.add(item));
} else if (typeof originalObject === "object" && originalObject !== null) {
// Clear the object and assign new properties
for (const key in originalObject) {
delete originalObject[key];
}
Object.assign(originalObject, newValue);
} else {
throw new Error("Unsupported object type");
}
}
const colorGray = "\x1b[90m";
const colorRed = "\x1b[31m";
const colorCyan = "\x1b[36m";
const colorReset = "\x1b[39m";
function getErrorPreview(error, code, pid) {
const [message, ...serializedStackFrames] = error.stack.split("\n");
// Check if error originates from inside the user function
const stackFrame = serializedStackFrames.find(frame => frame.includes("data:application/javascript;base64"));
if (!stackFrame) {
return error.stack;
}
// Split at the comma of data:application/javascript;base64,
const [functionPart, tracePart] = stackFrame.split(",");
const [encodedBodyPart, lineNumberStr, columnNumberStr] = tracePart.split(":");
const lineNumber = parseInt(lineNumberStr);
const columnNumber = parseInt(columnNumberStr);
const codeLines = code.split(/\r?\n/);
const amountOfPreviousLines = Math.min(3, lineNumber - 1);
const amountOfNextLines = 2;
const previewLines = codeLines.slice(lineNumber - (amountOfPreviousLines + 1), lineNumber + amountOfNextLines);
const previousLineLength = codeLines[lineNumber - 1].trimEnd().length - columnNumber;
previewLines.splice(amountOfPreviousLines + 1, 0, colorRed + " ".repeat(columnNumber - 1) + "^".repeat(previousLineLength) + " " +
// "Error" +
message + colorGray);
const index = serializedStackFrames.indexOf(stackFrame);
serializedStackFrames[index] = ` at ${colorCyan}<Thread_${pid}>${colorReset}\n` + colorGray + " " + previewLines.join("\n ") + colorReset;
// return message + "\n" + serializedStackFrames.slice(0, index + 1).join("\n");
return message.split(":").slice(1).join(":").trim() + "\n" + serializedStackFrames.slice(0, index + 1).join("\n");
}
function announceOwnership(queue, valueName, value) {
// Get first worker in queue
const worker = queue[0];
worker.postMessage({
[EventType]: ClaimAcceptance,
[EventValue]: {
[Name]: valueName,
[Value]: value
}
});
}
function setupWorkerListeners(worker, context, valueOwnershipQueue, invocationQueue, workerPool, workerCodeString, pid) {
worker.onmessage = e => {
switch (e.data[EventType]) {
case Return:
const invocationId = e.data[EventValue][InvocationId];
const value = e.data[EventValue][Value];
const {
resolve
} = invocationQueue.get(invocationId);
resolve(value);
invocationQueue.delete(invocationId);
break;
case Claim:
{
const valueName = e.data[EventValue];
const value = context[valueName];
const queue = valueOwnershipQueue.get(value);
queue.push(worker);
if (queue.length === 1) {
announceOwnership(queue, valueName, value);
}
break;
}
case Unclaim:
{
const data = e.data[EventValue];
const valueName = data[Name];
const value = context[valueName];
const ownershipQueue = valueOwnershipQueue.get(value);
// Check if worker is first in queue
if (ownershipQueue[0] !== worker) break;
const newValue = data[Value];
// Update local value with new value
replaceContents(value, newValue);
// Synchronize all other workers with new value
for (const otherWorker of workerPool) {
if (otherWorker === worker) continue;
worker.postMessage({
[EventType]: Synchronization,
[EventValue]: {
[Name]: valueName,
[Value]: newValue
}
});
}
ownershipQueue.shift();
if (ownershipQueue.length > 0) {
announceOwnership(ownershipQueue, valueName, value);
}
break;
}
case Error$1:
{
const error = e.data[EventValue];
error.message = getErrorPreview(error, workerCodeString, pid);
error.stack = ""; // Deno doesn't like custom stack traces, use message instead
invocationQueue.forEach(({
reject
}) => reject(error));
}
}
};
}
async function parseImport(name) {
const resolved = await import.meta.resolve(name);
if (resolved.startsWith("http://") || resolved.startsWith("https://") || resolved.startsWith("npm:") || resolved.startsWith("node:")) return resolved;
// Check if running in browser
const isBrowser = typeof window !== "undefined";
if (isBrowser) {
// If running in browser, return the resolved URL
return resolved;
}
const {
pathToFileURL
} = await import('node:url');
return pathToFileURL(resolved).toString();
}
async function parseTopLevelYieldStatements(fnStr) {
const bodyStart = fnStr.indexOf("{") + 1;
const code = fnStr.slice(bodyStart, -1).trim();
const lines = code.split(/(?:\s*[;\r\n]+\s*)+/);
const yieldList = [];
// let insideCommentBlock = false;
for (const line of lines) {
// Skip comments
// if (line.startsWith("/*")) insideCommentBlock = true;
// if (line.endsWith("*/") || line.startsWith("*/")) {
// insideCommentBlock = false;
// continue;
// }
// if (insideCommentBlock || line.startsWith("//")) continue;
// If line is not a yield statement, stop parsing
if (!line.includes("yield ")) continue;
const yielded = line.split("yield ")[1];
if (/^["'`]/.test(yielded)) {
const name = yielded.slice(1, -1);
yieldList.push({
[Type]: "import",
[Name]: name,
[AbsolutePath]: await parseImport(name)
});
} else {
yieldList.push({
[Type]: "variable",
[Name]: yielded
});
}
}
return yieldList;
}
const inlineWorker = `Promise.withResolvers ??= function withResolvers() {
var a,
b,
c = new this(function (resolve, reject) {
a = resolve;
b = reject;
});
return {
resolve: a,
reject: b,
promise: c
};
};
const Init = 4;
const Claim = 5;
const Unclaim = 6;
const Return = 7;
const ClaimAcceptance = 8;
const Invocation = 12;
const Synchronization = 13;
const Error\$1 = 14;
const Variables = "a";
const Args = "b";
const EventType = "c";
const EventValue = "d";
const WasType = "e";
const Name = "f";
const YieldList = "g";
const InvocationId = "h";
const Value = "i";
const ProcessId = "j";
const Type = "l";
const AbsolutePath = "m";
const Code = "n";
const UserFunction = "p";
const deserialize = variables => {
const deserializedVariables = {};
for (const [key, value] of Object.entries(variables)) {
if (typeof value === "object" && WasType in value) {
switch (value[WasType]) {
default:
deserializedVariables[key] = value;
break;
}
} else {
deserializedVariables[key] = value;
}
}
return deserializedVariables;
};
function replaceContents(originalObject, newValue) {
if (Array.isArray(originalObject)) {
// Clear the array and push new values
originalObject.length = 0;
newValue.forEach(item => originalObject.push(item));
} else if (originalObject instanceof Map) {
// Clear the map and set new key-value pairs
originalObject.clear();
newValue.forEach(([key, value]) => originalObject.set(key, value));
} else if (originalObject instanceof Set) {
// Clear the set and add new values
originalObject.clear();
newValue.forEach(item => originalObject.add(item));
} else if (typeof originalObject === "object" && originalObject !== null) {
// Clear the object and assign new properties
for (const key in originalObject) {
delete originalObject[key];
}
Object.assign(originalObject, newValue);
} else {
throw new Error("Unsupported object type");
}
}
// Wrap in self-invoking function to avoid polluting the global namespace
// and avoid name collisions with the user defined function
globalThis.__internal = function () {
const state = {
[UserFunction]: function* () {},
[Code]: ""
};
// const originalLog = console.log;
// console.log = (...args) => {
// originalLog(\`\${cyan}[Thread_\${pid}]\${reset}\`, ...args);
// };
// const originalError = console.error;
// console.error = (...args) => {
// originalError(\`\${red}[Thread_\${pid}]\${reset}\`, ...args);
// };
globalThis.\$claim = async function \$claim(value) {
const valueName = shareableNameMap.get(value);
valueInUseCount[valueName]++;
// First check if the variable is already (being) claimed
if (valueClaimMap.has(valueName)) {
return valueClaimMap.get(valueName).promise;
}
valueClaimMap.set(valueName, Promise.withResolvers());
postMessage({
[EventType]: Claim,
[EventValue]: valueName
});
return valueClaimMap.get(valueName).promise;
};
globalThis.\$unclaim = function \$unclaim(value) {
const valueName = shareableNameMap.get(value);
if (--valueInUseCount[valueName] > 0) return;
valueClaimMap.delete(valueName);
postMessage({
[EventType]: Unclaim,
[EventValue]: {
[Name]: valueName,
[Value]: value
}
});
};
let yieldList = [];
const shareableNameMap = new WeakMap();
// ShareableValues that are currently (being) claimed
const valueClaimMap = new Map();
// ShareableValues that are currently in use by
// one of the invokations of the user defined function
const valueInUseCount = {};
function handleClaimAcceptance(data) {
const valueName = data[Name];
replaceContents(globalThis[valueName], data[Value]);
valueClaimMap.get(valueName).resolve();
}
async function handleInit(data) {
data[ProcessId];
yieldList = data[YieldList];
state[Code] = data[Code];
const variables = deserialize(data[Variables]);
for (const key in variables) {
const value = variables[key];
if (value instanceof Object) {
shareableNameMap.set(value, key);
valueInUseCount[key] = 0;
}
}
Object.assign(globalThis, variables);
}
async function handleInvocation(data) {
const gen = state[UserFunction](...data[Args]);
let isDone = false;
let returnValue = undefined;
let isFirstImport = true;
for (const yieldItem of yieldList) {
if (yieldItem[Type] === "import") {
const resolved = await import(yieldItem[AbsolutePath]);
if (isFirstImport) {
await gen.next();
isFirstImport = false;
}
const result = await gen.next(resolved);
if (result.done) {
isDone = true;
returnValue = result.value;
break;
}
} else {
const result = await gen.next();
if (result.done) {
isDone = true;
returnValue = result.value;
break;
}
}
}
if (!isDone) {
const result = await gen.next();
returnValue = result.value;
}
postMessage({
[EventType]: Return,
[EventValue]: {
[InvocationId]: data[InvocationId],
[Value]: returnValue
}
});
}
async function handleSynchronization(data) {
const valueName = data[Name];
replaceContents(globalThis[valueName], data[Value]);
}
// On unhandled promise rejection
self.addEventListener("unhandledrejection", event => {
event.preventDefault();
postMessage({
[EventType]: Error\$1,
[EventValue]: event.reason
});
close();
});
// On uncaught exception
self.addEventListener("error", event => {
event.preventDefault();
postMessage({
[EventType]: Error\$1,
[EventValue]: event.error
});
close();
});
globalThis.onmessage = async e => {
switch (e.data[EventType]) {
case Init:
handleInit(e.data[EventValue]);
break;
case Invocation:
handleInvocation(e.data[EventValue]);
break;
case ClaimAcceptance:
handleClaimAcceptance(e.data[EventValue]);
break;
case Synchronization:
handleSynchronization(e.data[EventValue]);
break;
}
};
return state;
}();
`;
async function $claim(value) {}
function $unclaim(value) {}
const workerPools = new WeakMap();
const valueOwnershipQueue = new WeakMap();
function threaded(configOrFn, maybeFn) {
const config = {
debug: false,
maxThreads: typeof navigator !== "undefined" ? navigator.hardwareConcurrency : 4
};
let fn;
if (typeof configOrFn === "function") {
fn = configOrFn;
} else {
Object.assign(config, configOrFn);
fn = maybeFn;
}
let context = {};
const workerPool = [];
const invocationQueue = new Map();
workerPools.set(fn, workerPool);
let invocationCount = 0;
const init = (async () => {
const fnStr = fn.toString();
const yieldList = await parseTopLevelYieldStatements(fnStr);
// @ts-ignore - Call function without arguments
const gen = fn();
for (const yieldItem of yieldList) {
// @ts-ignore - Pass empty object to prevent TypeError when user has destructured import
const result = await gen.next({});
if (yieldItem[Type] !== "variable") continue;
context[yieldItem[Name]] = result.value;
}
for (const key in context) {
// Initialize the ownership queue
valueOwnershipQueue.set(context[key], []);
}
const workerCode = [inlineWorker, `__internal.${UserFunction} = ${fnStr};`];
const serializedVariables = serialize(context);
for (const [key, value] of Object.entries(serializedVariables)) {
if (value[WasType] !== Function) continue;
// globalthis. is necessary to prevent duplicate variable names when the function is named
workerCode.unshift(`globalThis.${key} = ${value.value};`);
delete serializedVariables[key];
}
const workerCodeString = workerCode.join("\r\n");
for (let i = 0; i < config.maxThreads; i++) {
const worker = new (await Worker)(encodeURI("data:application/javascript;base64," + btoa(workerCodeString)), {
type: "module"
});
setupWorkerListeners(worker, context, valueOwnershipQueue, invocationQueue, workerPool, workerCodeString, i);
workerPool.push(worker);
worker.postMessage({
[EventType]: Init,
[EventValue]: {
[ProcessId]: i,
[YieldList]: yieldList,
[Variables]: serializedVariables,
[Code]: workerCodeString,
[DebugEnabled]: config.debug
}
});
}
})();
const wrapper = async (...args) => {
await init;
const worker = workerPool[invocationCount % config.maxThreads];
const pwr = Promise.withResolvers();
invocationQueue.set(invocationCount, pwr);
worker.postMessage({
[EventType]: Invocation,
[EventValue]: {
[InvocationId]: invocationCount++,
[Args]: args
}
});
return pwr.promise;
};
wrapper.dispose = () => {
for (const worker of workerPool) {
worker.terminate();
}
workerPools.delete(fn);
invocationQueue.forEach(pwr => pwr.reject("Disposed"));
invocationQueue.clear();
};
return wrapper;
}
export { $claim, $unclaim, threaded };