Reputation: 430
I'm using a webworker to calculate coordinates and values belonging to those places. The calculations happen in the background perfectly, keeping the DOM responsive. However, when I send the data from the webworker back to the main thread the DOM becomes unresponsive for a part of the transfer time.
My webworker (sending part):
//calculates happen before; this is the final step to give the calculated data back to the mainthread.
var processProgressGEO = {'cmd':'geoReport', 'name': 'starting transfer to main', 'current': c, 'total': polys}
postMessage(processProgressGEO);
postMessage({
'cmd':'heatmapCompleted',
'heatdata': rehashedMap,
'heatdatacount': p,
'current': c,
'total': polys,
'heatmapPeak': peakHM,
});
self.close();
The variable rehashedMap
in the code snippet above is an object with numerical keys. Each key contains an array
with another object
in.
My mainthread (only the relevant part:)
var heatMaxforAuto = 1000000; //maximum amount of datapoints allowed in the texdata. This takes into account the spread of a singel datapoint.
async function fetchHeatData(){
return new Promise((resolve, reject) => {
var numbercruncher = new Worker('calculator.js');
console.log("Performing Second XHR request:");
var url2 = 'backend.php?datarequest=geodata'
$.ajax({
type: "GET",
url: url2,
}).then(async function(RAWGEOdata) {
data.georaw = RAWGEOdata;
numbercruncher.onmessage = async function(e){
var w = (e.data.current/e.data.total)*100+'%';
if (e.data.cmd === 'geoReport'){
console.log("HEAT: ", e.data.name, end(),'Sec.' );
}else if (e.data.cmd === 'heatmapCompleted') {
console.log("received Full heatmap data: "+end());
data.heatmap = e.data.heatdata;
console.log("heatData transfered", end());
data.heatmapMaxValue = e.data.heatmapPeak;
data.pointsInHeatmap = e.data.heatdatacount;
console.log("killing worker");
numbercruncher.terminate();
resolve(1);
}else{
throw "Unexpected command received by worker: "+ e.data.cmd;
}
}
console.log('send to worker')
numbercruncher.postMessage({'mode':'geo', 'data':data});
}).catch(function(error) {
reject(0);
throw error;
})
});
}
async function makemap(){
let heatDone = false;
if (data.texdatapoints<= heatMaxforAuto){
heatDone = await fetchHeatData();
}else{
var manualHeatMapFetcher = document.createElement("BUTTON");
var manualHeatMapFetcherText = document.createTextNode('Fetch records');
manualHeatMapFetcher.appendChild(manualHeatMapFetcherText);
manualHeatMapFetcher.id='manualHeatTriggerButton';
manualHeatMapFetcher.addEventListener("click", async function(){
$(this).toggleClass('hidden');
heatDone = await fetchHeatData();
console.log(heatDone, 'allIsDone', end());
});
document.getElementById("toggleIDheatmap").appendChild(manualHeatMapFetcher);
}
}
makemap();
The call to the end()
function is needed to calculate the seconds since the start of the webworker. It returns the difference between a global set starttime and the time of calling.
What shows in my console:
HEAT: starting transfer to main 35 Sec. animator.js:44:19
received Full heatmap data: 51 animator.js:47:19
heatData transfered 51 animator.js:49:19
killing worker animator.js:52:19
1 allIsDone 51
The issue: My DOM freezes between the start of the data transfer and the message after receiving the full heatmap data. This is the phase between the first and second message in my console. It takes 16 seconds to transfer, but the DOM only goes unresponsive once for a part of that time. Webworkers can't share data with the mainthread, so a transfer is needed.
Question:
Firstly, how to prevent the freeze of the the DOM during the onmessage
phase of the webworker? Secondly, more out of curiosity: how can this freeze only occur during a part of that phase, as these are triggered by two consecutive steps with nothing going on in between?
What I tried so far:
onmessage
phase; however, there's no such option specified in the documentation (https://developer.mozilla.org/en-US/docs/Web/API/Worker/onmessage) as compared to the postMessage
phase (https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage). Am I missing something here?Upvotes: 1
Views: 1379
Reputation: 1075317
It's understandable you should associate this with the web worker, but it probably doesn't have anything to do with it. I was wrong, it does. I see two possible reasons for the problem:
(We know this is not true for the OP, but may still be relevant for others.) The problem is probably that you have a lot of DOM manipulation to do once you've received the heat map. If you do that in a tight loop that never lets the main thread do anything else, the page will be unresponsive during that time.
If that's what's going on, you have to either find a way to do the DOM manipulation more quickly (sometimes that's possible, other times not) or find a way to carve it up into chunks and process each chunk separately, yielding back to the browser between chunks so that the browser can handle any pending UI work (including rendering the new elements).
You haven't included the DOM work being done with the heat map so it's not really possible to give you code to solve the problem, but the "carving up" would be done by processing a subset of the data and then using setTimeout(fn, 0)
(possibly combined with requestAnimationFrame
to ensure that a repaint has occurred) to schedule continuing the work (using fn
) after briefly yielding to the browser.
If it's really the time spent transferring the data between the worker and the main thread, you might be able to use a transferable object for your heat map data rather than your current object, although doing so may require significantly changing your data structure. With a transferable object, you avoid copying the data from the worker to the main thread; instead, the worker transfers the actual memory to the main thread (the worker loses access to the transferable object, and the main thread gains access to it — all without copying it). For instance, the ArrayBuffer
used by typed arrays (Int32Array
, etc.) is transferable.
If it's really the time spent receiving the data from the worker (and from your experiments it sounds like it is), and using a transferable isn't an option (for instance, because you need the data to be in a format that isn't compatible with a transferable), the only remaining option I can see is to have the worker send the main script smaller blocks of data spaced out enough for the main thread to remain responsive. (Perhaps even sending the data as it becomes available.)
You've described an array of 1,600 entries, where each entry is an array with between 0 and "well over 7,000" objects, each with three properties (with number values). That's over 5.6 million objects. It's no surprise that cloning that data takes a fair bit of time.
Here's an example of the problem you've described:
const workerCode = document.getElementById("worker").textContent;
const workerBlob = new Blob([workerCode], { type: "text/javascript" });
const workerUrl = (window.webkitURL || window.URL).createObjectURL(workerBlob);
const worker = new Worker(workerUrl);
worker.addEventListener("message", ({data}) => {
if ((data && data.action) === "data") {
console.log(Date.now(), `Received ${data.array.length} rows`);
if (data.done) {
stopSpinning();
}
}
});
document.getElementById("btn-go").addEventListener("click", () => {
console.log(Date.now(), "requesting data");
startSpinning();
worker.postMessage({action: "go"});
});
const spinner = document.getElementById("spinner");
const states = [..."▁▂▃▄▅▆▇█▇▆▅▄▃▂▁"];
let stateIndex = 0;
let spinHandle = 0;
let maxDelay = 0;
let intervalStart = 0;
function startSpinning() {
if (spinner) {
cancelAnimationFrame(spinHandle);
maxDelay = 0;
queueUpdate();
}
}
function queueUpdate() {
intervalStart = Date.now();
spinHandle = requestAnimationFrame(() => {
updateMax();
spinner.textContent = states[stateIndex];
stateIndex = (stateIndex + 1) % states.length;
if (spinHandle) {
queueUpdate();
}
});
}
function stopSpinning() {
updateMax();
cancelAnimationFrame(spinHandle);
spinHandle = 0;
if (spinner) {
spinner.textContent = "Done";
console.log(`Max delay between frames: ${maxDelay}ms`);
}
}
function updateMax() {
if (intervalStart !== 0) {
const elapsed = Date.now() - intervalStart;
if (elapsed > maxDelay) {
maxDelay = elapsed;
}
}
}
<div>(Look in the real browser console.)</div>
<input type="button" id="btn-go" value="Go">
<div id="spinner"></div>
<script type="worker" id="worker">
const r = Math.random;
self.addEventListener("message", ({data}) => {
if ((data && data.action) === "go") {
console.log(Date.now(), "building data");
const array = Array.from({length: 1600}, () =>
Array.from({length: Math.floor(r() * 7000)}, () => ({lat: r(), lng: r(), value: r()}))
);
console.log(Date.now(), "data built");
console.log(Date.now(), "sending data");
postMessage({
action: "data",
array,
done: true
});
console.log(Date.now(), "data sent");
}
});
</script>
Here's an example of the worker sending the data in chunks as fast as it can but in separate messages. It makes the page responsive (though still jittery) when receiving the data:
const workerCode = document.getElementById("worker").textContent;
const workerBlob = new Blob([workerCode], { type: "text/javascript" });
const workerUrl = (window.webkitURL || window.URL).createObjectURL(workerBlob);
const worker = new Worker(workerUrl);
let array = null;
let clockTimeStart = 0;
worker.addEventListener("message", ({data}) => {
if ((data && data.action) === "data") {
if (clockTimeStart === 0) {
clockTimeStart = Date.now();
console.log(Date.now(), "Receiving data");
}
array.push(...data.array);
if (data.done) {
console.log(Date.now(), `Received ${array.length} row(s) in total, clock time to receive data: ${Date.now() - clockTimeStart}ms`);
stopSpinning();
}
}
});
document.getElementById("btn-go").addEventListener("click", () => {
console.log(Date.now(), "requesting data");
array = [];
clockTimeStart = 0;
startSpinning();
worker.postMessage({action: "go"});
});
const spinner = document.getElementById("spinner");
const states = [..."▁▂▃▄▅▆▇█▇▆▅▄▃▂▁"];
let stateIndex = 0;
let spinHandle = 0;
let maxDelay = 0;
let intervalStart = 0;
function startSpinning() {
if (spinner) {
cancelAnimationFrame(spinHandle);
maxDelay = 0;
queueUpdate();
}
}
function queueUpdate() {
intervalStart = Date.now();
spinHandle = requestAnimationFrame(() => {
updateMax();
spinner.textContent = states[stateIndex];
stateIndex = (stateIndex + 1) % states.length;
if (spinHandle) {
queueUpdate();
}
});
}
function stopSpinning() {
updateMax();
cancelAnimationFrame(spinHandle);
spinHandle = 0;
if (spinner) {
spinner.textContent = "Done";
console.log(`Max delay between frames: ${maxDelay}ms`);
}
}
function updateMax() {
if (intervalStart !== 0) {
const elapsed = Date.now() - intervalStart;
if (elapsed > maxDelay) {
maxDelay = elapsed;
}
}
}
<div>(Look in the real browser console.)</div>
<input type="button" id="btn-go" value="Go">
<div id="spinner"></div>
<script type="worker" id="worker">
const r = Math.random;
self.addEventListener("message", ({data}) => {
if ((data && data.action) === "go") {
console.log(Date.now(), "building data");
const array = Array.from({length: 1600}, () =>
Array.from({length: Math.floor(r() * 7000)}, () => ({lat: r(), lng: r(), value: r()}))
);
console.log(Date.now(), "data built");
const total = 1600;
const chunks = 100;
const perChunk = total / chunks;
if (perChunk !== Math.floor(perChunk)) {
throw new Error(`total = ${total}, chunks = ${chunks}, total / chunks has remainder`);
}
for (let n = 0; n < chunks; ++n) {
postMessage({
action: "data",
array: array.slice(n * perChunk, (n + 1) * perChunk),
done: n === chunks - 1
});
}
}
});
</script>
Naturally it's a tradeoff. The total clock time spent receiving the data is longer the smaller the chunks; the smaller the chunks, the less jittery the page is. Here's really small chunks (sending each of the 1,600 arrays separately):
const workerCode = document.getElementById("worker").textContent;
const workerBlob = new Blob([workerCode], { type: "text/javascript" });
const workerUrl = (window.webkitURL || window.URL).createObjectURL(workerBlob);
const worker = new Worker(workerUrl);
let array = null;
let clockTimeStart = 0;
worker.addEventListener("message", ({data}) => {
if ((data && data.action) === "data") {
if (clockTimeStart === 0) {
clockTimeStart = Date.now();
}
array.push(data.array);
if (data.done) {
console.log(`Received ${array.length} row(s) in total, clock time to receive data: ${Date.now() - clockTimeStart}ms`);
stopSpinning();
}
}
});
document.getElementById("btn-go").addEventListener("click", () => {
console.log(Date.now(), "requesting data");
array = [];
clockTimeStart = 0;
startSpinning();
worker.postMessage({action: "go"});
});
const spinner = document.getElementById("spinner");
const states = [..."▁▂▃▄▅▆▇█▇▆▅▄▃▂▁"];
let stateIndex = 0;
let spinHandle = 0;
let maxDelay = 0;
let intervalStart = 0;
function startSpinning() {
if (spinner) {
cancelAnimationFrame(spinHandle);
maxDelay = 0;
queueUpdate();
}
}
function queueUpdate() {
intervalStart = Date.now();
spinHandle = requestAnimationFrame(() => {
updateMax();
spinner.textContent = states[stateIndex];
stateIndex = (stateIndex + 1) % states.length;
if (spinHandle) {
queueUpdate();
}
});
}
function stopSpinning() {
updateMax();
cancelAnimationFrame(spinHandle);
spinHandle = 0;
if (spinner) {
spinner.textContent = "Done";
console.log(`Max delay between frames: ${maxDelay}ms`);
}
}
function updateMax() {
if (intervalStart !== 0) {
const elapsed = Date.now() - intervalStart;
if (elapsed > maxDelay) {
maxDelay = elapsed;
}
}
}
<div>(Look in the real browser console.)</div>
<input type="button" id="btn-go" value="Go">
<div id="spinner"></div>
<script type="worker" id="worker">
const r = Math.random;
self.addEventListener("message", ({data}) => {
if ((data && data.action) === "go") {
console.log(Date.now(), "building data");
const array = Array.from({length: 1600}, () =>
Array.from({length: Math.floor(r() * 7000)}, () => ({lat: r(), lng: r(), value: r()}))
);
console.log(Date.now(), "data built");
array.forEach((chunk, index) => {
postMessage({
action: "data",
array: chunk,
done: index === array.length - 1
});
});
}
});
</script>
That's building all the data and then sending it, but if building the data times time, interspersing building and sending it may make the page responsiveness smoother, particularly if you can send the inner arrays in smaller pieces (as even sending ~7,000 objects still causes jitter, as we can see in the last example above).
Each entry in your main array is an array of objects with three numeric properties. We could instead send Float64Array
s with those values in lat
/lng
/value
order, using the fact they're transferable:
const workerCode = document.getElementById("worker").textContent;
const workerBlob = new Blob([workerCode], { type: "text/javascript" });
const workerUrl = (window.webkitURL || window.URL).createObjectURL(workerBlob);
const worker = new Worker(workerUrl);
let array = null;
let clockTimeStart = 0;
worker.addEventListener("message", ({data}) => {
if ((data && data.action) === "data") {
if (clockTimeStart === 0) {
clockTimeStart = Date.now();
}
const nums = data.array;
let n = 0;
const entry = [];
while (n < nums.length) {
entry.push({
lat: nums[n++],
lng: nums[n++],
value: nums[n++]
});
}
array.push(entry);
if (data.done) {
console.log(Date.now(), `Received ${array.length} row(s) in total, clock time to receive data: ${Date.now() - clockTimeStart}ms`);
stopSpinning();
}
}
});
document.getElementById("btn-go").addEventListener("click", () => {
console.log(Date.now(), "requesting data");
array = [];
clockTimeStart = 0;
startSpinning();
worker.postMessage({action: "go"});
});
const spinner = document.getElementById("spinner");
const states = [..."▁▂▃▄▅▆▇█▇▆▅▄▃▂▁"];
let stateIndex = 0;
let spinHandle = 0;
let maxDelay = 0;
let intervalStart = 0;
function startSpinning() {
if (spinner) {
cancelAnimationFrame(spinHandle);
maxDelay = 0;
queueUpdate();
}
}
function queueUpdate() {
intervalStart = Date.now();
spinHandle = requestAnimationFrame(() => {
updateMax();
spinner.textContent = states[stateIndex];
stateIndex = (stateIndex + 1) % states.length;
if (spinHandle) {
queueUpdate();
}
});
}
function stopSpinning() {
updateMax();
cancelAnimationFrame(spinHandle);
spinHandle = 0;
if (spinner) {
spinner.textContent = "Done";
console.log(`Max delay between frames: ${maxDelay}ms`);
}
}
function updateMax() {
if (intervalStart !== 0) {
const elapsed = Date.now() - intervalStart;
if (elapsed > maxDelay) {
maxDelay = elapsed;
}
}
}
<div>(Look in the real browser console.)</div>
<input type="button" id="btn-go" value="Go">
<div id="spinner"></div>
<script type="worker" id="worker">
const r = Math.random;
self.addEventListener("message", ({data}) => {
if ((data && data.action) === "go") {
for (let n = 0; n < 1600; ++n) {
const nums = Float64Array.from(
{length: Math.floor(r() * 7000) * 3},
() => r()
);
postMessage({
action: "data",
array: nums,
done: n === 1600 - 1
}, [nums.buffer]);
}
}
});
</script>
That dramatically reduces the clock time to receive the data, while keeping the UI fairly responsive.
Upvotes: 2