内容


JavaScript 工作线程实现方式

使用 setTimeout、Google Gears 和 Web Worker

Comments

在 Ajax 应用中,有时候会需要在后台执行一些耗时较长,但是与页面主要逻辑无关的操作。比如对于一个在线文档编辑器来说,会需要定期的自动备份用户当前所编辑的内容,这样当应用异常崩溃时,用户还能恢复他所编辑的内容。这样的定期备份任务可能会需要花费一些时间,但是优先级较低。类似这样的任务还有页面内容的预先加载和日志记录等。对于这些任务,最好的实现方式是在后台工作线程中执行,这样不会对用户在主页面上的操作造成影响。用户并不会希望由于后台备份正在进行,而无法对当前的文档进行编辑。

这里使用的名词“JavaScript 工作线程”其实并不严谨,不过可以提供与操作系统中的线程概念相关的类比,从而更加容易理解其背后的动机和实现方式。本文会介绍三种 JavaScript 工作线程的实现方式,分别是 setTimeout()、Google Gears 和 Web Worker。首先从 JavaScript 中的定时器 setTimeout()开始。

使用 setTimeout()

浏览器中 JavaScript 引擎是单线程执行的。也就是说,在同一时间内,只能有一段代码被 JavaScript 引擎执行。如果同一时间还有其它代码需要执行的话,则这些代码需要等待 JavaScript 引擎执行完成当前的代码之后才有可能获得被执行的机会。正常情况下,作为页面加载过程中的重要一步,JavaScript 引擎会顺序执行页面上的所有 JavaScript 代码。当页面加载完成之后,JavaScript 引擎会进入空闲状态。用户在页面上的操作会触发一些事件,这些事件的处理方法会交给 JavaScript 引擎来执行。由于 JavaScript 引擎的单线程特性,一般会在内部维护一个待处理的事件队列。每次从事件队列中选出一个事件处理方法来执行。如果在执行过程中,有新的事件发生,则新事件的处理方法只会被加入到队列中等待执行。如果当前正在执行的事件处理方法非常耗时,则队列中的其它事件处理方法可能长时间无法得到执行,造成用户界面失去响应,严重影响用户的使用体验。

JavaScript 引擎的这种工作方式类似于早期的单核 CPU 的调度方式。单核 CPU 虽然也是支持多任务同时运行的,但是实际上同一时间只能有一个任务在执行。CPU 通过时间片的轮转来保证每个任务都有一定的执行时间。JavaScript 并没有原生提供与操作系统中的线程类似的结构,但是可以通过所提供的定时器机制来模拟。JavaScript 提供了两个基本的方法用来执行与定时相关的操作,分别是 setTimeout()setInterval()

setTimeout() 和 setInterval()

JavaScript 中的 setTimeout()用来设置在指定的间隔时间之后执行某个 JavaScript 方法。setTimeout()的方法声明非常简单:setTimout(func, time),其中参数 func表示的是要执行的 JavaScript 方法,可以是 JavaScript 方法对象或是方法体的字符串;参数 time表示的是以毫秒为单位的间隔时间。setInterval()方法用来设置根据指定的间隔重复执行某个 JavaScript 方法。setInterval()的方法声明与 setTimeout()相同:setInterval(func, time),这里参数 time指定的是方法 func重复执行的间隔。当 setTimeout()setInterval()被调用的时候,浏览器会根据设置的时间间隔来触发相应的事件。

假如代码的调用方式是 setTimeout(func, 100),那么该代码被执行 100 毫秒之后,定时器的事件被触发。如果这个时候 JavaScript 引擎没有正在执行的其它代码,则与此定时器对应的 JavaScript 方法 func就可以被执行。否则的话,该 JavaScript 方法的执行就被加入到等待的队列中。当 JavaScript 引擎空闲的时候,会从这个队列中选择一个等待的 JavaScript 方法来执行。也就是说,虽然在调用 setTimeout()的时候设置的间隔时间是 100 毫秒,与之对应的 JavaScript 方法实际被执行的间隔有可能大于设定的 100 毫秒,取决于是否有其它代码正在被执行和执行所花费的时间。因此 setTimeout()的实际生效的间隔时间可能大于设定的时间。

