Obeta

JavaScript 事件循环(EventLoop)

JavaScript 是单线程事件驱动的,也就是说同一时间内只能做一个任务,因此JavaScript使用事件循环和其中的任务队列来避免页面或者程序被阻塞.

在了解Promise/A+实现的过程中看到了两个关键词:macrotask,microtask.这两个是 js 中事件(EventLoop)循环需要执行的任务队列.

首先需要理解下面这些概念,然后才能开始理解事件循环.而浏览器端跟 NodeJS 中也有稍微的一些区别.

任务队列

JavaScript 是单线程事件驱动的,也就是说同一时间内只能做一个任务,做完当前的任务才能继续做下一个任务,轮流着来.这个我们称之为执行栈.

但是如果当某一个任务耗时很长,需要等待很长时间,比如一个网络请求或者说IO任务(通常这类我们都统称异步任务),那后面的任务就必须等着.而我们的 CPU 速度又非常的快,不能让他等着吧,那太浪费资源了,因此我们才有了任务队列(callback queue),把那些耗时长的任务扔到任务队列里面去,当当前的执行栈为空的时候再去检查任务队列,如果任务队列里有已经状态为结束的任务,就把此异步任务的回调函数放到执行栈中继续执行.

JavaScript 主线程就是一直在重复上面的过程,直到没有任务执行.需要注意的是任务队列是一个队列数据结构 LIFO,因此是先放入的先执行.而有些是有定时器,因此主线程还需要检测执行时间,当时间到要求的时候才会执行.

定时器

我们通常会看到如下的代码:

setTimeout(() => {
	// 执行栈清空后再执行...
}, 0);

这是因为我们需要确定同步操作完成后, 第一时间去做任务队列里的排第一的任务,这个 0 毫秒后立即执行就是这个意思.

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

执行结果总是先2 然后才1.有时候你定义的是 10 毫秒后执行,但是这个系统并不会满足你,它无法保证一定能 10 毫秒后去执行那段逻辑代码,因为执行栈可能有很多比较耗时的操作,此时等到去任务队列的时候可能已经十几毫秒了.

事实上, HTML5规定了低于4毫秒的延期时间都自动增加到了4毫秒,而在浏览器端为了省电和节省内存及 CPU 时间,都将一段时间不显示的 tab 的时延增加到 1000 毫秒,另外对于不在充电的笔记本,4毫秒时延将会被升至为系统定时器的时延,大概在15.6毫秒.

setTimeout这个特性可以让我们把很多会阻塞页面渲染的操作提取给它去做,比如某一个任务非常耗时且不太重要,但是又需要运行很多次,我们可以这样做:

function func() {
	setTimeout(func, 0);
	// 这里是一些dom操作,同步操作...
	// 不要忘记最后加上一些结束判断条件,否则这里会一直运行(除非是你特意需要的)
}

setTimeout(func, 0);

上面的代码只能适用一些同步操作,如果需要使用异步操作,那么需要把setTimeout(func, 0)放到你异步操作后的回调函数来执行:

function func() {
	fetch('url')
		.then(res => res.text())
		.then(res => {
			// 处理返回的值
			setTimeout(func, 0);
		});
}

setTimeout(func, 0);

轮询也是基于上面这类控制代码来完成的.

NodeJS 的事件循环

首先需要记住它们以下具体区别:

  • macrotasks: setTimeout setInterval requestAnimationFrame setImmediate I/O UI渲染
  • microtasks: Promise process.nextTick Object.observe MutationObserver

说道 NodeJS 的事件循环就应该提一下libuv, 这是一个开源的异步 IO 库 ,最初就是为 Node.js 开发的,现在很多项目都在用.

任务队列分的就是上面两种,开始我们介绍的是setTimeout, 它是属于macrotasks一类.那么这两个任务队列到底有什么区别了,这就是我想搞懂的问题.

事件循环
事件循环

以上图为例我们开始分析一下事件循环流程:

