diff --git a/debug/tile-cancellation.html b/debug/tile-cancellation.html new file mode 100644 index 00000000000..db1985a3257 --- /dev/null +++ b/debug/tile-cancellation.html @@ -0,0 +1,39 @@ + + + + Mapbox GL JS debug page + + + + + + + +
+ + + + + + diff --git a/src/util/actor.js b/src/util/actor.js index 940a0030f0b..f6677c12f1e 100644 --- a/src/util/actor.js +++ b/src/util/actor.js @@ -2,12 +2,23 @@ import {bindAll, isWorker, isSafari} from './util'; import window from './window'; +import browser from './browser'; import {serialize, deserialize} from './web_worker_transfer'; import ThrottledInvoker from './throttled_invoker'; import type {Transferable} from '../types/transferable'; import type {Cancelable} from '../types/cancelable'; +// Upper limit on time in ms, the actor allocates towards flushing the task queue per frame +const MAIN_THREAD_TIME_BUDGET = 1; +// Upper limit of number of tasks executed in a frame in the main thread +const MAIN_THREAD_TASK_BUDGET = 10; + +// Upper limit on time in ms, the actor allocates towards flushing the task queue per worker tick, see throttled_invoker.js +const WORKER_THREAD_TIME_BUDGET = 2; +// Upper limit of number of tasks executed per worker tick as per throttled_invoker. +const WORKER_THREAD_TASK_BUDGET = 20; + /** * An implementation of the [Actor design pattern](http://en.wikipedia.org/wiki/Actor_model) * that maintains the relationship between asynchronous tasks and the objects @@ -30,11 +41,13 @@ class Actor { cancelCallbacks: { number: Cancelable }; invoker: ThrottledInvoker; globalScope: any; + isWorker: boolean; constructor(target: any, parent: any, mapId: ?number) { this.target = target; this.parent = parent; this.mapId = mapId; + this.isWorker = isWorker(); this.callbacks = {}; this.tasks = {}; this.taskQueue = []; @@ -42,7 +55,7 @@ class Actor { bindAll(['receive', 'process'], this); this.invoker = new ThrottledInvoker(this.process); this.target.addEventListener('message', this.receive, false); - this.globalScope = isWorker() ? target : window; + this.globalScope = this.isWorker ? target : window; } /** @@ -110,21 +123,9 @@ class Actor { cancel(); } } else { - // In workers, store the tasks that we need to process before actually processing them. This - // is necessary because we want to keep receiving messages, and in particular, - // messages. Some tasks may take a while in the worker thread, so before - // executing the next task in our queue, postMessage preempts this and - // messages can be processed. We're using a MessageChannel object to get throttle the - // process() flow to one at a time. this.tasks[id] = data; this.taskQueue.push(id); - if (isWorker()) { - this.invoker.trigger(); - } else { - // In the main thread, process messages immediately so that other work does not slip in - // between getting partial data back from workers. - this.process(); - } + this.invoker.trigger(); } } @@ -132,20 +133,31 @@ class Actor { if (!this.taskQueue.length) { return; } - const id = this.taskQueue.shift(); - const task = this.tasks[id]; - delete this.tasks[id]; - // Schedule another process call if we know there's more to process _before_ invoking the - // current task. This is necessary so that processing continues even if the current task - // doesn't execute successfully. + + const timeBudget = this.isWorker ? WORKER_THREAD_TIME_BUDGET : MAIN_THREAD_TIME_BUDGET; + const taskBudget = this.isWorker ? WORKER_THREAD_TASK_BUDGET : MAIN_THREAD_TASK_BUDGET; + + const start = browser.now(); + let taskCtr = 0; + while (browser.now() - start < timeBudget && taskCtr < taskBudget && this.taskQueue.length > 0) { + this._processQueueTop(); + taskCtr++; + } + // We've reached our budget for this frame, defer processing of the rest of the tasks to the next tick, + // this allows the deferred tasks to be preempted on slower browsers. if (this.taskQueue.length) { this.invoker.trigger(); } + } + + _processQueueTop() { + const id = this.taskQueue.shift(); + const task = this.tasks[id]; + delete this.tasks[id]; if (!task) { // If the task ID doesn't have associated task data anymore, it was canceled. return; } - if (task.type === '') { // The done() function in the counterpart has been called, and we are now // firing the callback in the originating actor, if there is one. diff --git a/src/util/throttled_invoker.js b/src/util/throttled_invoker.js index ca1b97bfce1..f0426a4b7c6 100644 --- a/src/util/throttled_invoker.js +++ b/src/util/throttled_invoker.js @@ -1,45 +1,64 @@ // @flow +import window from './window'; +import {isWorker} from './util'; +const raf = window.requestAnimationFrame || + window.mozRequestAnimationFrame || + window.webkitRequestAnimationFrame || + window.msRequestAnimationFrame; /** * Invokes the wrapped function in a non-blocking way when trigger() is called. Invocation requests * are ignored until the function was actually invoked. * + * On the main thread, this uses requestAnimationFrame so the deferral of tasks is as low-latency with the render loop as possible. + * In the WebWorker context, we use a `MessageChannel` to send a message back to the same context, with a fallback to `setTimeout(..,0)`. + * * @private */ class ThrottledInvoker { - _channel: MessageChannel; + _channel: ?MessageChannel; _triggered: boolean; - _callback: Function + _callback: Function; + _bindFunc: Function; + _isWorker: boolean; constructor(callback: Function) { this._callback = callback; this._triggered = false; - if (typeof MessageChannel !== 'undefined') { - this._channel = new MessageChannel(); - this._channel.port2.onmessage = () => { - this._triggered = false; - this._callback(); - }; + // The function actually bound to the callback runner. + this._bindFunc = () => { + this._triggered = false; + this._callback(); + }; + this._isWorker = isWorker(); + if (this._isWorker) { + if (typeof MessageChannel !== 'undefined') { + this._channel = new MessageChannel(); + this._channel.port2.onmessage = this._bindFunc; + } } } trigger() { if (!this._triggered) { this._triggered = true; - if (this._channel) { - this._channel.port1.postMessage(true); + //Invoker is MessageChannel/setTimeout on worker side + if (this._isWorker) { + if (this._channel) { + this._channel.port1.postMessage(true); + } else { + setTimeout(this._bindFunc, 0); + } + // requestAnimationFrame on the Main Thread. } else { - setTimeout(() => { - this._triggered = false; - this._callback(); - }, 0); + raf(this._bindFunc); } } } remove() { - delete this._channel; this._callback = () => {}; + this._bindFunc = () => {}; } }