JS Advance --- event loop

350 阅读10分钟

进程和线程

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

进程(process): 计算机已经运行的程序,是操作系统管理程序的一种方式

线程(thread): 操作系统能够运行运算调度的最小单位,通常情况下它被包含在进程中

我们可以认为,启动一个应用程序,就会默认启动一个进程(也可能是多个进程)

每一个进程中,都会启动至少一个线程用来执行程序中的代码,这个线程被称之为主线程

所以我们也可以说进程是线程的容器。

举例而言:

  1. 操作系统类似于一个大工厂
  2. 工厂中里有很多车间,这个车间就是进程
  3. 每个车间可能有一个以上的工人在工厂,这个工人就是线程

在实际运行的过程中,计算机使用的是时间片轮转的方式进行程序的运行, 而CPU的运算速度非常快,它可以快速的在多个进程之间迅速的切换。当我们进程中的线程获取到时间片时,就可以快速执行我们编写的代码,所以 对于用户来说是感受不到这种快速的切换的

JS是单进程单线程的,JavaScript的线程的容器进程: 浏览器或者Node

而浏览器或node,是多进程多线程的,例如, 当我们打开一个tab页面时就会开启一个新的进程,这是为了防止一个页面卡死而造成所有页面无法响应,整个浏览器需要强制退出。

而JS是单进程单线程的,这就意味着JavaScript的代码,在同一个时刻只能做一件事。

如果这件事是非常耗时的,就意味着当前的线程就会被阻塞

所以真正耗时的操作,实际上并不是由JavaScript线程在执行的

而是交给浏览器其他线程来完成这个耗时的操作比如网络请求、定时器,DOM事件等

我们只需要在特性的时候执行应该有的回调即可;

所以js需要一个队列来管理和维护我们执行完毕或到时间需要js执行的耗时操作,而这个队列就是事件队列

事件循环

I3pzWO.png

  1. js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。
  2. 当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列
  3. 被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务
  4. 如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码...
  5. 如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)

macrotask 和 microtask

事件循环中并非只维护着一个队列,事实上是有两个队列

  • 宏任务队列(macrotask queue): ajax、setTimeout、setInterval、DOM监听、UI Rendering等

  • 微任务队列(microtask queue): Promise的then回调 (包括对应的catch方法等)、 Mutation Observer API、queueMicrotask()等

两个队列的优先级:

  • main script中的代码优先执行(编写的顶层script代码) --- 主线程中的代码优先被执行
  • 执行任何一个宏任务之前(不是队列,是一个宏任务),都会先查看微任务队列中是否有任务需要执行
    • 也就是宏任务执行之前,必须保证微任务队列是空的
    • 如果不为空,那么就优先执行微任务队列中的任务(回调)

这个就像去银行办业务一样,先要取号进行排号, 一般上边都会印着类似:“您的号码为XX,前边还有XX人。”之类的字样。

因为柜员同时职能处理一个来办理业务的客户,这时每一个来办理业务的人就可以认为是银行柜员的一个宏任务来存在的,当柜员处理完当前客户的问题以后,选择接待下一位,广播报号,也就是下一个宏任务的开始。

所以多个宏任务合在一起就可以认为说有一个任务队列在这,里边是当前银行中所有排号的客户。 任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中

而且一个宏任务在执行的过程中,是可以添加一些微任务的, 就像在柜台办理业务,你前边的一位老大爷可能在存款,在存款这个业务办理完以后,柜员会问老大爷还有没有其他需要办理的业务,这时老大爷想了一下:“最近P2P爆雷有点儿多,是不是要选择稳一些的理财呢”,然后告诉柜员说,要办一些理财的业务,这时候柜员肯定不能告诉老大爷说:“您再上后边取个号去,重新排队”。

所以本来快轮到你来办理业务,会因为老大爷临时添加的“理财业务”而往后推。

也许老大爷在办完理财以后还想 再办一个信用卡?或者 再买点儿纪念币

无论是什么需求,只要是柜员能够帮她办理的,都会在处理你的业务之前来做这些事情,这些都可以认为是微任务。

在当前的微任务没有执行完成时,是不会执行下一个宏任务的 ( 你大爷永远是你大爷 )

基本案例

// 这个setTImeout没有设置第二个参数,也就是没有设置需要多久后执行对应的回调
// 因为第二个参数默认值是0,也就是这个setTimeout函数对应的回调会被立即加入到对应的macrotask中
setTimeout(function () {
  console.log("setTimeout1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2");
  });
});

// then|catch方法会被加入到microtask queue中
// 但是executor需要被立即执行的,也就是executor是位于主线程中的被执行的
new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("then1");
});

setTimeout(function () {
  console.log("setTimeout2");
});

console.log(2);

// queueMicrotask的回调函数会被直接加入到微任务执行队列中
queueMicrotask(() => {
  console.log("queueMicrotask1")
});

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3");
});

// => promise1
// => 2
// => then1
// => queueMicrotask1
// => then3
// => setTimeout1
// => then2
// => then4
// => setTimeout2
// 异步函数代码内部在遇到await关键字之前的代码都是同步执行的
async function async1 () {
  console.log('async1 start')
  await async2();
  // await之后的代码可以被认为放入到了async2返回的promise的then方法中
  // 所以await后边的代码会被放入到微任务队列中
  console.log('async1 end')
}

async function async2 () {
  // async放置到了await之后
  // 所以可以认为这里的代码其实就是promise的executor函数
  // 所以这里的代码也是会被同步执行的
  console.log('async2')
  
  // 默认返回了undefined,但是async函数需要返回一个promise
  // 所以其实这里返回的值是 Promise.resolve(undefined)
}

