10

异步编程

JavaScript中异步编程问题可以说是基础中的重点,也是比较难理解的地方。首先要弄懂的是什么叫异步?

我们的代码在执行的时候是从上到下按顺序执行,一段代码执行了之后才会执行下一段代码,这种方式叫同步(synchronous)执行,也是我们最容易理解的方式。但是在某些场景下:

  1. 网络请求:常见的ajax
  2. IO操作:比如readFile
  3. 定时器:setTimeout

上面这些场景可能非常耗时,而且时间不定长,这时候这些代码就不应该同步执行了,先执行可以执行的代码,在未来的某个时间再来执行他们的handler,这就是异步。

通过这篇文章我们来了解几个知识点:

  1. 进程线程区别
  2. 消息队列与事件循环
  3. JavaScript处理异步的几种方法
  4. generator与async/await的关系

基础知识

先做些准备工作,补一补一些非常重要的前置的概念。

进程与线程

一个程序(program)至少包含一个进程(process),一个进程至少包含一个线程(thread)。

进程有以下特点:

  1. 一个进程可以包含一个或多个线程。
  2. 进程在执行过程中拥有独立的内存单元。
  3. 一个进程可以创建和撤销另一个进程,这个进程是父进程,被创建的进程称为子进程。

线程有以下特点:

  1. 线程不能独立运行,必须依赖进程空间。
  2. 线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
  3. 一个线程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行。
从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。

画张图来简单描述下:
clipboard.png
所有的程序都要交给CPU实现计算任务,但是CPU一个时间点只能处理一个任务。这时如果多个程序在运行,就涉及到了《操作系统原理》中重要的线程调度算法,线程是CPU轮转的最小单位,其他上下文信息用所在进程中的。

进程是资源的分配单位,线程是CPU在进程内切换的单位。

JavaScript单线程

浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

  1. GUI 渲染线程
  2. JavaScript引擎线程
  3. 定时触发器线程
  4. 事件触发线程
  5. 异步http请求线程

Javascript是单线程的,那么为什么Javascript要是单线程的?

这是因为Javascript这门脚本语言诞生的使命所致:JavaScript为处理页面中用户的交互,以及操作DOM树、CSS样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理。如果JavaScript是多线程的方式来操作这些UI DOM,则可能出现UI操作的冲突; 如果Javascript是多线程的话,在多线程的交互下,处于UI中的DOM节点就可能成为一个临界资源,假设存在两个线程同时操作一个DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。当然我们可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性,Javascript在最初就选择了单线程执行。

阻塞和非阻塞

这时候再理解阻塞非阻塞就好理解了,对于异步任务,单线程的JavaScript如果什么也不干等待异步任务结束,这种状态就是阻塞的;如果将异步消息放到一边,过会再处理,就是非阻塞的。

请求不能立即得到应答,需要等待,那就是阻塞;否则可以理解为非阻塞。

生活中这种场景太常见了,上厕所排队就是阻塞,没人直接上就是非阻塞。

事件循环(event-loop)

因为JavaScript是单线程的,每个时刻都只能一个事件,所以JavaScript中的同步和异步事件就有了一个奇妙的执行顺序。

JavaScript在运行时(runtime)会产生一个函数调用栈,先入栈的函数先被执行。但是有一些任务是不需要进入调用栈的,这些任务被加入到消息队列中。当函数调用栈被清空时候,就会执行消息队列中的任务(任务总会关联一个函数,并加入到调用栈),依次执行直至所有任务被清空。由于JavaScript是事件驱动,当用户触发事件JavaScript再次运行直至清空所有任务,这就是事件循环。

函数调用栈中的任务永远优先执行,调用栈无任务时候,遍历消息队列中的任务。消息队列中的任务关联的函数(一般就是callback)放入调用栈中执行。

举两个例子:异步请求

function ajax (url, callback){
    var req = new XMLHttpRequest();

    req.onloadend = callback;
    req.open('GET', url, true);
    req.send();
};

