# 异步编程 与 EventLoop

# 并发(concurrency)和并行(parallelism)区别

涉及面试题:并发与并行的区别?

异步和这小节的知识点其实并不是一个概念,但是这两个名词确实是很多人都常会混淆的知识点。其实混淆的原因可能只是两个名词在中文上的相似,在英文上来说完全是不同的单词。

并发是宏观概念,我分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。

并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。

# 回调函数(Callback)

涉及面试题:什么是回调函数?回调函数有什么缺点?如何解决回调地狱问题?

回调函数应该是大家经常使用到的,以下代码就是一个回调函数的例子:

ajax(url, () => {
	// 处理逻辑
});

但是回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:

ajax(url, () => {
	// 处理逻辑
	ajax(url1, () => {
		// 处理逻辑
		ajax(url2, () => {
			// 处理逻辑
		});
	});
});

以上代码看起来不利于阅读和维护,当然,你可能会想说解决这个问题还不简单,把函数分开来写不就得了

function firstAjax() {
	ajax(url1, () => {
		// 处理逻辑
		secondAjax();
	});
}
function secondAjax() {
	ajax(url2, () => {
		// 处理逻辑
	});
}
ajax(url, () => {
	// 处理逻辑
	firstAjax();
});

以上的代码虽然看上去利于阅读了,但是还是没有解决根本问题。

回调地狱的根本问题就是:

嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身 嵌套函数一多,就很难处理错误 当然,回调函数还存在着别的几个缺点,比如不能使用 try catch 捕获错误,不能直接 return。在接下来的几小节中,我们将来学习通过别的技术解决这些问题。

# Promise

涉及面试题:Promise 的特点是什么,分别有什么优缺点?什么是 Promise 链?Promise 构造函数执行和 then 函数执行有什么区别?

Promise 翻译过来就是承诺的意思,这个承诺会在未来有一个确切的答复,并且该承诺有三种状态,分别是:

  • 等待中(pending)
  • 完成了 (resolved)
  • 拒绝了(rejected) 这个承诺一旦从等待状态变成为其他状态就永远不能更改状态了,也就是说一旦状态变为 resolved 后,就不能再次改变
new Promise((resolve, reject) => {
	resolve("success");
	// 无效
	reject("reject");
});

当我们在构造 Promise 的时候,构造函数内部的代码是立即执行的

new Promise((resolve, reject) => {
	console.log("new Promise");
	resolve("success");
});
console.log("finifsh");
// new Promise -> finifsh

Promise 实现了链式调用,也就是说每次调用 then 之后返回的都是一个 Promise,并且是一个全新的 Promise,原因也是因为状态不可变。如果你在 then 中 使用了 return,那么 return 的值会被 Promise.resolve() 包装

Promise.resolve(1)
	.then((res) => {
		console.log(res); // => 1
		return 2; // 包装成 Promise.resolve(2)
	})
	.then((res) => {
		console.log(res); // => 2
	});

当然了,Promise 也很好地解决了回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:

ajax(url)
	.then((res) => {
		console.log(res);
		return ajax(url1);
	})
	.then((res) => {
		console.log(res);
		return ajax(url2);
	})
	.then((res) => console.log(res));

前面都是在讲述 Promise 的一些优点和特点,其实它也是存在一些缺点的,比如无法取消 Promise,错误需要通过回调函数捕获。

# async 及 await

涉及面试题:async 及 await 的特点,它们的优点和缺点分别是什么?await 原理是什么?

一个函数如果加上 async ,那么该函数就会返回一个 Promise

async function test() {
	return "1";
}
console.log(test()); // -> Promise {<resolved>: "1"}

async 就是将函数返回值使用 Promise.resolve() 包裹了下,和 then 中处理返回值一样,并且 await 只能配套 async 使用

async function test() {
	let value = await sleep();
}

async 和 await 可以说是异步终极解决方案了,相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码,毕竟写一大堆 then 也很恶心,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。

async function test() {
	// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
	// 如果有依赖性的话,其实就是解决回调地狱的例子了
	await ajax(url);
	await ajax(url1);
	await ajax(url2);
}

# 常用定时器函数

涉及面试题:setTimeout、setInterval、requestAnimationFrame 各有什么特点?

异步编程当然少不了定时器了,常见的定时器函数有 setTimeout、setInterval、requestAnimationFrame。我们先来讲讲最常用的 setTimeout,很多人认为 setTimeout 是延时多久,那就应该是多久后执行。