setInterval()的执行方式与 setTimeout()有很大不同。假如代码的调用方式是 setInterval(func, 100),则每隔 100 毫秒,定时器的事件就会被触发。与 setTimeout()相同的是,如果当前 JavaScript 引擎空闲的话,则定时器对应的方法 func会被立即执行。否则的话,该 JavaScript 方法的执行就会被加入到等待队列中。由于定时器的事件是每隔 100 毫秒就触发一次,有可能某一次事件触发的时候,上一次事件的处理方法还没有机会得到执行,仍然在等待队列中。这个时候,这个新的定时器事件就被丢弃。需要注意的是,由于 JavaScript 引擎的这种执行方式,有可能定时器事件处理方法的两次被执行的实际时间间隔小于设定的间隔。比如上一个定时器事件的处理方法触发之后,等待了 50 毫秒才获得被执行的机会。而第二个定时器事件的处理方法被触发之后,马上就被执行了。那么这两者之间的时间间隔实际上只有 50 毫秒。因此,setInterval()并不适合实现精确的按固定间隔的调度操作。

总的来说,使用 setTimeout()setInterval()的时候,都不能满足精确的时间间隔。通过 setTimeout()设置的 JavaScript 方法的实际执行间隔时间不小于设定的时间,而通过 setInterval()设置的重复执行的 JavaScript 方法的间隔可能会小于设定的时间。

使用 setTimeout()实现工作线程

前面提到,setTimeout()可以设置在某个时间间隔之后再执行某个 JavaScript 方法。setTimeout()的另外一个作用是可以将某些操作推迟执行,让出 JavaScript 引擎来处理其等待队列中的其它事件,以提高用户体验。比如某个操作由于需要进行大量计算,平均耗时在 3 秒左右。当这个操作开始执行之后,就会一直占用 JavaScript 引擎,直到执行结束。在这个过程中,其它的 JavaScript 方法就被放置到 JavaScript 引擎的等待队列中。比如,如果用户在这个过程中点击了页面上的某个按钮,则相应的事件处理方法并不能马上被执行。给用户的感觉就是整个 Web 应用暂时失去了响应。这对于用户体验来说,是很不好的。

如果使用 setTimeout()的话,就可以把一个需要较长执行时间的任务分成若干个小任务,这些小任务之间用 setTimeout()串联起来。在这些小任务的执行间隔中,就可以给其它正在等待的 JavaScript 方法以执行机会。考虑下面的一个例子,计算 100000 以内的质数的个数。求质数的方法有不少,这里使用一种简单的方法来做示例。对于每一个正整数,通过判断其是否能被小于或等于其平方根的整数整除,就可以确定其是否为质数。基本的实现方法如 代码清单 1所示:

清单 1. 判断是否为质数的简单方法
 function isPrime(n) { 
    if (n == 0 || n == 1) { 
        return false; 
    } 
    var bound = Math.floor(Math.sqrt(n)); 
    for (var i = 2; i <= bound; i++) { 
       if (n % i == 0) { 
           return false; 
       } 
    } 
    return true; 
 }

如果使用一般的计算方式的话,只需要一个很大的循环,对范围之内的每个整数都进行判断即可,如 代码清单 2所示。

清单 2. 普通的计算方式
 function calculateNormal() { 
    var count = 0; 
    for (var i = 2; i <= MAX; i++) { 
        if (isPrime(i)) { 
            count++; 
        } 
    } 
    alert("计算完成,质数个数为:" + count); 
 }

代码清单 2 的问题在于在整个计算过程中,JavaScript 引擎被全部占用。整个页面是无法响应用户的其它请求的,页面会呈现“假死”的状态。而通过 setTimeout()可以把计算任务分成若干个小任务来执行,提高页面的响应能力。实现方式如 代码清单 3 所示。

清单 3. 使用 setTimeout() 分隔任务
 function calculateUsingTimeout() { 
    var jobs = 10, numberPerJob = Math.ceil(MAX / jobs); 
    var count = 0; 
    
    function calculate(start, end) { 
        for (var i = start; i <= end; i++) { 
            if (isPrime(i)) { 
                count++; 
            } 
        } 
    } 
    
    var start, end, timeout, finished = 0; 
    function manage() { 
        if (finished == jobs) { 
            window.clearTimeout(timeout); 
            alert("计算完成,质数个数为:" + count); 
        } 
        else { 
            start = finished * numberPerJob + 1, 
                end = Math.min((finished + 1) * numberPerJob, MAX); 
            timeout = window.setTimeout(function() { 
                calculate(start, end); 
                finished++; 
                manage(); 
            }, 100); 
        } 
    } 
    
    manage(); 
 }