console.log(1);
ajax('/api/xxxx', function(res){
    console.log(res);
});
console.log(2);

一个开发经常遇到的业务场景,异步请求一个数据,上述过程用图表示:
clipboard.png
图中三条线分别表示函数执行的调用栈,异步消息队列,以及请求所依赖的网络请求线程(浏览器自带)。执行顺序:

  1. 调用栈执行console.log(1);
  2. 调用栈执行ajax方法,方法里面配置XMLHttpRequest的回调函数,并交由线程执行异步请求。
  3. 调用栈继续执行console.log(2);
  4. 调用栈被清空,消息队列中并无任务,JavaScript线程停止,事件循环结束。
  5. 不确定的时间点请求返回,将设定好的回调函数放入消息队列。
  6. 事件循环再次启动,调用栈中无函数,执行消息队列中的任务function(res){console.log(res);}

定时器任务:

console.log(1);
setTimeout(function(){
    console.log(2);
}, 100);
setTimeout(function(){
    console.log(3);
}, 10);
console.log(4);

// 1
// 4
// 3
// 2

跟上面的例子很像,只不过异步请求变成了定时器,上述代码的指向过程图:
clipboard.png
执行顺序如下:

  1. 调用栈执行console.log(1);
  2. 执行setTimeout向消息队列添加一个定时器任务1。
  3. 执行setTimeout向消息队列添加一个定时器任务2。
  4. 调用栈执行console.log(4);
  5. 调用栈执行完毕执行消息队列任务1。
  6. 调用栈执行完毕执行消息队列任务2。
  7. 消息队列任务2执行完毕调用回调函数console.log(3);
  8. 消息队列任务1执行完毕调用回调函数console.log(2);

通过上面例子可以很好理解,就像工作中你正在做一件事情,这时候领导给你安排一个不着急的任务,你停下来跟领导说'等我忙完手里的活就去干',然后把手里的活干完去干领导安排的任务。所有任务完成相当于完成了一个事件循环。

macrotasks 和 microtasks

macrotask 和 microtask 都是属于上述的异步任务中的一种,分别是一下 API :

  • macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
  • microtasks: process.nextTick(node), Promises, Object.observe(废弃), MutationObserver

setTimeout 的 macrotask ,和 Promise 的 microtask 有什么不同呢:

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
console.log('script end');

// "script start"
// "script end"
// "promise1"
// "promise2"
// "setTimeout"

这里的运行结果是Promise的立即返回的异步任务会优先于setTimeout延时为0的任务执行。

原因是任务队列分为 macrotasks 和 microtasks,而Promise中的then方法的函数会被推入 microtasks 队列,而setTimeout的任务会被推入 macrotasks 队列。在每一次事件循环中,macrotask 只会提取一个执行,而 microtask 会一直提取,直到 microtasks 队列清空

所以上面实现循环的顺序:

  1. 执行函数调用栈中的任务。
  2. 函数调用栈清空之后,执行microtasks队列任务至清空。
  3. 执行microtask队列任务至清空。

并发(Concurrency)

并发我们应该经常听过,跟他类似的一个词叫并行。

并发:多个进程在一台处理机上同时运行,一个时间段内处理多件事情,宏观上好比一个人边唱边跳,微观上这个人唱一句跳一步。(可以类比时间片轮转法,多个线程同时占用一个CPU,外部看来可以并发处理多个线程)

并行:多态拥有相同处理能力的处理机在同时处理不同的任务,好比广场上多个大妈同时再调广场舞。(多个CPU同时处理多个线程任务)

在JavaScript中,因为其是单线程的原因,所以决定了其每时刻只能干一件事情,事件循环是并发在JavaScript单线程中的一种处理方式。

但是在日常开发中我们肯定见过,同时发送多个请求。这种情况下多个网络线程和js线程共同占用一个CPU,就是并发。

异步解决方法

