作为一个从其他编程语言(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 日

      感谢支持

发表评论到 小阳