1

I'm a beginner using web workers and I'm dealing with a little problem.

I'm creating several workers to process audio buffers and draw its waveform on a offscreen canvas:

main thread:

// foreach file
 let worker = new Worker('js/worker.js');
 let offscreenCanvas = canvas.transferControlToOffscreen();
 
 worker.addEventListener('message', e => {
    if (e.data == "finish") {
           worker.terminate();
        }
    });

 worker.postMessage({canvas: offscreenCanvas, pcm: pcm}, [offscreenCanvas]);
// end foreach

worker:

importScripts('waveform.js');

self.addEventListener('message', e => {
    let canvas = e.data.canvas;
    let pcm = e.data.pcm;
    
    displayBuffer(canvas, pcm); // 2d draw function over canvas
    self.postMessage('finish');
});

The result is strange. The thread is terminate immediately at displayBuffer() finish, but as you can see in the profiling, the GPU is still rendering the canvas, which sometimes causes that render crash. No error, only black canvas.

enter image description here

I'm running over Chrome 83.0

Kaiido
  • 123,334
  • 13
  • 219
  • 285

1 Answers1

2

That is to be expected, "committing" to the main thread is not done synchronously, but when the browser dims it appropriate (i.e often at next painting frame) so when you call worker.terminate(), the actual painting may not have occurred yet, and won't ever.

Here is an live repro for curious:

const worker_script = `
self.addEventListener('message', (evt) => {
  const canvas = evt.data;
  const ctx = canvas.getContext( "2d" );
  // draw a simple grid of balck squares
  for( let y = 0; y<canvas.height; y+= 60 ) {
    for( let x = 0; x<canvas.width; x+= 60 ) {
      ctx.fillRect(x+10,y+10,40,40);  
    }
  }
  self.postMessage( "" );
});`;
const worker_blob = new Blob( [ worker_script ], { type: "text/javascript" } );
const worker_url = URL.createObjectURL( worker_blob );
const worker = new Worker( worker_url );
worker.onmessage = (evt) => worker.terminate();
const canvas_el = document.querySelector( "canvas" );
const off_canvas = canvas_el.transferControlToOffscreen();
worker.postMessage( off_canvas, [ off_canvas ] );
<h3>Run this snippet a few times (in Chrome), sometimes it will work, sometimes it won't.</h3>
<canvas width="500" height="500"></canvas>

To circumvent this, there is an OffscreenCanvasRendering2DContext.commit() method that you could call before you terminate your worker, but it's currently hidden under chrome://flags/#enable-experimental-web-platform-features.

if( !( 'commit' in OffscreenCanvasRenderingContext2D.prototype ) ) {
  throw new Error( "Your browser doesn't support the .commit() method," +
    "please enable it from chrome://flags" );
}
const worker_script = `
self.addEventListener('message', (evt) => {
  const canvas = evt.data;
  const ctx = canvas.getContext( "2d" );
  // draw a simple grid of balck squares
  for( let y = 0; y<canvas.height; y+= 60 ) {
    for( let x = 0; x<canvas.width; x+= 60 ) {
      ctx.fillRect(x+10,y+10,40,40);  
    }
  }
  // force drawing to element
  ctx.commit();
  self.postMessage( "" );
});`;
const worker_blob = new Blob( [ worker_script ], { type: "text/javascript" } );
const worker_url = URL.createObjectURL( worker_blob );
const worker = new Worker( worker_url );
worker.onmessage = (evt) => worker.terminate();
const canvas_el = document.querySelector( "canvas" );
const off_canvas = canvas_el.transferControlToOffscreen();
worker.postMessage( off_canvas, [ off_canvas ] );
<h3>Run this snippet a few times (in Chrome), it will always work ;-)</h3>
<canvas width="500" height="500"></canvas>

So a workaround, without this method is to wait before you terminate your Worker. Though there doesn't seem to be a precise amount of time nor a special event we can wait for, by tests-and-error I came up to wait three painting frames, but that may not do it on all devices, so you may want to be safest and wait a few plain seconds, or even just to let the GarbageCollector take care of it:

const worker_script = `
self.addEventListener('message', (evt) => {
  const canvas = evt.data;
  const ctx = canvas.getContext( "2d" );
  // draw a simple grid of balck squares
  for( let y = 0; y<canvas.height; y+= 60 ) {
    for( let x = 0; x<canvas.width; x+= 60 ) {
      ctx.fillRect(x+10,y+10,40,40);  
    }
  }
  self.postMessage( "" );
});`;
const worker_blob = new Blob( [ worker_script ], { type: "text/javascript" } );
const worker_url = URL.createObjectURL( worker_blob );
const worker = new Worker( worker_url );


worker.onmessage = (evt) =>
  // trying a minimal timeout
  // to be safe better do setTimeout( () => worker.terminate(), 2000 );
  // or even just let GC collect it when needed
  requestAnimationFrame( () => // before next frame
    requestAnimationFrame( () => // end of next frame
      requestAnimationFrame( () => // end of second frame
        worker.terminate()
      )
    )
  );
const canvas_el = document.querySelector( "canvas" );
const off_canvas = canvas_el.transferControlToOffscreen();
worker.postMessage( off_canvas, [ off_canvas ] );
<h3>Run this snippet a few times (in Chrome), it should always work.</h3>
<canvas width="500" height="500"></canvas>

Now, I should note that creating a new Worker for a single job is generally a very bad design. Starting a new js context is a really heavy operation, and making the Worker thread's link with the main thread's GPU instructions is an other one, I do'nt know much about what you're doing, but you should really consider if you won't need to reuse both the Worker and the OffscreenCanvas, in which case you should rather keep them alive.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Thanks @Kaiido for your reply, I really appreciate that. Are you sure about "commit()" is still supported? I have the following problem: `ctx.commit is not a function`, even in the code snippet. – franco.stramana Jul 07 '20 at 15:58
  • @franco.stramana sorry I missed that it's hidden under a flag (that I had on). Edited the answer accordingly, and sorry for the delay. – Kaiido Jul 10 '20 at 01:58