A Typescript job system By drenwick on 2019-11-20 13:23:00

I've been thinking a lot lately about engine development, and I've decided that I want to create a set of utilities for making browser game, or simply browser app development easier.

The first of these is a job system.
Now, it's a fairly well-known fact that JavaScript doesn't have threads, so the parallelization benefits of jobifying code don't apply here. However multithreading is not the only benefit to jobification.

App architecture

If you were developing a browser game, how would you handle the repetitive nature of game code?
You could use ECS, or mimic Unity's Object Composition model, but even then you need some core event loop.
Perhaps you'd use setInterval() to simply run an update function every 16ms. That works, but it's quite clunky, and places a hard lock on frame timing and tickspeed.

My solution to this is a job system I've chosen to call Workaholic.

Workaholic

Workaholic operates on Job objects. A Job is simply a wrapper around a function that also stores state and a numerical status.

The function is the "task" to run within the Job, and follows the footprint

task: (job: Job) => IYieldResult;

If you're not familiar with Typescript function footprints, this means that the function takes a single argument of type Job, and returns an IYieldResult.

In Typescript, interfaces do not exist at runtime, they merely act as a schema for any object.
In our case, an IYieldResult has the following schema:

interface IYieldResult {
    type: string;
    job: Job;
    status: number;
    counterLabel: string;
}

Currently,type is unused, and should always equal "yield".
job is of course the Job object that is yielding.
status is the current status of the job.
counterLabel is only relevant under certain job statuses, and is used to tell Workaholic that this job should now wait until an atomic counter with the given label reaches 0. This label is also stored in the Job, but is provided here for ease of access.

Workaholic maintains 3 queues of jobs, for 3 priorities: High, Medium, and Low.
Workaholic operates on a loop, and will continue running provided there is at least 1 job available to run, terminating as soon as no jobs are available.

Each loop, Workaholic will attempt to find a "valid" job, ie. one that is currently available to run.
A job is available to run if one of the following is true:

  • Its status is STATUS_YIELDED or STATUS_UNINITIALIZED
  • Its status is STATUS_HELD, and the atomic counter referenced by its counterLabel is at 0
  • Its status is STATUS_WAITING, and the datetime specified by its waitUntil has passed Workaholic will start with the high priority queue, checking the first Job in the queue against the above conditions, and will progress to the next priority.
    If there are jobs queued, but none are available to run, Workaholic will throw an error, as this is an invalid state that should ideally never be reached.

In the future I will likely look at moving unavailable jobs to the back of the queue when checking availability, so that every job in the queue is checked before moving to the next priority.

When a job is run, the task function of the job is called, passing the job as the only argument.
This function should, if necessary, read the job's state object and perform whatever behaviour it is intended to run, then return an IYieldResult, typically with:

return job.yield(status, counterLabel);

If no counterLabel is given, "" is used.
This will set the job's status and, if the status is STATUS_HELD, STATUS_CANCELLED, or STATUS_COMPLETED, will also set the job's counterLabel.

The IYieldResult is then consumed by Workaholic, which decides what to do with the job based on the status:

  • STATUS_UNINITIALIZED - A job should never have this status after running. A job with this status after running is removed from the queue.
  • STATUS_COMPLETED and STATUS_CANCELLED - The job has finished, it should be removed from the queue and, if the job has a counterLabel set, the atomic counter with that label should be decremented.
  • STATUS_YIELDED - The job has reached an acceptable breakpoint, Workaholic will push the job to the back of the queue, and run the next available job.
  • STATUS_HELD - The job is waiting on an atomic counter. Workaholic will push the job to the back of the queue, and the job will not be run again until the atomic counter specified by the job's counterLabel reaches 0.
  • STATUS_WAITING - The job is waiting for a specific timestamp to be reached. Workaholic will push the job to the back of the queue, and the job will not be run again until the datetime specified by the job's waitUntil has passed.

Priorities

In most cases, medium priority should be used.
High priority should be reserved for timing-critical jobs, such as rendering.
Low priority should be reserved for timing-independent jobs that can safely be put behind other jobs.

