你不知道的javascript(中)异步和性能读书笔记

399 阅读11分钟

异步

异步类比现实就是 我在烧水的时候刷微博,等水开了通知我,我在刷完微博之后把水倒进暖瓶,这个过程中"我"是js单线程,水开了倒进暖瓶是在烧水的时候注册的一个handler,烧水的过程是其他线程在运行的任务,烧完水通知我是把这个倒进暖瓶的handler推入到我的待办事项中,我刷完微博再倒进暖瓶是排队处理任务

第二个类比是定时器的,定时器就是现在我告诉Siri启动一个定时器八分钟后提醒我去买菜,然后Siri注册了一个提醒我去买菜的handler,我就接着刷微博,八分钟到了之后Siri提醒我去买菜也就是将买菜推入到了我的任务队列,我刷完微博就去买菜了。
以上两个类比可以知道异步就是一种现在和未来任务的组织方式,保证在这段空闲的时间能去做别的事不至于阻塞住。当然了如果我一直刷微博导致买菜或者倒开水任务没机会执行也算是被一个任务给阻塞住了,这也是为什么setTimeout有时候不准的问题,因为回调函数的任务队列前面有占用过多时间的任务

如何表达和控制持续一段时间的程序行为?指的是程序在现在运行但是结果是要在未来返回的,比如网络请求,文件处理等。

1、分块的程序

程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

这个程序有两个块: 现在执行的部分,以及将来执行的部分。

任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点 击、 Ajax 响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序 中引入了异步机制。

console.* 方法族如何工作——它们并不是 JavaScript 正式 的一部分,而是由宿主环境(请参考本书的“类型和语法”部分)添加到 JavaScript 中的。 因此,不同的浏览器和 JavaScript 环境可以按照自己的意愿来实现,有时候这会引起混淆。 尤其要提出的是,在某些条件下,某些浏览器的 console.log(..) 并不会把传入的内容立 即输出。出现这种情况的主要原因是,在许多程序(不只是 JavaScript)中, I/O 是非常低 速的阻塞部分。所以,(从页面 /UI 的角度来说)浏览器在后台异步处理控制台 I/O 能够提 高性能,这时用户甚至可能根本意识不到其发生

如果遇到这种少见的情况,最好的选择是在 JavaScript 调试器中使用断点, 而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强 制执行一次“快照”,比如通过 JSON.stringify(..)。(引用类型可能在I/O导致延迟输出之前这段间隔又被别的操作给修改了,导致输出的数据不正确)

2、事件循环

直到es6,javascript在真正内建有直接的异步的概念,在这个之前javascript引擎要做的只是在需要的时候在给定的任意时刻执行程序中的代码块。这个“需要”在es6之前是需要在javascript的宿主环境进行调度。

JavaScript 引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是 Web 浏览器。经过最近几年(不仅于此)的发展, JavaScript 已经超出了浏览器的范围, 进入了其他环境,比如通过像 Node.js 这样的工具进入服务器领域。实际上, JavaScript 现 如今已经嵌入到了从机器人到电灯泡等各种各样的设备中。 但是,所有这些环境都有一个共同“点”(thread,也指线程。不论真假与否,这都不算一 个很精妙的异步笑话),即它们都提供了一种机制来处理程序中多个块的执行,且执行每 块时调用 JavaScript 引擎,这种机制被称为事件循环。 换句话说, JavaScript 引擎本身并没有时间的概念,只是一个按需执行 JavaScript 任意代码 片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。 所以,举例来说,如果你的 JavaScript 程序发出一个 Ajax 请求,从服务器获取一些数据, 那你就在一个函数(通常称为回调函数)中设置好响应代码,然后 JavaScript 引擎会通知 宿主环境:“嘿,现在我要暂停执行,你一旦完成网络请求,拿到了数据,就请调用这个 函数。” 然后浏览器就会设置侦听来自网络的响应,拿到要给你的数据之后,就会把回调函数插入 到事件循环,以此实现对这个回调的调度执行。 那么,什么是事件循环? 先通过一段伪代码了解一下这个概念 :

// eventLoop是一个用作队列的数组
//(先进,先出)
var eventLoop = [ ];
var event;
//“永远”执行
while (true) {
  // 一次tick
  if (eventLoop.length > 0) {
  // 拿到队列中的下一个事件
   event = eventLoop.shift();
   // 现在,执行下一个事件
  try {
   event();
  }
  catch (err) {
   reportError(err);
  }
 }
}

用一个while循环实现持续的循环,循环的每一轮称为一个tick对每个tick而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件执行,这个时间就是你的回调函数
setTimeout(..) 并没有把你的回调函数挂在事件循环队列中。它所做的是设 定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来 某个时刻的 tick 会摘下并执行这个回调。
如果这时候事件循环中已经有 20 个项目了会怎样呢?你的回调就会等待。它得排在 其他项目后面——通常没有抢占式的方式支持直接将其排到队首。这也解释了为什么 setTimeout(..) 定时器的精度可能不高。大体说来,只能确保你的回调函数不会在指定的 时间间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,要根据事件队列的 状态而定。
所以换句话说就是,程序通常分成了很多小块,在事件循环队列中一个接一个地执行。严 格地说,和你的程序不直接相关的其他事件也可能会插入到队列中。
事件循环中任务执行的最小粒度是单个函数,所以一旦一个函数开始执行只有执行结束了才能再继续执行下一个任务函数,这成为完成运行特性。(生成器可以打破这种完成运行特性,在函数运行过程中暂停住,再从外部传入新的参数进行运行)

上面说到es6开始,javascript开始有了内建的异步概念,那么这个内建的异步概念是什么?,再截取《你不知道的javascript(中)》上面一段话:

