前言
在刚开始学习 JavaScript 的时候,我们可能会听到一些话,就例如,你这代码异步了吧?那到底什么是异步的呢,什么又是同步呢,那么通过这篇文章我们来学习一些相关的基础知识,也能更好的学习后面的 Promise 等异步解决方案打下坚实的基础。
一、JavaScript是单线程的
JavaScript 所做的事情决定了 JavaScript 只能是单线程的,其作为浏览器的脚本语言,主要是实现用户与浏览器的交互,以及操作 DOM。我们都知道浏览器需要渲染 DOM,而 JavaScript 可以操作 DOM,在 JavaScript 执行的时候,;浏览器 DOM 渲染需要停止,如果同时执行多段 JavaScript 代码,如果这多段 JavaScript 代码同时操作同一个 DOM,那么就会出现冲突,也就是我们 竞态。到这来你可能会有疑问:不是有一个 web worker,这不就是多线程吗,你到底行不行啊? ...先别急,在 MDN文档 中有这么几个字 在 worker 内,不能直接操作 DOM 节点,也不能使用window对象的默认方法和属性😏😏😏
单线程最大的好处就是不用多线程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。同样单线程也是有它自身的弱点,这些弱点具体有以下3方面:
- 无法利用多核
CPU; - 错误会引起整个应用退出,应用的健壮性值得考验;
- 大量计算占用
CPU导致无法继续调用异步I/O(web worker正是为了解决这样问题而诞生的);
二、同步与异步
同步行为和异步行为的对立统一是计算机科学的一个基本概念。特别是在 JavaScript 这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。异步行为是为了优化因计算量大而 时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做 就是务实的。
异步操作并不一定是计算量大或者等待时间很长。只要你不想为等待某个异步操作而阻塞线程执行,你任何时候都可以这么做。
- 在维基百科中
同步和异步是这么定义的:
同步,指在一个系统中所发生的事件之间进行协调,在时间上出现一致性与统一化的现象。在系统中进行同步,也被称为及时或同步化的。同步还可以理解为:发出一个调用时,在没有得到结果之前,该调用就不返回;一旦调用返回,就得到返回值。换句话说,就是由调用者主动等待这个调用的结果。而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。
异步或异步是相对于同步的概念,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知或通过回调函数,让调用者能响应结果。对于非阻塞情形,同步非阻塞是观察者定期主动的去查看目标对象状态;异步非阻塞是目标对象状态改变后去通知观察者做出相应处理。
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每一条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析 程序在执行到代码任意位置时的状态(比如变量的值)。
- 同步操作的例子可以是执行一次简单的数学计算:
let x = 3;
x = x + 4;
在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才 会执行。等到最后一条指定执行完毕,存储在 x 的值就立即可以使用。操作系统会在栈 内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的 内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。
相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问 一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。
- 异步操作的例子可以是在定时回调中执行一次简单的数学计算:
let x = 77;
setTimeout(() => {
x += 7;
}, 1000);
这段程序最终与同步代码执行的任务一样,都是进行加法操作,但这一次执行线程不知道 x 值何时会发生改变,因为取决于回调何时从消息队列出列并执行。
三、并行、串行与协作
3.1 并行
异步 和 并行 常常被混为一谈,但实际上它们的意义完全不同。异步是关于现在和将来的时间间隙,而并行是关于能够同时发生的事情。
并行计算最常见的工具就是进程和线程。进程和线程独立运行,并可能同时运行,在不同的处理器,甚至不同的计算机上,但多个线程能够共享单个进程的内存。
与之相对的是,时间循环把自身的工作分成一个个任务并顺序执行,不允许对共享内存的并行访问和修改。通过分立线程中彼此合作的时间循环,并行和顺序执行可以共存。并行线程的交替执行和异步事件的交替调度,其粒度是完全不同的。
3.2 并发
并发,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
两个或多个 进程 在同一个程序内并发地交替运行他们的步骤或者时间时,如果这些任务彼此不相关,就不一定需要交互。如果进程间没有相互的影响的话,不确定性是完全可以接受的,例如:
const result = {};
function foo(res) {
result.foo = res;
}
function bar(res) {
result.bar = res;
}
axios("https://www.你小子.com").then(foo);
axios("https://www.你小子真不错.com").then(bar);
foo() 和 bar() 是两个并发执行的 "进程",按照什么顺序执行是不确定的。但是我们构建程序的方式使得无论按哪种顺序执行都无所谓,因为它们是独立运行的,不会相互影响的。这并不是竞态条件bug,因为不管顺序如何,代码总是正常工作的。
而另外一种常见的情况就是,并发的 "进程" 是需要相互交流的,通过作用域或 DOM 间接交互,如果出现这样的交互,就需要对它们的交互进行协调以避免竞态的出现。
- 下面的一个例子,两个并发的
"进程"通过隐含的顺序相互影响,这个顺序有时会破坏:
const result = [];
function foo(res) {
result.push(res);
}
axios("https://www.你小子.com").then(foo);
axios("https://www.你小子真不错.com").then(foo);
我们本来是希望的行为是 result[0] 中放置调用 https://www.你小子.com 的结果的,result[1] 放置调用 https://www.你小子真不错.com的结果的。有时候可能是这样,但有时候又不是这样,这就要看哪个调用先完成了。这种不确定性有可能就是一个竞争状态条件bug。这种情况虽然我们可以通过 if else 来进行协调,但是当项目庞大起来这些判断似乎就变得很没必要了。
3.3 协作
还有一种并发合作方式,称为并发协作。这里的目标是取到一个长期运行的 "进程",并将其分割成多个步骤或多批任务,使得其他开发并发 "进程" 有机会将自己的运算插入到事件循环队列中交替运行。
- 举个例子🌰,考虑一个需要遍历很长的结果列表进行值转换的
axioa相应处理函数,对其返回结果进行处理:
const result = [];
function foo(res) {
result = result.concat(res.map((value) => value * 2));
}
axios("https://www.你小子.com").then(foo);
axios("https://www.你小子真不错.com").then(foo);
如果 axios("https://www.你小子.com").then(foo) 首先会的结果,那么整个列表会立即映射到 result 中。如果记载有几千条或更少,那影响不大。但是如果有1000万条记录的话,那就可能运行一段时间了,这就阻塞了你接下来的代码运行了。
所以要创建一个协作性更强更友好且不会霸占时间循环队列的并发系统,你可以异步地批处理这些结果。每次处理之后返回事件循环,让其他等待事件有机会运行,这里给出一种非常简单的方法:
const result = [];
function foo(res) {
// 对数据进行分割,一次处理一千条
const chunk = res.splice(0, 1000);
// 添加到已有数据中
result = chunk.concat(res.map((value) => value * 2));
// 还有剩下的要处理
if (res.length > 0) {
setTimeout(() => {
foo(res);
}, 0);
}
}
axios("https://www.你小子.com").then(foo);
axios("https://www.你小子真不错.com").then(foo);
我们把数据集合放在最多包含1000条项目的块中。这样我们就确保了 "进程" 运行时间会很短,即使这意味着需要更多的后续 "进程",因为事件循环队列的交替运行会提高站点的的性能。
但是这也有一个缺点,我们并不能协调这些 "进程" 的顺序, 所以结果的顺序是不可能预测的。如果需要排序的话,这就要等到下一篇文章了🧐
四、CPU密集型和I/O密集型
4.1 CPU密集型
CPU密集型 英文为CPU-bound,直译为 CPU受限型。在计算机科学,如果一台计算机是CPU密集型(或计算密集型),那么它完成一项任务的时间是取决于中央处理器的速度。其处理器占用率高,也许在某段时间内保持100%占用率。外围设备产生中断时,可能处理得很慢,也可能被无限期地推迟。
CPU 使用率较高,例如:计算一些复杂的运算,逻辑处理等情况非常多的情况下,线程数一般只需要设置为 CPU 核心数的线程个数就可以了。 这一类型多出现在开发中的一些业务复杂计算和逻辑处理过程中。
CPU密集型,这一概念从早期计算机来的。当时在计算机部件间:CPU、磁带驱动器、硬盘、卡阅读器、打印机的数据传输较为简单,因此可以直观地看到一个部件在工作,另一个部件被挂起。
CPU 可以理解为 就是处理繁杂算法的操作,对硬盘等操作不是很频繁,比如一个算法非常之复杂,可能要处理半天,而最终插入到数据库的时间很快。
4.2 I/O密集型
I/O密集型 指的是系统的 CPU 性能相对硬盘、内存要好很多,此时系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。
CPU 使用率较低,程序中会存在大量的 I/O 操作占用时间,导致线程空余时间很多,所以通常就需要开CPU核心数两倍的线程。当线程进行 I/O 操作 CPU 空闲时,启用其他线程继续使用 CPU,以提高 CPU 的使用率。例如:数据库交互,文件上传下载,网络传输等。
从单线程的角度来说,Node.js 处理 I/O 的能力值得竖起拇指称赞的。Node.js 擅长 I/O密集型 的应用场景,其面向网络且擅长并行 I/O,能够有效地组织其更多的硬件资源,从而提供更多好的服务。Node.js 具有异步 I/O 的特性,每当有 I/O 请求发生时,Node.js 会提供给该请求一个 I/O 线程,然后继续执行主线程上的事情,只需要在该请求返回回调时再处理即可,这使其省去了很多等待请求的时间,这也是 Node.js 支持高并发的重要原因之一。
I/O密集型 可以理解为简单的业务逻辑处理,比如计算1+1=2,但是要处理的数据很多,每一条都要去插入数据库,对数据库频繁操作。
五、参考文献
六、结尾
- 本篇内容主要讲解了
JavaScript异步的一些前置知识,将会在下一篇文章中讲解解决异步的几种方法以及事件循环; - 自从学了操作系统之后,发现之前很多晦涩难懂的概念现在突然变得很清楚了,强烈推荐🧐
- 在接下来的文章会分别讲解异步编程的四个解决方案,以及深入讲解生成器和
async...await原理和事件循环; - 如果想要技术交流的可以私信加我微信;