美文网首页
最后一次谈论EventLoop

最后一次谈论EventLoop

作者: hellomyshadow | 来源:发表于2020-05-11 19:38 被阅读0次

Event LoopJavaScript 异步编程的核心思想
JavaScript 是一门 单线程的、动态的、解释型的、跨平台的 语言。

单线程意味着同一时间只能做一件事,这是因为JavaScript生来作为浏览器脚本语言,主要用来处理与用户的交互、网络请求以及DOM操作,更注重用户体验。这就决定了它只能是单线程,否则会带来很复杂的同步问题,对于一种网页脚本来说就太复杂了!
比如一个JS线程在某个DOM节点上添加内容,而另一个JS线程删除这个DOM节点,那么浏览器就懵逼了^_^,不知道听谁的!

JavaScript是单线程,其实是JS引擎是单线程的,跨平台也是指JavaScript的宿主环境(多数是浏览器)是跨平台的。

浏览器

浏览器是JavaScript最主要的宿主环境,它是多进程、多线程的

多进程

Browser进程:浏览器的主进程,负责协调、主控。
第三方插件进程:每种类型的插件对应一个进程,仅当使用时才创建。
GPU进程:最多一个,用于3D绘制。
渲染进程:浏览器内核,默认每个Tab页面对应一个进程,互不影响,控制页面渲染、脚本执行、事件处理等等。有时候会优化,如多个空白Tab页会合并成一个进程。

在浏览网页时,同时打开十几个窗口就会占用十几个进程,整个计算机就会越来越慢。

  • 浏览器选择多进程方案的优点
    • 避免页面渲染影响整个浏览器
    • 避免第三方插件影响整个浏览器
    • 多进程充分利用多核优势
    • 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性

通俗讲就是,其中一个窗口崩溃了,也不会影响整个浏览器,其他页面照常运行。

多线程

浏览器的渲染进程(内核)是多线程的,主要有以下几类:

  • GUI线程
    • 绘制页面,解析HTMLCSS,构建DOM树,布局和绘制等
    • 页面重绘和回流
    • JS引擎互斥,也就是说JS的执行会阻塞页面更新
  • JavaScript引擎线程
    • 也称为JS内核,负责JS脚本程序的执行,如V8引擎
    • 负责执行准备好的事件,即定时器计数结束,或异步请求成功并返回正确的事件
    • 运行JS脚本期间,GUI线程都是处于挂起状态,被冻结。如果JS执行时间过长,就会造成页面渲染不连贯,导致阻塞页面渲染的感觉。
  • 事件触发线程
    • 负责将准备好的事件交给JS引擎线程执行,这些事件可以是当前执行的代码块如定时任务,也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等
    • 多个事件加入任务队列时需要排对等待,因为JS是单线程的。
  • 定时器线程
    • 负责执行异步的定时器类的事件,如setTimeout、setInterval
    • 定时器计数结束后,把注册的回调函数加入到任务队列的队尾
    • 为什么不采用JS引擎计数呢?因为JS引擎是单线程,如果处于阻塞状态就会影响计时的准确性,因此通过一个单独的线程来计数并触发回调函数。
  • 网络请求线程
    • 负责执行异步请求(XMLHttpRequest连接)
    • 主线程执行代码遇到异步请求时,就会把新开一个线程发起请求。监听到状态变更事件时,如果有回调函数,该线程会把它加入到JS引擎处理的任务队列队尾等待处理。

Event Loop

数据结构

  • 栈:stack,遵循后进先出(LIFO)原则的有序集合。在编译器和内存中存储基本数据类型、对象的指针、方法调用等
  • 队列:queue,遵循先进先出(FIFO)原则的有序集合
  • 堆:heap,基于树抽象数据类型的一种特殊的数据结构

既然JavaScript是单线程的,那么在处理耗时任务时,就会阻塞主线程,即浏览器一直卡着。为此,JavaScript有了同步异步的概念。

JS运行机制