console.log('script start')

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

async1();

new Promise (function (resolve) {
  console.log('promise1')
  resolve();
}).then (function () {
  console.log('promise2')
})

console.log('script end')

// => script start
// => async1 start
// => async2
// => promise1
// => script end
// => async1 end
// => promise2
// => setTimeout
Promise.resolve().then(() => {
  console.log(0);
  return 4
}).then((res) => {
  console.log(res)
})

Promise.resolve().then(() => {
  console.log(1);
}).then(() => {
  console.log(2);
}).then(() => {
  console.log(3);
}).then(() => {
  console.log(5);
}).then(() =>{
  console.log(6);
})

// => 0
// => 1
// => 4
// => 2
// => 3
// => 5
// => 6
Promise.resolve().then(() => {
  console.log(0);
  // thenable对象会延迟一次微任务的执行
  return {
    // then方法中可能存在大量的代码
    // 所以thenable对象的then方法会单独使用一次微任务来执行then方法中的代码
    then: function(resolve) {
      resolve(4)
    }
  }
}).then((res) => {
  console.log(res)
})

Promise.resolve().then(() => {
  console.log(1);
}).then(() => {
  console.log(2);
}).then(() => {
  console.log(3);
}).then(() => {
  console.log(5);
}).then(() =>{
  console.log(6);
})

// => 0
// => 1
// => 2
// => 4
// => 3
// => 5
// => 6
Promise.resolve().then(() => {
  console.log(0);
  // Promise.resolve会延迟2次微任务执行
  return Promise.resolve(4)
}).then((res) => {
  console.log(res)
})

Promise.resolve().then(() => {
  console.log(1);
}).then(() => {
  console.log(2);
}).then(() => {
  console.log(3);
}).then(() => {
  console.log(5);
}).then(() =>{
  console.log(6);
})

// => 0
// => 1
// => 2
// => 3
// => 4
// => 5
// => 6

node中的事件循环

浏览器中的EventLoop是根据HTML5定义的规范来实现的,不同的浏览器可能会有不同的实现,而Node中是由 libuv实现的

I3v44G.png

在node中,js线程会将我们的一些耗时操作交给libuv来进行处理

libuv将这些耗时操作执行完毕以后,会将对应的结果放置到node的event queue中

js线程会在合适的时机,去事件队列中,执行对应的耗时操作返回的结果

所以,事件循环可以看成是一个桥梁,是连接着应用程序的JavaScript和系统调用之间的通道

  • 无论是我们的文件IO、数据库、网络IO、定时器、子进程,在完成对应的操作后,都会将对应的结果和回调函 数放到事件循环(任务队列)中

  • 事件循环会不断的从任务队列中取出对应的事件(回调函数)来执行

和 浏览器不同的时候, 在node中,js不单单只是存在于一个宏任务队列和一个微任务队列

而是存在很多个宏任务队列和多个微任务队列, 各个微任务和各个宏任务之间都有自己的先后执行顺序

在node中,一次完整的事件循环,就被称之为一次tick阶段,而每一个tick阶段又可以被划分为以下几个阶段

  1. 定时器(Timers): 本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数
  2. 待定回调(Pending Callback): 对某些系统操作(如TCP错误类型)执行回调,比如TCP连接时接收到 ECONNREFUSED --- 主要是一些系统方法的回调
  3. idle, prepare: 仅系统内部使用 --- 一般不用考虑
  4. 轮询(Poll): 检索新的 I/O 事件;执行与 I/O 相关的回调 --- 这是node中最常见的callback类型
  5. 检测(check): setImmediate() 回调函数在这里执行 --- 所以所有的定时器函数回调中,如果执行时间都是0的时候,setImmediate是最后一个被执行的定时器函数
  6. 关闭的回调函数: 一些关闭的回调函数,如:socket.on('close', ...)

而在这些任务中

  • 宏任务(macrotask): setTimeout、setInterval、IO事件、setImmediate、close事件
  • 微任务(microtask): Promise的then回调、process.nextTick、queueMicrotask

上述的事件还可以继续细分为:

微任务队列:

  • next tick queue: process.nextTick

  • other queue: Promise的then回调、queueMicrotask

宏任务队列:

  • timer queue: setTimeout、setInterval
  • poll queue: IO事件
  • check queue: setImmediate
  • close queue: close事件

具体的执行顺序如下:

  • next tick microtask queue
  • other microtask queue
  • timer queue
  • poll queue
  • check queue
  • close queue

简单示例

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

async function async2() {
  console.log('async2')
}

console.log('script start')

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

setTimeout(function () {
  console.log('setTimeout2')
}, 300)

setImmediate(() => console.log('setImmediate'));

process.nextTick(() => console.log('nextTick1'));

async1();

// 注意: 打印 nextTick2 虽然在 打印 async1 end 后执行
// 如果像浏览器只维护一个宏任务和一个微任务的宿主环境
// 其执行结果因为是先打印 async1 end,再打印 nextTick2
// 但是, 因为在node环境中,微任务中的nextTick操作会永远比其它的微任务队列优先执行,
// 所以在node中会先打印 nextTick2, 再打印 async1 end
process.nextTick(() => console.log('nextTick2'));

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

console.log('script end')

// script start
// async1 start
// async2
// promise1
// promise2
// script end
// nexttick1
// nexttick2
// async1 end
// promise3
// settimetout0
// setImmediate
// setTimeout2