Yielding

Update 2019-11-20: I've discovered Generators and the yield statement in JavaScript. This might be an option for creating cleanly yield-able jobs.

Unfortunately, I could not find or think of a method for returning to the yield point in task functions. Javascript, and subsequently Typescript, does not allow gotos or manipulation of the instruction pointer, so there is no way to "resume" a function from a given point.

As a result I have made the decision that, after yielding, a job's task function will be called from the start when it is next run, and it is up to the writer of the task function to store any necessary information in the job's internal state, and resume from there.

Workaholic makes use of JavaScript generator functions. These are special functions, denoted with the function* syntax (note the asterisk). In Typescript, they return an IterableIterator<T>, where T is the type of values returned. In Workaholic, T is IYieldResult.

The advantage to using generator functions is proper yield support at the language level. This allows Workaholic's jobs to function very similarly to Unity Coroutines.

To demonstrate this, I've taken one of the most common looping yield patterns and implemented it in this system:

function task(job: Job): IterableIterator<IYieldResult> {
    for (let i = 0; i < job.internalState.list.length; i++) {
        let item = job.internalState.list[i];
        // Do some processing on item
        yield job.yield(Job.STATUS_YIELDED);
    }
}

Notice that this function never actually returns. This is because, in Workaholic, yielding or returning a value of undefined is considered equivalent to returning job.yield(Job.STATUS_COMPLETED), and in JavaScript, a function automatically returns undefined if it reaches the end of the function without returning.

Thanks to this default value, a "singleton" job, ie. a job intended to only run once, can simply never yield or return and, once execution reaches the end of the function, it will be assumed complete.

Speaking of singletons, Workaholic is intended to operate as a singleton. It is a class, however should be accessed via Workaholic.Instance, rather than new Workaholic(). This allows jobs to schedule other jobs when needed, as opposed to explicitly passing the Workaholic instance around.
This is not a strict requirement however, and multiple instances of Workaholic can be created, provided you develop your own pattern to handle running the different instances.

Initialization

Obviously, some initial setup has to be done outside of the job system. A job can't start the job system, because the system would need to already be running in order for the job to run.

My planned approach to initialization is that you will add initial jobs to the Workaholic system after creating it, but before starting it.
This means that initial setup may look something like this:

import { Job, Workaholic } from "workaholic";

Workaholic.Instance.scheduleJob(new Job(j => {
    // Initialization logic here
}), Workaholic.PRIORITY_HIGH);
Workaholic.Instance.run();

This initialization job could then initialize other systems and schedule them in the job system.

But what about Web Workers?

Web Workers are a relatively old method for running code in a separate background thread. Unfortunately there are a few major issues with Web Workers for my purposes:

  • Web Workers run outside of the window global context. This means that interacting with the DOM in any way, including interacting with a canvas' context, is difficult and generally frowned upon.
  • Web Workers take a Javascript file, not a Javascript function. This means that starting a Web Worker at runtime to run a function is not possible.

Now this doesn't mean that Web Workers can't be used for Workaholic. You can post messages to a Web Worker, and include JSON-serialized data in those messages. This means I could have a separate script that handles the job loop part of Workaholic, then spawn that in a Web Worker and pass jobs to it via posting messages.

I haven't entirely ruled out this option, and intend to research it further. But, for the time being, I'll be sticking with running on the main thread as there are still possible issues with this approach.

Firstly, data posted to a Web Worker is copied, not shared, meaning any changes the Web Worker makes to a job object (such as changing its internal state, changing its status, etc.) would need to be posted back to the main thread and applied to the object if the main thread needed to access that.

What, When, Why?

Workaholic is currently available in a sort of "alpha" state over on my GitHub.

Your idea sucks and I need to tell you it sucks

The entire purpose of this post is to gather feedback, thoughts, and ideas on my plan, so if you'd like to provide your thoughts, contact me via:

  • GitHub, link like 4 lines up
  • Email, link in the sidebar
  • Twitter, link in the sidebar
  • Discord, @Kaho#8557