Async And Sync

Table of Contents

单线程的 JavaScript 1

JavaScript 语言的核心特征之一就是 单线程 ,即同一时间只能做一件事。作为浏览器脚本语言, JavaScript 的主要用途是与用户互动,以及操作 DOM ,这 决定 了它只能是单线程,否则会带来很复杂的同步问题。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM 。所以,这个新标准并没有改变 JavaScript 单线程的本质。

JS 的运行机制2

如图所示, 主线程 运行的时候,产生堆(heap)和 栈(stack) ,栈中的代码调用各种 WebAPI ,它们在 “任务队列” 中加入各种事件,只要栈中的代码执行完毕,主线程就会去读取“任务队列”,一次执行那些事件所对应的 回调函数

其中:

  • 回调函数 ,就是那些会被主线程挂起来的代码,异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的对调函数。
  • 任务队列 ,是一个 先进先出 的数据结构,排在前面的事件,优先被主线程读取。除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等)及 定时事件

同步和异步3

JavaScript 是单线程的,同一时间只能做一件事,为了避免某些任务耗时过久造成页面响应迟钝,需要将此类任务异步执行。

其实,除了广义的同步任务和异步任务,我们对任务有更精细的定义,如下:

  • 宏任务(Macro Task) ,包括整体代码 script ,setTimeout ,setInterval ;
  • 微任务(Micro Task) ,包括 Promise , process.nextTick 。

我们来看一段示例代码,如下:

 1: setTimeout(() => {
 2:     console.log('setTimeout....')
 3: }, 300)
 4: 
 5: new Promise((resolve) => {
 6:     console.log('promise....');
 7: }).then(() => {
 8:     console.log('then....');
 9: })
10: 
11: console.log('console....');
12: 
13: // → promise....
14: // → console....
15: // → then....
16: // → setTimeout....

不同类型的任务会进入对应的任务队列,上述代码执行过程如下:

  1. 读取该段代码作为宏任务,进入主线程;
  2. 遇到 setTimeout ,调用定时器 API ,将其回调函数注册后分发到 宏任务队列
  3. 接下来遇到了 Promisenew Promise 立即执行, then 函数分发到 微任务队列
  4. 遇到 console.log() ,立即执行;
  5. 整体代码 script 作为第一个宏任务执行结束,看看有哪些微任务,发现了 then 在微任务里面,执行;
  6. 第一轮事件循环结束了,开始第二轮循环,从宏任务队列开始….
  7. 直到所有任务队列为空,结束。

事件循环,宏任务,微任务的关系如图所示:

总结

本质上,JavaScript 是单线程的,不管是新框架、新语法糖实现的所谓异步,其实都用同步的方法去模拟的。事件循环,是 JavaScript 实现异步的一种方法,也是 JavaScript 的执行机制。

DONE 关于定时器

  • State "DONE" from "TODO" [2020-01-23 Thu 09:31]

除了放置异步任务的事件,“任务队列”还可以放置定时器事件,以指定某些代码在多少事件之后执行。

定时器功能主要由 setTimeout()setInterval() 这两个函数来完成,它们的内部运行机制完全一样,却别在于前者指定的代码是一次性执行,后者则为循环执行。

1: setTimeout(function() {
2:     console.log('setTimeout....');
3: }, 300)

其中, setTimeout(fn, ms) 接收两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。

当第二个参数 ms0 时,表示指定某个任务在主线程最早可得的空闲时间执行,即尽可能早的执行。它在“任务队列”的尾部添加一个事件,因此要等到同步任务和“任务队列”中显示的事件都处理完,才会得到执行。

需要注意的是, setTimeout() 只是将事件插入了“任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以 并没有办法保证 回调函数一定会在 setTimeout() 指定的时间执行。

Node 中的事件循环

该章节摘录自 → Mr.Ruan's Blog ,仅供学习参考。

NodeJS 也是单线程的 Event Loop ,但是它的运行机制不同于浏览器环境。如下图:

其中,NodeJS 的运行机制如下:

  1. V8 引擎解析 JavaScript 脚本;
  2. 解析后的代码,调用 Node API ;
  3. libuv 库 负责 Node API 的执行,它将不同的任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给 V8 引擎;
  4. V8 引擎再将结果返回给用户。

除了 setTimeout 和 setInterval 这两个方法, NodeJS 还提供了另外两个与“任务队列”有关的方法: process.nextTicksetImmediate