JS运行机制.png
  • 执行栈:Call Stack
    所有同步任务都在主线程上执行,形成一个执行栈,因为JS是单线程的,所以执行栈每次只能执行一个任务。同步任务执行完后,由任务队列提供任务给执行栈执行。
  • 任务队列:Task Queue
    异步任务会被放置到Task Table(异步处理模块),当异步任务已经完成了,则压入任务队列中。
    所以,任务队列中存放的是已经完成的异步操作,而不是说注册一个异步任务就会被放进任务队列中。
    任务队列有多种,如宏任务队列microtask,微任务队列macrotask

当执行栈为空时,JS引擎会检查任务队列是否为空,如果有事件,则取来第一个任务压入执行栈中执行。所以本质上来说,JavaScript中的异步仍然还是同步行为!

正是因为JS是单线程,当异步任务完成时并不能一定立即得到响应,因为主线程可能正在处理其他同步任务,等空闲下来才会执行那些已经完成的异步任务(的回调函数)。这也就是为啥定时器并不可能精确地在指定时间输出回调函数的结果。

JS引擎的这种处理同步和异步操作的机制,称为事件循环-- Event Loop

结合WikipediaEvent Loop 的解释,简单来说就是Event Loop是一个程序结构,用于等待和分派消息和事件。
通俗地说,Event Loop是浏览器或Node的一种协调JavaScript单线程运行时不会阻塞的一种机制。

异步任务又分为微任务、宏任务,在ES6规范中,微任务称为Jobs宏任务称为Task

  • 宏任务
    script(整体代码)、setTimeout、setInterval、I/O、事件、postMessage、MessageChannel、setImmediate(Node)
  • 微任务
    Promise.then、MutationObserver(H5新特性)、process.nextTick(Node环境)

注意:Promise.then是微任务,但 Promiseexecutor 是一个同步函数!

function Promise(executor) {
    executor(resolve, reject)
}

另外,Promise 可以连续的 .then(),在第一个 then 没有执行结束之前,第二个 then 是不会加入微任务队列的!

浏览器EventLoop

事件执行顺序

  1. 执行同步任务,无需特殊处理,顺序执行 --> 第一轮从 script 开始,直到同步任务执行结束
  2. 从宏任务队列中取出第一个任务执行
    • 如果产生了宏任务,则放入宏任务队列,等待下次轮循执行
    • 如果产生了微任务,则放入微任务队列。
  3. 执行完当前宏任务之后,取出微任务队列中的所有任务依次执行
  4. 如果微任务执行过程中产生了新的微任务,仍然放入本次轮循的微任务队列等待执行,直到微任务队列为空
  5. 轮循,重复 2-4 步骤

举个栗子

    console.log('script start')
    setTimeout(function () {
        console.log('setTimeout')
    }, 0)
    new Promise((resolve, reject) => {
        console.log("promise1")
        resolve()
    }).then(() => {
        console.log("then11")
        new Promise((resolve, reject) => {
            console.log("promise2")
            resolve();
        }).then(() => {
            console.log("then2-1")
        }).then(() => {
            console.log("then2-2")
        })
    }).then(() => {
        console.log("then12")
    })
    new Promise((resolve, reject)=>{
        console.log("promise3")
        resolve()
    }).then(()=>{
        console.log("then31")
    })
    console.log('script end')
  1. console.log('script start') 执行同步任务, 输出script start
  2. setTimeout 产生宏任务,注册到宏任务队列表,计数结束会进入宏任务队列-- [setTimeout],下次 Event Loop 执行;
  3. new Promise (promise1) 的构造声明(同步任务),输出promise1,执行 resolve() 匹配到第一个then,产生微任务,进入微任务队列-- [then11]
  4. new Promise (promise3) 的构造声明,输出promise3resolve() 匹配到then,进入微任务队列-- [then11, then31]
  5. console.log('script end') 执行同步任务,输出script end,同步任务执行结束,任务栈为空;
  6. 检查微任务队列,取出第一个微任务 then11 压入执行栈执行,输出then11;向下遇到 promise2 的构造声明,输出promise2resolve() 匹配到第一个then,进入微任务队列 -- [then31, then2-1]
  7. then11执行结束,匹配到 promise1 的第二个then,进入微任务队列 -- [then31, then2-1, then12]
  8. 继续轮循微任务队列,取出then31输出then31 -- [then2-1, then12]
  9. 取出微任务队首then2-1输出then2-1,向下触发 promise2 的第二个then,进入微任务队列 -- [then12, then2-2]
  10. 取出微任务then12输出then12
  11. 取出微任务then2-2输出then2-2
  12. 微任务执行完毕,本次轮循结束。清空任务栈,进入下一次轮循;
  13. 取出宏任务队列的第一个任务setTimeout输出setTimeout

