精读JS(六)Javascript异步框架

666 阅读17分钟

哎……异步,说到难处,英雄也仿佛掩面啜泣的少女一般,无法摆酷……

前言

Javascript是非阻塞型单线程事件驱动的语言,故而JS和浏览器API(WebWorker)联合才能实现异步,异步并不是JS核心的一部分。如果接触过C++这类较底层的面向对象语言,就可知JS异步是并发编程的极大幅度简化,JS很完美的将底层封装起来,不需要程序员关注麻烦透顶的细节,只需要几行代码就能实现异步。

或许JS异步真的就是你人生的梦魇,即便是动动手就能实现,却始终无法出发如心,以至于经常在一些特殊情形出现纰漏。因此在进阶时,必须尽可能的去填补这个大豁口。但是想通透理解异步,就不得不接触一些较低层的内容。不过还请耐心看完,一定会有收获的,而且也真的不复杂啊。

注意术语

  1. 本文中所用的术语(例如阻塞同步等)皆是线程级,皆是线程级,皆是线程级(重要的事情说三遍)
  2. 进程层级和线程层级两者有本质上的不同。
  3. 换个角度说,学过系统编程的,请看看程序编程
  4. 零基础赛高!

总之,开始吧。


并发编程

理解异步,就必须要知道什么是异步。不过在此之前,首先要了解什么是并发编程,下面的内容会捎带一些底层知识,对一些概念也做了一些简化,因此详细请参考其他书籍。

首先,术语事件都是指任务, 两个术语都是通用的(事实上许多术语表达的都是同一个意思)

同一时刻,要准备完成(即待处理)两个及两个以上的任务时,就说有多个事件正在发生, 即并发;此时,这些事件就可以说成是并发事件不支持并发的系统在同一时刻只会有一个待处理的任务。

仍然是在同一时刻,必须同时执行两个及两个以上的任务时, 就说有多个事件正在进行(或称作处理)中,即并行。由于并行的前提是有多个并发事件,因此并行编程的前提是支持并发