process.nextTick 方法可以在当前“执行栈”的尾部 – 下一次事件循环(主线程读取“任务队列”)之前 – 触发回调函数。也就是说,它指定的任务总是发生在 所有异步任务之前setImmediate 方法则是在当前“任务队列”的尾部添加事件,与 setTimeout(fn, 0) 很像。

来看一段示例代码,如下:

1: process.nextTick(function A() {
2:   console.log(1);
3:   process.nextTick(function B(){console.log(2);});
4: });
5: 
6: setTimeout(function timeout() {
7:   console.log('TIMEOUT FIRED');
8: }, 0)

执行结果如下 ↓↓↓

// → 1
// → 2
// → TIMEOUT FIRED

注意,由于 process.nextTick 方法指定的回调函数,总是在当前“执行栈”尾部触发,所以不仅函数 A 比 setTimeout 指定的回调函数 timeout 先执行,而且函数 B 也比 timeout 先执行。这说明,如果有多个 process.nextTick 语句,不管它们是否嵌套,将全部在当前“执行栈”执行。

Stack 的三种含义4

在上述章节中,我们多次提到 执行栈 ,那么 栈(stack) 到底是什么呢?

1. 数据结构

stack

stack 的第一种含义是一组数据的存放方式,特点是 后进先出 ,如下图:

与这种结构配套的,是一些特定的方法,如下:

- push → 在最顶层加入数据;
- pop → 返回并移除最顶层的数据;
- top → 返回最顶层的数据的值,但不移除它;
- isempty → 返回一个布尔值,表示当前 stack 是否为空栈。

2. 代码运行方式

stack 的第二种含义是“调用栈”(call stack),表示函数或子例程像堆积木一样存放,以实现层层调用。

来看一段 Java 示例代码,如下:

 1: class Student {
 2:     int age;
 3:     String name;
 4: 
 5:     public Student(int Age, String Name) {
 6:         this.age = Age;
 7:         setName(Name);
 8:     }
 9: 
10:     public void setName(String Name) {
11:         this.name = Name;
12:     }
13: }
14: 
15: public class Main {
16:     public static void main(String[] args) {
17:         Student s;
18:         s = new Student(23, "John");
19:     }
20: }

上面这段代码运行的时候,首先调用 main 方法,里面需要生成一个 Student 的实例,于是又调用 Student 构造函数。在构造函数中,又调用到 setName 方法。

这三次调用像积木一样堆起来,就叫做“调用栈”。程序运行的时候,总是先完成最上层的调用,然后将它的值返回到下一层调用,直到完成整个调用栈,返回最后的结果。

3. 内存区域

stack 的第三种含义是存放数据的一种内存区域。程序运行的时候,需要内存空间存放数据。一般来说,系统会划分出两种不同的内存空间:栈(stack)和堆(heap)。它们的主要区别是:

  • 栈(stack)是有结构的,每个区块按照一定次序存放,可以明确知道每个区块的大小;
  • 堆(heap)是没有结构的,数据可以任意存放,寻址速度比栈慢;
  • 每个线程分配一个 stack ,是线程独占的创建的时候。大小是确定的,数据超过这个大小,就发生 stack overflow 错误;
  • 每个进程分配一个 heap ,是线程共用的。大小是不确定的,需要的话可以不断增加。

根据上面的这些区别,数据存放的规则是:只要是局部的、占用空间确定的数据,一般都存放在 stack 里面,否则就放在 heap 里面。

如图所示, iycls1 都存放在 stack ,因为它们占用内存空间都是确定的,而且本身也属于局部变量。但是, cls1 指向的对象实例存放在 heap ,因为它的大小不确定。当 Method1 方法运行结束,整个 stack 会被清空, iycls1 这三个变量小时,因为它们是局部变量,区块一旦结束,就没必要再存在了,而 heap 之中的那个对象实例继续存在,直到系统的垃圾清理机制(garbage collector)将这块内存回收。因此,一般来说,内存泄漏都发生在 heap ,即某些内存空间不再被使用了,却因为种种原因,没有被系统回收。

NEXT JS 的异步编程方案5

  • State "NEXT" from "TODO" [2020-01-23 Thu 09:31]

回调函数

回调函数是异步操作最基本的方法,缺点是容易写出 回调地狱 ,如多个请求存在依赖性时,代码就会像下面这样:

1: ajax(url, () => {
2:     // todo....
3:     ajax(url1, () => {
4:         // todo....
5:         ajax(url2, () => {
6:             // todo....
7:         })
8:     })
9: })