虽然已经理解了JavaScript中运行异步任务的过程,但是这样显然对开发不友好,因为我们通常并不知道异步任务在何时结束。所以前人开发了多种处理异步的方法。每种方法我们都从三个角度考虑其优缺点:

  1. 单个异步写法是否简便。
  2. 多个异步按顺序执行。
  3. 多个异步并发执行。

回调函数 (callback)

一种最常见的处理异步问题的方法,将异步任务结束时候要干的事情(回调函数)作为参数传给他,等任务结束时候运行回调函数。我们常用的$.ajax()setTimeout都属于这种方式,但是这样的问题很明显:多个异步任务按顺序执行非常恐怖。

// 著名的回调金字塔
asyncEvent1(()=>{
    asyncEvent2(()=>{
        asyncEvent3(()=>{
            asyncEvent4(()=>{
                ....
            });    
        });
    });
});

上面这种情况非常难以维护,在早期Node项目中经常出现这种情况,有人对上面小改动:

function asyncEvent1CB (){
    asyncEvent2(asyncEvent2CB);
}

function asyncEvent2CB (){
    asyncEvent3(asyncEvent3CB);
}

function asyncEvent3CB (){
    asyncEvent4(asyncEvent4CB);
}

function asyncEvent4CB () {
    // ...
}

asyncEvent1(asyncEvent1CB);

这样讲回调函数分离出来,逻辑清晰了一些,但是还是很明显:方法调用顺序是硬编码,耦合性还是很高。而且一旦同时发送多个请求,这多个请求的回调函数执行顺序很难保证,维护起来非常麻烦。

这就是回调函数的弊端

  1. 虽然简单,但是不利于阅读维护。
  2. 多层回调顺序执行耦合性很高。
  3. 请求并发回调函数执行顺序无法确定。
  4. 每次只能指定一个回调函数,出现错误程序中断易崩溃。

虽然回调函数这种方式问题很多,但是不可否认的是在ES6之前,他就是处理异步问题普遍较好的方式,而且后面很多方式仍然基于回调函数。

事件监听(litenter)

JavaScript是事件驱动,任务的执行不取决代码的顺序,而取决于某一个事件是否发生。DOM中有大量事件如onclickonloadonerror等等。

$('.element1').on('click', function(){
    console.log(1);
});

$('#element2').on('click', function(){
    console.log(2);
});

document.getElementById('#element3').addEventListener('click', function(){
    console.log(3);
}, false);

例如上面这段代码 你无法预知输出结果,因为事件触发无法被预知。跟这个很像的还有订阅者发布者模式:

github上有个有意思的小demo。注册在发布者里面的回调函数何时被触发取决于发布者何时发布事件,这个很多时候也是不可预知的。

回调函数与事件监听的区别:

  1. 回调函数多是一对一的关系,事件监听可以是多对一。
  2. 运行异步函数,在一个不确定的时间段之后运行回调函数;不确定何时触发事件,但是触发事件同步响应事件的回调。
  3. 事件监听相对于回调函数,可配置的监听(可增可减)关系减少了耦合性。

不过事件监听也存在问题:

  1. 多对多的监听组成了一个复杂的事件网络,单个节点通常监听了多个事件,维护成本很大。
  2. 多个异步事件仍然还是回调的形式。

Promise

promise出场了,当年理解promise花了我不少功夫。Promise确实跟前两者很不一样,简单说下promise。

  1. Promise中文可以翻译成承诺,现在与未来的一种关系,我承诺我会调用你的函数。
  2. Promise三种状态:pending(进行中),fulfilled(已成功),rejected(已失败),其状态只能从进行中到成功或者是失败,不可逆。
  3. 已成功和已失败可以承接不同的回调函数。
  4. 支持.then链式调用,将异步的写法改成同步。
  5. 原生支持了race, all等方法,方便适用常见开发场景。

promise更详细的内容可以看阮一峰老师的文章

