65.9K
CodeProject 正在变化。 阅读更多。
Home

结合 jQuery Deferred 和 HTML5 Web Workers API

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (12投票s)

2011年3月14日

CPOL

16分钟阅读

viewsIcon

61814

一种让 Web Workers API 行为更友好的方法。

为什么?

JavaScript 中耗时长的计算通常不是一个好主意。这是因为 JavaScript 是一个单线程环境;我们做的任何事情都在 UI 线程上进行,当脚本运行时,UI 将无响应。为了防止这种情况一直发生,浏览器实现了一些警告消息,允许用户在达到一定阈值后停止执行。

证据 A,Google Chrome

在过去几年里,浏览器的功能得到了显著的增强。JavaScript 执行引擎速度更快,优秀的浏览器(你们懂的……)可以非常快速地更新 DOM。基于浏览器的应用程序已从插件(Flash、Applets)领域扩展到原生 JavaScript 实现,例如 Google Docs(试试 Chrome Experiments)。这种方向的转变已被包含在 HTML5 规范的 Web Workers API 中得到体现。Web Workers 是一种启动新的 JavaScript 线程的方法,并且在当前版本的 Chrome、Firefox 和 Opera 中都有实现。不幸的是,目前还没有计划在 Internet Explorer 中实现它。

网上有一些 Web Workers 的实际应用示例 - 试试 Julia Map,它通过使用 Web Workers 加速了计算。

当我在 .NET 中编写并行代码时,我有 Task Parallel Library 来帮助我。它在进行分叉和连接(fork and join)等操作时提供了很多帮助,并且可以轻松地创建新任务。Web Workers API 并不那么友好,它要求我们将并行 JavaScript 代码写在另一个文件中,并通过消息发送和监听响应。因此,在本文中,我将探讨一种使 Web Workers API 行为更友好的方法。

HTML5 Web Workers 简介

网上有很多教程(这是一个不错的教程),但我只想关注一些特定的方面,即功能、构造和性能。然后,这将构成后续“Deferred”解决方案的基础。

特点

Web Workers 是通过在一个单独的 .js 文件中编写代码块来创建的。这段代码将在一个完全独立的环境中执行——它无法访问 window 对象,看不到 DOM,并且通过消息传递接收输入和发送输出。消息是序列化的,因此输入和输出总是被复制——这意味着我们无法将任何对象引用传递给 Workers。尽管最初这似乎是一个严重的缺点,但它也可以被视为一个巨大的优点——它强制了线程安全。

要实现 Worker,我们必须在一个新文件中创建 Worker 代码。它需要符合特定的“接口”

  • onmessage:实现此函数以接收来自 UI 线程的消息
  • onconnect:在 Shared Worker 中实现此函数,以接收当多个 UI 线程(即来自多个窗口)连接到同一个 Worker 实例时的通知
  • postMessage:调用此函数以将消息发送回 UI 线程

由于 Worker 无法访问 window 对象,因此您不能使用所有常用的 window 函数(self 是 Web Worker 中的全局对象)。但是,您仍然可以使用这些

  • setTimeout
  • setInterval
  • XMLHttpRequest

然后,当你开始迭代 2(这是构建迭代的开始)时,你可能想要复制测试用例并将它们重新分类到迭代 2。这还允许对测试用例进行粒度跟踪,并允许你说某个测试用例在一个迭代中是准备好的,但在另一个迭代中不是。同样,如何做到这一点取决于你以及你希望如何报告。 “场景”部分提供了更多细节。

下面是一个计算素数的 Worker 的简单实现,文件名为 primes.js

self.onmessage = function(event) {
    for(var n = event.data.from; n < = event.data.to; n += 1){
        var found = false;
        for (var i = 2; i <= Math.sqrt(n); i += 1) {
            if (n % i == 0) {
                found = true;
                break;
            }
        }
        if(!found) {
            // found a prime!
            postMessage(n);
        }
    }
}

在这里,我们实现了一个名为 onmessage 的函数,该函数调用 postMessage 并返回其结果。为了使用这个 Worker,我们在页面中有以下代码片段

var worker = new Worker('primes.js'); //Construct worker
worker.onmessage = function (event) { //Listen for thread messages
    console.log(event.data);           //Log to the Chrome console
};
worker.postMessage({from:1, to:100}); //Start the worker with args