回调函数 优点 是简单、容易理解和实现; 缺点 是不利于代码的阅读和维护,各个部分之间高度耦合,使得程序结构混乱、流程难以追踪,且每个任务只能指定一个回调函数,此外它不能使用 try catch 捕获错误,不能直接 return 6

事件监听

这种方式下,异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

假如想要实现函数 f2 必须等到 f1 执行完成,才能执行,可以这样做:

  1. 为 f1 绑定一个事件 done
  2. 当 f1 执行完成,触发事件 done
1: f1.on('done', f2);              // f1 绑定 done
2: 
3: funtion f1() {
4:     setTimeout(() => {
5:         // todo....
6:         f1.trigger('done');     // 触发 done
7:     }, 1000)
8: }
9: 

事件监听的 优点 是比较容易理解,可以绑定 多个 事件,每个事件可以指定 多个 回调函数,而且可以“去耦合”,有利于实现模块化; 缺点 是整个程序都要变成事件驱动型,运行流程会变得不清晰,阅读代码的时候,很难看出主流程。

发布订阅

我们假定,存在一个 “信号中心” ,某个任务执行完成,就向信号中心 “发布” (publish)一个信号,其他任务可以向信号中心 “订阅” (subscribe) 这个信号,从而知道什么时候自己可以开始执行。这就叫做 “发布/订阅模式” (publish-subscribe pattern),又称为 “观察者模式” (observer pattern)。

同上,假如想要实现函数 f2 必须等到 f1 执行完成,才能执行,可以这样做:

  1. f2 向信号中心 jQuery 订阅 done 信号;
  2. f1 执行完成后,向信号中心 jQuery 发布 done 信号,从而引发 f2 执行;
  3. f2 完成执行后,可以取消订阅(unsubscribe)。
 1: jQuery.subscribe('done', f2);   // f2 订阅 done
 2: 
 3: function f1() {
 4:     setTimeout(() => {
 5:         // ....
 6:         jQuery.publish('done'); // f1 发布完成信号 done
 7:     }, 1000);
 8: }
 9: 
10: // f1();
11: // jQuery.unsubscribe('done', f2);

这种方法的性质与“事件监听”类似,但是明显优于后者,因为可以通过查看 “消息中心” ,了解存在到少信号、每个信号有多少订阅者,从而监控程序的运行。

Promise

Promise 在程序中的意思就是 承诺 过一段时间后给你结果,用于异步操作,如网络请求、读取本地文件等。

Promise 的三种状态:

  • Pending ,Promise 对象实例创建时候的 初始状态
  • Fulfilled ,可以理解为成功的状态 resolved
  • Rejected ,可以理解为失败的状态 rejected

注意 一旦从等待状态变成为其他状态就永远不能更改状态了 。看段示例代码:

 1: let p = new Promise((resolve, reject) => {
 2:     reject('reject fail....');
 3:     resolve('resolve success....'); // 无效代码不会执行
 4: })
 5: 
 6: p.then(
 7:     val => {
 8:         console.log(val);
 9:     },
10:     res => {
11:         console.log(res);           // → reject fail....
12:     }
13: )

注意:在构造 Promise 的时候,构造函数内部的代码是 立即执行 的。

Promise 的链式调用

  • 每次调用返回的都是一个新的 Promise 实例(这就是 then 可用链式调用的原因);
  • 如果 then 中返回的是一个结果的话,就把这个结果传递下一次 then 中的成功回调;
  • 如果 then 中出现异常,会走下一个 then 的失败回调;
  • 在 then 中使用了 return ,那么 return 的值会被 Promise.resolve() 包装 (见例 1,2);
  • then 中可以不传递参数,如果不传递会透到下一个 then 中(见例 3);
  • catch 会捕获到没有捕获的异常。

来看几段示例代码,如下:

1: // e.g.1
2: Promise.resolve(1)
3:     .then(res => {
4:         console.log(res);
5:         return 2;               // 会被自动包装成 Promise.resolve(2)
6:     })
7:     .catch(err => 3)
8:     .then(res => console.log(res))
 1: // e.g.2
 2: Promise.resolve(1)
 3:     .then(x => x + 1)
 4:     .then(x => {
 5:         throw new Error('My Error')
 6:     })
 7:     .catch(() => 1)
 8:     .then(x => x + 1)
 9:     .then(x => console.log(x))  // 2
