浅谈事件循环-面试常考

41 阅读6分钟

事件循环是 JS 中重要的概念,用于处理同步代码、异步代码的执行顺序问题

因为 JS 的执行环境不同,分为浏览器事件循环和 Node.js 事件循环

1. 浏览器事件循环

浏览器事件循环比较简单

JS 的代码分为同步代码和异步代码,异步代码像这样

setTimeout(() => console.log(1)); // 像setTimeout这种函数的回调是异步的
console.log(2); // 普通的同步代码

JS 执行代码的顺序是,先清空执行栈,再执行异步代码,也就是同步代码先执行

我们再分析异步代码

首先我们把异步代码的回调分为:宏任务和微任务,同时有一条宏任务队列和一条微任务队列。整个事件循环的过程可以为以下几步

  1. 清空执行栈中的代码
  2. 清空微任务队列中的代码,放到执行栈中执行
  3. 如果微任务中包含有宏任务,放入宏任务队列中,如果有微任务,放入微任务队列中,直到微任务清空完毕
  4. 再清空宏任务队列,先取一个宏任务,如果其中包含微任务,那执行完宏任务之后需要再清空微任务队列之后再取下一个宏任务
  5. 清理完所有微任务和宏任务队列中的任务,事件循环完毕

典型的宏任务和微任务如下

  • 宏任务:setTimeoutsetIntervalrequestAnimationFrame
  • 微任务:Promise.resolve().then()await之后的代码MutationObserver

比如下面

setTimeout(() => console.log(1)); // 宏任务
Promise.resolve().then(() => console.log(2)); // 微任务

显然是 Promise.resolve().then() 先执行

其中有一种特殊情况,就是使用了 async await 关键字的函数,在使用 await 关键字之后的部分是异步代码

async function async1() {
	console.log('async1');
	await async2();
	// await 关键字之后的代码 可以 认为是Promise.then() 
	console.log('async1 end'); 
}

async function async2() {
	console.log('async2');
}

async1()

小试牛刀

试试多层嵌套的宏任务微任务

async function fn() {}
setTimeout(() => {
	console.log("Timeout 1");
	Promise.resolve().then(() => {
		setTimeout(() => {
			console.log("Timeout 2");
		}, 0);
		console.log("Promise 1");
	});
}, 0);
Promise.resolve().then(() => {
	console.log("Promise 2");
	setTimeout(async () => {
		console.log("Timeout 3");
		await fn();
		console.log("await 1");
		setTimeout(() => {
            console.log("Timeout 4");
          }, 0);
	}, 0);
});

可以尝试模拟任务插入队列,推出队列到执行栈中执行的过程

// 以下p1 t1 等为简写 (x) 表示已推出队列

// *1*
// 微任务:p2(x)
// 宏任务:t1 t3
// 输出:p2

// *2*
// 微任务:p2(x) p1(x)
// 宏任务:t1(x) t3 t2
// 输出:p2 t1 p1

// *3*
// 微任务:p2(x) p1(x) a1(x)
// 宏任务:t1(x) t3(x) t2 t4
// 输出:p2 t1 p1 t3 a1

// *4*
// 微任务:p2(x) p1(x) a1(x)
// 宏任务:t1(x) t3(x) t2(x) t4(x)
// 输出:p2 t1 p1 t3 a1 t2 t4

2. Node.js 事件循环

Node.js 的运行机制如下:

  1. V8 引擎解析 JS 脚本
  2. 解析后的代码,调用 Node API
  3. libuv 负责 Node API 的执行,它将不同任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给 V8 引擎
  4. V8 引擎再将结果返回给用户

1. 宏任务

Node.js 中会复杂一,同样还是宏任务、微任务,但是宏任务又分为几个阶段,如下

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

一般是从输入数据开始整个事件循环

常见的几个阶段对应的 api

  • poll:一些 IO 操作,比如 fs.readFile
  • check: setImmediate
  • close callback: 一般不考虑,但是可以知道 readStream.close
  • timerssetTimeoutsetInterval
  • pending callbacks:执行推迟到下一次循环迭代的 I/O 回调,比如 tcp 错误回调
  • idle, prepare:仅 Node.js 内部使用

比如下面代码

setImmediate(() => {
	console.log('check');
})

setTimeout(() => {
	console.log('timer');
})

fs.readFile('./index.js', () => {
	console.log('poll');
})

// 执行结果:
// timer
// check
// poll

这里顺序基本和上面一样,但是令人疑惑的是,为什么 poll 在 check 之后执行,不是说 poll -> check 吗

