事件循环机制Event loop

4,092 阅读8分钟

导言

我们经常听到这样一句话,Javascript是一门在单线程环境下运行的语言,什么是单线程呢?就是同一时间只能做一件事那为什么他不能设计成多线程呢?这样就能在同一时间做多件事。

想法是挺美好的,但是呢,Javascript的诞生就是为了与用户交互,以及操作DOM,假设是多线程,其中一个线程的工作是删除某个DOM节点,另一个线程的作用又是在这个节点里面添加一些东西,这个时候我们该以哪个线程为主呢???

有的人可能会想到html5的web worker,他允许Javascript脚本同时创建多个线程,在mdn中,他是这样定义的: 也就是说web workers是子线程,由主线程控制,也就是说他相当于主线程的辅助,为了避免主线程被阻塞或者减速

浏览器下的事件循环机制

调用栈和任务队列

栈是一种结构化的内存,遵循LIFO(先进后出)的原则

队列也是一种结构化的内存内存,遵循FIFO(先进先出)的原则

  • 调用栈

上面说的是两种常见的数据结构,我们的调用栈(也叫执行栈)和上面的的定义又有点不同,它指的是一种代码的运行方式, 维基百科的定义是这样的:

 a call stack is a stack data structure that stores information about the active subroutines of a computer program

the caller pushes the return address onto the stack, and the called subroutine, when it finishes, pulls or pops the return address off the call stack and transfers control to that address. If a called subroutine calls on yet another subroutine, it will push another return address onto the call stack, and so on.

被调用函数或子例程的返回的地址会被推入执行栈中,当这个过程完成后,返回的地址就会从调用栈中弹出,并且将这种控制调用栈的权利转移给该地址

如果调用函数(子例程)中又调用了其他函数(子例程),就又会重复上述操作。

以函数举例,也就是说,当函数被调用使,他就会进入执行栈,然后执行函数,遇到返回(return)的时候,这个函数就会被弹出栈,如果函数返回的又是一个函数,就会实现一种层层调用

举个栗子🌰


function one() {
  return 1;
}

function two() {
  return one() + 1;
}

function three() {
  return two() + 1;
}

console.log(three());

他的调用栈如下(动图来源于下面第四个参考链接):

  • 任务队列(task queue)

javascript是单线程运行的,也就是说,所有的任务都是排队执行的,只有一个任务被处理完成后,才会去处理另一个任务。

如果有的任务处理需要消耗很多时间,就会带来阻塞

而javascript的一大特点就是非阻塞的,实现非阻塞主要是依靠任务队列这一机制。

Javascript引擎执行代码是一段一段执行的,在执行一段代码时,他会先判断所执行的任务是同步任务(synchronous)还是异步任务(asynchronous),如果是同步任务,那么直接进入主线程,异步任务执行完后,得到的回调函数,进入任务队列。

任务进入执行栈中,判断是同步还是异步

若是同步,直接进入主线程按照调用栈的顺序被执行

若是异步,则进行一些处理,再将回调函数推入任务队列

当主线程中的同步任务执行完成后,执行栈为空,开始读取任务队列

任务队列中的任务依次进入执行栈被执行,直到任务队列为空

完成

如图:

再举个栗子🌰

1 console.log('a');

2 setTimeout(function () {
  console.log('b');
}, 4000);

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

4 console.log('d');

执行的结果是什么呢?

a d c b

任务进行执行栈,遇到1,是同步任务,立即执行 于是答案中就有了a

再执行到23,是异步任务,进入异步处理模块进行处理, 由于3的delay比2短,3的结果先返回,32的回调函数先进入任务队列

再执行到4,是同步任务,立即执行,于是答案变成了a d

现在主线程的执行栈为空,任务队列中的任务依次入执行栈执行

所以最终答案为a d c b

这样就结束了??当然不是👻👻

  • 宏任务(macrotask)和微任务(microtask)

上述的异步任务只是一个宏观的概念,若对异步任务进行细分的话,又可以分为宏任务和微任务,他们之间的执行顺序又是不同的。

在异步任务回调函数进入任务队列前会对这个异步任务进行判断看他是宏任务还是微任务

宏任务进入宏任务队列,微任务进入微任务队列

在同步任务执行完成后,会先执行微任务队列的任务,直到微任务队列为空,再执行宏任务队列中的任务

循环往复,完成!!

常见的宏任务

整体代码script
I/O
setTimeout
setInterval
requestAnimationFrame

常见的微任务

MutationObserver
Promise的回调

再举个栗子🌰

1 console.log('a');

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

3 Promise.resolve()
  .then(function () {
    console.log('c');
  })
  .then(function () {
    console.log('d');
  });

4 console.log('e');

执行的结果是什么呢?

a e c d b