10:     .catch(console.error)
 1: // e.g.3
 2: let fs = require('fs')
 3: 
 4: function read(url) {
 5:     return new Promise((resolve, reject) => {
 6:         fs.readFile(url, 'utf8', (err, data) => {
 7:             if(err) reject(err)
 8:             resolve(data)
 9:         })
10:     })
11: }
12: 
13: read('./name.txt')
14:     .then(
15:         data => throw new Error()                // then 中出现异常,会走下一个 then 的失败回调
16:     )                                            // 由于下一个 then 没有失败回调,就会继续往下找,如果没有,则被 catch 捕获
17:     .then( data => console.log('data....'))
18:     .then()
19:     .then(null, err => console.log('then', err)) // then ERROR
20:     .catch(err => console.log('error....'))

Promise 不仅能够捕获错误,而且也很好地解决了回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:

 1: ajax(url)
 2:     .then(res => {
 3:         console.log(res)
 4:         return ajax(url1)
 5:     })
 6:     .then(res => {
 7:         console.log(res)
 8:         return ajax(url2)
 9:     })
10:     .then(res => console.log(res))

它也是存在一些缺点的,比如无法取消 Promise ,错误需要通过回调函数捕获。

Gernerator

i.e. 生成器 Gernerators/yield

Gernerator 函数是 ES6 提供的一种异步编程方案,语法与传统函数完全不同,最大的 特点 是可以控制函数的执行。

  • 语法上,首先可以理解成, Gernerator 函数是一个 状态机 ,封装了多个内部状态;
  • Gernerator 函数除了是状态机,还是一个 遍历器对象生成函数
  • 可暂停函数, yield 可暂停, next 方法可启动,每次返回的是 yield 后的表达式结果;
  • yield 表达式本身没有返回值,或者说总是返回 undefinednext 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。

来看一段代码示例:

 1: function* foo(x) {
 2:     let y = 2 * (yield (x + 1))
 3:     let z = yield (y / 3)
 4:     return (x + y + z)
 5: }
 6: 
 7: let it = foo(5)
 8: 
 9: console.log(it.next())          // => {value: 6,  done: false}
10: console.log(it.next(12))        // => {value: 8,  done: false}
11: console.log(it.next(13))        // => {value: 42, done: false}

可能结果和想象的不一致,我们来逐行分析一下上述代码:

  • 首先 Generator 函数调用和普通函数不同,它会 返回一个迭代器
  • 当执行第一次 next 时,传参会被忽略,并且函数暂停在 yield (x + 1) 处,所以返回 5 + 1 = 6
  • 当执行第二次 next 时,传入的参数 12 就会被当作上一个 yield 表达式的返回值,如果不传参, yield 永远返回 undefined 。此时 let y = 2 * 12 ,所以第二个 yield 等于 2 * 12 / 3 = 8
  • 当执行第三次 next 时,传入的参数 13 就会被当作上一个 yield 表达式的返回值,所以 z = 13, x = 5, y = 24 ,相加等于 42

再来看一个例子,假如有三个本地文件,名称及内容如下:

# 1.txt
2.txt                           # 内容

# 2.txt
3.txt                           # 内容

# 3.txt
结束                            # 内容

下一个请求依赖上一个请求的结果,想通过 Generator 函数依次调用三个文件。

 1: let fs = require('fs')
 2: 
 3: function read(file) {
 4:     return new Promise((resolve, reject) => {
 5:         fs.readFile(file, 'utf8', (err, data) => {
 6:             if(err) reject(err)
 7:             resolve(data)
 8:         })
 9:     })
10: }
11: 
12: function* r() {
13:     let r1 = yield read('./1.txt')
14:     let r2 = yield read(r1)
15:     let r3 = yield read(r2)
16:     console.log(r1)
17:     console.log(r2)
18:     console.log(r3)
19: }
20: 
21: let it = r()
22: let {value, done} = it.next()   // value 是一个 promise
23: 
24: value.then(data => {
25:     console.log(data)           // data → 2.txt
26: 
27:     let {value, done} = it.next(data)
28:     value.then(data => {
29:         console.log(data)       // data → 3.txt'
30: 
31:         let {value, done} = it.next(data)
32:         value.then(data => {
33:             console.log(data)   // data → 结束
34:         })
35:     })
36: })
37: 
38: // → 2.txt
39: // → 3.txt
40: // → 结束

从上面的例子中,可以看出手动迭代 Generator 函数是很麻烦的,逻辑实现有点绕。实际开发中,一般会引入并配置 co 库 – 一个为 NodeJS 和浏览器打造的基于生成器的流程控制工具,借助于 Promise ,可以使用更加优雅的方式编写非阻塞代码。