这会使用我们的 Worker 定义文件构建一个新的 Worker 对象。每次它从 Worker 收到消息时,都会输出到控制台。

性能:Worker 线程 vs. UI 线程

为了运行以下测试,我更新了上面的 Worker,在 onmessage 函数的开始和结束处添加了时间戳测量。然后将它们通过最后的 result 对象传出。这使我能够准确地测量函数开始执行和完成执行的时间,从而测量发送消息到 Worker、Worker 执行以及 Worker 发送消息回 UI 线程所需的时间。

var time = new Date().getTime();

我还在没有使用任何 Workers 的情况下运行了相同的算法。两种情况下的参数都是从 1 到 100000。所有测试都在 Chrome、Firefox、Opera 和 IE 中重复进行。

在 Chrome 中,Worker 的执行时间比 UI 线程稍长,并且设置时间比其他浏览器更大。由于这是一个常数,当 Worker 执行更多工作或被重用时,它的影响会减小。

在 Opera 中,执行在 Worker 中也花费的时间稍长,但同样,设置时间是比 Chrome 更大的一个因素。

在 Firefox 中,Worker 的速度快了一倍多!我不知道原因。我唯一的猜测是 UI 线程正忙于执行其他事情。设置时间最少。Firefox 似乎喜欢 Workers,但即便如此,它仍然比 Chrome 和 Opera 慢。

在 IE 中……嗯,它没有实现 Workers,而且 UI 线程花费的时间很长。在 IE9 中,我们将看到更好的 JavaScript 性能,但我们将看不到 Web Workers。

性能:多个 Workers vs. 单个 Worker

在以上所有测试中,我的双核 CPU 的核心 1 使用率飙升至 100%,而核心 2 保持空闲。这有点浪费,而 Web Workers 的优势应该在这里体现。

所以,让我们重复上面的测试,使用两个 Worker 而不是一个。这次由于显而易见的原因,将排除 IE。所有时间单位均为毫秒。

浏览器 然后,当你开始迭代 2(这是构建迭代的开始)时,你可能想要复制测试用例并将它们重新分类到迭代 2。这还允许对测试用例进行粒度跟踪,并允许你说某个测试用例在一个迭代中是准备好的,但在另一个迭代中不是。同样,如何做到这一点取决于你以及你希望如何报告。 “场景”部分提供了更多细节。 平均消息
发送
平均
执行
平均消息
接收
总时间
(从加载到完成)
Chrome 9 1 175 92 7 290
Opera 11 200 50 99 50 202
Firefox 3.6 1 32 525 5 614

我们一致看到,两个 Worker 的速度只比一个 Worker 略快,但这完全是由于创建每个 Worker 所涉及的开销——实际执行时间速度翻倍。

但 Opera 确实有些奇怪:构建 Workers 所花费的时间几乎等于总时间。这意味着 UI 线程在 Workers 运行时很忙,而 UI 线程看不到任何好处,就像 Chrome 和 Firefox 一样。然而,这可能只适用于运行时间短且开销大的 Workers。

发送/接收大型消息

Workers 通过消息与 UI 线程通信,这些消息会被复制。如果我们传递一个对象给 Worker,它会被序列化为 JSON,而这个序列化和复制过程需要付出努力。让我们精确地衡量需要多少努力。我移除了 Worker 中的工作,只传递给它一个对象,然后它将该对象 ping 回来。我们在 Worker 中使用时间戳,以便确切知道它何时运行。这是 Worker 代码

self.onmessage = function (event) {
  postMessage({input:event.data, received: new Date().getTime() });
};

这是我们如何使用它的

var worker = new Worker('ping_worker.js'),
startT = new Date().getTime();
worker.onmessage = function(event){
    console.log("Time to send message to worker: "+ 
                (event.data.received - startT));
    console.log("Time to receive message from worker: "+ 
                (new Date().getTime() - event.data.received));
};
worker.postMessage({/* test object */});

对于每个浏览器,我都运行了上述代码,分别带有和不带大型(100KB)对象作为 postMessage 参数。这让我可以找到时间差,表明传递对象引起的延迟。同样,所有时间单位均为毫秒。

