你不知道的js(中卷)第6章 异步

      使用像JavaScript这样的语言编程时,很重要是如何表达和控制持续一段时间的程序行为。

      程序总是一部分现在运行,而另一部分则在将来运行——现在和将来之间有段间隙。
      所有重要的程序都需要通过这样或那样的方法来管理这段时间间隙。
      程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

1.分块的程序

      JavaScript程序几乎一定是由多个块构成的。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。
      任何时候,只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、Ajax响应等)时执行,你就是在代码中创建了一个将来执行的块,也由此在这个程序中引入了异步机制。

      异步控制台
      并没有什么规范或一组需求指定console方法族如何工作——它们并不是JavaScript正式的一部分,而是由宿主环境添加到JavaScript中的。
      由于在许多程序(不只是JavaScript)中,I/O是非常低速的阻塞部分,所以浏览器很可能出于这种考虑,选择在后台异步处理控制台I/O以提高性能。
      如果遇到console异步影响调试的情况,最好的选择是在JavaScript调试器中使用断点,而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强制执行一次“快照”,比如通过JSON.stringify(..)。


2.事件循环

      直到ES6,JavaScript才真正内建有直接的异步概念。
      在那之前,JavaScript的异步靠的是它所依托的宿主环境(通常是web浏览器,也有Node.js甚至其它宿主)来决定要不要运行一块JavaScript代码,宿主觉得要运行的时候,会调用一下JavaScript引擎。
      所以,在ES6以前,JavaScript引擎本身并没有时间的概念。
      比如说常用的Ajax,JavaScript会告诉宿主,需要监听网络请求,请在请求结束的时候调用回调,然后浏览器就在网络请求结束的时候,把请求结果传递给回调函数,把回调函数插入到JavaScript事件循环,使得回调函数能被JavaScript引擎执行到。

      那么什么是事件循环?
      我们可以简单地理解为,有一个永远在运行的循环,循环的每一轮称为一个tick。对每个tick而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。
      像setTimeout,它就并没有直接把回调函数挂到事件循环队列中。它所做的是向宿主环境设定一个定时器。当定时器到时后,宿主环境会把你的回调函数插入到事件循环队列中。

      从ES6开始,ECMAScript规范精确指定了事件循环的工作细节,这意味着JavaScript引擎要开始管控事件循环,而不是只由宿主环境来管理。
      这个改变的一个主要原因是ES6中Promise的引入,因为这项技术要求对事件循环队列的调度运行能够直接进行精细控制。


3.并行线程

      关于“并行”与“异步”的区别:“异步”指的是在同一个线程里,不同代码块在不同时刻被执行(而不是全都立即执行)。“并行”指的是不同线程/不同进程之间可以同时运行。ps:多个线程能够共享单个进程的内存
      JavaScript不跨线程共享数据,所以不用担心“并行”带来的不确定。但JavaScript仍可能因为代码块执行先后顺序不同,存在不确定性。

      完整运行
      JavaScript具有单线程特性,单块代码块具有原子性,也就是说,单块代码块会一次性运行完成。
      不确定什么时候会被执行的代码块(比如回调函数)可以称之为 异步的。


4.并发

      虽然JavaScript是单线程的,但仍然可以有概念上的并发,暂且叫它“虚拟并发”。
      比如说有一个展示状态更新列表(比如社交网络新闻种子)的网站,其随着用户向下滚动列表而逐渐加载更多内容。它需要至少两个独立的虚拟进程同时(同一时段内)运行。
      第一个“进程”在用户向下滚动页面触发onscroll事件时响应这些事件(发起Ajax请求要求新的内容)。第二个“进程”接收Ajax响应(把内容展示到页面)。
       如果用户滚动页面足够快的话,就会在较短时间内多次触发onscroll,因此也会多次触发Ajax响应。
      于是事件循环队列里可能就交替地插入了多个onscroll事件处理代码块、多个Ajax响应代码块,其中,Ajax响应代码块被插入事件循环队列的时机是不确定的,也不一定跟触发的顺序保持一致。比如:请求1,请求2,请求3,响应1,响应3,请求4,响应2,响应4……可能就会这样。
      所以单线程事件循环,是(概念上的)并发的一种形式。

4.1 非交互

      两个或多个“进程”在同一个程序内并发地交替运行它们的步骤/事件时。如果进程间没有相互影响的话,不确定性是完全可以接受的。
      比如虚拟进程1只关心数据a,虚拟进程2只关心数据b,两个并发的虚拟进程不构成竞态条件,也就无所谓哪个先哪个后。

4.2 交互

      有时候虚拟进程1和虚拟进程2所影响的数据之间存在一种关联,可以在存取数据时加上一些判断,以避免不确定性带来的bug。

4.3 协作

      将一个长期运行的“进程”,分割成多个步骤或多批任务,使得其它虚拟进程有机会将自己的运算插入到事件循环队列中交替运行。
      举例:如需要处理1000万条数据,可能占用很长时间,导致页面长时间不响应用户事件,为减短阻塞时间,可把大量数据分批处理,比如每一次处理1000条,如果还有剩的,setTimeout(...,0),把它插入到事件循环队列的队尾。

      注意:
      其实setTimeout并不是直接把代码块插到事件循环队尾,而是让宿主环境把代码块插入到事件循环队尾。
      两个连续的setTimeout(..0)调用不能保证会严格按照调用顺序处理。
      在Node.js中有process.nextTick(..),也一样不能保证异步事件处理的顺序。


5.任务

      从ES6开始,除了事件循环队列以外,还多了个概念,叫做任务队列。
      不过由于没有这个机制没有被公开,所以目前只能从概念上对它进行描述。
      我们可以把任务队列理解为:它是挂在事件循环队列的每个tick之后的一个队列。在事件循环的每个tick中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前tick的任务队列末尾添加一个任务。

      一个任务可能引起更多任务被添加到同一个队列末尾。所以,理论上说,任务循环(job loop)可能无限循环,进而导致程序的饿死,无法转移到下一个事件循环tick。(这跟写了死循环代码的体验一样)

      任务与setTimeout(..0)的思路类似,但对顺序保证性更强。
      任务是把代码块插入到本次tick的代码队列的末尾,一定会在本次tick内执行完。setTimeout起码要等到下一个tick。
      由于Promise的异步特性是基于任务的,所以我们要先了解任务的概念。


6.语句顺序

      代码中语句的顺序和JavaScript引擎执行语句的顺序并不一定一致,因为JavaScript引擎会在编译期间会对语句进行优化,一般来说引擎做的这些优化都是安全的,但要明白:代码编写的方式(从上到下的模式)和编译后执行的方式之间的联系非常脆弱。
      编译器语句重排序几乎就是并发和交互的微型隐喻。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容