我们引入 co 库,对上述代码进行改造,如下:

 1: function* r() {
 2:     let r1 = yield read('./1.txt')
 3:     let r2 = yield read(r1)
 4:     let r3 = yield read(r2)
 5:     console.log(r1)
 6:     console.log(r2)
 7:     console.log(r3)
 8: }
 9: 
10: let co = require('co')                  // 引入 co 库
11: co(r()).then(data => console.log(data))
12: 
13: // → 2.txt
14: // → 3.txt
15: // → 结束
16: // → undefined

我们可以通过 Generator 函数解决回调地狱的问题,把之前的回调地狱的例子用 Generator 函数改写,如下:

 1: function* fetch() {
 2:     yield ajax(url,  () => {})
 3:     yield ajax(url1, () => {})
 4:     yield ajax(url2, () => {})
 5: }
 6: 
 7: let it = fetch()
 8: let result1 = it.next()
 9: let result2 = it.next()
10: let result3 = it.next()

Async/Await

1. Async/Await 简介

使用 async/await ,你可以轻松地达成之前使用 Generator 和 co 函数所做地工作,它有如下特点:

  • async/await 是基于 Promise 实现的,它不能用于普通地回调函数;
  • async/await 与 Promise 一样,是非阻塞的;
  • async/await 使得一部代码看起来像同步代码,这正是它的魔力所在。

一个函数如果加上 async ,那么该函数就会返回一个 Promise 。

1: async function async1() {
2:     return '1'
3: }
4: 
5: console.log(async1())           // → Promise {<resolved>: '1'}

Generator 函数依次调用三个文件那个例子使用 async/await 写法改写,如下:

 1: let fs = require('fs')
 2: 
 3: function read(file) {
 4:     return new Promise((resolve, reject) => {
 5:         fs.readFile(file, 'utf8', (err, data) => {
 6:             if(err) reject(err)
 7:             resolve(data)
 8:         })
 9:     })
10: }
11: 
12: async function readResult(params) {
13:     try {
14:         let p1 = await read(params, 'utf8') // await 后面跟的是一个 Promise 实例
15:         let p2 = await read(p1, 'utf8')
16:         let p3 = await read(p2, 'utf8')
17:         console.log('p1', p1)
18:         console.log('p2', p2)
19:         console.log('p3', p3)
20: 
21:         return p3
22:     } catch (err) {
23:         console.log(err)
24:     }
25: }
26: 
27: readResult('1.txt')
28:     .then(
29:         data => console.log(data),
30:         err  => console.log(err)
31:     )
32: // p1 2.txt
33: // p2 3.txt
34: // p3 结束
35: // 结束

2. Async/Await 并发请求

如果请求两个文件,可以通过并发请求:

 1: let fs = require('fs')
 2: 
 3: function read(file) {
 4:     return new Promise((resolve, reject) => {
 5:         fs.readFile(file, 'utf8', (err, data) => {
 6:             if(err) reject(err)
 7:             resolve(data)
 8:         })
 9:     })
10: }
11: 
12: function readAll() {
13:     read1()
14:     read2()                     // 这个函数同步执行
15: }
16: 
17: async function read1() {
18:     let r = await read('1.txt', 'utf8')
19:     console.log(r)
20: }
21: 
22: async function read2() {
23:     let r = await read('2.txt', 'utf8')
24:     console.log(r)
25: }
26: 
27: readAll()
28: // → 2.txt
29: // → 3.txt

总结

JavaScript 异步编程进化史: callback → promise → generator → async + await 。

async/await 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里。

async/await 可以说是异步终极解决方案了,相对于 Promise , 优势 体现在:

  • 处理 then 的调用链,能够更清晰准确的写出代码;
  • 并且也能优雅地解决回调地狱问题。

当然 async/await 函数也存在一些 缺点 ,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低,代码没有依赖性的话,完全可以使用 Promise.all 的方式。

async/await 函数对 Generator 函数的改进,体现在一下三点:

1. 内置执行器

Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说, async 函数的执行,与普通函数一模一样,只要一行。

2. 更广的实用性

co 函数库约定, yield 命令后面指能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等于同步操作)。

3. 更好的语义

async 和 await ,比起星号 *yield ,语义更清楚了。 async 表示函数里有异步操作, await 表示紧跟在后面的表达式需要等待结果。

Footnotes:

Date: 2020-01-20 Mon 19:31

Author: Jack Liu

Created: 2020-05-20 Wed 20:04

Validate