Promise对于异步处理已经十分友好,大多生产环境已经在使用,不过仍有些缺点:

  1. Promise一旦运行,不能终止掉。
  2. 利用Promise处理一个异步的后续处理十分简便,但是处理多个请求按顺序执行仍然很不方便。

Generator

中文翻译成'生成器',ES6中提供的一种异步编程解决方案,语法行为与传统函数完全不同。简单来说,我可以声明一个生成器,生成器可以在执行的时候暂停,交出函数执行权给其他函数,然后其他函数可以在需要的时候让该函数再次运行。这与之前的JavaScript听起来完全不同。

详细的内容参考阮一峰老师的文章,这里我们来据几个例子,正常的ajax调用写法看起来如下:

// 使用setTimeout模拟异步
function ajax (url, cb){
    setTimeout(function(){
        cb('result');
    }, 100);
}

ajax('/api/a', function(result){
    console.log(result);
});

// 'result'

一旦我们想要多个异步按顺序执行,简直是噩梦。这里使用generator处理异步函数利用了一个特点:调用next()函数就会继续执行下去,所以利用这个特点我们处理异步原理:

  1. 将异步逻辑封装成一个生成器。
  2. 将生成器的异步部分yield出去。
  3. 在异步的回调部分调用next()将生成器继续进行下去。
  4. 这样同步,异步,回调分离,处理异步写起来非常简便。

我们对上面的例子加以改进:

// 使用setTimeout模拟异步
function ajax (url, cb){
    setTimeout(function(){
        cb(url + ' result.');
    }, 100);
}

function ajaxCallback(result){
    console.log(result);
    it.next(result);
}

function* ajaxGen (){
    var aResult = yield ajax('/api/a', ajaxCallback); 
    console.log('aResult: ' + aResult);
    var bResult = yield ajax('/api/b', ajaxCallback); 
    console.log('bResult: ' + bResult);
}

var it = ajaxGen();
it.next();

// /api/a result.
// aResult: /api/a result.
// /api/b result.
// bResult: /api/b result.

运行下上面代码,可以看到控制台输出结果居然跟我们书写的顺序一样!我们稍加改动:

// 使用setTimeout模拟异步
function ajax (url, cb){
    setTimeout(function(){
        cb(url + ' result.');
    }, 100);
}

function run (generator) {
    var it = generator(ajaxCallback);
    
    function ajaxCallback(result){
        console.log(result);
        it.next(result);
    }
    
    it.next();
};

run(function* (cb){
    var aResult = yield ajax('/api/a', cb); 
    console.log('aResult: ' + aResult);
    var bResult = yield ajax('/api/b', cb); 
    console.log('bResult: ' + bResult);
});

简单几下改造便可以生成一个自执行的生成器函数,同时也完成了异步场景同步化写法。generator的核心在于:同步,异步,回调三者分离,遇到异步交出函数执行权,再利用回调控制程序生成器继续进行。上面的run函数只是一个简单的实现,业界已经有CO这样成熟的工具。实际上开发过程中通常使用generator搭配Promise实现,再来修改上面的例子:

// 使用setTimeout模拟异步
function ajax (url){
    return new Promise(function(resolve, reject){
        setTimeout(function(){
            resolve(url + ' result.');
        }, 100);
    });
}

function run (generator) {
    var it = generator();
    
    function next(result){
        var result = it.next(result);
        if (result.done) return result.value;
        result.value.then(function(data){
            console.log(data);
              next(data);
        });
    }
    
    next();
};

run(function* (){
    var aResult = yield ajax('/api/a'); 
    console.log('aResult: ' + aResult);
    var bResult = yield ajax('/api/b'); 
    console.log('bResult: ' + bResult);
});

使用Promise来代替callback,理解上花费点时间,大大提高了效率。上面是一种常见,之前我用过generator实现多张图片并发上传,这种情况下利用generator控制上传上传数量,达到断断续续上传的效果。