当执行栈为空的时候, 开始去macrotask队列里面执行最早的一个任务,当macrotask为空或者执行完成后开始去microtask队列里执行所有任务,当microtask为空或者执行完成后再回过头来从macrotask再来一次

以上过程内容挺复杂,我只是简单说明了一下,其中的比如说队列先进先出,执行的是最早的那个进入队列的任务,执行完成后将会移出任务等等过程就不用再细说了.

这是个循环,系统会一直检测这两个队列,直到都为空为止,因为有时候你会在macrotask中创建microtask任务,而有时候会在microtask中创建macrotask任务,因此这是系统会不断的进行循环的原因.而任务队列当然也有先后之分了,整个程序执行的过程就是: first macrotask--all microtask--first macrotask--all microtask--first macrotask--all microtask.....

再简单点可以总结为:

  1. macrotask 队列中执行最早的那个 task,然后移出
  2. 执行 microtask 队列中所有可用的任务,然后移出
  3. 下一个循环,执行下一个 macrotask 中的任务 (再跳到第 2 步)

有些文章跟我这部分表述并不一样,他们会假定一开始没有第一个macrotask(或者描述的样子带给了读者一种错觉),这其实是错误的,比如上面的事件循环图,其实macrotask是第一个 task,然后才是microtask.

首先要知道整个程序一开始就全被推入到了执行栈,执行的过程中会根据不同的调用再进行分配到不同的 task 中去.既然我们弄懂了事件循环了,那么我们开始看看如下的代码:

console.log('start');

const interval = setInterval(() => {
	console.log('setInterval');
}, 0);

setTimeout(() => {
	console.log('setTimeout 1');
	Promise.resolve()
		.then(() => {
			console.log('promise 3');
		})
		.then(() => {
			console.log('promise 4');
		})
		.then(() => {
			setTimeout(() => {
				console.log('setTimeout 2');
				Promise.resolve()
					.then(() => {
						console.log('promise 5');
					})
					.then(() => {
						console.log('promise 6');
					})
					.then(() => {
						clearInterval(interval);
					});
			}, 0);
		});
}, 0);

new Promise((resolve, reject) => {
	console.log('promise'); // new Promise是同步代码
	resolve('promise next');
}).then(res => {
	console.log(res);
});

Promise.resolve()
	.then(() => {
		console.log('promise 1');
	})
	.then(() => {
		console.log('promise 2');
	});
console.log('end');

正确答案:

start promise end promise next promise 1 promise 2 setInterval setTimeout 1
promise 3 promise 4 setInterval setTimeout 2 promise 5 promise 6
  1. 执行的setTimeout,setInterval其实都是调用的浏览器 API,浏览器根据其中时间进行倒计时,当到达了指定的时间后将 task 推入到macrotask中,因此上面一开始marcotask是空的
  2. Promise.then是异步执行的,而创建 Promise 实例的构造函数是同步执行的,如果你需要使用 Promise 那么你需要知道这个.

Node.js 提供了另外两个与"任务队列"有关的方法: process.nextTicksetImmediate

process.nextTick 方法可以在当前"执行栈"(也就是 microtask)的尾部----下一次 Event Loop(主线程读取"任务队列",也就是 macrotask)之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行.

process.nextTick(function A() {
	console.log(1);
	process.nextTick(function B() {
		console.log(2);
	});
});

setTimeout(function timeout() {
	console.log('TIMEOUT FIRED');
}, 0);
// 1
// 2
// TIMEOUT FIRED

上面代码中,由于 process.nextTick 方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数 A 比 setTimeout 指定的回调函数 timeout 先执行,而且函数 B 也比 timeout 先执行。这说明,如果有多个 process.nextTick 语句(不管它们是否嵌套),将全部在当前"执行栈"执行.

参考

说明

本文章仅供参考与学习,如有问题请联系我GitHub,如需要转载请注明出处,谢谢啦.

个人随笔记录,内容不保证完全正确,若需要转载,请注明作者和出处.