浅谈 JavaScript 异步编程(二):JS 异步编程的基石

引擎与运行环境的“角逐”

我们再来看看刚才的这段代码,Node.js 在读取文件结束后将 Callback 交给 JS 引擎执行,但这其中有一个问题:运行环境交还 Callback 的时机如何确定?

如果运行环境在任务结束时立刻将 Callback 交还引擎,而此时引擎很可能正在执行一个函数的中途,那么强行插入执行的 Callback 很可能会导致一些难以预料的后果。

例如这段代码,setTimeout 是运行环境提供的一个定时器,我们这里将定时器设为 0,也就是说运行环境得到这个任务后可以立即完成。假如运行环境完成后立即塞回引擎执行,那么counter is 0既可能被打印一次,也可能被打印两次。

JS 作为一门单线程语言竟然会出现多线程并发问题!

事件循环与任务队列

幸运的是,上述的情况是不可能在 JS 中发生的。为了让引擎和运行环境在恰当的时间点交互,运行环境都采用了一套基于事件循环与任务队列的设计。

上图为浏览器中的事件循环与任务队列模型,它最初由 HTML5 标准制定,后来 ES6 标准又作出了修改。

此模型除了引入事件循环和任务队列的概念,还规定了一个基本原则:每当引擎的 stack 被清空后才运行事件循环清空任务队列,由此往复。
先前的代码在 for 中执行时 stack 始终不为空,所以运行结果一定是:counter is 0只被打印了一次。

下面我们通过一个具体的例子来理解这个模型:

小结

事件循环与任务队列的引入为 JS 执行异步代码提供了一个安全的、稳定的、可预测的环境,成为了 JS 异步编程的基石。

另外,ES6 标准中提出了宏任务与微任务的概念,并对运行环境提出了进一步的要求,例如不完成所有微任务就不能进入下一次事件循环等,让 JS 在拓展异步编程的功能时有了更为安全可靠的保障。

接下来让我们继续前进,探索 JS 异步编程的发展轨迹。

链接

浅谈 JavaScript 异步编程(一):JS 异步编程的含义

异步与同步

在介绍 JS 异步编程以前,我们首先得理清一下什么是异步,以及什么是并发。异步与同步是消息通信机制中两个相对的概念。

  • 同步:发出调用后,在得到结果之前,调用不返回。
  • 异步:发出调用后,调用直接返回,此时并没有获得结果。

让我们设想一个场景:有一天中午,我从教学楼里走出来,正想着去吃饭,突然一时兴起在图书馆里找一本书,于是拿出手机向图书管理员打电话。

高颜值的我与图书管理员在线对谈

接下来,我们分别用同步和异步去理解这个过程:

同步:

  1. 我打电话给图书管理员,提出请求(调用)。
  2. 我在电话里等图书管理员找书(等待结果)。
  3. 图书管理员完成找书,在电话里告诉我结果之后,我挂断电话(调用返回)。

异步:

  1. 我打电话给图书管理员,提出请求(调用)。
  2. 我挂断电话,骑车吃饭去了(调用返回)。
  3. 图书管理员完成找书,主动打电话告诉我结果(获得结果)。

这两个过程最显著的区别在于:我有没有干等着“找书”任务出结果

并发

并发是指一个系统支持两个或以上的任务同时存在。如果能够同时执行,这种并发也叫并行。不幸的是 JavaScript 本身是一门单线程的脚本语言,同一时间只能执行一个任务。单个 JavaScript 引擎实例无法做到并行。

不过,这并不意味着 JavaScript 不能借助外力来同时处理多个任务。还记得前面的漫画吗?我挂断电话后可以去食堂吃饭,但是此时找书的任务仍然在进行中。因为我将这个任务甩给了图书管理员,而他向我保证会在之后将结果告诉我。于是,我通过打电话给图书管理员这一异步操作,做到了一边找书一边吃饭。这种并发叫分时并发。

一边找书一边恰饭

JS 里的异步并发例子

类似地,在这段 JavaScript 代码中,我们要同时处理这两个任务:

  • 读取一个文件
  • 打印 do something else

我们的代码将读取文件的任务交给了 Node.js,然后打印了 do something else,最后 Node.js 读取文件完成,将最终结果输入 Callback 并交给 JS 引擎执行。

以上这个例子向我们粗略地阐述了 JS 中的异步与并发的概念。

为什么 JS 长于异步并发

今天,如果我们去翻开 JavaScript 的维基百科,上面会说它是一个“单线程非阻塞异步并发脚本语言”,那么为什么 JS 会以“异步并发”为特色呢?

历史原因

JS 自 1995 年诞生开始就是一门单线程的脚本语言,起初的设计目的只是辅助浏览器完成诸如验证表单这类简单的任务。并且,直到 Node.js 爆红之前,JS 的主要运行环境都是浏览器,所以它和浏览器之间存在着非常深的耦合关系。