其实这个观点是错误的,因为 JS 是单线程执行的,如果前面的代码影响了性能,就会导致 setTimeout 不会按期执行。当然了,我们可以通过代码去修正 setTimeout,从而使定时器相对准确。

接下来我们来看 setInterval,其实这个函数作用和 setTimeout 基本一致,只是该函数是每隔一段时间执行一次回调函数。

通常来说不建议使用 setInterval。第一,它和 setTimeout 一样,不能保证在预期的时间执行任务。第二,它存在执行累积的问题,请看以下伪代码

function demo() {
	setInterval(function() {
		console.log(2);
	}, 1000);
	// 耗时操作
	sleep(2000);
}
demo();

以上代码在浏览器环境中,如果定时器执行过程中出现了耗时操作,多个回调函数会在耗时操作结束以后同时执行,这样可能就会带来性能上的问题。

如果你有循环定时器的需求,其实完全可以通过 requestAnimationFrame 来实现

首先 requestAnimationFrame 自带函数节流功能,基本可以保证在 16.6 毫秒内只执行一次(不掉帧的情况下),并且该函数的延时效果是精确的,没有其他定时器时间不准的问题。

<div
	id="a"
	style="width: 100px; 
            height: 100px;
            background-color: red;
            position: absolute;"
></div>

<script>
	/**
	 * 定义一个div盒子,动画设置向右平移 500px
	 *
	 * 注:这里使用的requestAnimaFrame和cancelAnimFrame方法名为自己封装的方法
	 */
	let start = 0,
		end = 500,
		ele = document.querySelector("#a"),
		req;
	let f = function() {
		start += 10;
		ele.style.left = start + "px";
		// 距离页面左边的距离
		let left = ele.getBoundingClientRect().left;
		if (left < end) {
			req = requestAnimaFrame(f);
		} else {
			cancelAnimFrame(req);
		}
	};
</script>

# Event Loop

在前面我们了解了 JS 异步相关的知识。在实践的过程中,你是否遇到过以下场景,为什么 setTimeout 会比 Promise 后执行,明明代码写在 Promise 之前。这其实涉及到了 Event Loop 相关的知识,这一章节我们会来详细地了解 Event Loop 相关知识,知道 JS 异步运行代码的原理,并且这一部分也是面试常考知识点。

# 进程与线程

涉及面试题:进程与线程区别?JS 单线程带来的好处?

相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?

讲到线程,那么肯定也得说一下进程。本质上来说,两个名词都是 CPU 工作时间片的一个描述。

进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。

把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作。

# 执行栈

涉及面试题:什么是执行栈?

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。

执行栈可视化 当开始执行 JS 代码时,首先会执行一个 main 函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo 函数后执行,当执行完毕后就从栈中弹出了。

平时在开发中,大家也可以在报错中找到执行栈的痕迹

function foo() {
	throw new Error("error");
}
function bar() {
	foo();
}
bar();

大家可以在上图清晰的看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。

当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题

function bar() {
	bar();
}
bar();

# 浏览器中的 Event Loop

涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?

大家知道了当我们执行 JS 代码的时候其实就是往执行栈中放入函数,那么遇到异步代码的时候该怎么办?其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

# 事件循环

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。下面来看以下代码的执行顺序:

console.log("script start");

async function async1() {
	await async2();
	console.log("async1 end");
}
async function async2() {
	console.log("async2 end");
}
async1();

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

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

console.log("script end");
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout

注意:新的浏览器中不是如上打印的,因为 await 变快了,具体内容可以往下看

首先先来解释下上述代码的 async 和 await 的执行顺序。当我们调用 async1 函数时,会马上输出 async2 end,并且函数返回一个 Promise,接下来在遇到 await 的时候会就让出线程开始执行 async1 外的代码,所以我们完全可以把 await 看成是让出线程的标志。

然后当同步代码全部执行完毕以后,就会去执行所有的异步代码,那么又会回到 await 的位置执行返回的 Promise 的 resolve 函数,这又会把 resolve 丢到微任务队列中,接下来去执行 then 中的回调,当两个 then 中的回调全部执行完毕以后,又会回到 await 的位置处理返回值,这时候你可以看成是 Promise.resolve(返回值).then(),然后 await 后的代码全部被包裹进了 then 的回调中,所以 console.log('async1 end') 会优先执行于 setTimeout。

所以 Event Loop 执行顺序如下所示:

首先执行同步代码,这属于宏任务 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行 执行所有微任务 当执行完所有微任务后,如有必要会渲染页面 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数 所以以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。

微任务包括 process.nextTick ,promise ,MutationObserver。

宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。

这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。

上次更新: 11/22/2019, 10:01:47 AM