js的单线程和异步

js的单线程和异步

JavaScript是单线程的,即负责编译并执行js代码的线程只有一个。但对于数据库 文件系统等I/O操作,包括HTTP请求等等这些容易堵塞等待的操作通常会异步处理。
异步,我们通常的理解就是,多个任务可以同时执行,而同步则是必须是一个任务完成之后,下一个任务才能开始执行。那么平时我们所说的异步(如ajax的异步加载)不就是和单线程冲突吗?
其实,JavaScript执行的平台通常是在浏览器,这里的异步是主要由浏览器完成的。现在把JavaScript的单线程称为主线程/执行线程,在一个浏览器中只能有一个JavaScript的执行线程,即在同一个浏览器打开多个页面的时候,只有一个js主线程在解析代码,实现异步的实际是浏览器除了js的主线程之外还有很多其他的线程。异步执行的步骤大致为

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

其中任务队列中秉承先进先出的原则,回调函数会按顺序放进队列的尾部。当为函数设置了setTimeout()的时候,在主线程的执行栈清空之后,任务队列中的回调函数才会开始执行,期间主线程会首先要检查一下该事件的执行时间,某些事件只有到了规定的时间,才能返回主线程。

1
2
3
setTimeout(function(){console.log(1)},1000);
console.log('2');
// 2 1

其中setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。但它仍是按顺序在”任务队列”的尾部添加一个事件,因此要等到同步任务和”任务队列”前面的事件都处理完,才会得到执行。

上述的主线程从任务队列中读取事件又称为event loop。主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。

node.js中的Event Loop

实际上,主线程一直在做的一件事情就是读取和执行,若执行栈非空则执行任务,直至执行栈为空则读取任务进入执行。而当遇到异步I/O操作和定时器的时候,才会进入eventloop并把相应的回调结果放进任务队列,等待进入执行栈。下列为eventloop中各阶段执行顺序
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘

而在文档中,eventloop各阶段解释如下:

timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
I/O callbacks: executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate(). //处理I/O异常错误
idle, prepare: only used internally. //只用于内部
poll: retrieve new I/O events; node will block here when appropriate.
check: setImmediate() callbacks are invoked here.
close callbacks: e.g. socket.on(‘close’, …).//处理各种close事件回调

其中:

Between each run of the event loop, Node.js checks if it is waiting for any asynchronous I/O or timers and shuts down cleanly if there are not any.

在不同的eventloop之间,node.js会先检查是否有任何异步的I/O操作和定时器,即若程序仅有一句话(var a= ‘hello’; ),处理完目标代码后,不会进入eventloop,而是直接结束程序。

eventloop最重要的三个阶段及其顺序为
timers(setTimeout&setInterval) -> poll(I/O) -> check(immediates)

  • timers
    执行定时任务(setTimeOut和setInterval),将达到延时的回调依次执行完。其中该阶段中定时器中嵌套的setTimeOut和setInterval都不会在该次eventloop中执行,即使已到达延时。(不一定会准确按照设置的定时阈值执行,操作系统调度或其他回调的运行可能会延迟它们。)
  • poll 轮询
    获取新的I/O事件, 适当的条件下node将阻塞在这里
    • 执行轮询队列中的事件
    • 若轮询队列为空 且设置了setImmediate,终止轮询阶段,进入check阶段执行
    • 若轮询队列为空 而没设置setImmediate,检查是否有定时器任务到期,有就回到timers阶段,执行回调函数;若无,eventloop将阻塞在该阶段等待回调加入轮询队列(有时间限制)。
      因而timers和I/O回调的执行顺序并不一定,下述例子可以帮助理解运行原理
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      var fs = require('fs');
      function someAsyncOperation (callback) {
      var time = Date.now();
      // 花费9毫秒
      fs.readFile('/path/to/xxxx.pdf', callback);
      }
      var timeoutScheduled = Date.now();
      var fileReadTime = 0;
      var delay = 0;
      var immediateTime=0;
      setTimeout(function () {
      delay = Date.now() - timeoutScheduled;
      console.log('timeout');
      }, 5);
      setImmediate(function immediate () {
      immediateTime= Date.now() - timeoutScheduled;
      console.log('immediate');
      });
      someAsyncOperation(function () {
      fileReadtime = Date.now();
      while(Date.now() - fileReadtime < 20) {
      }
      console.log('setTimeout: ' + (delay) + "ms have passed since I was scheduled");
      console.log('fileReaderTime',fileReadtime - timeoutScheduled);
      });
      console.log('immediate:',immediateTime);