线程执行任务最小运作单位,简单来说就是一个线程在同一时刻负责处理一个任务。每个线程各自都有一个调用栈(对应于执行栈),并负责处理自己调用栈的任务(确切来说是执行上下文

单核心CPU处理器的计算机上,它只有一个线程,因此在某一时刻只能执行一个任务;处理多个并发事件时,

  • 线程上切割成数个时间片
  • 将这些任务再次分割为数个独立片段(真正的原子级别ATOM),并将它们一一分配到时间片中。
  • 采取的策略是:这个任务执行一些,其他的任务再执行一些,即在单位时间内交替执行任务。由于切换速度极快,因此根本察觉不到任务交替。
  • 不同任务的切换是需要开销的。

画成图就是:

在这里插入图片描述
但是在多核心处理器中,有多个线程,因此可以同时执行多个任务(即并行啦), 并行可以有效节省任务切换的开销。 即:
在这里插入图片描述
注意

  • 无论是并发还是并行,所说的都是同一时刻的事情;只是并发所说的事件是未处理的,因此还是停留在发生阶段; 而并行则所说的事件是正在处理的, 已经进入到了执行阶段
  • 一个事件要被处理,首先能够检查到它已经发生。一个未发生的事件是不可能得到处理的
  • 并发相较于并行侧重点在于如何更快处理待处理的事件并考虑如何更快的进行事件切换并行相较于并发侧重点在于如何同时处理正在执行中的事件
  • 高级编程语言中,并发并行两个概念在并发编程中不会做严格区分(只会在硬件编程领域区分,哎,仿佛看到了来自深渊的怨念……);通俗来讲,我们的并发就是默认并行的。

关于JS:

  • Javascript本身是单线程的,但它支持并发,但是无法并行
  • 正因为如此,JS异步框架也只能是基于单线程的。

好了,并发就这么简单。


线程通信

当一个任务完成前, 可能会使用上一个任务的处理结果。这时就可以说前一个任务依赖于后一个任务。当然,如果任务之间并无依赖,那么各自执行完毕即可。

例如:

  let taskA = (a,b)=>a+b  // 相加 
  let taskB = (a,b)=>a-b  // 相乘
  let taskC = (a,b)=>a*b  // 平方差
  let a = 100, b = 200;
  let res = taskC(taskA(a,b), taskC(a,b))
  console.log(res);  // 6000000

如上面代码, taskC需要使用taskAtaskB的处理结果(这里指的是返回值),就可以说taskC依赖于taskAtaskB两个任务。但是taskAtaskB两个任务并无依赖,因此谁先完成都可以。

当然下面的代码也可以说成是依赖:

  var  o={}
  var f1=(a,b)=>{o.a=a, o.b=b}
  var f2=()=>{
       f1(1111,2222);
       console.log(`a=${o.a},b=${o.b}`);
   }
  
  f2();

如果在f2之前, 不执行f1处理对象 of2就无法顺利完成(因为会中途报错),尽管不是返回值的形式,但这也是一种依赖

综合所述,两个线程:ThreadAThreadB, 如果ThreadA中的任务依赖于ThreadB的任务,就可以说成ThreadA依赖于ThreadB。 也就是在这个时候,并发编程变得扑朔迷离起来,即并发编程难点在于如何管理两者依赖的资源。

一旦线程之间有了依赖, 线程之间就必须建立通信;即必须要有一个有效的手段告知对方线程在何时执行或是对于或是告知己方线程在何时处理完成。线程通信共有两种方式: 一、共享内存, 二、消息传递。

备注:

  • 没有依赖的线程,自己完成自己的任务就Okay了
  • 简单来说,就是有事联系,没事就……(看来技术也来自生活啊,哎)。

消息传递

现在假设有两个线程TATB,且TA依赖于TB。 在消息传递通信机制中,,那么 :

  1. TA 需要得到 TB 的处理结果时,这称作请求
    • 请求操作可以是一个函数调用,或是发送一个请求消息
    • 请求时也可以提供一些附带消息,例如函数参数(上例中的f1(1111,2222));或是消息中包含回调函数等等,例如setTimeout(....)
    • 但凡是能在两个线程间传递的,都可以称作消息
  2. TB 接收 TA 的请求后,TB便进入处理任务的过程,这称作响应
    • TB可能会立即执行任务, 即立即响应,它可以直接返回(返回时也可以附带一个返回值
    • TB也可能不立即执行任务,但是会将请求消息保存在自己的消息序列中,等候处理。

模型:(C是提供给Thread的交流组件)

在这里插入图片描述
注意

  • 细节远比上面所说的复杂,详细参考经典Actor模型 ; 它是真正面向对象的。
  • 消息传递尽管是基于异步(它本身没有同步),但不代表它无法实现同步。
  • 请求响应没有那么大的必然性。请求时并不会关心对方线程如何响应,它关心的是响应的结果是否符合预期;同理,响应时也不会关心对方线程是怎么请求的,它关心的只是到最后给予响应,至于何时才会响应是己方线程的事情
  • 举一个例子:在上课时,老师会让一个学生站起来回答问题,此时老师向学生问的问题就是一个请求学生回答问题就是一个响应,至于学生是否应该站起来,与老师无关
  • 再举一个例子:张三老婆让张三下班回家顺便买菜。这里顺便买菜就是张三老婆请求命令),张三买菜就是响应,至于是立即就买菜,还是下班买菜,与张三老婆无关。张三可以把买菜这则请求牢记于心下班再买,这就类似于把请求放到消息队列中。

共享内存

这也是最常见的通信方式。简而言之,即在内存中开辟出一块公共内存空间,称作共享内存。 线程会把所需的资源(变量)都放在共享内存中,这样对方线程就可以通过这个变量的操作告知对方状态。

为防止线程恶心竞争共享内存,会使用一个来管理资源。即当一个线程使用资源时,会把资源上锁,通过这个的状态,其他线程就知道现在无法操作,可能会等待,也可能会继续执行。 当线程使用完毕,便解锁资源,此时其他线程就可以使用、上锁、解锁。如此反复。

如:

在这里插入图片描述
(这里只是一个简单模型,事实上也涉及了许多复杂内容。详细请直接谷歌百度……)

但是这种通信方式极容易产生问题:

  • 当某个线程迟迟不解锁,那么其余线程就一直无法访问到资源,有的线程可能会被迫长时间陷入等待状态。这种情形就称作死锁
  • 其次,多个线程对资源总会有一个竞争关系,如果没有好的方式管理,会导致更大的灾难。

对硬件的了解就这么多好了。 至少,作为一个开发者,了解一下这些底层有什么关系;Javascript开发者也不应该例外


同步、异步、阻塞、非阻塞

正如之前所说的,己方线程请求后对方线程会立即响应,所以己方线程没必要再急着继续执行任务, 只需要稍稍等待一(亿)点点时间就可以接收到响应然后执行;类似于这种一方请求后会等待对方立即响应的通信称作同步,同步通信中,请求后必须等待对方响应后才可以执行。

与之相反, 己方线程请求后根本不关心对方线程是不是立即响应,并且对方线程也真的不会立即响应;然后己方继续完成自己的剩余任务,类似于这种通信方式称作异步;换言之,异步通信中,请求后是立即接收到对方响应的

同步异步是两种截然不同的实现方式,但只要效果一致就可以。

常见线程状态:

  1. 线程在不做任何事情时,处于一种空闲状态,即休眠(dormant)。
  2. 当线程正在处理一个任务时并发送一个请求,在得到响应之前线程原则上是可以做任何事的,诸如线程会一直检查通信的对方线程的完成状态,但是这样会耗费宝贵的CPU资源; 为此一般会强制挂起该线程(使之暂停), 令其在得到响应前一直处于等待状态, 这种情形称作阻塞(Blocking)。其次,还有在对方线程响应时间超长的情况下,己方线程也可能会进入阻塞状态。
  3. 当然,线程发送请求后并不要求立即得到响应,这时线程在真正得到响应前仍然继续执行任务,这就是非阻塞(Non-Blocking)

其次:

  • 同步与异步, 与线程是否阻塞并无概念的关联。只是阻塞的确可能导致同步,相反如果同步不阻塞就导致了异步。
  • 一般而言,请求等价于发送消息和函数调用; 响应等价于另一方线程执行任务。
  • 因此,是同步还是异步,取决于响应时机,换言之,要求立即响应的,就是同步;不要求立即响应的,就是异步。还是之前的一句话,不要用阻塞非阻塞区分同步和异步。

术语统一:

  1. 一个函数调用后并且能够立即得到响应, 那么就可以称作这个函数调用就可以称作同步请求。
  2. 一个函数调用后只是发送消息,并不能立即得到响应。那么这个函数调用就是异步请求。

现在正式认识一下JS异步吧。


JavaScript异步框架

JS异步并发处理框架:

  1. 事件循环(EventLoop),用于处理消息序列中的所有任务,拥有自己的调用栈(对应于执行栈)。
  2. 消息序列(EventBus/MessageQueue):线程要处理的所有任务链表。
  3. 事件循环可以有许多个消息序列。

因为JS是单线程语言,结合常见的并发异步框架,可以得到下图:

在这里插入图片描述

注意:

  • 上面的图示是根据Vert.x进行改动的(JS的确可以用这种并发模型,详见官方介绍)
  • 并没有消息序列进行详细划分(例如延迟队列、宏任务序列、微任务序列……)
  • 和网上流传的模型,是同义的。

事件循环如下:

  1. 事件循环开启
  2. 新消息序列设为当前消息序列
  3. 当前消息序列中取出任务 消息序列是先入先出结构,也就是说它是按照顺序取出的。
  4. 处理任务 自上而下运行JS代码 如果发出异步请求,然后将消息保存到这个新消息序列(若无则新建)中 新消息序列的任务全部被阻塞,等待下次事件循环迭代处理。
  5. 检查当前消息序列是否为空,是则继续,否则转至 (3)
  6. 是否触发UI Rendering事件,是则立即进行视图渲染。 否则继续
  7. 是否新增消息序列, 如果是,开始下一轮事件循环,回到(2) 否则继续
  8. 确定再无事件,关闭事件循环。线程进入休眠; 直至有事件发生,新建消息序列并保存消息,转至(1)。

整理以上内容,可以得到下面的结论:

  • 事件循环一旦开启就不会陷入阻塞(可以理解为事件循环不会被强制暂停)
  • 阻塞的任务放到消息序列中, 不会阻塞的任务直接在事件循环中执行。
  • 每个事件循环的执行上下文的状态都是不同的,换言之,每个事件循环不会共享状态。

附注:
上面没有提到microtasksmacrotasks,这只是暂时了解。

setTimeout是最常见的异步操作;本质上它是一个定时器。它可以将消息加入到延迟队列中(浏览器提供的)。当消息到期后便会被取出放到事件循环中运行。这里可以将延迟队列视作一个特殊的消息队列

例如:

   console.log(1111);
   setTimeout(()=>{
       console.log('Easy Asynchronous Javascript');    
   },3000)
   // 毫秒为单位
   console.log(2222);
   

输出如下:

1111
2222
Easy Asynchronous Javascript  // 3s后输出

事实上,我们提供给setTimeout的时间指的是最小延迟时间(最小是4ms),例如下面的代码将会延长当前事件循环的执行周期:

 var start = new Date();
 console.log('开始延长3s');
 do{
     var current = new Date();
 }while(current-start <= 3000);
 console.log('3s后输出这里');
 setTimeout(()=>{console.log('easy js');}, 0)

下面的代码会同时输出。

    console.log(1111);
    var start = new Date();
    setTimeout(()=>{
        console.log('Easy Asynchronous Javascript');    
    },3000)
    do{
        var current = new Date();
    }while(current-start<=3000)

JS异步追踪:

异步相较于同步很难理解,其难点在于不能很清楚的分析线程何时才能得到响应

下面给出一个同步代码:

 var tmp = 0;
 var syncTask = ()=>{tmp+=1000}
 var main=()=>{
     
     syncTask();
     console.log(tmp); // 1000
 }
 main();

JS执行栈机制,保证了syncTask响应前main函数无法继续执行(main被挂起了),换言之,main函数总能获取syncTask的响应,因此同步代码中,无须担心得不到响应的问题。

但是换成这样:

  var tmp = 0;
  var asyncTask = ()=>{tmp+=1000}
  var main=()=>{
      
      setTimeout(asyncTask)    /// (*)
      console.log(tmp); // 0 
  }
  
  main();
  setTimeout(()=>{console.log(tmp)}) // 1000  (**)

这是为什么?

(*)行结束后,由于main函数没有被挂起,因此继续执行。所以:tmp仍然是 0 。 同样的, asyncTask匿名回调这两个任务都将在下一回合被处理

script标签内的所有代码完成,又处理完了其他事件,此时消息队列就成了这样:

最后输出tmp

okay,先到这里。

Javascript完整模型:

结合我们所学,执行上下文、执行栈、浏览器客户端、内存、消息序列……就可以得到下面的图:

解释说明:

  1. Javascript是单线程的语言, 故而同一时刻只有一个事件循环存在 ;
  2. 一个事件循环维护着一个 Context , 即Execution Stack;每一回合的事件循环的Context都可能不同。
  3. handler被触发后,就已经拥有了词法环境的相关数据, 并保存在内存Stack/Heap中。
  4. 事件循环正在处理的任务中,可能会新增消息序列等候下一回合处理。
  5. 事件循环本轮结束后,会从消息序列中取出消息然后处理,这一操作会切换上下文。 可以这么说吧,进阶第一步就是为了理解这张破图,深挖JS然后看看它的本质是什么。

注意,此模型不能应用到NodeJS事件循环上:

  1. NodeJS本身是多线程的(但运行Javascript仍然是单线程),因此NodeJS的事件循环尽可能多线程模式表达。
  2. NodeJS事件循环并不是 NodeJS本身特性, 而是由它的底层组件 libuv实现的,并且运用了 多路复用IO 以及 线程池 技术。
  3. 上面皆为个人理解,若有不同,多多交流为善。

最后

因为Javascript本身就是事件驱动的语言, 而事件驱动的语言也是基于异步的,因此可以说只有理解了异步,你才能看到Javascript一点点本来面目,才能真正的看到Javascript隐藏在表面下的不被人察觉特征。

看了上面的内容,您可能不免有疑问:如此底层细节,何用之有? 答案很让人失望,没有用处。不过事实上是,在你第一次学习JS后,实用的东西都学完了, 进阶就如之前所说,不是巩固复习而是不断地挖掘深处的底层细节,这对解读源代码是有帮助的。

每个人学习都应该有自己的态度,就如我,做好笔记保持沉默;就如你,学以致用且保留置疑然后自行解决

下面是两个参考资料: 《Vert.x线程解密》 《C++并发编程 》