浏览器 发送空 接收空 发送大 接收大 发送大
差值
接收大
差值
Chrome 9 112 9 135 34 23 25
Opera 11 1 0 8 4 7 7
Firefox 3.6 27 3 34 38 7 4

我认为我们可以安全地得出结论,序列化/反序列化和消息传递所需的时间并不多,尤其是与创建 Worker 的开销相比。

jQuery Deferred

Deferred 对象表示一个异步活动,并将其成功或失败以及结果传递给任何已注册的回调。过去,如果您要执行异步操作并希望在最后进行回调,您会让消费者传入一个回调函数。现在,您只需将 Deferred 对象返回给消费者,并在您希望任何监听器收到通知时调用其 resolve 函数。以 jQuery 1.4 的 ajax 函数为例,在它使用 Deferred 之前

$.ajax({
  url: "w.php",
  success: function(result){
    //Do something with the result
  }
});

而在 jQuery 1.5 中,它变为如下所示,其中“success”不再是简单的回调——而是由 $.ajax 请求创建的 Deferred 对象上的一个函数

$.ajax("w.php").success(function(result){
    //Do something with the result
})

请注意,为了混淆视听,$.ajax 请求返回一个专门的 Deferred 对象,它为我们提供了 successerrorcomplete 回调钩子以便使用——标准的 Deferred 方法是在内部实现的。所以这可能不是最好的例子。这里有一个很好的例子,其中创建一个 Deferred 对象来表示动画的完成

function fadeIn(selector){
    //Create a deferred object
    return $.Deferred(function(dfd) {
        //Fade in the element, on completion resolve the deferred.
        $(selector).fadeIn( 1000, dfd.resolve );
    });
}

为了消费它,我们可以调用 fadeIn 并将完成处理程序附加到 Deferred 结果

fadeIn(".elem").then(function(){
    alert("Fade is complete!");
});

实际上,任何操作都可以表示为一个 Deferred 对象,这将非常有用,因为我们可以以简单的方式将耗时的操作链接在一起。例如

//Fade elem1, and when it's complete, fade elem2.
fadeIn(".elem1").then(function(){
    alert("elem1 complete!");
    fadeIn(".elem2").then(function(){
        alert("elem2 also complete!");
    });
});

//Fade both elems at once
$.wait(fadeIn(".elem1"), fadeIn(".elem2")).then(function(){
    alert("Fading both elems is complete!");
});

.NET Tasks vs. jQuery Deferred

.NET Task Parallel Library 是 .NET Framework 并行编程的一大进步。它允许我们在不担心实际线程创建的情况下,轻松地在另一个线程中运行匿名方法。Task 对象封装了一段并行代码,并在其完成后发出通知。我们可以使用 Task.WaitAllTaskFactory.ContinueWhenAll 函数在一组 Task 全部完成后执行某些操作,或者使用 Task.WaitAnyTaskFactory.ContinueWhenAny 等待其中一个完成。ContinueWith 方法安排代码在单个任务完成后运行。

听起来是否很熟悉?jQuery Deferred 的概念与 Task 类非常相似,因为它们都用于表示耗时操作。让我们直接比较一下

TPL Deferred 描述
new Task(action) $.Deferred(function) 从函数创建新的 TaskDeferred
ContinueWith(action) then(function), done(function) 从函数创建新的 TaskDeferred,在当前 TaskDeferred 完成后运行。
WaitAll 阻塞当前线程直到所有任务完成。在 JavaScript 中是个坏主意,因为您会阻塞 UI 线程!
WaitAny 阻塞当前线程直到任何任务完成。
TaskFactory.ContinueWhenAll $.when(function) 创建一个新的 TaskDeferred,当提供的 Task/Deferred 对象集合完成后运行。
TaskFactory.ContinueWhenAny 创建一个新的 Task,当提供的 Task 对象集合中的任何一个完成时运行。

Deferred 没有的操作,例如 WaitAllWaitAny,缺失是有原因的:在 JavaScript 中阻塞 UI 线程是不可能的,因为它是一种完全异步的语言。

将 Web Workers 封装在 Deferred 中

现在我们已经了解了 Web Workers 和 jQuery Deferred,让我们来看看如何将两者结合起来,以便实现类似 .NET Task 的编程环境。首先,我们定义一个简单的 Web Worker 对象并将其放在文件 test_worker.js