1
2
3
4
5
6
-> node eventloop.js
timeout
immediate // settimeout和setimmediate顺序不定,经测试在另一台机器可能会相反
setTimeout: 8ms have passed since I was scheduled
fileReaderTime 12 // 即settimeout先于I/O回调执行
immediate:9

解释:
当时程序启动时,event loop初始化:

  1. timer阶段(无callback到达,setTimeout需要5毫秒)
  2. I/O callback阶段,无异步I/O完成
  3. 忽略
  • check
    只执行一个最先设置的setimmediate回调,若timers和poll阶段若设置了setimmediates的回调,并且此回调是最先设置的回调,则该回调会在该次event loop的check阶段执行。
    注意:新版node.js会把所有的setimmediate的都先执行!!!
    下面这个例子(来自《深入浅出Node.js》,node.js版本为0.10.13)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //加入两个nextTick的回调函数
    process.nextTick(function () {
    console.log('nextTick延迟执行1');
    });
    process.nextTick(function () {
    console.log('nextTick延迟执行2');
    });
    // 加入两个setImmediate()的回调函数
    setImmediate(function () {
    console.log('setImmediate延迟执行1');
    process.nextTick(function () {
    console.log('强势插入');
    });
    });
    setImmediate(function () {
    console.log('setImmediate延迟执行2');
    });
    console.log('正常执行');

老版本的Node会优先执行process.nextTick。
当process.nextTick队列执行完后再执行一个setImmediate任务。然后再次回到新的事件循环。所以执行完第一个setImmediate后,队列里只剩下第一个setImmediate里的process.nextTick和第二个setImmediate。所以process.nextTick会先执行。
书中结果为

1
2
3
4
5
6
正常执行
nextTick延迟执行1
nextTick延迟执行2
setImmediate延迟执行1
强势插入
setImmediate延迟执行2

而在新版的Node中,process.nextTick执行完后,会循环遍历setImmediate,将setImmediate都执行完毕后再跳出循环。所以两个setImmediate执行完后队列里只剩下第一个setImmediate里的process.nextTick。最后输出”强势插入”。
所以在6.x的版本中 结果为

1
2
3
4
5
6
正常执行
nextTick延迟执行1
nextTick延迟执行2
setImmediate延迟执行1
setImmediate延迟执行2
强势插入

setImmediate() vs setTimeout()

之前在网上看到很多人说文档对setImmediate() 和 setTimeout()执行顺序描述得不是很准确(setImmediate指定的回调函数,总是排在setTimeout前面),可是我回去看文档的时候发现文档的描述还是挺完整的(?),可能后来对其进行了补充吧。回正题,现在文档是这样描述的:

if we run the following script which is not within an I/O cycle (i.e. the main module), the order in which the two timers are executed is non-deterministic, as it is bound by the performance of the process
However, if you move the two calls within an I/O cycle, the immediate callback is always executed first.

也就是说 只有在 同一个I/O周期的时候,setImmediate才是总比setTimeout先执行的,而不在同一个I/O 周期中的时候,二者的执行顺序是取决于程序的性能,是不确定的。下述是官网给的例子

1
2
3
4
5
6
7
8
9
10
// 不在同一个I/O周期
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// timeout immediate 或 immediate timeout

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在同一个I/O周期
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
//immediate timeout

在当二者在异步I/O callback内部调用时,poll阶段不会被堵塞而直接进入check阶段执行setImmediate,然后在下一个eventloop进入timers阶段执行setTimeout。
如下述例子

1
2
3
4
5
6
7
8
9
10
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})

1
2
3
$ node timeout_vs_immediate.js
immediate
timeout

