xingbofeng.github.io icon indicating copy to clipboard operation
xingbofeng.github.io copied to clipboard

继microtask后,再谈event loop

Open xingbofeng opened this issue 6 years ago • 1 comments

本文灵感源于《深入浅入Node.js》第三章

先看microtasks

这是一道常见的无聊的面试题:

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

new Promise((resolve, reject) => {
  console.log(2);
  for (let i = 0; i < 10000; i++) {
    i === 9999 && resolve();
  }
  console.log(4);
}).then(() => {
  console.log(5);
});

console.log(6);

相信仔细看过之前的JavaScript中的tasks与microtasks可以很快给出答案,在Chrome 60.0浏览器中的输出顺序为:2、4、6、5、1。

这道题主要是两个点:

  • Promise属于microtask,而setTimeout回调属于task,对于JavaScript引擎而言,event loop的优先级是不同的,所以Promiseresolve会先于setTimeout的回调输出
  • Promise的构造函数参数是同步调用的,故2和4会先于6输出

Node是单线程吗

我们通常理解的是,JavaScript引擎是单线程的,那问题就是,没有多线程,如何处理非同步I/O、网络请求逻辑?

其实我们知道,在Node.js中,我们使用的是异步I/O,通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程做计算处理,通过线程之间的通信将I/O得到的数据进行传递,这种异步I/O的方式就是线程池。

所以,对于Node.js而言,这些非同步的逻辑均是由线程池实现的,因此,说Node是单线程这个说法是不准确的,仅对于你书写的JavaScript代码而言,可以说Node是单线程。

事件循环

Node.js进程启动时,Node.js会创建一个类似于while(true)的循环,每执行一次循环体的过程我们称之为一次Tick。每次Tick的过程就是查看是否有事件待处理,如果有,就取出事件相关回调函数。如果存在关联的回调函数,就执行它们。然后进入下一次循环,如果不再有事件处理,就退出进程。

在每次Tick的过程中,如何判断事件需要处理呢?这里引入的概念是观察者。每个事件循环都有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。

Node中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等,观察者对事件进行分类。

事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。

这时,在JavaScript线程层面的异步调用第一阶段就结束了,JavaScript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不管它是否阻塞I/O,都不会影响到JavaScript线程的后续执行。一旦线程池中的任务执行完毕,便会通知到JavaScript线程执行相关回调。

process.nextTick()方法与setImmediate()方法

JavaScript自身所有的setTimeoutsetInterval两个非I/O的异步API之外,Node.js自身还具有process.nextTicksetImmediate方法。

process.nextTick不是setTimeout(fn, 0)的别名。它更加有效率。事件轮询随后的Tick调用,会在任何I/O事件(包括定时器)之前运行。

setImmediate也是延时执行一个回调,callback函数会按照它们被创建的顺序依次执行。 每次事件循环迭代都会处理整个回调队列。 如果一个立即定时器是被一个正在执行的回调排入队列的,则该定时器直到下一次事件循环迭代才会被触发。

如果 callback 不是一个函数,则抛出 TypeError

按照观察者来分,process.nextTick属于idle观察者,setImmediate属于check观察者。在每一轮事件循环中,idle观察者先于I/O观察者,I/O观察者先于check观察者。

看下面这个例子就明白了:

process.nextTick(() => {
  console.log('nextTick延时执行1');
});

process.nextTick(() => {
  console.log('nextTick延时执行2');
});

setImmediate(() => {
  console.log('setImmediate延时执行1');
  process.nextTick(() => {
    console.log('强势插入');
  });
});

setImmediate(() => {
  console.log('setImmediate延时执行2');
});

console.log('正常执行');

这段代码摘自《深入浅出Node.js》 by 朴灵,但在我实际操作时,实际上由于现在已经是Node 8.4.0版本了,运行结果已经与原书不一致

读者可以自行下来运行一遍,便大概懂的几个观察者的优先级。

高性能Node服务器

一般说来服务器模型分为以下几种:

  • 同步式的服务,一次只能处理一个请求,处理时别的请求都处于等待状态。
  • 多线程的服务,如Apache,对每个请求启动一个线程来处理,但线程的创建和销毁以及切换需要消耗CPU资源,因而并不如单线程服务高效。
  • 基于事件的服务,如Node.jsNginx,无需进行线程上下文切换,消耗CPU资源较少。

然而,比起Nginx,虽然其自身具有反向代理和负载均衡机制,但其性能仍然不如Node.js服务器。

xingbofeng avatar Aug 31 '17 14:08 xingbofeng

Promise resolve会先于setTimeout的回调输出的原因感觉可以说的更清楚一点,执行顺序会先执行macrotask然后执行相应的microtask,而先执行Promise resolve是因为会把主进程算作一次macrotask,所以执行之后会限制性Promise resolve 也就是mircotask,然后再去执行setTimeout的回调也就是macrotask,如此往复。

nightInSummer avatar Nov 07 '17 02:11 nightInSummer