self.onmessage = function (event) {
    //Do some work
    var result = "the result of lots of work";
 
    //Post the result message
    postMessage(result);
};

为了使用 Deferred 来消费 Worker,我们有以下辅助函数

//Add a work helper function to the jQuery object
$.work = function(args) { 
    var def = $.Deferred(function(dfd) {
        var worker;
        if (window.Worker) {
            //Construct the Web Worker
            var worker = new Worker(args.file); 
            worker.onmessage = function(event) {
                //If the Worker reports success, resolve the Deferred
                dfd.resolve(event.data); 
            };
            worker.onerror = function(event) {
                //If the Worker reports an error, reject the Deferred
                dfd.reject(event); 
            };
            worker.postMessage(args.args); //Start the worker with supplied args
        } else {
            //Need to do something when the browser doesn't have Web Workers
        }
    });
 
    //Return the promise object (an "immutable" Deferred object for consumers to use)
    return def.promise(); 
};

最后,剩下的是调用 $.work 函数来启动 Worker!

//Call the helper work function with the name of the Worker file and arguments.
$.work({file: 'test_worker.js', args: { anArg: "hello!" }}).then(function(data) {
    //Worker completed successfully
    console.log(data);
}).fail(function(data){
    //Worker threw an error
    console.log(data);
});

太棒了!现在让我们来看一个 Deferred 如何让生活变得更轻松的例子。假设我们已经完成了编写一个计算两个值之间的素数的 Worker "primes.js" 的简单任务。我们的任务是消费该 Worker 并计算 1 到 100 万之间的素数。我们可以将其拆分成两个 Worker,如下所示

var worker1 = $.work({file: 'primes.js', args: { from: 1, to: 500000 }});
var worker2 = $.work({file: 'primes.js', args: { from: 500001, to:1000000 }});
 
$.when(worker1, worker2).done(function(result1, result2){
    //All finished! Combine the results from both workers.
});

到目前为止,我们已经将 Deferred 与 Web Workers 结合起来了,但还有一些问题需要解决

  • 我们必须将 Worker 代码放入一个单独的文件中——这不太好
  • 它在不支持 Web Workers 的浏览器中不起作用

创建通用 Worker

如果您甚至不必编写 Worker 文件,那不是很棒吗?为了实现这一点,我们必须克服 Web Workers 需要用包含 Worker 定义的文件名来构造的事实,而不是用要运行的函数。为了解决这个问题,我们只需创建一个 Web Worker,它接收一个函数定义和参数作为消息,所有这些都编码为 JSON 字符串。

这项技术为我们的任务增加了一些代码:将函数转换为字符串和从字符串转换回来需要付出一些努力。要将函数转换为字符串,我们只需这样做

var funcStr = func.toString();

但是反过来——从字符串中获取函数——则更困难。我们可以尝试使用 eval

var funcStr = func.toString();
eval("var func = " + funcStr);

但是,如果您尝试这样做,您会发现 Chrome 中运行该函数的性能非常糟糕:该函数未被预编译,最终执行时间会慢 10 倍以上。另一种选择是使用 new Function 语法来构造函数。在下表中,我比较了它们的性能(所有时间单位均为毫秒——越低越好)

原生 Eval new Function
Chrome 9 207 2955 204
IE 8 4078 4890 4047
Opera 11 240 1080 240
Firefox 3.6 341 342 336