在人们对提升 JS 性能的呼声越来越高时,浏览器支撑的庞大的 Web 体系不可能让 JavaScript 骤变变成一门支持多线程的语言来提高性能,所以 JavaScript 只能在单线程的道路上追求性能的极致。

环境原因

事实上在浏览器中,运行环境包括了 DOM、BOM 等浏览器自身暴露给 JavaScript 的 API,这些 API 通常由 C++编写,并且由浏览器执行,JS 只负责调用它们,也就是通知浏览器执行任务。

这一事实让人们找到了突破口:用支持多线程的运行环境为单线程的 JS 提供并发能力。于是,JS 变成了“大老板”,运行环境里的各种 C++线程成为了“打工仔”,专门为它服务。

JS 向运行环境发出若干个任务的指令,任务由运行环境完成并通知 JS,这一过程正是所谓的“异步并发”, 由此我们不难理解为什么“异步并发”是 JS 的一大特色了。

JS: like a boss

偏科的 JS

由此可见,JS 的设计使得它非常适合用于 I/O 密集型场合,因为这个“老板”能够高效地对若干个 C++线程发号施令,使得整个程序有条不紊地快速调度大量的 I/O 任务。

不过,它并不适合计算密集型场合,因为它只是一个单线程的“老板”,如果单看干活的多少,它显然不可能干得比两个人多。因此像 bcrypt 这样的加密库不得不像 I/O 相关的 API 那样借助多线程的 C++的力量来执行任务。

Node.js 在 Web 服务端领域占领的份额,其实主要集中在中间件等典型的高并发 I/O 密集型设施,其中一大关键原因就是因为 JS 的上述“偏科”缺陷。

另外,JS 单线程的设计也导致它难以充分利用多核 CPU 的性能,虽然 Node.js 能够通过 child_process 等方式实现这一点,但在 Web 服务端中的计算领域,相比其他成熟的方案(Java、C++等)优势并不明显,因此 Node.js 直到今天也难以撼动这些方案的地位。

面对Java无能狂怒的JS

链接

浅谈 JavaScript 异步编程:前言

2009 年,Ryan Dahl 正式发布服务端 JavaScript 解释器 Node.js,以其极高的性能震惊世人。在发布后的短短几年之间,Node.js 攻城略池,在 Web 服务端领域中占据了相当大的市场份额,并在之后的几年为伟大的前后端分离策略的诞生提供了物质基础。

Node.js 的成功也让 JavaScript 有了十足的改变。从 Node.js 广泛采用 Callback 开始,JS 开始了对异步编程的进一步探索。从 Callback 到 Promise、从 ES5 到今天的 ES2020, JS 异步编程逐步走向成熟,造福了成千上万的程序员,也不断地提升着 JS 的性能。

本系列文章取自笔者在学校的俱乐部的技术宣讲的 PPT,将简要地介绍 JS 异步编程的含义、基础与发展,希望能给大家带来一些灵感。如果存在疑问或发现有表述不妥之处,欢迎在评论区中指出并友善交流!

链接

我回来了...

本博客已经创建六年,不知道还有多少人记得这个博客、记得Darkyoooooo这个ID呢?

高考结束的那天下午,伴随着一阵收卷铃声,我给高中三年的学习生涯画上句号。走出试室后,我做的第一件事是抬头望向天空。高考前几天都还在下的阴雨,却在高考这两天停息了脚步,任凭蓝天白云和阳光明媚将其抛在身后。走在回宿舍的路上,我刻意放慢脚步,与周围簇拥着的同学们沐浴在傍晚五时的温暖阳光下,显得有些熠熠生辉。嘈杂的议论声传入我耳畔,反射的阳光在我虹膜中制造虚像,突然间,我再也感觉不到双腿在运动,再也感觉不到身体的实在感,仿佛我不再属于这副躯体,而只是分享到了双眼的视觉,成了一种虚无缥缈的精神并寄生着。

这股幻象在我回到宿舍之后便结束了,我找回了四肢的感觉。舍友正各自收拾着行李。我把笔袋放下,坐在床铺上,望着这间凌乱的宿舍,心中思绪万千,思维开始漂流…

高一、高二、高三,三年光阴,杂糅着欢喜与失落。令我印象最为深刻的是后者。失落的倾盆大雨,在这三年间不断冲刷着我原本幼稚的内心,让我了解到个人的不足,也深刻地确立了自我的定位。我的缺点,让我做了许多无法挽回的错事,影响至今。高一的我或许还对它们视而不见,但如今我却要敢于承认它们。正是它们组成了今天的我的一部分,无法分割。因此我选择带着它们走向未来,寻觅新的希望。

高考成绩放榜之后,我的内心比较平静,那是个不好不坏的成绩。我最终填报计算机专业的志愿。现在,重新打开这个被我遗忘了三年的博客,我带着一种希望写下了这篇文章,这种希望是:这些年来访问过这个博客的人们啊,我回来了!

本博客经过“翻修”,先前的文章全部废弃。