Eventloop

100 阅读8分钟

浏览器和线程、进程

浏览器打开一个页面就相当于开了一个进程(程序),在程序中,我们会同时做很多事情,每一个事情都有一个"线程"去处理,所以一个进程中可能会包含多个线程。

一个 Tab 对应一个渲染进程,渲染进程是多线程的:

  • GUI 渲染线程:页面渲染、绘图、绘制、3d动画

  • Js 渲染引擎:执行 js 代码,当 js 执行时,渲染线程会挂起,当然渲染中也不能执行js。

  • 事件触发线程:也就是Eventloop

  • webapi 线程:事件、定时器、ajax请求都会创造一个线程

  • network 线程:http请求

  • webWorker 线程 等

EventLoop

我们知道,浏览器是多线程的,但是 Js 只拥有 Js 引擎线程,Js 本身是单线程的。

这也导致了一种现象,Js 中大部分操作是同步的,但是有少部分操作,结合 EventLoop 机制,实现了异步处理,所以 Js 是单线程异步操作。

宏任务「macrotask」

  • js 整体代码块
  • ui 渲染
  • 定时器
  • ie 中的 setImmediate
  • I/O事件
  • 数据请求 Ajax/Fetch
  • 消息队列
  • setImmediate (node 独有)

微任务 「microtask」

  • Promise.then/catch/finally
  • async/await
  • generator
  • queueMicrotask (手动创建异步微任务的方法)
  • MutationObserver (监听 Dom 变化)
  • IntersectionObserver (监听 dom 与浏览器窗口是否交叉)
  • process.nextTick (node 独有)

渲染任务

下面这两个方法,虽然都是在本轮微任务之后执行,下轮宏任务之前执行「一轮事件环结束执行渲染任务,不过它们并不属于微任务或者宏任务」。

  • requestAnimationFrame: 浏览器大约 16.6ms 会渲染一次页面,浏览器判断需要渲染的话,渲染之前会调用该方法。
  • requestIdleCallback: 本轮渲染结束后,没有等到下一个16.6ms「下次渲染」,此时的空闲时间会执行 requestIdleCallback

每一轮 eventLoop 后,浏览器都会判断页面要不要渲染「只是有可能渲染」,还取决于有没有到时间(16.6ms) 或者 处于性能考虑,要不要做一次合并渲染,16.6ms 是 1s / 60 哦。

浏览器中的 EventLoop

结合两道题,我们来看下 EventLoop 是怎么执行的。

console.log(1);

// 定时器设置为 0 也不是立即执行
setTimeout(() => {
	console.log(2);
}, 0)

console.log(3);

console.time('loop');
for (let i = 0; i < 99999999; i++) {

}
console.timeEnd('loop');
console.log(4);

// 1 
// 3 
// loop: 95.189ms
// 4
// 2

图来:

题目难度升级:

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

console.log(2);

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

console.log(4);

for (let i = 0; i < 90000000; i++) {}  // 79ms 左右

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

console.log(7);

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

console.log(9);


// 2 
// 4
// 7
// 9
// 3
// 1
// 6
// 8

一道关于结合渲染线程的题

<body>
	<script>
		document.body.style.background = 'red';
		console.log(1);

		Promise.resolve().then(() => {
			console.log(2);
			document.body.style.background = 'yellow';
		});

		console.log(3);
	</script>
</body>

揭晓答案:

// 1 3 2 页面变黄色

这道题主要迷惑的点在于,渲染线程什么时候执行,因为整体代码作为一个宏任务执行时,此时给页面赋值红色,页面不会进行渲染,渲染进程只有在一轮事件循环结束才有可能执行「宏任务 -> 微任务队列 -> 考虑渲染页面」,所以直到最后页面被设置成黄色,还只是在一轮事件循环里面,最后页面变色被合并,直接变成黄色。

结合渲染线程的变形题

<body>
	<script>
		document.body.style.background = 'red';
		console.log(1);

		setTimepit(() => {
			console.log(2);
			document.body.style.background = 'yellow';
		}, 0);

		console.log(3);
	</script>
</body>

揭晓答案:

// 1 3 2 页面变红色 2 页面变黄色
不过也有可能页面颜色不闪烁,因为浏览器什么时候渲染页面并不固定,仅仅是有可能渲染。