代码清单 3所示,通过 setTimeout()把耗时较长的计算任务分成了 10 个小任务,每个任务之间的执行间隔是 100 毫秒。在这些小任务的执行间隔中,JavaScript 引擎是可以处理其它事件的。这样就保证了对用户的响应时间。虽然整个任务总的执行时间变长了,但是带来了用户体验的提升。

在介绍完 setTimeout() 之后,下面介绍 Google Gears。

使用 Google Gears

通过 setTimeout()虽然可以暂时推迟某些操作的执行,但是不能实现真正意义上的 JavaScript 工作线程。JavaScript 引擎的单线程特性决定了在同一时间最多只能有一个任务在执行。Google 开发的 Google Gears 是一个浏览器插件工具,可以增强现有浏览器的功能。这些增强的功能包括应用程序本地缓存、本地数据库和 JavaScript 工作线程池等。JavaScript 工作线程池可以用来在后台执行 JavaScript 代码,而不影响浏览器中 JavaScript 引擎的正常执行。在安装了 Google Gears 之后,就可以在页面中通过 JavaScript 代码来创建工作线程池(worker pool)。如 google.gears.factory.create('beta.workerpool')就创建了一个工作线程池。之后就可以向该线程池中添加要执行的任务,每个任务由工作线程(worker)来完成。每个页面可以创建多个线程池,线程池之间是互相隔离的。一个线程池里面可以创建多个工作线程。线程池和工作线程之间通过简单的消息传递来进行通信。

每个工作线程的任务也非常简单,就是执行一段给定的 JavaScript 代码。执行完成之后,工作线程的任务就结束了。JavaScript 代码的内容既可以直接指定,也可以指定一个 URL,由工作线程在执行时进行下载。通过工作线程池的两个方法 createWorker(scriptText)createWorkerFromUrl(scriptUrl)可以分别创建出这两类工作线程。这两个方法的返回值是工作线程的标识符,可以在发送消息的时候使用。

消息传递机制由两部分组成,即消息的发送和处理。消息的发送是通过工作线程池的 sendMessage(message, destWorkerId)来实现的。该方法的参数 message表示的是消息的内容,可以是任何 JavaScript 对象;参数 destWorkerId是接收消息的工作线程的标识符。消息的处理是在工作线程池上注册事件处理方法来实现的。通过设置工作线程池的 onmessage属性可以注册当接收到消息的时候要调用的 JavaScript 方法。该 JavaScript 方法接收 3 个参数:messageTextsenderIdmessageObject。前两个参数 messageTextsenderId已经被废弃,不推荐使用。第三个参数 messageObject是一个包含了消息相关信息的 JavaScript 对象,其中的属性包括:body表示消息内容本身;sender表示消息发送者的标识符;origin表示消息发送者的来源。前面提到的在发送消息时所需的消息接收者的标识符可以从创建工作线程的方法 createWorker()createWorkerFromUrl()的返回值得到,也可以从 onmessage处理方法的参数 messageObject中得到。

如果浏览器中安装了 Google Gears 之后,就可以利用其提供的工作线程池支持来在后台执行耗时较长的操作,而不会对页面的前台显示造成影响。下面继续以求质数个数的例子来演示 Google Gears 的用法。

在使用 Google Gears 进行计算的时候,可以把需要由工作线程执行的代码放到一个单独的文件中,然后通过 createWorkerFromUrl()方法来在工作线程池中创建一个工作线程。工作线程池和工作线程之间的通信方式是:工作线程池通过 sendMessage()方法把质数个数计算范围的上限发送给工作线程。在工作线程的 onmessage处理方法中进行计算,完成之后再通过 sendMessage()方法把计算结果发送回工作线程池。工作线程池的 onmessage处理方法中把得到的计算结果显示出来。具体的实现如 代码清单 4所示。

清单 4. 使用 Google Gears 的主页面代码
 var workerPool = google.gears.factory.create('beta.workerpool'); 

 workerPool.onmessage = function(a, b, message) { 
    var result = message.body; 
    alert("计算完成,质数个数为:" + result); 
 }; 

 function calculate() { 
    var limit = parseInt(document.getElementById("limit").value) || 100000; 
    var childWorkerId = workerPool.createWorkerFromUrl('prime.js'); 
    workerPool.sendMessage(limit, childWorkerId); 
 }

