JS的同步与异步编程

来源:互联网 发布:次元矩阵 编辑:程序博客网 时间:2024/06/08 19:50

JS同步与异步编程

在前端入坑近一年后,才着手写自己的第一篇博客,似乎有点晚
这篇博客只是记录了在前端学习中对JS同步与异步编程的一些看法与理解,写的可能不好,如果有错误或者需要改进的,抑或是某些文字不小心侵权的时候希望大家指出。

JS是单线程的

要了解JS的同步与异步编程,那就要先了解JavaScript这门语言的语言特性。
JS是一门解释型的脚本语言,是单线程的。但是随着计算机的发展cpu的计算性能越来越强大,为什么不把JS设计成多线程的来提高运行效率呢?为什么JS一定要是单线程的呢?这其中有以下几个原因:

  • JS的用途
    个人觉得将JS设计成单线程最大的原因取决于它的用途。
    JS是负责与用户接触与操作DOM元素的,就和java操作GUI也是单线程一样,若是多个线程同时操作一个dom元素,将会带来很严复杂的线程同步问题。
    比如一个线程将要删除这个dom元素,但是另一个元素需要修改这个dom元素,这将产生复杂的问题,所以这注定JS必须只能设计成单线程的。
  • 多线程对JS的影响
    虽然可以为JS引入“锁”的概念来解决线程同步的问题,但这会大大的增加编程的复杂度。
    JS是一门解释型的语言,如果设计成多线程,JS引擎将要兼顾线程调度并保证线程安全,这将大大增加JS引擎的压力。
  • 个人猜想
    JS最初的设计目的仅仅是为了替服务器分担数据验证的压力,在前端进行表单数据验证,对JS的性能要求不高。但是可能当时的设计者也没有想到JS将来会发展到与用户进行复杂的交互,并且逐渐的运行环境也不仅仅局限于浏览器,比如服务端的Node.js或是在物联网中的应用。所以当时设计JS是单线程的。

同步与异步任务

JS是单线程的,意味着在同一时间只能处理一项任务,所有的任务都是排队执行,后面的任务需要等前面的任务执行完成后才能执行。这样将带来一个问题,当某个任务耗费大量时间的时候将会长时间阻塞后续任务的实行,这将使页面假死,极大的降低用户体验。例如Ajax,在我们发送请求到得到回应的这段时间里,系统一直处于等待的状态,这对计算机资源是很大的浪费。
所以为了能够利用计算机多核运算的能力,某些耗时的任务在执行时我们完全没有必要去等待,而是可以将它们挂起,知道得到结果后再继续执行后续的操作,也就是“回调函数”。
于是任务就有了同步任务与异步任务。
当主线程运行的时候,大概有以下几个步骤:

  • 所有的同步任务在主线程中排队执行,形成一个“执行栈”;
  • 异步任务则被挂起,每个异步任务都会指定一个回调函数;当异步任务返回结果后,会在“任务队列”中放置一个回调函数。
  • 当主线程空闲的时候就会读取“任务队列”中的函数,任务队列中的函数则进入“执行栈”并执行。
  • 主线程循环执行以上三步。

异步任务的执行过程

上面所说的,异步任务被挂起,那么,他是如何被挂起的呢?又是怎样通知主线程该继续执行这个异步任务的呢?

首先我们看一个典型的异步函数:

    setTimeout(callback,time);

定时器是典型的异步函数,这个函数可以看作是包含三个部分:

  • 发起函数:setTimeout()
  • 回调函数:callback
  • 定时时间:time
    一个异步任务的发起函数和回调函数是在主线程中执行的,而其他异步任务,比如这里的计时,则是在其他线程中执行的。这“其他线程”,可能是JS主线程的子线程,也可能是浏览器线程。

那么一个异步任务从被挂起到回调函数被执行由以下几个步骤组成:

  • 主线程执行发起函数(注册函数),通过发起函数通知相应的线程可以开始执行相应的异步任务了;
  • 异步任务则在其他线程中执行,并不会阻塞JS主线程的执行;
  • 异步任务得到结果后则通知主线程这个异步任务可以继续往下执行了。那这个通知是如何实现的呢?就是通过回调函数,异步任务得到结果后会在“任务对列”中放置一个回调函数,通常也称之为“事件”或是“消息”;
  • 只要主线程一空,程序就会读取任务队列中的事件。任务队列的事件进入主线程并执行。



Event Loop

JS执行栈不断读取任务队列中的事件并执行,读取并执行的运行机制就是“Event Loop”。就如同下图:
Event Loop
在上图中,主线程中的代码会调用各种API,并且在异步任务完成后在“任务队列”中放置回调函数。
主线程做的事就是当主线程空的时候从任务队列中取一个事件并执行,等主线程空了在从任务队列取一个事件执行如此循环。就如同以下代码:

    while(true){        var message = queue.get();        execute(message);    }