前面提到的“直到最近”是指 ES6 从本质上改变了在哪里管理事件循环。本 来它几乎已经是一种正式的技术模型了,但现在 ES6 精确指定了事件循环 的工作细节,这意味着在技术上将其纳入了 JavaScript 引擎的势力范围,而 不是只由宿主环境来管理。这个改变的一个主要原因是 ES6 中 Promise 的引 入,因为这项技术要求对事件循环队列的调度运行能够直接进行精细控制

从这段话可以理解这个也就是微任务(microtask) 任务队列(job queue)。

在许多地方是用宏任务和微任务来区分两种不同的任务的,在这里根据书上来的话是事件循环队列和任务队列,两者的关系是执行事件循环队列,事件循环队列执行结束在下一轮tick来临前,清空任务队列。

在 ES6 中,有一个新的概念建立在事件循环队列之上,叫作任务队列(job queue)。从书上的内容理解这个任务队列就是微任务(microtask),这个任务队列是挂载在事件循环队列之后的一个队列,会在下一次tick之前清空掉,书上的比喻是:

事件循环队列类似于一个游乐园游戏:玩过了一个游戏之后,你需要重新到队尾排队才能 再玩一次。而任务队列类似于玩过了游戏之后,插队接着继续玩。
一个任务可能引起更多任务被添加到同一个队列末尾。所以,理论上说, 任务循环(job loop)可能无限循环(一个任务总是添加另一个任务,以此类推),进而导致程序的 饿死,无法转移到下一个事件循环 tick。从概念上看,这和代码中的无限循环(就像 while(true)..)的体验几乎是一样的。 任务和 setTimeout(..0) hack 的思路类似,但是其实现方式的定义更加良好,对顺序的保 证性更强:尽可能早的将来。

针对以上这段话,写个demo测试一下:

//任务队列如何陷入循环的
setTimeout(() => {
	console.log('setTimeout');
});

Promise.resolve().then(() => {
	console.log('promise');
	Promise.resolve().then(() => {
		console.log('promise');
		Promise.resolve().then(() => {
			console.log('promise');
			Promise.resolve().then(() => {
				console.log('promise');
				Promise.resolve().then(() => {
					console.log('promise');
					Promise.resolve().then(() => {
						console.log('promise');
						Promise.resolve().then(() => {
							console.log('promise');
							Promise.resolve().then(() => {
								console.log('promise');
								Promise.resolve().then(() => {
									console.log('promise');
									Promise.resolve().then(() => {
										console.log('promise');
									});
								});
							});
						});
					});
				});
			});
		});
	});
});
//  输出结果
//  promise
//  promise
//  promise
//  promise
//  promise
//  promise
//  promise
//  promise
//  promise
//  promise
//  setTimeout

//从输出可见优先执行的一直是任务队列要等到任务队列清空才能进入下一次事件循环执行setTimeout的事件,所以宏任务过多也会导致事件循环被阻塞
// 微任务如何进行插队

setTimeout(()=>{
    console.log('setTimeout1');
    Promise.resolve().then(()=>{
        console.log('promise1')
    })
    Promise.resolve().then(()=>{
        console.log('promise2')
    })
})
setTimeout(()=>{
    console.log('setTimeout2');
})
// 输出结果
// setTimeout1
// promise1
// promise2
// setTimeout2

在第一个settimeout中在任务队列中加入了俩任务,然后这个时候完成了插队,js引擎优先清空微任务队列,也就导致了promise1、promise2、在setTimeout2之前输出。

事件循环队列(宏任务 macrotask):

script(整体代码)、setTimeout、setInterval、UI 渲染、 I/O、postMessage、 MessageChannel、setImmediate(Node.js 环境)

任务队列(微任务 microtask):

Promise、 MutaionObserver、process.nextTick(Node.js环境)

以上各方法执行顺序测试

const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = function () {
  console.log("MessageChannel");
};
setTimeout(() => {
  console.log("setTimeout");
});
let observer = new MutationObserver(() => {
  console.log("MutationObserver");
});
let counter=0;
const textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
  characterData: true,
});
counter = (counter + 1) % 2;
textNode.data = String(counter);
port.postMessage(null);
Promise.resolve().then(() => {
  console.log("Promise");
});

// 输出顺序
// MutationObserver
// Promise
// MessageChannel
// setTimeout

//任务队列中 MutationObserver和Promise没有先后顺序之分,谁在前执行谁。

事件循环和浏览器渲染的关系

事件循环的应用

3、并行并发和协作

1、异步和并行的概念

异步:关于现在和将来的时间间隙。->将自身工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。

并行:是关于能够同时发生的事情。->最常见的工具进程和线程。

2、异步中函数的特性

完整运行:由于 JavaScript 的单线程特性, 函数中的代码具有原子性。也就是说,一旦 函数 开始运行,它的所有代码都会在 下一个任务任意代码运行之前完成,或者相反。这称为完整运行(run-to-completion)特性。

在 JavaScript 的特性中,函数顺序的不确定性就是通常所说的竞态条件(race condition),如果两个异步之间没有相互影响,不确定性是完全可以接受的。但是如果存在交互,这个不确定性可能就是个BUG。

5、任务

6、语句顺序

回调

回调函数就是异步在将来执行的目标

回调函数存在的几个问题:

1、多个回调嵌套成的回调地狱

2、信任问题

回调函数相当于于一种控制翻转,在类似ajax的这种请求中回调函数的控制权掌握在了第三方的手里,这样就涉及到了一个信任的问题,因为可能不知道第三方的框架怎么调用你的回调函数,可能一下子调用很多次(promise的出现部分也是为了解决这个问题,建立一种约定这种约定可以保证调用方注册方都能遵循防止出现问题。)

Promise的出现是解决了什么问题

解决回调函数的缺乏顺序性可信任性