任务进行执行栈,遇到1,是同步任务,立即执行 于是答案中就有了a

再执行到2,是异步宏任务,进入异步处理模块进行处理, 他的回调函数进入宏任务队列

再执行到3,是异步微任务,进入异步处理模块进行处理,他的回调函数进入微任务队列

再执行到4,是同步任务,立即执行,答案变成a e

现在主线程的执行栈为空,先去查看微任务队列,3的回调函数进入执行栈执行,答案变成a e c, 这个函数返回'undefined',触发下一个回调函数,由于它又是异步微任务,进入异步处理模块进行处理,他的回调函数进入微任务队列

现在主线程中的执行栈又为空了,又去查看微任务队列,3的回调的回调进入执行栈执行后出栈,答案为a e c d

主线程中的执行栈又为空了,重复上面的步骤,微任务队列也为空,查看宏任务队列,执行宏任务队列中的任务

最后答案为a e c d b

完成 ✅

  • 思考一下🤔,下面的代码执行顺序又是怎样的呢
console.log(0);

setTimeout(function () {
    console.log(1);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})

console.log(4);

答案是

                                                                                                  0 2 4 3 1

Node下的事件循环机制

process.nextTick和setImmediate

Node也是单线程的运行环境,仅从api的角度来讲,相对于浏览器的event loop, 多了一个微任务的process.nextTick以及宏任务的setImmediate,

  • process.nextTick

process.nextTick的回调和Promise的回调都是微任务,但是process.nextTick的回调会比Promise的回调先执行 对于以下代码

process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
(() => console.log(5))();

交换位置后,得出来同样的结果

process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
(() => console.log(5))();

所以,如果我们想要一个异步任务能够尽快的执行,就可以使用process.nextTick

  • setImmediate setImmediate是宏任务,从官方的定义来讲,setImmediate会在一次Event loop中立即执行,但从运行上来讲,得到的结果确是不一定的(原因在后面),比如以下代码:
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));

事件循环的阶段

  • 事件循环解析
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

  • 阶段概述

timers: 执行setTimeout()setInterval() 的调度(看是否满足delay的要求)的回调函数,不满足将直接离开这个阶段

I/O callbacks: 执行延迟到下一个循环迭代的 I/O 回调 (也就是除了以下操作以外的回调)

setTimeout()和setInterval()的回调函数
setImmediate()的回调函数
用于关闭请求的回调函数,比如socket.on('close', ...)

idle, prepare: 仅libuv系统内部使用

Poll: 这个阶段是轮询阶段

在poll队列不为空的时候,会检索并执行新的I/O回调事件
如果为空:
  若调用了setImmediate(), 就结束poll阶段,直接进入check阶段,
  如果没有调用setImmediate(),就会等待,等待新的回调I/O事件的到来,然后立即执行
  如果脚本没有调用了setImmediate(),并且poll队列为空的时候,事件循环将检查哪些计时器 timer 已经到时间。 如果一个或多个计时器 timer 准备就绪,则事件循环将返回到计时器阶段,以执行这些计时器的回调,这也相当于开启了新一次的循环(tick)

check: 在这个阶段执行setImmediate() 的回调函数

close callbacks: 执行关闭请求的回调函数,比如socket.on('close', ...)

  • setTimeout和setImmediate

回到之前的问题,为什么这两句代码的执行顺序是不一样的呢?

setTimeout(() => console.log(1),0);
setImmediate(() => console.log(2));

照理来说,setTimeout在timers阶段,并且它回调执行的delay参数是0,而setImmediate在check阶段,但是nodejs官网关于setTimeout的定义有这样一句话

When delay is larger than 2147483647 or less than 1, the delay will be set to 1. Non-integer delays are truncated to an integer.

也就是说,下面这两个表达式是等价

setTimeout(() => console.log(1),0); === setTimeout(() => console.log(1),1);

而实际执行的时候,进入事件循环以后,有可能到了1ms,也有可能还没到,这取决于系统当时的状况。如果没到1ms,就会跳过timers,向下执行,到了check,先执行setImmediate,然后再在下一次循环中执行setTimeout

但是对于下面的代码,一定会先打印2,再打印1

const fs = require('fs');

fs.readFile('test.js', () => {
  setTimeout(() => console.log(1));
  setImmediate(() => console.log(2));
});

他的执行过程是会先跳过timers阶段,回调直接进入I/0 callback,然后向下执行,到了check阶段执行setImmediate,然后才在下一次循环的timers执行setTimeout

参考链接

维基百科中的调用栈

Tasks, microtasks, queues and schedules

JavaScript 运行机制详解:再谈Event Loop

How JavaScript Works: An Overview of JavaScript Engine, Heap, and Call Stack

Node定时器详解

node官网关于event loop的机制

node关于setTimeout的官方文档