JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准呢?所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

因为JavaScript 单线程,也就是说,同一个时间只能做一件事。为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,防止主线程的不阻塞,Event Loop 的方案应用而生。Event Loop 包含两类:一类是基于 Browsing Context,一种是基于 Worker。二者的运行是独立的,也就是说,每一个 JavaScript 运行的”线程环境”都有一个独立的 Event Loop,每一个 Web Worker 也有一个独立的 Event Loop。

下面我们介绍的事件循环是基于 Browsing Context。

进程与线程

  • 进程: 运行的程序就是一个进程,比如你正在运行的浏览器,它会有一个进程。
  • 线程: 程序中独立运行的代码段。一个进程 由单个或多个 线程 组成,线程是负责执行代码的。

我们都知道 JavaScript 是单线程的,那么既然有单线程就有多线程,首先看看单线程与多线程的区别:

  • 单线程: 从头执行到尾,一行一行执行,如果其中一行代码报错,那么剩下代码将不再执行。同时容易代码阻塞。
  • 多线程: 代码运行的环境不同,各线程独立,互不影响,避免阻塞。

    浏览器执行线程

    在解释事件循环之前首先先解释一下浏览器的执行线程:
    浏览器是多进程的,浏览器每一个 tab 标签都代表一个独立的进程,其中浏览器渲染进程(浏览器内核)属于浏览器多进程中的一种,主要负责页面渲染,脚本执行,事件处理等
    其包含的线程有:GUI 渲染线程(负责渲染页面,解析 HTML,CSS 构成 DOM 树)、JS 引擎线程、事件触发线程、定时器触发线程、http 请求线程等主要线程

关于执行中的线程:

主线程:也就是 js 引擎执行的线程,这个线程只有一个,页面渲染、函数处理都在这个主线程上执行。
工作线程:也称幕后线程,这个线程可能存在于浏览器或js引擎内,与主线程是分开的,处理文件读取、网络请求等异步事件。

任务队列(callback queue)

“任务队列”是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。主线程读取”任务队列”,就是读取里面有哪些事件。
“任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。
所谓”回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,”任务队列”上第一位的事件就自动进入主线程。但是,由于存在后文提到的”定时器”功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务。

在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:

  • 在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一次)
  • 检查是否存在 Microtasks,如果存在则不停地执行,直至清空 Microtasks Queue
  • 更新 render
  • 主线程重复执行上述步骤

在上诉tick的基础上需要了解几点:

  • JS分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。

image.png

同步任务、异步任务、宏任务、微任务

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。于是,广义上将 JavaScript 所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

同步任务

同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;

异步任务

异步任务指的是,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

JS的执行机制

  • 首先判断JS是同步还是异步,同步就进入主线程,异步就进入event table
  • 异步任务在event table中注册函数,当满足触发条件后,被推入event queue
  • 同步任务进入主线程后一直执行,直到主线程空闲时,才会去event queue中查看是否有可执行的异步任务,如果有就推入主线程中

以上三步循环执行,这就是event loop

image.png

除了广义上的定义,我们可以将任务进行更精细的定义,分为宏任务与微任务:

宏任务

(macro)task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:

(macro)task->渲染->(macro)task->…

宏任务包含:

  • script(整体代码)
  • setTimeout
  • setInterval
  • I/O
  • UI交互事件
  • postMessage
  • MessageChannel
  • setImmediate(Node.js 环境)

微任务

microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。

微任务包含:

  • Promise.then
  • Object.observe
  • MutaionObserver
  • process.nextTick(Node.js 环境)

具体来说,宏任务与微任务执行的运行机制如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
  • 主线程不断重复上面的步骤

以上两种运行机制,主线程都从”任务队列”中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)
image.png

setTimeout()、setInterval()

setTimeout() 和 setInterval() 这两个函数,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。setTimeout() 和 setInterval() 产生的任务是 异步任务,也属于 宏任务
如果将第二个参数设置为0或者不设置,意思 并不是立即执行,而是指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在”任务队列”的尾部添加一个事件,因此要等到同步任务和”任务队列”现有的事件都处理完,才会得到执行。setTimeout(function(){},3000) 是异步任务,先被放入event table, 3秒之后才被推入event queue里,而event queue(事件队列)里的任务,只有在主线程空闲时才会执行。

定时器包括setTimeout与 setInterval 两个方法。它们的第二个参数是指定其回调函数推迟/每隔多少毫秒数后执行。对于第二个参数有以下需要注意的地方:当第二个参数缺省时,默认为 0;当指定的值小于 4 毫秒,则增加到 4ms(4ms 是 HTML5 标准指定的,对于 2010 年及之前的浏览器则是 10ms);也就是说至少需要4毫秒,该setTimeout()拿到任务队列中。