结果: script start -> promise1-> promise3 -> script end -> then11 -> promise2 -> then31-> then2-1 -> then12 -> then2-2 -> setTimeout

微任务存在的原因

宏任务和微任务都是异步任务,简单来说,微任务的存在就是为了及时处理一些任务,以免等到最后再执行的时候拿到已经污染的数据。
比较经典的一个案例:在银行排队办理业务,每个排号人就是一个宏任务,当叫到你时,表示一个宏任务开始执行。你在办理存款业务后,又想咨询一下最近的理财情况,这就会产生一个微任务,柜员不可能再让你去重新排号,而是立即给你讲解,即微任务执行。而且,在当前的微任务没有执行完成时,也不会执行下一个宏任务。

宏任务和微任务的真实面目

其实JavaScript中并不存在什么宏任务和微任务,Chrome源码中也没有相关的代码或说明!
JS大会上提到过微任务这个名词,但并没有说明什么是微任务。
个人认为,宏任务和微任务只是为了方便学习JavaScript而总结出来的概念。

宏任务的理解

Chrome里,每个Tab页面就对应一个进程,且该进程由包含诸如GUI线程、JS引擎线程、定时器线程、网络线程等等,这些线程之间的通信是通过向任务队列中添加一个任务(postTask)来实现的。宏任务的本质可认为是多线程事件循环或消息循环,也就是线程间通信的一个消息队列
setTimeout来说,当执行它时,浏览器会通过Event Loop:我有一个任务交给你。Event Loop:好的,我把它加入我的todoList中,之后我会执行它,它是需要调用API的。
宏任务的真实面目是浏览器派发的,与JS引擎无关,参与了 Event Loop 调度的任务

微任务的理解

微任务是在运行宏任务/同步代码时产生的,属于当前任务,所以它不再需要浏览器的支持,内置在JS当中,不需要API支持,直接在JS引擎中执行。

Event Loop 遇到 async/await
console.log('script start')

async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
}
async1()

setTimeout(function () {
    console.log('setTimeout')
}, 0)

new Promise(resolve => {
    console.log('Promise')
    resolve()
}).then(function () {
    console.log('promise1')
}).then(function () {
    console.log('promise2')
}).then(function () {
    console.log('promise3')
})

console.log('script end')

await 只会影响 async 函数内的执行顺序,async函数之外的代码正常执行。
async/await 是生成器generator的语法糖,分析时可以转化为Promise.resolve().then()

console.log('script start')
function async1() {
    Promise.resolve(async2()).then(() => {
        console.log('async1 end')
    })
}
function async2() {
    console.log('async2 end')
}
async1()
setTimeout(function () {
    console.log('setTimeout')
}, 0)
new Promise(resolve => {
    console.log('Promise')
    resolve()
}).then(function () {
    console.log('promise1')
}).then(function () {
    console.log('promise2')
}).then(function () {
    console.log('promise3')
})
console.log('script end')

在执行 async1() 时,Promise.resolve(async2())是同步代码,则会先执行 async2()输出async2 end,然后匹配到then,产生微任务,加入微任务队列--[async1 end]
执行结果:script start -> async2 end -> Promise -> script end -> async1 end -> promise1 -> promise2 -> promise3 -> setTimeout