代码清单 4所示,通过 google.gears.factory.create('beta.workerpool')创建了一个新的工作线程池,并指定了其上的 onmessage处理方法。这一步需要在创建新的工作线程之前完成,否则的话可能造成消息丢失,无法得到处理。接着就通过 createWorkerFromUrl()创建出一个工作线程,保存该工作线程的标识符,在使用 sendMessage()发送消息时使用。createWorkerFromUrl()使用的 prime.js包含的是计算质数个数的代码。完整的代码与 setTimeout()使用的 代码清单 2类似,与工作线程池通信部分如 代码清单 5所示。

清单 5. 工作线程所执行的 JavaScript 代码中的通信部分
 var wp = google.gears.workerPool; 
 wp.onmessage = function(a, b, message) { 
    var limit = message.body; 
    var count = calculateNormal(limit); 
    wp.sendMessage(count, message.sender); 
 }

代码清单 5所示,工作线程中的 onmessage处理方法也是注册在对象上的。在该处理方法中,工作线程进行计算,并把结果发送回去。通过 message.sender可以获取到发送消息的工作线程池的标识符,并把计算结果通过 sendMessage()发送回去。

在介绍完 Google Gears 之后,下面介绍 Web Worker。

使用 Web Worker

Google Gears 虽然提供了工作线程的支持,允许在后台执行 JavaScript 代码,但是 Google Gears 是以浏览器插件的方式实现的。用户需要在自己的浏览器上下载并安装 Google Gears 之后,Web 应用才能利用 Google Gears 提供的能力。Google Gears 的这种实现也并非一个标准的做法。对于这种情况,WHATAG 工作组借鉴了 Google Gears 的成功经验,创建了 Web Worker 规范,并作为 HTML 5 标准的一部分。Web Worker 规范定义了一套 API,允许 Web 应用创建在后台执行 JavaScript 代码的工作线程。工作线程在执行过程中不会对主页面造成影响。Web Worker 规范受 Google Gears 的影响很大,在规范中可以看到很多 Google Gears 的影子。在 Web Worker 规范出现之后,Google 也终止了 Google Gears 的开发工作,拥抱新的标准。

Web Worker 规范中定义了两类工作线程,分别是专属工作线程和共享工作线程。顾名思义,专属工作线程只能为一个页面所使用,而共享工作线程则可以被多个页面所共享。通过 Worker构造方法可以创建一个新的专属工作线程,创建的时候要指定需要执行的 JavaScript 文件的 URL。Web Worker 规范并不支持从 JavaScript 代码文本中直接创建工作线程。创建工作线程的页面和工作线程之间通过简单的消息传递来进行交互。通过工作线程对象的 postMessage()方法就可以发送消息给工作线程,而通过 onmessage属性就可以设置接收到消息时候的处理方法。在工作线程的 JavaScript 代码中做法也是相同的。代码清单 6中给出了使用 Web Worker 计算质数个数的实现。

清单 6. 使用专属工作线程的主页面代码
 var worker = new Worker("prime_worker.js"); 

 worker.onmessage = function(event) { 
    var result = event.data; 
    alert("计算完成,质数个数为:" + result); 
 }; 

 function calculate() { 
    var limit = parseInt(document.getElementById("limit").value) || 100000; 
    worker.postMessage(limit); 
 }

代码清单 6所示,主页面创建了一个专属工作线程,让它在后台执行 JavaScript 文件 prime_worker.js代码清单 7中给出了 prime_worker.js中与主页面通信部分的代码。

清单 7. 专属工作线程所执行的 JavaScript 代码
 onmessage = function(event) { 
    var limit = event.data; 
    var count = calculateNormal(limit); 
    postMessage(count); 
 }

共享工作线程的使用方式与专属工作线程有所不同。共享工作线程允许多个页面共享使用。每个页面都是连接到该共享工作线程的某个端口(port)上面。页面通过此端口与共享工作线程进行通信。使用 SharedWorker构造方法可以创建出共享工作线程的实例。在发送消息的时候,需要使用的是工作线程的 port对象。如 代码清单 8所示。

清单 8. 使用共享工作线程的主页面代码
 var worker = new SharedWorker("prime_worker.js"); 

 worker.port.onmessage = function(event) { 
    var result = event.data; 
    alert("计算完成,质数个数为:" + result); 
 }; 

 function calculate() { 
    var limit = parseInt(document.getElementById("limit").value) || 100000; 
    worker.port.postMessage(limit); 
 }