进化到generator这一步可以说是相当智能了,无论是单个异步,多个按顺序异步,并发异步处理都十分友好,但是也有几个问题:

  1. ES6浏览器支持问题,需要polyfill和babel的支持。
  2. 需要借助CO这样的工具来完成,流程上理解起来需要一定时间。

有没有更简便的方法?

async/await

理解了上面的generator,再来理解async/await就简单多了。

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。async 函数是什么?一句话,它就是 Generator 函数的语法糖。

再看一遍上面的例子,然后修改上面的例子用async/await:

// 使用setTimeout模拟异步
function ajax (url){
    return new Promise(function(resolve, reject){
        setTimeout(function(){
            console.log(url + ' result.');
            resolve(url + ' result.');
        }, 100);
    });
}

async function ajaxAsync () {
    var aResult = await ajax('/api/a'); 
    console.log('aResult: ' + aResult);
    var bResult = await ajax('/api/b'); 
    console.log('bResult: ' + bResult);
}

ajaxAsync();

可以明显的看到,async/await写法跟generator最后一个例子很像,基本上就是使用async/await关键字封装了一个自执行的run方法。

async函数对 Generator 函数的改进,体现在以下四点。

  1. 内置执行器:Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。
  2. 更好的语义:async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  3. 更广的适用性:co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
  4. 返回值是 Promise:async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

这里async/await不做深入介绍,详情移步阮一峰老师的博客

Web worker

一个很不常用的api,但是是一个异步编程的方法,跟以上几种又不太一样。

你可能会遇到一个非常耗时的计算任务,如果在js线程里运行会造成页面卡顿,这时使用web worker,将计算任务丢到里面去,等计算完成再以事件监听的方式通知主线程处理,这是一个web work的应用场景。在这时候,浏览器中是有多个线程在处理js的,worker同时可以在创建子线程,实现js'多线程'。web worker的文档。实战的话看这篇

与前面几种方法不同的是,我们绞尽脑汁想把异步事件同步化,但是web worker却反其道而行,将同步的代码放到异步的线程中。

目前,web worker通常用于页面优化的一种手段,使用场景:

  1. 使用专用线程进行数学运算:Web Worker最简单的应用就是用来做后台计算,而这种计算并不会中断前台用户的操作。
  2. 图像处理:通过使用从<canvas>或者<video>元素中获取的数据,可以把图像分割成几个不同的区域并且把它们推送给并行的不同Workers来做计算。
  3. 大量数据的检索:当需要在调用 ajax后处理大量的数据,如果处理这些数据所需的时间长短非常重要,可以在Web Worker中来做这些,避免冻结UI线程。
  4. 背景数据分析:由于在使用Web Worker的时候,我们有更多潜在的CPU可用时间,我们现在可以考虑一下JavaScript中的新应用场景。例如,我们可以想像在不影响UI体验的情况下实时处理用户输入。利用这样一种可能,我们可以想像一个像Word(Office Web Apps 套装)一样的应用:当用户打字时后台在词典中进行查找,帮助用户自动纠错等等。

总结

JavaScript中的异步编程方式目前来说大致这些,其中回调函数这种方式是最简单最常见的,Promise是目前最受欢迎的方式。前四种方式让异步编码模式使我们能够编写更高效的代码,而最后一种web worker则让性能更优。这里主要是对异步编程流程梳理,前提知识点的补充,而对于真正的异步编程方式则是以思考分析为主,使用没有过多介绍。最后补充一个连接:JavaScript异步编程常见面试题,帮助理解。

参考

  1. 《你所不知道JavaScript》
  2. 《JavaScript高级程序设计》
  3. 浏览器进程?线程?傻傻分不清楚!
  4. 线程和进程的区别是什么?
  5. 并发模型与事件循环
  6. 理解 JavaScript 中的 macrotask 和 microtask
  7. 【转向Javascript系列】深入理解Web Worker

Aus0049
2.4k 声望231 粉丝

console.log(([][[]]+[])[+!![]]+([]+{})[!+[]+!![]])