所以说,setTimeout() 和 setInterval() 第二个参数设置的时间并不是绝对的,它需要根据当前代码最终执行的时间来确定的,简单来说,如果当前代码执行的时间(如执行200ms)超出了推迟执行(setTimeout(fn, 100))或反复执行的时间(setInterval(fn, 100)),那么setTimeout(fn, 100) 和 setTimeout(fn, 0) 也就没有区别了,setInterval(fn, 100) 和 setInterval(fn, 0) 也就没有区别了。

Promise

Promise 相对来说就比较特殊了,在 new Promise() 中传入的回调函数是会 立即执行 的,但是它的 then() 方法是在 执行栈之后,任务队列之前 执行的,它属于 微任务

process.nextTick

process.nextTick 是 Node.js 提供的一个与”任务队列”有关的方法,它产生的任务是放在 执行栈的尾部,并不属于 宏任务微任务,因此它的任务 总是发生在所有异步任务之前。

setImmediate

setImmediate 是 Node.js 提供的另一个与”任务队列”有关的方法,它产生的任务追加到”任务队列”的尾部,它和 setTimeout(fn, 0) 很像,但优先级都是 setTimeout 优先于 setImmediate。
有时候,setTimeout 的执行顺序会在 setImmediate 的前面,有时候会在 setImmediate 的后面,这并不是 node.js 的 bug,这是因为虽然 setTimeout 第二个参数设置为0或者不设置,但是 setTimeout 源码中,会指定一个具体的毫秒数(node为1ms,浏览器为4ms),而由于当前代码执行时间受到执行环境的影响,执行时间有所起伏,如果当前执行的代码小于这个指定的值时,setTimeout 还没到推迟执行的时间,自然就先执行 setImmediate 了,如果当前执行的代码超过这个指定的值时,setTimeout 就会先于 setImmediate 执行。

代码执行的优先级:

同步代码(宏任务) > process.nextTick > Promise(微任务)> setTimeout(fn)、setInterval(fn)(宏任务)> setImmediate(宏任务)> setTimeout(fn, time)、setInterval(fn, time),其中time>0

示例代码

  1. // 这是一个同步任务
  2. console.log('1') --------> 直接被执行
  3. 目前打印结果为:1
  4. // 这是一个宏任务
  5. setTimeout(function () { --------> 整体的setTimeout被放进宏任务列表
  6. console.log('2') 目前宏任务列表记为【s2
  7. });
  8. new Promise(function (resolve) {
  9. // 这里是同步任务
  10. console.log('3'); --------> 直接被执行
  11. resolve(); 目前打印结果为:13
  12. // then是一个微任务
  13. }).then(function () { --------> 整体的then[包含里面的setTimeout]被放进微任务列表
  14. console.log('4') 目前微任务列表记为【t45
  15. setTimeout(function () {
  16. console.log('5')
  17. });
  18. });
  19. 最终结果为: 13425
  1. process.nextTick(function () { // m1
  2. console.log('1');
  3. });
  4. function loop() {
  5. new Promise(function (resolve) {
  6. console.log('loop-2');
  7. resolve();
  8. }).then(function () { // m2
  9. console.log('loop-3');
  10. setTimeout(function () { // t3
  11. console.log('loop-4');
  12. });
  13. });
  14. //loop();
  15. }
  16. new Promise(function (resolve) {
  17. console.log('2');
  18. loop();
  19. resolve();
  20. }).then(function () { // m3
  21. console.log('3');
  22. setTimeout(function () { // t4
  23. console.log('4');
  24. });
  25. });
  26. setTimeout(function () { // t1
  27. console.log('5');
  28. });
  29. new Promise(function (resolve) {
  30. setTimeout(function () { //t2
  31. console.log('6');
  32. });
  33. resolve();
  34. }).then(function () { // m4
  35. setTimeout(function () { // t5
  36. console.log('7');
  37. new Promise(function (resolve) {
  38. setTimeout(function () { // t6
  39. console.log('8');
  40. });
  41. resolve();
  42. }).then(function () { //m5
  43. setTimeout(function () { // t7
  44. console.log('9');
  45. });
  46. });
  47. });
  48. });
  49. console.log('10');
  50. // 执行结果 microTask macroTask
  51. //
  52. // 2 nextTick-2 10 | m1 m2 m3 m4 | t1 t2 |执行代码(t0)
  53. // 1 | m2 m3 m4 | t1 t2 |执行代码(m1)
  54. // nextTick-3 | m3 m4 | t1 t2 t3 |执行代码(m2)
  55. // 3 | m4 | t1 t2 t3 t4 |执行代码(m3)
  56. // | | t1 t2 t3 t4 t5 |执行代码(m4)
  57. // 5 | | t2 t3 t4 t5 |执行代码(t1)
  58. // 6 | | t3 t4 t5 |执行代码(t2)
  59. // nextTick-4 | | t4 t5 |执行代码(t3)
  60. // 4 | | t5 |执行代码(t4)
  61. // 7 | m5 | t6 |执行代码(t5)
  62. // | | t6 t7 |执行代码(m5)
  63. // 8 | | t7 |执行代码(t6)
  64. // 9 | | |执行代码(t7)