补充一点,给settimeout传入的第二个参数,实际上只是指定了把动画代码添加到浏览器UI线程队列以等待执行的时间,即如果队列中本有任务,则需先等前面的任务执行完再开始。如果第二个参数设为0,表示当前执行栈清空以后,立即执行指定的回调函数。但实际上,HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,否则还是默认间隔4毫秒。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()的效果要好于setTimeout()。

node.js的process.nextTick()

the nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop.

process是node.js用来代表进程的对象,process.nextTick()方法无论eventloop执行到哪个阶段,都会在当前阶段结束之时立即执行,nextTick所注册的回调会被push到nextTickQueue,然后再继续进行eventloop循环。主要用处有以下两点

  1. 它可以预设不同错误发生情况的处理方法,清理任何不需要的资源,或者可能在事件循环继续之前再次尝试请求。
  2. 再继续eventloop下一阶段之前,当前的执行栈执行完毕前执行回调
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const EventEmitter = require('events');
    const util = require('util');
    function MyEmitter() {
    EventEmitter.call(this);
    // use nextTick to emit the event once a handler is assigned
    process.nextTick(() => {
    this.emit('event');
    });
    }
    util.inherits(MyEmitter, EventEmitter);
    const myEmitter = new MyEmitter();****
    myEmitter.on('event', () => {
    console.log('an event occurred!');
    });

(但如果使用不当,递归使用该方法会使I/O“饿死”无法进入poll阶段)

macro-task vs micro-task

我在寻找eventloop的相关资料的时候,偶然发现ECMAScript有Macrotask和Microtask两个概念,可以在另外一个角度理解eventloop。下面做一下整理汇总~
异步任务可以通常分为以下两种:

  • macro-task/task: script(即整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering
  • micro-task: Promises(这里指浏览器实现的原生 Promise), Object.observe, MutationObserver

首先在 macrotask队列(或称为taskQueue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。
而一开始的时候,第一个执行的任务自然也就是script,进入指定的代码。
当执行栈为空时,执行一次 microtask 检查点。这也确保了无论是一个macrotas 还是一个 microtask 在执行完毕之后都会生成一个 microtask 检查点,也保证了 microtask 队列能够一次性执行完毕。

然而在网上大部分资料都说process.nextTick()应该要放在microtask中的,但是在上面对process.nextTick的介绍可知,process.nextTick()方法无论eventloop执行到哪个阶段,都会在当前阶段结束之时立即执行,因而process.nextTick都会比microtask的其他的事件先执行。

那问题来了,队列是遵循先进先出原则的,process.nextTick()是怎么优先microtask队列中其他事件执行的(如promise.then)的呢?

后来我看到一篇文章中的评论区,文章作者对于这个问题是这样解释的

Other way around. setImmediate is task-queuing, whereas nextTick is before other pending work such as I/O, so it’s closer to microtasks.

与其说process.nextTick()是microtask,说它近似于microtask更为准确。在Node中,_tickCallback在每一次执行完TaskQueue中的一个任务后被调用,而这个_tickCallback中实质上干了两件事:

  1. nextTickQueue中所有任务执行掉(长度最大1e4,Node版本v6.9.1)
  2. 第一步执行完后执行_runMicrotasks函数,执行microtask中的部分(promise.then注册的回调)

在node.js的源码中,可以发现microtask是通过nextTick驱动的,下列为其中的一段代码

1
2
3
4
5
// This tickInfo thing is used so that the C++ code in src/node.cc
// can have easy access to our nextTick state, and avoid unnecessary
// calls into JS land.
const tickInfo = process._setupNextTick(_tickCallback, _runMicrotasks);
_runMicrotasks = _runMicrotasks.runMicrotasks;

更多具体的代码分析可以看这里,从中可以发现,当nextTickQueue中的事件被执行完的时候,_runMicrotasks()就会执行,microtask也就会开始执行。

因而事件优先级:process.nextTick > promise.then >( setImmediate ?setTimeout )

参考链接

[Node.js文档] Event Loop, Timers, and process.nextTick()
[cnodejs] Node.js Event Loop 的理解 Timers,process.nextTick()
JavaScript 运行机制详解:再谈Event Loop ——阮一峰
setTimeout(fn, 0) running before setImmediate #6034
Tasks, microtasks, queues and schedules
Node.js中process.nextTick是由v8的microtask queue驱动的么?