js的单线程和异步
JavaScript是单线程的,即负责编译并执行js代码的线程只有一个。但对于数据库 文件系统等I/O操作,包括HTTP请求等等这些容易堵塞等待的操作通常会异步处理。
异步,我们通常的理解就是,多个任务可以同时执行,而同步则是必须是一个任务完成之后,下一个任务才能开始执行。那么平时我们所说的异步(如ajax的异步加载)不就是和单线程冲突吗?
其实,JavaScript执行的平台通常是在浏览器,这里的异步是主要由浏览器完成的。现在把JavaScript的单线程称为主线程/执行线程,在一个浏览器中只能有一个JavaScript的执行线程,即在同一个浏览器打开多个页面的时候,只有一个js主线程在解析代码,实现异步的实际是浏览器除了js的主线程之外还有很多其他的线程。异步执行的步骤大致为
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
- 一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
其中任务队列中秉承先进先出的原则,回调函数会按顺序放进队列的尾部。当为函数设置了setTimeout()的时候,在主线程的执行栈清空之后,任务队列中的回调函数才会开始执行,期间主线程会首先要检查一下该事件的执行时间,某些事件只有到了规定的时间,才能返回主线程。
其中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回调的执行顺序并不一定,下述例子可以帮助理解运行原理1234567891011121314151617181920212223242526272829303132var 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);
|
|
解释:
当时程序启动时,event loop初始化:
- timer阶段(无callback到达,setTimeout需要5毫秒)
- I/O callback阶段,无异步I/O完成
- 忽略
- check
只执行一个最先设置的setimmediate回调,若timers和poll阶段若设置了setimmediates的回调,并且此回调是最先设置的回调,则该回调会在该次event loop的check阶段执行。
注意:新版node.js会把所有的setimmediate的都先执行!!!
下面这个例子(来自《深入浅出Node.js》,node.js版本为0.10.13)12345678910111213141516171819//加入两个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会先执行。
书中结果为
而在新版的Node中,process.nextTick执行完后,会循环遍历setImmediate,将setImmediate都执行完毕后再跳出循环。所以两个setImmediate执行完后队列里只剩下第一个setImmediate里的process.nextTick。最后输出”强势插入”。
所以在6.x的版本中 结果为
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 周期中的时候,二者的执行顺序是取决于程序的性能,是不确定的。下述是官网给的例子
|
|
在当二者在异步I/O callback内部调用时,poll阶段不会被堵塞而直接进入check阶段执行setImmediate,然后在下一个eventloop进入timers阶段执行setTimeout。
如下述例子
|
|
补充一点,给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循环。主要用处有以下两点
- 它可以预设不同错误发生情况的处理方法,清理任何不需要的资源,或者可能在事件循环继续之前再次尝试请求。
- 再继续eventloop下一阶段之前,当前的执行栈执行完毕前执行回调1234567891011121314151617const EventEmitter = require('events');const util = require('util');function MyEmitter() {EventEmitter.call(this);// use nextTick to emit the event once a handler is assignedprocess.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中实质上干了两件事:
- nextTickQueue中所有任务执行掉(长度最大1e4,Node版本v6.9.1)
- 第一步执行完后执行_runMicrotasks函数,执行microtask中的部分(promise.then注册的回调)
在node.js的源码中,可以发现microtask是通过nextTick驱动的,下列为其中的一段代码
更多具体的代码分析可以看这里,从中可以发现,当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驱动的么?