结合原生事件的一道面试题

<body>
	<script>
		button.addEventListener('click', () => {
			console.log('listener1');
			Promise.resolve().then(() => console.log('micro task1'))
		});

		button.addEventListener('click', () => {
			console.log('listener2');
			Promise.resolve().then(() => console.log('micro task2'))
		});

		// 点击按钮会发生什么
	</script>
</body>

揭晓答案:

dom事件是一个宏任务,所以输出顺序为 
// listener1   micro task1
// listener2   micro task

结合原生事件的一道变形题

<body>
	<script>
		button.addEventListener('click', () => {
			console.log('listener1');
			Promise.resolve().then(() => console.log('micro task1'))
		});

		button.addEventListener('click', () => {
			console.log('listener2');
			Promise.resolve().then(() => console.log('micro task2'))
		});

		button.click(); // 自动触发
	</script>
</body>

揭晓答案:

自动触发的 dom api 不会产生宏任务了,相当于是两个自动执行的函数,是同步代码
// listener1      listener2
// micro task1    micro task2

一道不符合 promise A+ 规范的典型题「必看」

// 前置知识点
//   @1 「源码机制」promise1 中返回一个新的 promsie2,会根据 promise1 的 resolve 
//      和 reject 当成 promise2 的成功或失败回调,也就是 
//      promise2.then(resolve, reject);
Promise.resolve().then(() => {
	console.log(0);
	return Promise.resolve(1); 
}).then(res => {
	console.log(res);
});

Promise.resolve().then(() => {
	console.log(2);
}).then(() => {
	console.log(3);
}).then(() => {
	console.log(4);
}).then(() => {
	console.log(5);
});

我们先尝试利用 promise A+ 的描述去解这道题:

  1. 第一段代码执行, then0 入微任务队列,此时微任务队列为 [then0]
  2. 第二段代码执行,then2 入微任务队列,至此,整体代码执行完毕,此时微任务队列为 [then0, then2],同步代码执行完毕,开始清空微任务队列
  3. then0 开始执行, 先输出 0,然后执行 return Promise.resolve(1),我们假设 Promise.resolve(1) 返回值为 promise2, 其源码内隐藏的 promsie2.then 入微任务队列,代码执行完毕,注意:then1 在等 promise2.then 返回结果,所以 then1 没有入微任务队列,此时微任务队列为 [then2, promsie2.then]
  4. then2 执行,输出 2,然后 then3 入微任务队列,此时微任务队列为 [promsie2.then, then3]
  5. promsie2.then 执行,然后 then1 入微任务队列,此时微任务队列为 [then3,then1]
  6. then3 执行,输出 3,然后 then4 入微任务队列,此时微任务队列为 [then1,then4]
  7. then1 执行,输出 1,此时微任务队列为 [then4]
  8. then4 执行,输出 4,然后 then5 入微任务队列,此时微任务队列为 [then5]
  9. then5 执行,输出 5,任务队列清空

所以我们得到了结果为 0,2,3,1,4,5,然后我们执行代码发现,输出顺序为 0,2,3,4,1,5,为什么会这样呢?

原来本题中涉及到一个 ecma 规范和 promise A+ 规范描述不一致的地方,在 ecmascript 规范中描述:如果返回了一个 promise,它不会立刻处理这个 promise,会将这个 promise 放到异步方法中进行处理(会用一层新的微任务包裹)。

所以,我们进行一次包裹,简称该微任务为 thenWrap

1. [then0] 					
2. [then0, then2] 		  -> then0 执行,输出 0
3. [then2, thenWrap]		-> then2 执行,输出 2
4. [thenWrap, then3]        -> thenWrap 执行,promise2.then 入队列,没有输出 
5. [then3,promise2.then]   -> then3 执行,输出 3
6. [promise2.then,then4]   -> promsie2.then 执行,then1 入队列,没有输出
7. [then4, then1]           -> then4 执行,输出 4,then5 入队列
8. [then1, then5]           -> then1 执行,输出 1
9. [then5]                  -> then5 执行,输出 5

Node 中的 EventLoop

node 自己实现了一个事件环机制(新版本的 node 执行结果和浏览器完全一致),不过底层实现方式不一样,参考node官方指南