而在共享工作线程所执行的 JavaScript 代码中,不能使用 onmessage来直接定义接收到消息时的处理方法,而需要使用 onconnect来定义接收到连接时的处理逻辑,如 代码清单 9所示。

清单 9. 共享工作线程所执行的 JavaScript 代码
 onconnect = function(event) { 
    var port = event.ports[0]; 
    port.onmessage = function(event) { 
        var limit = event.data; 
        var count = calculateNormal(limit); 
        port.postMessage(count); 
    }; 
 }

代码清单 9所示,在 onconnect的处理方法中,设置了当从某个端口上接收到消息时,应该执行的处理逻辑。

除了通过使用 onmessage属性来设置消息处理方法之外,还可以使用 addEventListener()来添加消息处理方法。这样的好处是可以添加多个事件处理方法。需要注意的是,如果使用 addEventListener()来添加事件处理方法的话,添加完成之后,需要调用 port.start()方法来显式地启动端口上的通信。

在介绍完 Web Worker 规范之后,下面介绍如何把这些 JavaScript 工作线程方式统一起来。

统一的抽象方式

前面的章节详细介绍了 JavaScript 工作线程的三种实现方式,setTimeout()、Google Gears 和 Web Worker。就这三种实现方式来说,Web Worker 规范是最好的选择,因为它是 HTML 5 规范的一部分,是符合标准的使用方式。但是 Web Worker 目前只在一些较新的浏览器上支持,如 Firefox 3.5、Safari 4、Google Chrome 4 和 Opera 10.6 等及其更高版本。由于 IE 的主流版本 6/7/8 都不支持 Web Worker,使用 Web Worker 编写的 Web 应用是不能在 IE 上面运行的。Google Gears 由于需要用户安装额外的浏览器插件,对用户来说并不友好。setTimeout()的实现方式,并不能做到真正意义上的在后台执行 JavaScript 代码,只是可以提高用户体验。但是这种方式在所有主流浏览器上都是支持的。综上所述,最好的做法是能够根据用户浏览器的支持情况,选择最适合的工作线程实现方式。实现方式的选择顺序是 Web Worker -> Google Gears -> setTimeout()

不过这三种实现方式在具体的使用方式上存在差别,包括工作线程的创建方式和消息通信机制等。如果三种方式都要兼顾的话,就需要开发出三套不同的代码。这其中所带来的维护成本是很高的。比较好的做法是提供一套底层的库来屏蔽这些细节,允许一套代码在不同的实现方式上都能正常的工作。由于 Web Worker 规范是推荐的标准,因此开发人员应该使用符合 Web Worker 规范的方式来编写其工作线程的 JavaScript 代码。由底层的库来保证这样的代码在 Google Gears 和使用 setTimeout()的时候也能正常运行的。

由于这个库是为了屏蔽不同 JavaScript 线程实现方式之间的不同,因此需要提供一个统一的接口给开发人员使用。开发人员通过使用此接口就可以创建工作线程,并与工作线程进行通信。在创建工作线程方面,由于 Web Worker 规范只支持从 JavaScript 文件 URL 创建工作线程,因此这个库也只提供从 URL 创建工作线程的方式。创建时需要提供的 JavaScript 文件的 URL,接收到消息之后的处理方法和出现错误时候的处理方法。创建方法的返回结果是一个表示工作线程的 JavaScript 对象,通过该对象的 postMessage()方法可以向工作线程发送消息,而在工作线程执行的 JavaScript 代码中同样可以通过 postMessage()方法把消息发送回去。这样就完成了主页面和工作线程之间的通信机制。

这样的一个库,可以成为公司内部可复用的资产。

总结

随着 HTML 5 的流行,Web Worker 会得到更广泛的浏览器支持,从而可以在 Ajax 应用中得到更多的使用。对于 Ajax 应用开发者来说,一方面需要紧跟 HTML 5 标准的发展潮流,另外一方面也要面对目前浏览器支持程度的现实限制。大量用户仍然还在使用版本较低的浏览器。当 JavaScript 工作线程对 Ajax 应用是一个比较迫切的实现要求时,可以考虑使用 Google Gears 和 setTimeout()。本文对这三种实现方式都做了比较详细的介绍,可以帮助读者更好的理解这三种方式的用法。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development
ArticleID=660890
ArticleTitle=JavaScript 工作线程实现方式
publish-date=05262011