其实这一题是颇具争议的,原因是await之后的代码[async1 end] 与当前循环的其他微任务[promise1, promise2, promise3] 的执行顺序问题。
不同浏览器甚至同一浏览器的不版本,执行顺序也是不同的。在Chrome73(金丝雀)版本之前,V8在处理async/await时很暴力地额外增加了两个Promise,致使await之后的代码会在执行两轮微任务之后执行--> [promise1, promise2, async1 end, promise3]
为了降低V8的损耗,Chrome73版本减少了一层Promise,使用 PromiseResolveasync 做了优化,立即把await之后的代码加入微任务队列,执行顺序正如题目中所分析的那样。

Node Event Loop

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。Node采用 V8 作为 JS 的解析引擎,而 I/O 处理方面使用了自己设计的libuv
libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。

事件循环机制的六个阶段

Node有多个宏任务队列,而浏览器只有一个!

  1. Timers:计时器,执行 setTimeout、setInterval 的回调函数,由Poll控制
  2. I/O Callbacks:处理一些上一轮循环中的少数未执行的 I/O 回调(网络、流、文件操作等)
  3. Idel,Prepare:闲置阶段,Node内部使用
  4. Poll:轮循,是一个至关重要的阶段,适当的条件下会阻塞在这里,但不会一直阻塞;
    1. 这个阶段主要做两件事:[ 回到 Timer 阶段执行回调 ], [ 执行 I/O 回调 ]
    2. 如果Timers阶段已经设置了定时器,Poll会检查计数是否结束,如果计数超时且I/O队列为空,则回到Timers阶段执行回调;
    3. 如果没有设置定时器,会发生两件事:
      • 如果Poll(I/O)队列不为空,会遍历队列并同步执行,直到队列为空或者达到系统限制;
      • 如果队列为空,也会发生两件事:
        • 如果有 setImmediate 需要执行,则进入Check阶段执行回调;
        • 如果没有setImmediate,则等待I/O回调被加入Poll队列,会有一个超时时间,以防一直等待。
  5. Check:检查,处理 setImmediate 的回调函数
  6. Close Callbacks:关闭回调,如socket.on('close')
    Close Callbacks 是最后一个阶段,然后主线程会去检查是否还有异步任务,如果有,则继续重新轮询,否则程序结束。
轮循顺序
  • 每个阶段对应一个宏任务队列,相当于对宏任务做了一个分类;
  • 每个阶段都要等待对应的宏任务队列执行完,才会进入下一个阶段的宏任务队列;
    Timers -> I/O Callbacks -> Poll -> setImmediate -> Close Callbacks
  • 各个阶段之间执行微任务队列。
Event Loop 过程
  1. 执行全局的script同步代码;
  2. 执行微任务队列,先执行所有的 nextTick 队列中的所有任务,再执行其他的微任务队列中的所有任务;
  3. 开始执行宏任务,共六个阶段,从第一个阶段开始执行自己宏任务队列中的所有任务(浏览器是从宏任务队列中取出第一个任务执行!)
  4. 每个阶段的宏任务执行完毕后,开始执行微任务
  5. TimersQueue -> 步骤2 -> I/O Queue -> 步骤2 -> CheckQueue -> 步骤2 -> CloseQueue -> 步骤2 -> TimersQueue ->...

值得注意的是,nextTick是一个独立的队列,优先级高于微任务,所有在当前宏任务/同步任务之后完毕后,会先执行nextTick队列中的所有任务,然后再去执行微任务队列中的所有任务。

