JavaScript 从一开始被创造出来就使用的单线程,这主要与他的用途相关。JavaScript主要用来与用户交互、操作网页上的dom元素等工作。
如果JavaScript是多线程程序,那么就需要开发者考虑很多并发的问题,如多个线程对同一个 dom 进行修改以后,那浏览器会采取哪一个呢,这个无法确定,当然可以提供锁的机制来解决这个问题,那将会提高JavaScript的复杂性。
标题中说的JavaScript单线程并不是说程序运行真的只是依赖一条线程,他实际有多条协助线程,只有一条主线程来调度协助线程,协助线程会用来做一些耗时任务,这样做是为了防止耗时任务阻碍了网页响应用户的操作,提升网页性能等。这些线程功能不一,都有着自己独有的任务,下面将简单介绍下这些协助线程有哪些(介绍的都是浏览器渲染进程中的线程):
浏览器event loop遵循HTML5标准,node环境下的event loop是通过libuv实现,两个环境下的JavaScript事件循环机制几乎不是同一回事,因此下文将浏览器和node环境下的事件循环分开介绍。
在讲 event loop 之前,我们需要知道程序执行多任务的方式有哪些(以下内容来自阮一峰博客):
JavaScript 采用第一种方式执行任务的程序,第一种任务执行方式会有如下两个问题:
JavaScript中同步任务都需要在主线程执行栈中运行,只有当前面任务执行完成以后才能处理运行后面的同步任务。
主线程运行时,会产生堆和栈,执行栈中运行的时候会去调用一些API,如果调用的是异步函数API,如处理I/O(ajax请求)、定时器、DOM事件监听等,执行栈就会将这些异步任务挂到对应的线程中,然后执行栈再运行其他同步任务。这些被分配到其他线程中的任务只有当事件触发的时候,异步线程就会将带有回调函数的事件放入到事件触发线程中的事件队列里面去。
被放到事件队列里面的任务不会立即执行,需要等待主线程主动读取这些事件,然后在执行栈中执行这些任务的回调函数。
当JavaScript执行栈处于空闲的状态时,主线程就会主动去查看事件队列是否存在未处理的事件。
前面说到的主线程往复的判断读取事件队列的过程就是 event loop(如下图展示过程)
(图片来自https://vimeo.com/96425312)
前面只是讲述了浏览器JavaScript event loop过程,以及提及到有一个事件队列来存放这些触发的事件。下面将介绍事件触发线程中存在哪些任务队列,以及这些事件队列的优先级。
事件触发线程中存在多个任务队列,异步线程中触发的事件都会将事件存放到这些任务队列中。
这些任务可以分为两类,microtask(微任务)、macrotask(宏任务),在事件触发线程中微任务队列只能有一个,而宏任务队列就可以有多个,实际我们平时开发时用到的宏任务队列也只用到了一个。
JavaScript中异步API分类:
对于微宏任务,主线程调用这些任务也是有一定的顺序,下面将介绍一下微任务和宏任务的调用顺序:
event loop会循环执行上面3步。
下面例子中我们会使用performance工具来监控执行顺序,橘色代表js运行,紫色代表页面布局计算,绿色代表绘制任务。
栗子1: 5.js
setTimeout(function() {
console.log('setTimeout 1');
Promise.resolve().then(function() {
console.log('promise 1');
}).then(function() {
console.log('promise 2');
})
});
setTimeout(function() {
console.log('setTimeout 2');
Promise.resolve().then(function() {
console.log('promise 3');
}).then(function() {
console.log('promise 4');
})
});
// 执行结果
setTimeout 1
promise 1
promise 2
setTimeout 2
promise 3
promise 4
流程:
栗子2: 1.js
setTimeout(() => {
text.textContent = 2;
});
text.textContent = 1;
Promise.resolve().then(() => {
console.log('promise')
})
// 这里实际运行出来有两种结果
结果一:
页面只渲染一次:2
结果二:
页面会渲染两次分别是1,2
这里简述下结果二的流程:
结果一流程:
很显然结果一与上面说的event loop过程不一致,实际上浏览器在实现为了防止大量重排和重绘,在更新渲染过程做了优化,让 UI rendering 并不是在每轮循环中都运行,UI rendering 执行时机具有不确定性,GUI线程中实际也存放了一个更新队列,当存放到一定时间、存放的数量到达临界值就会释放队列,还有一个情况也会迫使GUI线程去更新页面,那就是使用js去获取dom元素样式的时候,浏览器为了给出一个准确的值,只能将更新队列中的任务。UI rendering调用时机取决于浏览器以及程序执行时cpu、gpu的状态决定。
栗子3: 4.js
setTimeout(() => {
text.textContent = 2;
})
text.textContent = 1;
for (let i = 0; i < 1000000; i++) {
Promise.resolve().then(() => {})
}
我们知道执行一次宏任务后,就会检测微任务队列,并且只有当微任务队列为空的时候才能够执行其他的任务,因此大量微任务将阻塞整个应用程序的运行,上面例子中,只有当所有的Promise回调执行完以后才会更新渲染和执行宏任务(GUI线程和主线程是互斥的,当JavaScript主线程代码执行的时候,GUI线程会被挂起),浏览器可能对微任务数量做了限制,但是实际操作中没有测试出来(微任务多的时候页面几乎卡死了)。
这里不在讲述event loop概念了,概念和前面差不多,就是不断从任务队列中取任务然后执行。node 中将每一次轮循分成6个阶段,就是下面展示的六个阶段,每走完一次循环就是一个tick,并且还要注意的是node的事件循环运行在主线程。
┌───────────────────────┐
┌>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└─┤ close callbacks │
└───────────────────────┘
(从node官网偷的图形)
每一个阶段都有一个用来存放回调函数的任务队列。
node离开某个阶段需要满足后面的条件,node将任务队列中的回调执行完毕或者任务队列中的回调数超过最高限制之后,就会离开这个阶段。
前面还没有提到 process.nextTick和promise的,这两个后面单独谈。
下面重点谈一下timers阶段、poll阶段、check阶段这三个阶段的流程。
setTimeout和setInterval定义的超过定时的回调函数将会存放到这个阶段中的任务队列中,当运行到这个阶段的时候,就会依次将回调函数取出来并执行,node中的记时器定时任务定时最小是 1,最大是多少记不到了。
poll阶段执行的是I/O回调函数,当异步I/O任务执行完成的时候,就会将他们的回调函数压入到任务队列中,node处于这个阶段的时候就会将该阶段存放的任务队列中的回调函数执行完。
poll阶段有以下两个重要的功能:
栗子1:1.js
const fs = require('fs');
const readerFile = callback => {
fs.readFile('./file.txt', callback);
};
const startTime = Date.now();
let readEndTime = 0;
setTimeout(() => {
const delay = Date.now() - startTime;
console.log('延迟执行计时器callback:', delay);
console.log('读取文件耗时:', readEndTime - startTime);
}, 10)
readerFile(() => {
readEndTime = Date.now();
while (Date.now() - readEndTime < 20) {}
});
// 执行结果
延迟执行计时器callback: 23
读取文件耗时: 3
整个流程如下:
这个阶段执行都是setImmediate定义的回调,当这个阶段中的任务队列不为空的时候,会让 event loop 暂时不阻塞在 poll 阶段。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// 运行结果会有两种:
timeout
immediate
或者
immediate
timeout
node环境:setTimeout(f, 0) === setTimeout(f, 1)。
上面代码有两种结果的原因如下:在进入依次 loop前需要做一些耗时任务,每次tick都是先检测timers queue是否为空,是哪种结果完全由进入loop前的准备工作耗时是否超过1ms决定(定时器任务初始化也需要一定的时间,实际需要判断loop准备耗时是否超过1ms多)
const fs = require('fs');
fs.readFile('./file.txt', () => {
setTimeout(() => {
console.log('timeout');
});
setImmediate(() => {
console.log('immediate');
});
});
// 结果只有一个
immediate
timerout
第一个tick,loop会阻塞在 poll 阶段,直到文件读取完成,将回调函数压入poll queue。检测到poll queue不为空,取出并执行回调函数,回调函数中执行 setTimeout生成一个定时任务(计时为1ms),执行setImmediate,向check queue压入回调函数。poll阶段的queue为空后,检测timers queue是否为空,检测check queue是否为空(实际上node中不管是timers还是check中的任务队列不为空的时候,都会经过这两个阶段,然后再阻塞在poll阶段)。执行poll阶段回调后的具体流程如下(这里假设定时任务已完成):
setTimeout和setImmediate这两个异步函数函数放到一个I/O回调函数中的时候,setImmediate回调始终优先调用,是由六个阶段的执行顺序决定的。
process.nextTick不属于上面提到的任何阶段,每个阶段结束的时候都会执行完nextTick任务队列中的回调,并且在进入新的一轮loop的时候就会有一次机会去清空nextTick的回调。
setTimeout(() => {
console.log('timeout')
process.nextTick(() => {
console.log('nextTick 2')
})
})
process.nextTick(() => {
console.log('nextTick 1')
})
setImmediate(() => {
console.log('immediate');
})
// 执行结果(由于loop准备和nextTick 1执行耗费时间,才导致timeout在immediate之前被打印出来)
nextTick 1
timeout
nextTick 2
immediate
nextTick中的任务队列执行完以后,还有其他的工作,如执行microtask,如promise回调。
栗子5: 5.js
setTimeout(() => {
console.log('timeout')
process.nextTick(() => {
console.log('nextTick 2')
Promise.resolve().then(() => {
console.log('promise 1')
})
})
})
process.nextTick(() => {
console.log('nextTick 1')
Promise.resolve().then(() => {
console.log('promise 2')
})
})
setImmediate(() => {
console.log('setImmediate');
})
// 执行结果如下
nextTick 1
promise 2
timeout
nextTick 2
promise 1
setImmediate
前面讲浏览器事件循环的一个例子:
setTimeout(function() {
console.log('setTimeout1');
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
})
});
setTimeout(function() {
console.log('setTimeout2');
Promise.resolve().then(function() {
console.log('promise3');
}).then(function() {
console.log('promise4');
})
});
// 执行结果有两种情况,大多数情况是结果一
setTimeout1
setTimeout2
promise1
promise3
promise2
promise4
或者
setTimeout1
promise1
promise2
setTimeout2
promise3
promise4
结果一的流程:
结果二流程(由于系统调度导致记时器定时器出现不准确的问题,进入loop时,可能一个定时器定时完成,而另一个没有完成定时):
浏览器执行出来的结果是结果二,流程如下:
check阶段执行回调,如果回调中使用了setImmediate定义新的异步任务,那么新的回调将会在下一轮loop的check阶段执行。
栗子7: 7.js
setTimeout(() => {
console.log('timeout')
}, 2);
setImmediate(() => {
console.log('immediate 1')
setImmediate(() => {
console.log('immediate 2')
})
})
// 运行结果
immediate 1
timeout
immediate 2
这样设计的目的是替代process.nextTick用来实现递归调用,我们知道nextTick任务队列会在每个阶段结束后清空。但是,如果遇到递归调用的时候,就会因为不断向nextTick任务队列中写入回调,导致整个程序阻塞,而无法运行其他更重要的任务,例子如下:
const fs = require('fs');
const startTime = Date.now();
let index = 0;
fs.readFile('./file.txt', () => {
console.log('文件读取回调时间', Date.now() - startTime);
});
function nextTick () {
if (index > 100000) return;
index++;
process.nextTick(nextTick);
}
nextTick();
// 运行结果
文件读取回调时间 26
const fs = require('fs');
const startTime = Date.now();
let index = 0;
fs.readFile('./file.txt', () => {
console.log('文件读取回调时间', Date.now() - startTime);
});
function immediate() {
if (index > 100000) return;
index++;
setImmediate(immediate);
}
immediate();
// 运行结果
文件读取回调时间 3
Node.js Event Loop 的理解 Timers,process.nextTick()
Node.js 事件循环,定时器和 process.nextTick()https://www.cnblogs.com/kidney/p/6079530.html)