实际上如果是正常的同步代码,那么 poll 的回调应该马上交给队列,但是因为需要 libuv 读取文件,因此 fs.readFile 的回调延迟交给了队列,所以先执行了 timer 和 check

比如像下面这种情况,执行顺序就和上述的一样了

fs.readFile("./index.js", () => {
	setTimeout(() => {
		console.log("timer");
	});

	setImmediate(() => {
		console.log("check");
	});

	console.log("poll");
});

// 执行结果
// poll
// check
// timer

对应宏任务的每个阶段,它们都是使用队列管理的,比如下面代码

fs.readFile("./index.js", () => {
	setTimeout(() => {
		console.log("timer1");
		fs.readFile("./index.js", () => {
			console.log("poll2");
		});
		setImmediate(() => {
			console.log("check3");
		});
	});

	setTimeout(() => {
		console.log("timer2");
	});

	setImmediate(() => {
		console.log("check1");
		fs.readFile("./index.js", () => {
			console.log("poll1");
		});
	});
	console.log("poll");
	setImmediate(() => {
		console.log("check2");
	});
});

// 输出结果
// poll
// check1
// check2
// timer1
// timer2
// check3
// poll1 这两个poll的顺序不一定
// poll2

像这里,只要是同一个事件循环内 push 到队列中的任务,都需要清空相应的队列之后才会执行下一阶段队列,比如 checktimer 这两个阶段

而其中的 poll 因为需要 libuv 读取文件处理,有一定的延时,因此几乎不用考虑队列,因为基本不太可能两个 poll 任务在同一个事件循环内执行

2. 微任务

Node.js 环境中,微任务还有一种叫做 process.nextTickNode.js 官网给出的解释是

Looking back at our diagram, any time you call process.nextTick() in a given phase, all callbacks passed to process.nextTick() will be resolved before the event loop continues. 回顾我们的图表,任何时候在给定阶段调用 process.nextTick(),传递给 process.nextTick() 的所有回调都将在事件循环继续之前得到解决。

我的理解是,process.nextTick 的设计是为了能够在事件循环任何阶段开始前执行,比如下面代码

// 这是一轮事件循环
setTimeout(() => {
	console.log("timer1");
})

setImmediate(() => {
	console.log("check1");
})

Promise.resolve().then(() => {
	console.log("promise1");
})

process.nextTick(() => {
	console.log("nextTick1");
})

fs.readFile("./index.js", () => {
	// 这里是新的一轮事件循环
	console.log("poll1");
	setImmediate(() => {
		console.log("check2");
	})
	setTimeout(() => {
		console.log("timer2");
	})
	process.nextTick(() => {
		console.log("nextTick2");
	})
})

// 输出结果
/** nextTick作为微任务,优先级比Promise还高,所以每次事件循环都是nextTick先执行 **/
// nextTick1
// promise1
// timer1
// check1
/** 这是新的一轮事件循环,nextTick也是在check之前就执行了,印证了官网的说法 **/
// poll1
// nextTick2
// check2
// timer2

Node.js 中,宏任务和微任务的执行顺序依然遵循,先清空微任务,再执行一个宏任务,再清空微任务这种执行顺序

小试牛刀

const fs = require("fs");

const start = Date.now();

setTimeout(() => {
	console.log("timer1");
}, 200);

new Promise((resolve) => {
	fs.readFile("./index.js", () => {
		console.log("poll1");
		while (Date.now() - start < 500);
		setImmediate(() => {
			console.log("immediate1");
		});
		process.nextTick(() => {
			console.log("nextTick1");
			Promise.resolve().then(() => {
				console.log("promise1");
			});
		});
	});
	setTimeout(() => {
		console.log("timer2");
		resolve();
	}, 100);
}).then(() => {
	console.log("promise2");
});


// 输出结果
/** fs.readFile 完毕 开始一轮事件循环 **/
// poll1
/** nextTick 作为微任务,又有最高优先级最先执行 **/
// nextTick1
/** 推入微任务队列的Promise 立即执行 **/
// promise1
/** 到了宏任务的check阶段 **/
// immediate1
/** 到了宏任务的timer阶段,因为这个延迟时间更少,所以先执行 **/
// timer2
/** resolve promise之后,执行微任务 **/
// promise2
/** 最后执行timer1 其实当前还是在一轮事件循环的timer阶段 **/
// timer1

3. 总结规律

  1. 知道基本的事件循环机制,即先清空微任务队列,再执行一个宏任务,再清空微任务队列,以此往复
  2. 知道当前属于哪一轮事件循环,切勿将不是一轮循环的两个回调进行对比
  3. 知道当前执行到宏任务的哪个阶段,下一个阶段会先清空微任务再执行,微任务中 process.nextTick 优先级最高