Coroutines
- Andy Mikulski
- Technology , Data
- September 23, 2023
Nemo vel ad consectetur namut rutrum ex, venenatis sollicitudin urna. Aliquam erat volutpat. Integer eu ipsum sem. Ut bibendum lacus vestibulum maximus suscipit. Quisque vitae nibh iaculis neque blandit euismod.
Lorem ipsum dolor sit amet consectetur adipisicing elit. Nemo vel ad consectetur ut aperiam. Itaque eligendi natus aperiam? Excepturi repellendus consequatur quibusdam optio expedita praesentium est adipisci dolorem ut eius!
Usage
import Coroutine from 'coroutine.ts';
// Coroutines support generator functions
function* myLongRunningAsyncFunction() {
let i = 0;
while (i < 500){ i++; yield null; }
return ["list", "of", "results"]
}
// Here's an example of defining, running, and waiting for a coroutine.
function runAsyncThing() {
// 1. Define the coroutine
const co = new Coroutine<null|string[]>(() => myLongRunningAsyncFunction());
// 2. Await the promise to detect when the coroutine is done
try { await co.promise; }
catch {
// There was an error running the coroutine!
return null;
}
// 3. Check the value returned by the coroutine object for the result
if (co.value) {
// co.value === ["list", "of", "results"]
}
}
Implementation
export enum CoroutineType {
RequestIdleCallback,
RequestAnimationFrame,
SetTimeout,
}
export default class Coroutine<T> {
private generator: IterableIterator<T>;
private lastValue: T;
public stopped: boolean = false;
public complete: boolean = false;
private resolve: (coroutine: this) => void;
private reject: (result: { error: Error; coroutine: Coroutine<T> }) => void;
private promise_: Promise<Coroutine<T>>;
public timeRunning: number = 0;
public get promise() {
return this.promise_;
}
public get value() {
return this.lastValue;
}
constructor(private fn: () => IterableIterator<any>) {
this.generator = fn();
this.stopped = false;
this.promise_ = new Promise<this>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
CoroutineManager.Instance.add(this);
}
public run = () => {
if (this.stopped || this.complete) {
return;
}
const start = Date.now();
let result;
try {
result = this.generator.next();
} catch (err) {
this.reject({ error: err, coroutine: this });
}
this.lastValue = result?.value === undefined ? this.lastValue : result.value;
if (result?.value === undefined) {
this.complete = true;
this.reject({ error: new Error("No return value found!"), coroutine: this });
return;
}
if (result.done) {
this.complete = true;
this.resolve(this);
}
this.timeRunning += Date.now() - start;
};
public reset = () => {
this.generator = this.fn();
this.lastValue = undefined;
this.stopped = false;
this.complete = false;
this.promise_ = new Promise<this>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
return this;
};
public stop = () => {
this.stopped = true;
return this;
};
}
export class CoroutineManager {
public static Instance = new CoroutineManager();
private readonly rICOptionsObj = { timeout: 1000 / 24 };
private coroutines: Coroutine<any>[] = [];
private maxRunning: number = 5;
public get count(): number {
return this.coroutines.length;
}
public type: CoroutineType = CoroutineType.RequestIdleCallback;
private running = true;
// this should be at MOST 1000 / 60.
// anything > 60 will look smooth, but will take long.
// 90 here is arbitrary and found after some testing
public frameBudgetMs = 1000 / 90;
private constructor() {
this.start();
}
public start = () => {
this.running = true;
this.update();
};
public stop = () => {
this.running = false;
};
public add = (coroutine: Coroutine<any>) => {
this.coroutines.push(coroutine);
return this;
};
public remove = (co: Coroutine<any>) => {
this.coroutines = this.coroutines.filter((x) => x !== co);
return this;
};
public update = () => {
if (!this.running) {
return;
}
const start = Date.now();
let runningCount = 0;
for (const coroutine of this.coroutines) {
if (!coroutine.complete && !coroutine.stopped) {
runningCount++;
if (runningCount > this.maxRunning) {
break;
}
coroutine.run();
const cost = Date.now() - start;
if (cost > this.frameBudgetMs) {
console.warn(`Coroutines exceeded frame budget! (${cost} / ${this.frameBudgetMs})`);
// we have exceeded our time budget; stop updating coroutines
break;
}
}
}
// Prune complete coroutines
this.coroutines = this.coroutines.filter((x) => !x.complete);
// Queue next update based on the type of coroutine
switch (this.type) {
case CoroutineType.RequestIdleCallback:
requestIdleCallback(this.update, this.rICOptionsObj);
break;
case CoroutineType.RequestAnimationFrame:
requestAnimationFrame(this.update);
break;
case CoroutineType.SetTimeout:
setTimeout(this.update, 1);
break;
default:
throw new Error(`Invalid coroutine type provided: "${this.type}"`);
}
};
}