当主线程空闲时,主线程从“任务队列”取一个事件并执行,这就是一个“事件循环”。
这就是JS在处理异步任务的运行机制。

主线程中的任务总是先于“任务队列” 中的事件运行的。如下代码:

    setTimeout(function(){        alert("这是一个计时器函数");    },0);    alert("这是一个同步任务!");

执行上面的函数会看到

    这是一个同步任务;    这是一个计时器函数;

虽然setTimeout()是最先执行的,可是为什么首先弹出的是“这是一个同步任务”呢?
这就是因为setTimeout()是一个异步函数,执行后将会回调函数放置进“任务队列”,只有当执行栈空的时候才会执行。

可是setTime()的延迟时间是0,为什么不是马上执行呢?


定时器

上面提到的,为什么setTime()的延迟时间是0但是没有马上执行呢?

setTime(callback,time) ;setInterval(callback,time);

上面两个函数的实现机制是一样的,只不过一个是一次性执行,一个是重复执行。
在函数中传入的参数“time”,我们要知道的是,这个延迟时间并不是指延迟time时间后执行该回调函数,而是在time时间后将该回调函数添加到“任务队列”中。
虽然在大多数的情况下回调函数的延迟执行时间与我们设置的time差不多,但是当任务队列中存在很多事件在排队的时候,回调函数的执行时间可能就和我们所传入的time不一致了。
所以运行以下代码:

alert(1);setTimeout(function(){    alert(2);},0);alert(3);

无论如何我们得到的输出永远是1,3,2


异步编程模式

谈到异步编程,那就免不了谈谈异步编程的几种方法。

  • 回调函数
    这是异步编程最基本的方法

    fn1();fn2();

    假设fn2的执行需要依赖fn1的执行结果,而fn1又是一个极其耗费时间的函数,则可以把fn2作为fn1的回调函数,然后将fn1当作异步函数处理。如下:

        fn1(callback){            setTimeout(function(){                //原fn1中的代码;                callback();            },1000);        }        fn1(fn2);

这样改写之后将会把原fn1中的代码作为setTimeout()的回调函数添加到任务队列中,fn1不会阻塞主线程的执行,并且fn2也能正常执行。

  • 事件监听
    另外一种思路就是利用事件监听来实现异步编程,也可以创建自定义事件。(Jquery的on()与trigger())
    fn1.on("event",fn2);//将fn1绑定event事件,在发生event事件时触发fn2;    function fn1(){        setTimeout(function(){            //fn1代码            fn1.trigger("event");        });    }    fn1();

利用事件触发的模式编写异步任务的优点是一个对象可以绑定多个事件,指定多个回调函数,而且代码的耦合性低。
但是当绑定过多的事件时,就会使整个程序的流程变成事件驱动型,不容易阅读。

  • 发布-订阅者模式(观察者模式)
    发布-订阅者模式与事件监听的事件机制其实相同,其实就是将事件理解成一个“信号”。假设有一个订阅对象,某个任务执行完毕后就向这个“订阅对象”发布一个消息(发布者),其他任务可以向这个“订阅对象”订阅这个信号,当发现这个信号就执行相应的操作。
    我们可以通过自己写pub/sub函数来实现该模式,也可以采用Ben Alman的Tiny Pub/Sub来实现
    jQuery.subscribe("message",fn2);//fn2向jQuery订阅message信号    function fn1(){        setTimeout(){            //fn1任务代码            jQuery.publish("message");//fn1发布message信号        }    }

通过订阅对象我们可以知道有多少个信号、多少个订阅者从而监控程序。

  • Promises对象
    Promises对象是CommonJS工作组提出的一种规范,目的是为异步编程提供统一接口。
    简单说,它的思想是,每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。比如,fn1的回调函数fn2,可以写成:
      fn1().then(fn2);
    f1要进行如下改写(这里使用的是jQuery的实现):
  function fn1(){    var dfd = $.Deferred();    setTimeout(function () {      // fn1的任务代码      dfd.resolve();    }, 500);    return dfd.promise;  }

这样写的优点在于,回调函数变成了链式写法,程序的流程可以看得很清楚,而且有一整套的配套方法,可以实现许多强大的功能。
比如,指定多个回调函数:

fn1().then(fn2).then(fn3);

再比如,指定发生错误时的回调函数:

fn1().then(fn2).fail(fn3);

promises对象之后将会重新详细介绍


参考:
  • 《JavaScript 运行机制详解:再谈Event Loop》
  • 《Javascript异步编程的4种方法》
  • 版权声明:自由转载-非商用-非演绎-保持署名(创意共享3.0许可证)
1 0