Today I am making mapgen4 multithreaded. I'm trying to blog more often, so I'm putting my notes here instead of keeping them to myself.
Multithreaded Javascript? Yes, with Web Workers. I've known about these for a while but I've never used them until now. The basics:
- Web Workers are more like separate processes rather than threads. They have their own memory. Some browsers support shared memory but it's not something I can count on right now. Introduction and reference.
- Workers can communicate by sending a message to another worker. The message is a data-only object (no functions or resources). Details.
- You can also transfer data to another worker, if it's a contiguous array. This is particularly useful for large arrays of numbers such as WebGL data or, in my case, mapgen4 data including the Voronoi mesh. It avoids a copy.
I find that I learn best by reading a little bit and then playing with something. So I decided to make a simple worker and try it out.
First problem: how do I make Web Workers work with my build system? I know all the cool kids use Webpack and Parcel and Rollup but I'm still using Browserify like a neanderthal. It works well for me, and I often want to run things in Node, so I don't plan to switch right now. Normally you tell the browser to start a web worker by giving it the name of a JS file. Browserify+Budo produces a single output file and I think I need a separate file for each worker. Options:
- Abandon budo and use browserify/watchify directly.
- Use the same output file, but detect whether it's a worker or not by looking at the Window object.
- Use webworkify, which packages up modules into blobs and then starts a worker on the blob.
I decided to use webworkify.
const WebWorkify = require('webworkify'); const w = WebWorkify(require('./worker.js'));
Here's the main program:
w.addEventListener('message', function (event) { console.log('from webworker', event); }); w.postMessage({test: 5});
and here's worker.js:
console.log('WORKER INIT'); module.exports = function (self) { self.addEventListener('message',function (event) { console.log('received from main', event); self.postMessage({reply: 8}); }); };
What happens?
WORKER INIT WORKER INIT received from main {test: 5} from webworker {reply: 8}
Great! I have a worker. But what's going on with the INIT
being there twice? It's because of the way webworkify works. It loads the module into the main thread and then sends the module to the worker, which runs it again. This means I need to be careful with running things at load time, including constructing globals.
The next thing I wanted to learn was transferring arrays back and forth. The map generator uses some large arrays, and I'd like to be able to transfer them instead of copy them.
- The Web Workers API takes a second parameter with a list of arrays to transfer.
- What I didn't realize at first was that you also have to send the array in the first parameter. Otherwise there's no way to get to it.
- You can't send the typed array itself; you have to send its buffer.
- You receive a buffer, not a typed array, so you need to construct a new typed array from it.
- Once you transfer the array, its length will be set to 0 on the sending side.
let myArray = new Int8Array(300); myArray[0] = 55; console.log('Before send: length is', myArray.length); w.postMessage({buffer: myArray.buffer}, [myArray.buffer]); console.log('After send: length is', myArray.length);
and here's worker.js:
module.exports = function (self) { self.addEventListener('message',function (event) { let array = new Int8Array(event.data.buffer); console.log('received from main', array[0]); }); };
Before send: length is 300 After send: length is 0 received from main 55
Great! I can send an array from the main thread to the worker thread. I can do the same the other way around. However I can't put the buffer back into the original typed array:
myArray[0].buffer = event.data.buffer;
It turns out the buffer field is read-only. So I have to create a new typed array with the buffer I got back from the worker:
myArray = new Int8Array(event.data.buffer);
I think those are the main things I needed to learn.
Stepping back, here's my rough plan for using Web Workers:
- The map will be generated in the worker thread. It stays in the worker thread.
- The mesh will be generated in the main thread and copied into the worker thread. It's needed for both the map (worker thread) and for the renderer (main thread).
- The geometry will be passed from the main thread to the worker thread, filled in, and passed back to the main thread for rendering.
- The input and output will be in the main thread.
Whenever the input parameters are changed (by drawing on the map), the main thread will send a message to the worker thread to recalculate the map. Once it's finished, it will send a message back saying it's ready. The main thread will send the geometry buffer to the worker thread, the worker thread will fill it, and the worker will send it back. Then the main thread will render it.
It's a bit more involved than this because there's both elevation and river data, and elevation and river geometry, and soon there will also be biome data. Also, once I have a single worker implemented, I want to implement multiple workers, as some of the work can be run in parallel. I think a single worker is a good first step.
Post a Comment