同步、异步? EventLoop(JS执行机制)?

300 阅读5分钟

JS 是单线程语言

也就是说,同一时间只能做一件事。

JS 是单线程(同步)的原因:防止出现矛盾。

  如果 JS 是多线程的话,那么对于一个 dom 元素,若一个线程删除掉这个元素,另一个线程要修改这个元素,这是就会出现矛盾。

JS 需要多线程(异步)的原因:节省程序执行时间。

  JS 是单线程的,单线程在执行程序时是按顺序自上而下执行的,排在前面的代码处理好后才会执行后面的代码。若 JS 没有异步的话,当前面的代码执行耗时较长,但这段代码与后面的代码没有关联,这个时候就会导致后面的代码被堵塞,影响用户体验。

JS 执行机制 —— EventLoop

为了提高网页的运行效率,将 JS 的任务分为了两类: 同步任务异步任务。比如网页的渲染就是同步任务,而资源加载则是异步任务。他们的执行机制可以用下图概括:

2005247-20200517183922042-212707888.jpg

  • 同步任务和异步任务的执行场所不同。同步任务在主线程执行,异步任务进入任务列表(Event Table)并注册回调函数,然后把这个函数放进任务队列(Event Queue)中。
  • 主线程的任务执行完毕为空后,就从任务队列(Event Queue)中读取对应函数,调入主线程执行。
    (JS 引擎中的监控进程会不断检查主线程执行栈是否为空。一旦为空就从 Event Queue 调取函数。)
  • 以上过程 Loop,就是EventLoop。

除了广义的同步任务和异步任务的区分,我们对异步任务还有更精细的定义:

  • 宏任务(macro-task):包括整体代码script,setTimeout,setInterval;
  • 微任务(micro-task):Promise.then()/.catch()/.finally(),process.nextTick

而在一个EventLoop里面,还有这样的执行顺序。

2005247-20200517185747509-627597523.jpg

关于 EventLoop 的执行规则:

  1. 不同类型的任务会进入对应的任务队列(Event Queue)。比如执行一个宏任务,在这个过程中如果遇到微任务,就将所有微任务都放到微任务的事件队列中,当遇到宏任务时,则会把宏任务放在宏任务的事件队列。

  2. 事件循环的顺序,决定 JS 代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务,找到其中一个宏任务的事件队列执行完毕,再执行所有的微任务。

  3. 如果在执行微任务的过程中,又产生了微任务,那么会将其加入到微任务队列的末尾,也会在这个周期被调用执行,即在一个宏任务执行的过程中遇到的微任务都会在这个宏任务周期内执行,微任务会按照队列顺序执行。

在执行的过程中,如果遇到同步代码,则会先执行,即使是像 for 循环这种需要花费时间的。
代码示例:

setTimeout(function(){
     console.log('定时器开始啦')
 });
 
 new Promise(function(resolve){
     console.log('马上执行for循环啦');
     for(var i = 0; i < 10000; i++){
         i == 88 && resolve();
     }
 }).then(function(){
     console.log('执行then函数啦')
 });
 
 console.log('代码执行结束');
  • 开始第一次循环,首先进入整体代码 script (宏任务)。
  • setTimeout是异步任务,将其回调函数注册后分发到宏任务的事件队列。
  • new Promise会立即执行,进入主线程执行 console.log('马上执行for循环啦') ; 后面的 then 是异步任务,则发给微任务的事件队列。
  • console.log('代码执行结束')进入主线程执行。
  • 这时候主线程没宏任务执行了,看看有哪些微任务?我们发现了 then 在微任务(Event Queue)里面,执行console.log('执行then函数啦')
  • 第一轮事件循环结束,开始第二轮。从宏任务(Event Queue)开始。这时发现里面还有个 setTimeout 对应的回调函数,执行console.log('定时器开始啦')
定时器函数:
 setTimeout(function(){
    console.log('执行了')
 },3000)

上面的代码,我们一般说3秒后会执行回调函数里面的代码,但这种说法并不严谨,要记住 JS 是单线程,如果3秒后 JS 还在执行其他的代码,就不会执行定时器里的回调函数,得等到主线程空闲的时候才会执行,也有可能10秒后执行。

总结

  1. JS 的异步
    我们从最开头就说 javascript 是一门单线程语言,不管是什么新框架新语法糖实现的所谓异步,其实都是用同步的方法去模拟的,牢牢把握住单线程这点非常重要。

  2. 事件循环
    EventLoop 事件循环是 JS 实现异步的一种方法,也是js的执行机制。

  3. javascript 的执行和运行
    执行和运行有很大的区别,javascript在不同的环境下,比如node,浏览器,Ringo等等,执行方式是不同的。而运行大多指javascript解析引擎,是统一的。

  4. setImmediate
    微任务和宏任务还有很多种类,比如 setImmediate 等等,执行都是有共同点的,有兴趣的同学可以自行了解。

最后

javascript 是一门单线程语言
EventLoop 是 javascript 的执行机制


最后的最后来段复杂代码测试一下看懂了没有?懂了可以直接跳过。

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

// 1, 7, 6, 8, 2, 4, 3, 5, 9, 11, 10, 12

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,遇到 console.log,输出1。
  • 遇到 setTimeout,其回调函数被分发到宏任务事件队列中。我们暂且记为 setTimeout1。
  • 遇到 process.nextTick(),其回调函数被分发到微任务事件队列中。我们记为 process1。
  • 遇到 Promise,new Promise 直接执行,输出7。then 被分发到微任务事件队列中。我们记为 then1。
  • 又遇到了 setTimeout,其回调函数被分发到宏任务事件队列中,我们记为 setTimeout2。

第一轮主线程宏任务执行完成,已经输出了 1 和 7。

此时的任务列表情况如下:
宏任务 Event Queue : setTimeout1、setTimeout2
微任务 Event Queue : process1、then1

  • 我们发现了微任务事件列表有 process1 和 then1 两个微任务。
  • 执行 process1,输出 6。
  • 执行 then1,输出 8。

第一轮事件循环正式结束,这一轮的结果是输出 1,7,6,8。

那么通过查看第一轮宏任务事件列表,第二轮由 setTimeout1 宏任务开始

  • 首先输出 2。接下来遇到了 process.nextTick(),同样将其分发到微任务事件列表中,记为 process2。new Promise 立即执行输出 4,then 也分发到微任务事件列表中,记为 then2。

第二轮主线程宏任务执行完成,已经输出了 2 和 4。

此时的任务列表情况如下:
宏任务 Event Queue : setTimeout2
微任务 Event Queue : process2、then2

  • 我们发现了微任务事件列表有 process2 和 then2 两个微任务。
  • 执行 process2,输出 3。
  • 执行 then2,输出 5。

第二轮事件循环正式结束,第二轮输出 2,4,3,5。

第三轮由 setTimeout2 宏任务开始:

  • 直接输出 9。
  • 将 process.nextTick() 分发到微任务事件列表中。记为 process3。
  • 直接执行 new Promise,输出 11。
  • 将 then 分发到微任务事件列表中,记为 then3。

第三轮主线程宏任务执行完成,已经输出了 9 和 11。

此时的任务列表情况如下:
宏任务 Event Queue : 空
微任务 Event Queue : process3、then3

  • 我们发现了微任务事件列表有 process3 和 then3 两个微任务。
  • 执行 process3,输出 10。
  • 执行 then3,输出 12。

第三轮事件循环结束,第三轮输出 9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为 1,7,6,8,2,4,3,5,9,11,10,12。(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)