node 认为,下图 6 个阶段就代表了完整的事件环,事件环中的 api (比如 setTimeout) 是它自己实现的,而对于 promise、process.nextTick 这些没有出现在事件环中的原因是,它们只是 v8 实现的异步 api,从技术角度讲并不是事件循环的一部分。

  1. 浏览器宏任务只有一个队列,但是对于我们 node 而言,有6个阶段「队列」 ┌───────────────────────────┐ ┌─>│ timers │ 本阶段执行已经被 setTimeout()和 setInterval() 的调度回调函数。 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ 执行延迟到下一个循环迭代的 I/O 回调(上一轮 poll 阶段没有执行到的,这里执行) │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │仅系统内部使用,内置调度方法执行。 │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    │ │ poll │轮询,检索 I/O 事件并执行回调(定时器检查也在这里进行,如果可以执行定时器回调,则考虑跳回 │ │ timer 阶段『取决于当前或后面两个阶段还有没有要执行的回调』),几乎除了 timer 和 check 阶段以外的 │ │ 回调都在这里执行。 │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    │ │ check │检测阶段,setImmediate() 回调函数在这里执行。 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │一些关闭的回调函数,如:socket.on('close', ...)。 └───────────────────────────┘

其中,第二 & 第三个阶段我们控制不了,为系统内部调度使用的,需要解释的是第二个阶段我们并不知道 I/O 操作什么时候执行完毕,这轮能不能执行上。

那么 Node 中的 eventLoop 究竟是怎么执行的呢?

// Node 事件循环执行流程
//   @1 整体代码作为第一个宏任务执行
//   @2 检查 timer 队列,立即执行 timer 回调
//   @3 进入 poll 阶段轮询,清空 poll 队列中的 I/O 回调,如果后两个阶段有可执行的回调,
//      则继续往下扫描,否则:
//         + 有等待中的 I/O 或者定时器任务,在这里阻塞,检测 I/O 和 timer 的完成时间,如果 
//           I/O 回调先到达可执行阶段,I/O 回调在这里直接执行,如果 timer 回调先到达可执行
//           阶段,则跳回 timer 阶段。
//         + 没有 I/O 或者定时器任务,事件环结束。
//   @4 如果 check 队列不为空,检查 check 队列,立即执行 setImmeiate 的回调,清空微任务队列
//   @5 如果 close 队列不为空,检查 close 队列,立即执行队列中的回调,并跳回 timer 阶段 

// 每一轮任务结束(宏任务或微任务),都会检查 nextTick 队列。
// 每一次清空宏任务队列,都会清空微任务队列(queueMicrotask,promise.then 等)

setTimeout 和 setImmediate

setImmeiate: 预定在 I/O 事件的回调之后立即执行的 callback。

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

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

二者的执行顺序不一定哦,需要看执行到 timer 阶段时,timeout 是否已经准备好,延时 0 并不代表可以直接放到 timer 队列中,跟机器的性能也有关系哦。

require('fs').readFile('./note.md', () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);

  setImmediate(() => {
    console.log('immediate');
  });
});
// immediate  timeout

这样写的话,setImmediate 必定先执行

不一样的 node 11+

setTimeout(() => {
  console.log('timer1');
  Promise.resolve().then(function() {
    console.log('promise1');
  });
}, 0);

setTimeout(() => {
  console.log('timer2');
  Promise.resolve().then(function() {
    console.log('promise2');
  });
}, 0);

node 10 输出如下:

timer1
timer2
promise1
promise2

node11 运行后居然是:

timer1
promise1
timer2
promise2

可以看到,执行了一个 setTimeout 之后,立刻执行了微任务

那么为什么要这么做呢? 为了和浏览器更加趋同。

现在 node11+ 在 timer 阶段的 setTimeout, setInterval 和在 check 阶段的 immediate 都修改为一旦执行一个阶段里的一个任务就立刻扫描 nextTick 队列和微任务队列。

setTimeout(() => {
  console.log('timer1');
  process.nextTick(() => {
    console.log('nextTick1');
  });
}, 0);

setTimeout(() => {
  console.log('timer2');
  process.nextTick(() => {
    console.log('nextTick2');
  });
}, 0);