作为一个从其他编程语言(C#/Java)转到Javascript的开发人员,在学习Javascript过程中,setTimeout()方法的运行原理是我遇到的一个不太好理解的部分,本文尝试结合其他编程语言的实现,从setTimeout说事件循环模型

1.从setTimeout说起

setTimeout()方法不是ecmascript规范定义的内容,而是属于BOM提供的功能。查看w3school对setTimeout()方法的定义,setTimeout() 方法用于在指定的毫秒数后调用函数或计算表达式。

语法setTimeout(fn,millisec),其中fn表示要执行的代码,可以是一个包含javascript代码的字符串,也可以是一个函数。第二个参数millisec是以毫秒表示的时间,表示fn需推迟多长时间执行。

调用setTimeout()方法之后,该方法返回一个数字,这个数字是计划执行代码的唯一标识符,可以通过它来取消超时调用。

起初我对 setTimeout()的使用比较简单,对其运行机理也没有深入的理解,直到看到下面代码

在我最初对setTimeout()的认识中,延时设置为500ms,所以输出应该为Time elapsed: 500 ms。因为在直观的理解中,Javascript执行引擎,在执行上述代码过程中,应当是一个由上往下的顺序执行过程,setTimeout函数是先于while语句执行的。可是实际上,上述代码运行多次后,输出至少是延迟了1000ms。

2.Java对setTimeout的实现

联想起以往学习Java的经验,上述Javascript的setTimeout()让我困惑。Java对setTimeout的实现有多种API实现,这里我们以java.util.Timer包为例。使用Timer在Java中实现上述逻辑,运行多次,输出都是Time elapsed: 501 ms。

这里深究setTimeout()为什么出现这一差异之前,先说说java.util.Timer的实现原理。

上述代码几个关键要素为Timer、TimerTask类以及Timer类的schedule方法,通过阅读相关源码,可以了解其实现。

Timer:一个Task任务的调度类,和TimerTask任务一样,是供用户使用的API类,通过schedule方法安排Task的执行计划。该类通过TaskQueue任务队列和TimerThread类完成Task的调度。

TimerTask:实现Runnable接口,表明每一个任务均为一个独立的线程,通过run()方法提供用户定制自己任务。

TimerThread:继承于Thread,是真正执行Task的类。

TaskQueue:存储Task任务的数据结构,内部由一个最小堆实现,堆的每个成员为TimeTask,每个任务依靠TimerTask的 nextExecutionTime属性值进行排序,nextExecutionTime最小的任务在队列的最前端,从而能够现实最早执行。

Timer

 

3.根据结果找原因

看过了Java.util.Timer对类似setTimeout()的实现方案,继续回到前文Javascript的setTimeout()方法中,再来看看之前的输出为什么与预期不符。

通过阅读代码不难看出,setTimeout()方法执行在while()循环之前,它声明了“希望”在500ms之后执行一次匿名函数,这一声明,也即对匿名函数的注册,在setTimeout()方法执行后立即生效。代码最后一行的while循环会持续运行1000ms,通过setTimeout()方法注册的匿名函数输出的延迟时间总是大于1000ms,说明对这一匿名函数的实际调用被while()循环阻塞了,实际的调用在while()循环阻塞结束后才真正执行。

而在Java.util.Timer中,对于定时任务的解决方案是通过多线程手段实现的,任务对象存储在任务队列,由专门的调度线程,在新的子线程中完成任务的执行。通过schedule()方法注册一个异步任务时,调度线程在子线程立即开始工作,主线程不会阻塞任务的运行。

这就是Javascript与Java/C#之类语言的一大差异,即Javascript的单线程机制。在现有浏览器环境中,Javascript执行引擎是单线程的,主线程的语句和方法,会阻塞定时任务的运行,执行引擎只有在执行完主线程的语句后,定时任务才会实际执行,这期间的时间,可能大于注册任务时设置的延时时间。在这一点上,Javascript与Java/C#的机制很不同。

4.事件循环模型

在单线程的Javascript引擎中,setTimeout()是如何运行的呢,这里就要提到浏览器内核中的事件循环模型了。简单的讲,在Javascript执行引擎之外,有一个任务队列,当在代码中调用setTimeout()方法时,注册的延时方法会交由浏览器内核其他模块(以webkit为例,是webcore模块)处理,当延时方法到达触发条件,即到达设置的延时时间时,这一延时方法被添加至任务队列里。这一过程由浏览器内核其他模块处理,与执行引擎主线程独立,执行引擎在主线程方法执行完毕,到达空闲状态时,会从任务队列中顺序获取任务来执行,这一过程是一个不断循环的过程,称为事件循环模型。

参考一个演讲中的资料,上述事件循环模型可以用下图描述。

eventloop

Javascript执行引擎的主线程运行的时候,产生堆(heap)和栈(stack)。程序中代码依次进入栈中等待执行,当调用setTimeout()方法时,即图中右侧WebAPIs方法时,浏览器内核相应模块开始延时方法的处理,当延时方法到达触发条件时,方法被添加到用于回调的任务队列,只要执行引擎栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些满足触发条件的回调函数。

以演讲中的示例进一步说明

s1

s2

以图中代码为例,执行引擎开始执行上述代码时,相当于先讲一个main()方法加入执行栈。继续往下开始console.log('Hi')时,log('Hi')方法入栈,console.log方法是一个webkit内核支持的普通方法,而不是前面图中WebAPIs涉及的方法,所以这里log('Hi')方法立即出栈被引擎执行。

s3

s4

console.log('Hi')语句执行完成后,log()方法出栈执行,输出了Hi。引擎继续往下,将setTimeout(callback,5000)添加到执行栈。setTimeout()方法属于事件循环模型中WebAPIs中的方法,引擎在将setTimeout()方法出栈执行时,将延时执行的函数交给了相应模块,即图右方的timer模块来处理。

s5

执行引擎将setTimeout出栈执行时,将延时处理方法交由了webkit timer模块处理,然后立即继续往下处理后面代码,于是将log('SJS')加入执行栈,接下来log('SJS')出栈执行,输出SJS。而执行引擎在执行万console.log('SJS')后,程序处理完毕,main()方法也出栈。

s6

s7

s8

 

这时在在setTimeout方法执行5秒后,timer模块检测到延时处理方法到达触发条件,于是将延时处理方法加入任务队列。而此时执行引擎的执行栈为空,所以引擎开始轮询检查任务队列是否有任务需要被执行,就检查到已经到达执行条件的延时方法,于是将延时方法加入执行栈。引擎发现延时方法调用了log()方法,于是又将log()方法入栈。然后对执行栈依次出栈执行,输出there,清空执行栈。

清空执行栈后,执行引擎会继续去轮询任务队列,检查是否还有任务可执行。

5.webkit中timer的实现

到这里已经可以彻底理解下面代码的执行流程,执行引擎先将setTimeout()方法入栈被执行,执行时将延时方法交给内核相应模块处理。引擎继续处理后面代码,while语句将引擎阻塞了1秒,而在这过程中,内核timer模块在0.5秒时已将延时方法添加到任务队列,在引擎执行栈清空后,引擎将延时方法入栈并处理,最终输出的时间超过预期设置的时间。

前面事件循环模型图中提到的WebAPIs部分,提到了DOM事件,AJAX调用和setTimeout方法,图中简单的把它们总结为WebAPIs,而且他们同样都把回调函数添加到任务队列等待引擎执行。这是一个简化的描述,实际上浏览器内核对DOM事件、AJAX调用和setTimeout方法都有相应的模块来处理,webkit内核在Javasctipt执行引擎之外,有一个重要的模块是webcore模块,html的解析,css样式的计算等都由webcore实现。对于图中WebAPIs提到的三种API,webcore分别提供了DOM Binding、network、timer模块来处理底层实现,这里还是继续以setTimeout为例,看下timer模块的实现。

Timer类是webkit 内核的一个必需的基础组件,通过阅读源码可以全面理解其原理,本文对其简化,分析其执行流程。

webkittimer

通过setTimeout()方法注册的延时方法,被传递给webcore组件timer模块处理。timer中关键类为TheadTimers类,其包含两个重要成员,TimerHeap任务队列和SharedTimer方法调度类。延时方法被封装为timer对象,存储在TimerHeap中。和Java.util.Timer任务队列一样,TimerHeap同样采用最小堆的数据结构,以nextFireTime作为关键字排序。SharedTimer作为TimerHeap调度类,在timer对象到达触发条件时,通过浏览器平台相关的接口,将延时方法添加到事件循环模型中提到的任务队列中。

TimerHeap采用最小堆的数据结构,预期延时时间最小的任务最先被执行,同时,预期延时时间相同的两个任务,其执行顺序是按照注册的先后顺序执行。

上述代码输出依次为

参考资料

1.《Javascript异步编程》

2.JavaScript 运行机制详解:再谈Event Loophttp://www.ruanyifeng.com/blog/2014/10/event-loop.html

3.Philip Roberts: Help, I'm stuck in an event-loop.https://vimeo.com/96425312

4.How JavaScript Timers Work.http://ejohn.org/blog/how-javascript-timers-work/

5.How WebKit’s event model works.http://brrian.tumblr.com/post/13951629341/how-webkits-event-model-works

6.Timer实现.http://blog.csdn.net/shunzi__1984/article/details/6193023

原创文章转载请注明:

转载自AlloyTeam:http://www.alloyteam.com/2015/10/turning-to-javascript-series-from-settimeout-said-the-event-loop-model/

  1. 深入理解Web Worker-IT大道 2015 年 12 月 3 日

    […] 上一篇文章《从setTimeout说事件循环模型》从setTimeout入手,探讨了Javascript的事件循环模型。有别于Java/C#等编程语言,Javascript运行在一个单线程环境中,对setTimeout/setInterval、ajax和dom事件的异步处理是依赖事件循环实现的。作为一个转向Javascript的开发人员,很自然的产生一个疑问,如何实现Javascript多线程编程呢?随着学习的深入,我了解到HTML5 Web Worker,本文将分析Web Worker为Javascript带来了什么,同时带大家看看worker模型在其他语言的应用。 […]

  2. 沁湖边 2015 年 11 月 30 日

    求教一个问题。
    引用内容中一句话 “执行引擎先将setTimeout()方法入栈被执行,执行时将延时方法交给内核相应模块处理。引擎继续处理后面代码,while语句将引擎阻塞了1秒,而在这过程中,内核timer模块在0.5秒时已将延时方法添加到任务队列”
    也就是说 执行引擎 和 内核的timer 是不同线程。不会存在一个阻塞另一个。
    window.document.onclick = function (argument) {
    alert(“click”);
    }
    setTimeout(function (argument) {
    alert(“timeout”);
    },0)
    var now = (new Date()).getTime();
    while((new Date()).getTime() – now <3000){
    }
    这段代码, 在 document上 注册click事件。 下面阻塞3秒 。

    如果是 打开页面后过了2秒。 此时 setTimeout 中所要执行的事件早已被放在事件循环中了。
    然后 点击一下 。此时又把 click的的handle放在 事件循环中。
    此时事件队列中就应该是 [setTimeout,click-handle]。应该先执行 alert("timeout"),再执行 alert("click");

    但是,当阻塞结束后,却是先执行 alert("click"),再执行 timeout。

    这是为何???

    • 沁湖边 2015 年 11 月 30 日

      我对浏览器事件循环这一块也蛮感兴趣。可以加 qq,512315958 ,讨论一下。谢谢。

      • TAT.ronnie

        TAT.ronnie 2015 年 12 月 1 日

        阅读webkit相关文章可知,JS执行引擎 和 内核的timer是不同的线程。
        文章没提及,webkit对dom event和ajax请求也是由专用线程进行处理的
        回复中代码,按我的理解,首先为页面声明了一个click handler,然后调用setTimeout注册了一个期望立即执行的回调函数,然后页面阻塞3秒。此时在页面打开,至脚本开始运行的3秒内,UI线程是被while循环阻塞的,所以在这3秒内点击页面,不会触发click事件,只有在阻塞结束后,点击页面才会触发click事件,并将click handler添加至事件队列
        所以上述代码的实际执行顺序,是脚本开始后页面阻塞3秒,结束后alert(‘timeout’),之后响应页面点击事件,alert(‘click’)

        • 沁湖边 2015 年 12 月 3 日

          不是吧。
          你可以运行上面代码试试。
          把 3秒 改为10秒。
          打开页面过了 一段时间后,你点击一下页面即可。

          对了,我补充说明下。
          这个循环的 3秒内,点击是会产生 click事件的。 (测试浏览器 chrome 47.0.2526.73 (64-bit));
          应该所有chrome 都这样。

          ps: 我例子中也说明了。
          打开页面后,在阻塞的时间中过了 2秒后,点击一下。
          然后 什么操作也不做了。
          等循环结束, 会先 alert(“click”); 再alert timeout.

          你可以自己 事实,在死循环的时候,点击是否会产生 click。

          • WRB 2015 年 12 月 9 日

            好像只有在chrome里面会出现这种情况,其他浏览器的ui线程都会被while循环阻塞,所以像楼主说的,在循环的3秒内点击页面是不会触发click事件的。而在chrome里面可能dom event也有一个专门的线程吧,而且dom event线程的优先级比timer线程的优先级高吧。

            • 沁湖边 2015 年 12 月 10 日

              之前和一个搞浏览器的同事讨论过。他对我说的也是 dom event 优先级比 timer 高。不知道为何会有这样的设定。并且这在 queue 中如何实现(难倒每次dom event 往 queue中插入的时候会从后往前找?)。没看过 webkit 源码。所以有些疑问。

        • 小阳 2017 年 3 月 15 日

          我在Firefox 52下测试该代码发现虽然触发的顺序是一致的,但是在阻塞的3秒内点击页面还是会在3秒后先 alert(‘timeout’) 然后再 alert(‘click’) 。这个我猜想可能在Firefox中在阻塞的时候UI线程还是将点击的事件的回调函数注册到了 timer ,也就是在 setTimeout 注册之后,然后主线程空闲时就依次执行 timer 中的回调。而且在webkit 的浏览器中还是会在3秒内点击然后先 alert(‘click’) 然后 alert(‘timeout’) 。

  3. 梦璐 2015 年 11 月 23 日

    超值强文,帮你顶,^_^

  4. nemo 2015 年 10 月 30 日

    好文!

  5. newbility 2015 年 10 月 21 日

    感谢分享,学习了。

  6. 杨帆 2015 年 10 月 19 日

    调试工具是什么?

  7. 铜须龙与酒 2015 年 10 月 19 日

    不知道楼主有没有研究,settimeout方法造成内存泄漏的具体流程是怎么样的?

    • TAT.ronnie 2015 年 10 月 19 日

      settimeout的内存溢出问题资料比较多,对他造成的内存泄漏问题没有研究,可以介绍下?

  8. wZi 2015 年 10 月 19 日

    感谢分享。

    • TAT.ronnie 2015 年 10 月 19 日

      感谢支持

发表评论