EventLoop 分享

965 阅读7分钟

什么是Event loop?

ECMAScript规范没有提到事件循环。相反,事件循环在HTML规范中有详细说明,尽管事件循环是在HTML规范中定义的,但是在其他环境中,比如Nodejs,也使用了它。

HTML Standard中的定义

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的event loop。 Events,Parsing,Callbacks,Using a resource,Reacting to DOM manipulation 事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由event loop协调的。触发一个click事件,进行一次ajax请求,背后都有event loop在运作。

浏览器的event loop

An event loop has one or more task queues. A task queue is a set of tasks.

一个 event loop有一个或多个任务队列,一个任务队列是一个多任务的集合。

Task queues are sets, not queues, because step one of the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

任务队列是集合,而不是队列,因为事件循环处理模型的第一步从选择的队列中获取第一个可运行的任务,而不是退出第一个任务。

The microtask queue is not a task queue.

微任务队列不是任务队列。

在规范的Processing model定义了event loop的循环过程:

  • 一个event loop只要存在,就会不断执行下边的步骤:
1.在tasks队列中选择最老的一个task,用户代理可以选择任何task队列,
如果没有可选的任务,则跳到下边的microtasks步骤。
2.设置oldestTask成为任务队列中第一个可运行的任务,并将其从任务队列中删除。
5.运行oldestTask里面的一系列脚本
7.从其任务队列中删除oldestTask
8.Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
11.更新渲染(Update the rendering)...

event loop会不断循环上面的步骤,概括说来:

  1. event loop会不断循环的去取tasks队列的中oldestTask(可执行的)一个任务推入栈中执行。
  2. 执行栈中的oldestTask一步一步执行完后,出栈。
  3. 当执行栈为空时,检查微任务(microtask checkpoint),不为空执行微任务
  4. 当微任务队列最终为空时,事件循环检查是否需要UI呈现更新,如果需要,则重新呈现UI。
  5. 这将结束事件循环的当前迭代,该循环将回到开始并再次检查宏任务队列。

图片出处:livebook.manning.com/book/secret…

microtask checkpoint

所做的就是执行microtask队列里的任务。什么时候会调用microtask checkpoint呢?

  • 每次回调之后,只要没有其他JavaScript处于执行中
  • 在event loop的第8 步(Microtasks: Perform a microtask checkpoint)执行checkpoint,也就是在每个任务结束时,更新渲染之前。

演示

<div class="outer">
	<div class="inner"></div>
</div>
<script>

	// Let's get hold of those elements
	var outer = document.querySelector('.outer');
	var inner = document.querySelector('.inner');

	// Let's listen for attribute changes on the
	// outer element
	new MutationObserver(function() {
		console.log('mutate');
	}).observe(outer, {
		attributes: true
	});

	// Here's a click listener…
	function onClick() {
		console.log('click');

		setTimeout(function() {
			console.log('timeout');
		}, 0);

		Promise.resolve().then(function() {
			console.log('promise');
		});

		outer.setAttribute('data-random', Math.random());
	}

	// …which we'll attach to both elements
	inner.addEventListener('click', onClick);
	outer.addEventListener('click', onClick);

</script>

点击打印结果

click
promise
mutate
click
promise
mutate
timeout
timeout

此代码出处和解释> jakearchibald.com/2015/tasks-…

在node里面的解释

事件循环允许Node.js执行非阻塞的I/O操作(尽管JavaScript是单线程的),方法是尽可能地将操作卸载到系统内核。

node 官网的 EventLoop

下图简要的概述了event loop的操作顺序:

注:每一个框代表event loop中的一个阶段

每个阶段都有一个FIFO(先进先出)的回调队列等待执行。虽然每个阶段都有其独特之处,但总体而言,当event loop进入到指定阶段后,它会执行该阶段的任何操作,并执行对应的回调直到队列中没有可执行回调或者达到回调执行上限,而后event loop会进入下一阶段。

由于任何这些阶段的操作可能产生更多操作,内核也会将新的事件推入到poll阶段的队列中,所以新的poll事件被允许在处理poll事件时继续加入队,这也意味着长时间运行的回调可以允许poll阶段运行的时间比计时器的阈值要长

阶段概览

翻译:

  • timers:执行的是setTimeout()和setInterval()的回调
  • pending callbacks:执行延迟到下一个循环迭代的I/O回调
  • idle, prepare:仅内部使用
  • poll:检索新的I/O事件;执行与I/O相关的回调(除了close回调、计时器调度的回调和setimmediation()之外,几乎所有回调都执行);节点将在适当的时候在这里阻塞
  • check:setImmediate回调在这里触发
  • close callbacks:比如socket.on('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.

在每次运行事件循环之间,Node.js检查它是否在等待任何异步I/O或计时器,如果没有,则干净地关闭。

setImmediate() vs setTimeout()

二者的调用顺序取决于它们的执行上下文。如果两者都在主模块被调用,那么其回调被执行的时间点就取决于处理过程的性能(这可能被运行在同一台机器上的其他应用影响)

比如说,如果下列脚本不是在I/O循环中运行,这两种定时器运行的顺序是不一定的

// timeout_vs_immediate.js
setTimeout(function timeout() {
  console.log('timeout');
}, 0);

setImmediate(function immediate() {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是如果你把上面的代码置于I/O循环中,setImmediate回调会被优先执行:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用setImmediate()而不是setTimeout()的主要好处是:如果代码是在I/O循环中调用,那么setImmediate()总是优先于其他定时器(无论有多少定时器存在)

setTimeout 的时延

When delay is larger than 2147483647 or less than 1, the delay will be set to 1.

在node下运行

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

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

# output 可以看到1在前 0在后
1 2
0 4

在浏览器中,setTimeout()/setInterval() 的每调用一次定时器的最小间隔是4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度),或者是由于已经执行的setInterval的回调函数阻塞导致的。例如:

	let i = 0
	let now = Date.now()

	function f(){
		let newD = Date.now()
		console.log(newD-now)
		now = newD

	}

	function cb() {
		f();
		i++
		if(i>10) return
	setTimeout(cb, 0);
	}

	setTimeout(cb, 0)

从输出结果看也有小于4ms的,原因就不知道了。

process.nextTick()

你可能已经注意到process.nextTick()不在上面的图表中,即使它也是异步api。这是因为严格意义上来说process.nextTick()不属于event loop中的一部分,它会忽略event loop当前正在执行的阶段,而直接处理nextTickQueue中的内容。

回过头看一下图表,你在任何给定阶段调用process.nextTick(),在继续event loop之前,所有传入process.nextTick()的回调都会被执行。这可能会导致一些不好的情况,因为它允许你递归调用process.nextTick()从而使得event loop无法进入poll阶段,导致无法接收到新的 I/O事件

为什么要使用process.nextTick()?

这里有两个主要的原因

  1. 让开发者处理错误、清除无用的资源或者在event
  2. loop继续之前再次尝试重新请求资源 有时需要允许回调在调用栈展开之后但在事件循环继续之前运行

process.nextTick() 和 setImmediate()

以下截图来源:《深入浅出node.js》

参考

Q&A

  1. 深入浅出讲到的idle观察者 和 node讲到的idle阶段有什么关系?
  2. vue.nexttick 优先使用微任务promise实现的好处?