setTimeout setImmediate process.nextTick
  1. setTimeoutsetInterval 的区别除了执行频次不同,其他都相同。
    在底层创建一个setTimeout的中间对象,并放到实现定时器的红黑树中,每次tick开始时,都会到这个红黑树中检查是否存在超时的回调,如果存在,则一一按照超时顺序取出来加入任务队列。
    因此,可以得出这样一个结论:JS定时器是不可靠的,因为单线程,如果一个tick耗时过长,其后触发的定时器都将被延迟
    特点:精确度不高,可能延迟执行,且因为动用了红黑树,所以消耗资源较大;
  2. setImmediate 用于把一些需要长时间运行的操作放在一个回调函数中,并在浏览器完成其他操作后立即运行回调函数。从定义上来看是为了防止一些耗时长的操作阻塞后面的操作,所以属于比较靠后的Check阶段。
    特点:消耗的资源小,也不会造成阻塞,但效率也是最低的。
  3. process.nextTick 会加入一个单独的微任务队列,优先级高于其他微任务。
    特点:效率最高,消费资源小,但会阻塞CPU的后续调用;
  4. setTimeout采用的类似I/O观察者setImmediate采用的是Check观察者,而process.nextTick()采用的是Idle观察者
    执行优先级:Idle观察者 > I/O观察者> Idle观察者
  5. setTimeout(fn, 0)看似与setImmediate(fn)效果相同,然而事实并非如此!**
  setTimeout(() => {
      console.log('setTimeout')
  }, 0)
  setImmediate(() => {
      console.log('setImmediate')
  })

多次执行这段代码会发现,两者的顺序并不固定。虽然Timers阶段确实早于Check阶段,但setTimeout的计数真的结束了吗?
在浏览器中,setTimeout的最小延迟是4msNode中的最小值是1ms
在代码启动、运行时会消耗一定的事件,如果在Event Loop检查到Timers阶段之前的耗时>=1ms,那么就执行setTimeout的回调函数,即setTimeout早于setImmediate;反之,setImmediate早于setTimeout
利用这个原理,就可以控制setTimeoutsetImmediate的先后顺序,比如:

  • 可以手动延长启动时间,让setTimeout始终早于setImmediate
  • setTimeoutsetImmediate放入读取文件的回调中,让程序从I/O Callbacks阶段开始执行,那么setImmediate将早于setTimeout
    require('fs').readFile('./xxx', () => {
        setTimeout(() => {
            console.log('setTimeout')
        }, 0)
        setImmediate(() => {
            console.log('setImmediate')
        })
    })
    
  1. 考虑到 setTimeout 需要使用红黑树,且计数设置为0时会被Node强制转换为1,存在性能上的问题,所以对于 setTimeout(fn, 0) 建议替换为setImmediate / process.nextTick
  2. 最后一个栗子
async function async1() {
    console.log("async1 start");   // 2
    await async2();
    console.log("async1 end");   // 7
}
async function async2() {
    console.log('async2');      // 3
}
console.log("script start");   // 1
setTimeout(function () {
    console.log("setTimeout");   // 9
});
async1()
new Promise(function (resolve) {
    console.log("promise1");   // 4
    resolve();
}).then(function () {
    console.log("then11");   // 8
});
setImmediate(() => {
    console.log("setImmediate")   // 10
})
process.nextTick(() => {
    console.log("process")   // 6
})
console.log('script end');    // 5

执行顺序:script start -> async1 start -> async2 -> promise1 -> script end -> process -> async1 end -> then11 -> setTimeout -> setImmediate

Node11.x

Node11.x开始,Event Loop的运行原理发生了一些变化,一旦执行了一个阶段里的宏任务(setTimeout、setImmediate),就立即执行其微任务队列,这一变化与浏览器端一致了。

setTimeout(() => console.log('timeout1'))
setTimeout(() => {
    console.log('timeout2')
    Promise.resolve().then(() => console.log('promise resolve'))
})
  • node 10 及之前的版本
    要考虑上一个定时器执行完成时,下一个定时器是否到时间并加入了任务队列中,如果未到时间,先执行其他的代码。
    timer1 执行完之后 timer2 到了任务队列中,顺序为 timer1 -> timer2 -> promise resolve
    timer1 执行完之后 timer2 还没到任务队列中,顺序为 timer1 -> promise resolve -> timer2
  • node 11 及其之后的版本
    timeout1 -> timeout2 -> promise resolve

相关文章

网友评论

      本文标题:最后一次谈论EventLoop

      本文链接:https://www.haomeiwen.com/subject/ajyjnhtx.html