在所有情况下,使用 new Function 构造函数与自然 JavaScript 函数的性能相同,所以我们将使用它而不是 eval。在以下代码中,我们看到了如何使用 Function 构造函数将字符串编码的函数转换为真正的函数。它只需要一点点操作函数的字符串以获取函数体和函数参数的名称,然后将它们传递给 Function 构造函数。结合“通用”Worker,我们可以编写这个 Worker 文件(worker.js

self.addEventListener('message', function (event) {
    //Get the action from the string-encoded arguments
    var action = self.getFunc(event.data.action);
 
    //Execute the newly-defined action and post result back to the callee
    self.postMessage(action(event.data.args));
 
}, false);
 
//Gets a Function given an input function string.
self.getFunc = function (funcStr) {
    //Get the name of the argument. We know there is a single argument
    //in the worker function, between the first '(' and the first ')'.
    var argName = funcStr.substring(funcStr.indexOf("(") + 1, funcStr.indexOf(")"));
 
    //Now get the function body - between the first '{' and the last '}'.
    funcStr = funcStr.substring(funcStr.indexOf("{") + 1, funcStr.lastIndexOf("}"));
 
    //Construct the new Function
    return new Function(argName, funcStr);
}

请注意,在上面的 Worker 中,我们使用标准的 addEventListener 语法来附加到消息事件。这比将函数添加到 onmessage 属性的老式方法要好得多,并且允许我们在需要时附加多个监听器。

为了消费这个 Web Worker,我们必须序列化要运行的函数及其参数,以便它们可以作为消息传递。我们的 $.work 函数可以做到这一点。我们还将添加另一项细节:通过同步执行操作来使其跨浏览器兼容,当没有 Worker 定义时。

$.work = function(action, args) {
    var def = $.Deferred(function(dfd) {
        if (window.Worker) {
            var worker = new Worker('worker.js');
            worker.addEventListener('message', function(event) {
                //Resolve the Deferred when the Web Worker completes
                def.resolve(event.data);
            }, false);
 
            worker.addEventListener('error', function(event) {
                //Reject the Deferred if the Web Worker has an error
                def.reject(item);
            }, false);
 
            //Start the worker
            worker.postMessage({
                action: action.toString(),
                args: args
            });
        } else {
            //If the browser doesn't support workers then execute synchronously.
            //This is done in a setTimeout to give the browser a chance to execute
            //other stuff before starting the hard work.
            setTimeout(function(){
                try {
                    var result = action(args);
                    dfd.resolve(result);
                } catch(e) {
                    dfd.reject(e);
                }
            }, 0);
        }
    });
 
    //Return the promise to do this work at some point
    return def.promise();
};

要定义代码,您可以编写任何接受单个参数的函数

//Define a function to be run in the worker.
//Note that this function will not be run in the window context,
//and therefore cannot see any global vars!
//Anything this function uses must be passed to it through its args object.
var findPrimes = function (args) {
    var divisor, isPrime, result = [],
        current = args.from;
    while (current < args.to) {         
        divisor = parseInt(current / 2, 10);         
        isPrime = true;         
        while (divisor > 1) {
            if (current % divisor === 0) {
                isPrime = false;
                divisor = 0;
            } else {
                divisor -= 1;
            }
        }
        if (isPrime) {
            result.push(current);
        }
        current += 1;
    }
    return result;
}

然后运行它就变成了这简洁的美

$.work({action: findPrimes, args: { from:2, to:50000 }}).then(function(data) {
    alert('all done');
}).fail(function(data){
    alert('oops');
});

性能

为了尝试性能,我将在常用的四种浏览器中进行以下三个测试

  1. 在 UI 线程中运行 findPrimes 函数(不涉及 worker)
  2. 在 Web Worker 中运行 findPrimes 函数(保持 UI 线程空闲)
  3. 在两个 Web Workers 中运行 findPrimes 函数(将计算分成两半)
UI 线程 一个 worker 两个 workers 观察
Chrome 9 4915 4992 3268 CPU 1 个 worker 时为 50%,2 个 worker 时为 100%
Firefox 3.6 7868 7862 5289 CPU 1 个 worker 时为 50%,2 个 worker 时为 100%
Opera 11 5754 5780 5676 CPU 在两种情况下均为 50%(但 UI 线程是空闲的)
IE 8 108689 相同 相同 CPU 在所有情况下均为 50%(UI 线程始终被使用)

在上述测试中,我们可以看到执行时间在 Web Worker 中与在 UI 线程中始终相同。在 Chrome 和 Firefox 中,我们看到同时执行两个 Web Workers 可以通过利用用户机器上的多个 CPU 来提供不错的性能提升。这些是非常积极的结果,尤其是考虑到创建和发送 Web Workers 的开销。

Chrome

这种包装 Web Workers 的技术在 Google Chrome 中效果很好,尽管 Chrome 创建 Web Worker 对象的开销最大。正如您所料,Chrome 通过在单独的线程中运行 Web Workers 来利用多个核心,并且与单核机器相比,我们可以在多核机器上获得良好的性能提升。

Firefox

Firefox 的性能也非常出色。在多核机器上能获得良好的提速,此外,Firefox 在创建 Web Worker 对象方面的开销很小。

Opera

尽管 Opera 支持 Web Workers,但它似乎没有在自己的线程中运行它们——在上表中,我们可以看到在多核机器上运行多个 Worker 的性能并不比运行单个 Worker 好。我注意到在我的双核机器上,即使运行多个 Worker,CPU 使用率也只达到 50%。我相信 Opera 最终会解决这个问题,而且使用 Web Workers 仍然可以释放 UI 线程,使浏览器在长时间运行时保持响应。

Internet Explorer

在 IE 中,由于我们完全在 UI 线程中执行,因此长时间运行的计算将导致消息:“此页面上的脚本导致 Internet Explorer 运行缓慢”。如果您的 Worker 函数执行超过 500 万条语句,就会发生这种情况。作为一种变通方法,我只能建议以下措施

  • 将 Worker 分割成多个更小的 Worker,以确保不会达到 500 万条语句的限制。您应该努力定期提供用户反馈,让用户知道应用程序没有崩溃。
  • 如果您可以控制客户端的注册表(例如,公司内部应用程序),则可以更改限制,尽管这是一个坏主意,因为浏览器会很长时间无响应。
  • 为 IE 用户提供应用程序的替代版本,该版本计算强度较低。告知用户他们可以在其他浏览器中使用完整版本。

jQuery 插件

这个解决方案被包装在一个漂亮的jQuery 插件中。本文的其余部分将展示其用法。为了最佳地使用此插件,您应该将代码中符合以下标准的函数分离出来

  1. 函数必须是“静态的”——它不能访问任何闭包变量,只能访问通过其参数传递给它的变量。您可以使用 setTimeoutsetIntervalXMLHttpRequest 以及构造 Web Workers——但其他全局变量不可用。
  2. 函数运行时间超过 100 毫秒。这确保了在后台 Worker 中运行它的好处大于创建 Worker 的开销。
  3. 如果您想支持 IE,该函数应执行少于 500 万条语句。否则,您应该将工作分成多个部分,在其中植入 setTimeout 调用,或者为 IE 用户提供替代应用程序。当然,如果您要优化现有应用程序,您的代码在 Internet Explorer 中的运行速度不会比现在慢。

基本用法

调用 $.work 函数在另一个线程中运行一个函数。这将返回一个 Deferred 对象,您可以像使用其他 Deferred jQuery 对象一样使用它。

假设您的应用程序中有一个耗时函数“doStuff

function doStuff(arg1, arg2) {
    //Do lots of stuff with arg1 and arg2
}

var result = doSomething("a", "b");
//do something with the result

通过重新调整函数以接受单个参数,并向“done”助手函数添加一个回调,可以实现并行化

function doStuff(args) {
    //Do lots of stuff with args.arg1 and args.arg2
}

$.work(doStuff, {a:1, b:100}).done(function(result){
    //do something with the result
});

处理错误

上面的 done 函数仅在函数成功执行且没有异常时才会被调用。要处理异常,请使用 thenfail 助手函数

function doStuff(args) {
    //Do lots of stuff with args.arg1 and args.arg2
}

$.work(doStuff, {a:1, b:100}).then(function(result){
    //do something with the result
}).fail(function(event){
    //exception occurred! look at the event argument.
});

多线程(Fork and Join)

您可以使用 $.when Deferred 助手函数运行多个 Worker 并轻松连接结果

function doStuff(args) {
    //Do lots of stuff with args.arg1 and args.arg2
}

//Split your work into multiple workers (fork)
var work1 = $.work(doStuff, {a:1, b:50});
var work2 = $.work(doStuff, {a:51, b:100});

//Use $.when to be notified when they're all complete (join)
$.when(work1, work2).then(function(result1, result2){
    //success - do something with result1 and result2
}).fail(function(event){
    //exception occurred! look at the event argument.
});

结论

随着基于浏览器的应用程序变得越来越复杂,并且 CPU 核心数不断增加,自然会需要将工作卸载到单独的线程。HTML5 Web Workers 很可能将成为其中的重要组成部分,我认为将它们与 jQuery Deferred 对象结合起来,可以使我们开发者更容易地编写简单、易读的并行代码,而不会增加任何额外的开销。

© . All rights reserved.