WebVR Part 2: Web Workers and Browser Edge Computing
WebVR is bringing virtual reality to our browsers, but how do we make sure we have enough computing power to deliver a good experience? How do we make the most of multi-core, hyperthreaded processors?
In part two of our series, Toptal Full-stack Developer Michael Cole explains the basics of browser edge computing and the role of web workers.
WebVR is bringing virtual reality to our browsers, but how do we make sure we have enough computing power to deliver a good experience? How do we make the most of multi-core, hyperthreaded processors?
In part two of our series, Toptal Full-stack Developer Michael Cole explains the basics of browser edge computing and the role of web workers.
Michael is an expert full-stack web engineer, speaker, and consultant with over two decades of experience and a degree in computer science.
Expertise
PREVIOUSLY AT
Our astrophysics simulator is powered by a potent rocket fuel mixture of hope, hype, and access to new computing power.
We can access this computing power with web workers. If you’re already familiar with web workers, you might want to grok the code and jump ahead to WebAssembly, which will be discussed in the next article.
JavaScript became the most installed, learned, and accessible programming language because it brought some incredibly useful features to the static web:
- Single-threaded event loop
- Asynchronous code
- Garbage collection
- Data without rigid typing
Single-threaded means we don’t have to worry much about the complexity and pitfalls of multithreaded programming.
Asynchronous means we can pass around functions as parameters to be executed later - as events in the event loop.
These features and Google’s massive investment in the performance of Chrome’s V8 JavaScript engine, along with good developer tools made JavaScript and Node.js the perfect choice for microservice architectures.
Single-threaded execution is also great for browser makers who have to securely isolate and run all your spyware-infested browser tab runtimes across a computer’s multiple cores.
Question: How can one browser tab access all your computer’s CPU cores?
Answer: Web workers!
Web Workers and Threading
Web workers use the event loop to asynchronously pass messages between threads, bypassing many of the potential pitfalls of multithreaded programming.
Web workers can also be used to move computation out of the main UI thread. This lets the main UI thread handle clicks, animation, and managing the DOM.
Let’s look at some code from the project’s GitHub repo.
If you remember our architecture diagram, we delegated the entire simulation to nBodySimulator
so it manages the web worker.
If you remember from the intro post, nBodySimulator
has a step()
function called every 33ms of the simulation. It calls calculateForces()
, then updates the positions and repaints.
// Methods from class nBodySimulator
/**
* The simulation loop
*/
start() {
// This is the simulation loop. step() calls visualize()
const step = this.step.bind(this)
setInterval(step, this.simulationSpeed)
}
/**
* A step in the simulation loop.
*/
async step() {
// Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip.
if (this.ready()) {
await this.calculateForces()
} else {
console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`)
}
// Remove any "debris" that has traveled out of bounds - this is for the button
this.trimDebris()
// Now Update forces. Reuse old forces if we skipped calculateForces() above
this.applyForces()
// Ta-dah!
this.visualize()
}
The web worker’s contribution is to host a separate thread for the WebAssembly. As a low-level language, WebAssembly only understands integers and floats. We can’t pass JavaScript Strings or Objects - just pointers to “linear memory.” So for convenience, we package our “bodies” into an array of floats: arrBodies
.
We’ll come back to this in our article on WebAssembly and AssemblyScript.
Here, we are creating a web worker to run calculateForces()
in a separate thread. This happens below as we marshal the bodies’ (x, y, z, mass) into an array of floats arrBodies
, and then this.worker.postMessage()
to the worker. We return a promise the worker will resolve later in this.worker.onMessage()
.
// src/nBodySimulator.js
/**
* Use our web worker to calculate the forces to apply on our bodies.
*/
calculateForces() {
this.workerCalculating = true
this.arrBodies = []
// Copy data to array into this.arrBodies
...
// return promise that worker.onmessage will fulfill
const ret = new Promise((resolve, reject) => {
this.forcesResolve = resolve
this.forcesReject = reject
})
// postMessage() to worker to start calculation
// Execution continues in workerWasm.js worker.onmessage()
this.worker.postMessage({
purpose: 'nBodyForces',
arrBodies: this.arrBodies,
})
// Return promise for completion
// Promise is resolve()d in this.worker.onmessage() below.
// Once resolved, execution continues in step() above - await this.calculateForces()
return ret
}
From the top, the browser GET’s index.html
which runs main.js
which creates a new nBodySimulator()
and in its constructor we find setupWebWorker()
.
// nBodySimulator.js
/**
* Our n-body system simulator
*/
export class nBodySimulator {
constructor() {
this.setupWebWorker()
...
Our new nBodySimulator()
lives in the main UI thread, and setupWebWorker()
creates the web worker by fetching workerWasm.js
from the network.
// nBodySimulator.js
// Main UI thread - Class nBodySimulator method
setupWebWorker() {
// Create a web worker (separate thread) that we'll pass the WebAssembly module to.
this.worker = new Worker("workerWasm.js");
// Console errors from workerWasm.js
this.worker.onerror = function (evt) {
console.log(`Error from web worker: ${evt.message}`);
}
...
At new Worker()
, the browser fetches and runs workerWasm.js
in a separate JavaScript runtime (and thread), and starts passing messages.
Then, workerWasm.js
gets into the grit of WebAssembly but it’s really just a single this.onmessage()
function containing a switch()
statement.
Remember that web workers can’t access the network, so the main UI thread must pass the compiled WebAssembly code into the web worker as a message resolve("action packed")
. We’ll deep dive into that in the next post.
// workerWasm.js - runs in a new, isolated web worker runtime (and thread)
this.onmessage = function (evt) {
// message from UI thread
var msg = evt.data
switch (msg.purpose) {
// Message: Load new wasm module
case 'wasmModule':
// Instantiate the compiled module we were passed.
...
// Tell nBodySimulation.js we are ready
this.postMessage({ purpose: 'wasmReady' })
return
// Message: Given array of floats describing a system of bodies (x, y, z, mass),
// calculate the Grav forces to be applied to each body
case 'nBodyForces':
...
// Do the calculations in this web worker thread synchronously
const resultRef = wasm.nBodyForces(dataRef);
...
// See nBodySimulation.js’ this.worker.onmessage
return this.postMessage({
purpose: 'nBodyForces',
arrForces
})
}
}
Jumping back to setupWebWorker()
method of our nBodySimulation
class, we listen for the web worker’s messages using the same onmessage() + switch()
pattern.
// Continuing class nBodySimulator’s setupWebWorker() in the main UI thread
// Listen for messages from workerWasm.js postMessage()
const self = this
this.worker.onmessage = function (evt) {
if (evt && evt.data) {
// Messages are dispatched by purpose
const msg = evt.data
switch (msg.purpose) {
// Worker’s reply that it has loaded the wasm module we compiled and sent. Let the magic begin!
// See postmessage at the bottom of this function.
case 'wasmReady':
self.workerReady = true
break
// wasm has computed forces for us
// Response to postMessage() in nBodySimulator.calculateForces() above
case 'nBodyForces':
self.workerCalculating = false
// Resolve await this.calculateForces() in step() above
if (msg.error) {
self.forcesReject(msg.error)
} else {
self.arrForces = msg.arrForces
self.forcesResolve(self.arrForces)
}
break
}
}
}
...
In this example, calculateForces()
creates and returns a promise saving resolve()
and reject()
as self.forcesReject()
and self.forcesResolve()
.
This way, worker.onmessage()
can resolve the promise created in calculateForces()
.
If you remember our simulation loop’s step()
function:
/**
* This is the simulation loop.
*/
async step() {
// Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip.
if (this.ready()) {
await this.calculateForces()
} else {
console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`)
}
This lets us skip calculateForces()
and reapply the previous forces if the WebAssembly is still calculating.
This step function fires every 33ms. If the web worker is not ready, it applies and paints the previous forces. If a particular step’s calculateForces()
works past the start of the next step, the next step will apply forces from the previous step’s position. Those previous forces are either similar enough to look “right” or happening so fast as to be incomprehensible to the user. This trade-off increases the perceived performance - even if it’s not recommended for actual human space travel.
Could this be improved? Yes! An alternative to setInterval
for our step function is
requestAnimationFrame()
.
For my purpose, this is good enough to explore Canvas, WebVR, and WebAssembly. If you believe something could be added or swapped out, feel free to comment or get in touch.
If you’re looking for a modern, complete physics engine design, check out the open-source Matter.js.
What About WebAssembly?
WebAssembly is a portable binary that works across browsers and systems. WebAssembly can be compiled from many languages (C/C++/Rust, etc.). For my own purpose, I wanted to try AssemblyScript - a language based on TypeScript, which is a language based on JavaScript, because it’s turtles all the way down.
AssemblyScript compiles TypeScript code to a portable “object code” binary, to be “just-in-time” compiled into a new high-performance runtime called Wasm. When compiling the TypeScript into the .wasm
binary, it’s possible to create a .wat
human-readable “web assembly text” format describing the binary.
The last part of setupWebWorker()
begins our next post on WebAssembly and shows how to overcome web worker’s limitations on access to the network. We fetch()
the wasm
file in the main UI thread, then “just-in-time” compile it to a native wasm module. We postMessage()
that module as a message to the web worker:
// completing setupWebWorker() in the main UI thread
…
// Fetch and compile the wasm module because web workers cannot fetch()
WebAssembly.compileStreaming(fetch("assembly/nBodyForces.wasm"))
// Send the compiled wasm module to the worker as a message
.then(wasmModule => {
self.worker.postMessage({ purpose: 'wasmModule', wasmModule })
});
}
}
workerWasm.js
then instantiates that module so we can call its functions:
// wasmWorker.js - web worker onmessage function
this.onmessage = function (evt) {
// message from UI thread
var msg = evt.data
switch (msg.purpose) {
// Message: Load new wasm module
case 'wasmModule':
// Instantiate the compiled module we were passed.
wasm = loader.instantiate(msg.wasmModule, importObj) // Throws
// Tell nBodySimulation.js we are ready
this.postMessage({ purpose: 'wasmReady' })
return
case 'nBodyForces':
...
// Do the calculations in this thread synchronously
const resultRef = wasm.nBodyForces(dataRef);
This is how we access WebAssembly functionality. If you’re looking at the unredacted source code, you’ll notice the ...
is a bunch of memory management code to get our data into dataRef
and our results out of resultRef
. Memory management in JavaScript? Exciting!
We will dig into WebAssembly and AssemblyScript in more detail in the next post.
Execution Boundaries and Shared Memory
There’s something else to talk about here, which is execution boundaries and shared memory.
The WebAssembly article is very tactical, so here is a good place to talk about runtimes. JavaScript and WebAssembly are “emulated” runtimes. As implemented, every time we cross a runtime boundary, we are making a copy of our body data (x, y, z, mass). While copying memory is cheap, this isn’t a mature high-performance design.
Luckily, a lot of very smart people are working on creating specs and implementations of these cutting-edge browser technologies.
JavaScript has SharedArrayBuffer to create a shared memory object that would eliminate postMessage()
s copy from (2) -> (3) on the call and onmessage()
s copy of the arrForces
from (3) -> (2) on the result.
WebAssembly also has a Linear Memory design that could host a shared memory for the nBodyForces()
call from (3) -> (4). The web worker could also pass a shared memory for the result array.
Join us next time for an exciting journey into JavaScript memory management.
Understanding the basics
What is edge computing?
Edge computing is the concept of using the computing power of all edge devices running your app before renting new ones.
What is a web worker?
Web workers are pieces of JavaScript code running independently in the background and thereby not affecting the performance of the page.
How is data passed to/from web workers?
By using onMessage() and postMessage() functions of the window and web worker.
How is data passed to/from WebAssembly functions?
Data must be integers, floats, or an index into a linear memory.
Michael Cole
Dallas, United States
Member since September 10, 2014
About the author
Michael is an expert full-stack web engineer, speaker, and consultant with over two decades of experience and a degree in computer science.
Expertise
PREVIOUSLY AT