await & async 深度剖析

es2015 stage3 javascript await

2016-04-20 20:35 PM

await 语法最早被引入到流行语言是 c# 5.0 ,微软在 c# 中添加了 await & async 关键字和一系列配套 API ,引起了开发者的一致好评。await 可以极大的简化异步编程,而使用异步编程最多的就是 javascript 了。所以开发者们也迫不及待的向 javascript 中加入 await 关键字。目前此特性已经在 babel 中比较完美的实现了。而 await 特性也被加入了 stage-3 (Candidate),不过貌似是赶不上今年的 ES2016 了,估计最晚会在 ES2017 中被正式加入 javascript 。那么本文就来深度剖析一下 await & async 的用法、好处以及实现方式。



异步?同步?

异步编程模型对于 IO 密集型的任务具有得天独厚的优势。这里用一个例子来解释。

假如我们需要做一个爬虫,爬到的东西有两种,一种是索引页,一种是内容页。大概需要以下几步:

  • 使用 http 请求一个索引页,从这个从索引中取出所有的 URL
  • 分别请求所有的 URL
  • 如果请求到的还是 索引页 那么递归这个操作(回到步骤 1 )
  • 如果是 内容页 ,那么我们对内容页做一些处理
  • 最后,把处理后的保存到数据库

那么,分别使用原生支持异步编程的 NodeJS 和原生不支持异步编程 golang 的语言实现这个爬虫。分别使用两种语言 最常见 的实现方式。

因为发 http 请求和保存数据库还需要一些额外代码,会干扰视线,所以例子代码是递归的读一个文件夹,并把所有 html 文件做一些修改,然后保存到源文件。但是原理上和爬虫是相通的。

golang 实现爬虫

  1. package main
  2. import (
  3. "io/ioutil"
  4. "log"
  5. "path"
  6. "path/filepath"
  7. "strings"
  8. )
  9. const extname = ".html"
  10. var counter = 0
  11. func handleDir(dir string) {
  12. // 读取文件夹列表
  13. files, err := ioutil.ReadDir(dir)
  14. if err != nil {
  15. log.Println("error occurs when readDir", dir, err)
  16. return
  17. }
  18. // iterate 文件列表
  19. for _, file := range files {
  20. fullFilename := path.Join(dir, file.Name())
  21. // 判断文件类型
  22. if !file.IsDir() && filepath.Ext(file.Name()) == extname {
  23. counter++
  24. thisCount := counter
  25. // 打开始 log
  26. log.Println("start processing", fullFilename,
  27. "[", thisCount, "]")
  28. // 读取文件
  29. fileData, err := ioutil.ReadFile(fullFilename)
  30. if err != nil {
  31. log.Println("error occurs when processing",
  32. fullFilename, err)
  33. continue
  34. }
  35. // 做一些处理
  36. fileString := string(fileData)
  37. fileString = strings.Replace(fileString,
  38. "http://", "https://", -1)
  39. // 保存文件
  40. if err := ioutil.WriteFile(fullFilename,
  41. []byte(fileString), 0644); err != nil {
  42. log.Println("error occurs when processing",
  43. fullFilename, err)
  44. continue
  45. }
  46. // 打结束 log
  47. log.Println("finish processing", fullFilename,
  48. "[", thisCount, "]")
  49. } else if file.IsDir() {
  50. // 文件夹的话递归
  51. handleDir(fullFilename)
  52. }
  53. }
  54. }
  55. func main() {
  56. handleDir("/Users/zzz/hzzz.lengzzz.com/")
  57. }

NodeJS 实现爬虫

  1. import fs from 'fs';
  2. import path from 'path';
  3. let counter = 0;
  4. const extname = '.html';
  5. function handleDir(dir) {
  6. // 读取文件夹列表
  7. fs.readdir(dir, function (err, files) {
  8. // iterate 文件列表
  9. files.map(file => {
  10. let fullFilename = path.join(dir, file);
  11. fs.stat(fullFilename, function (err, stats) {
  12. if (err) {
  13. console.error("error occurs when processing", file, err);
  14. return;
  15. }
  16. // 判断文件类型
  17. if (stats.isFile() && path.extname(file) == extname) {
  18. let thisCount = counter++;
  19. // 打开始 log
  20. console.log('start processing', fullFilename,
  21. '[', thisCount, ']');
  22. // 读取文件
  23. fs.readFile(fullFilename, 'utf-8',
  24. function (err, fileString) {
  25. if (err) {
  26. console.error("error occurs when processing",
  27. file, err);
  28. return;
  29. }
  30. // 做一些处理
  31. fileString = fileString.replace(
  32. /http:\/\//g, 'https://');
  33. // 写入文件
  34. fs.writeFile(fullFilename, fileString, function (err) {
  35. if (err) {
  36. console.error("error occurs when processing",
  37. file, err);
  38. return;
  39. }
  40. // 打结束 log
  41. console.log('finish processing', fullFilename,
  42. '[', thisCount, ']');
  43. })
  44. })
  45. } else if (stats.isDirectory()) {
  46. handleDir(fullFilename);
  47. }
  48. })
  49. })
  50. });
  51. }
  52. function main() {
  53. handleDir('/Users/zzz/hzzz.lengzzz.com/');
  54. }
  55. main();

公平起见 使用 goroutine 实现

  1. package main
  2. import (
  3. "io/ioutil"
  4. "log"
  5. "path"
  6. "path/filepath"
  7. "strings"
  8. )
  9. const channelBuffer = 50
  10. const workerCount = 30
  11. type payload struct {
  12. thisCount int
  13. fullFilename string
  14. fileString string
  15. }
  16. var producerToRead chan *payload
  17. var readToReplace chan *payload
  18. var replaceToWrite chan *payload
  19. var writeToComplete chan *payload
  20. func init() {
  21. producerToRead = make(chan *payload, channelBuffer)
  22. readToReplace = make(chan *payload, channelBuffer)
  23. replaceToWrite = make(chan *payload, channelBuffer)
  24. writeToComplete = make(chan *payload, channelBuffer)
  25. }
  26. func reader() {
  27. for data := range producerToRead {
  28. fileData, err := ioutil.ReadFile(data.fullFilename)
  29. if err != nil {
  30. log.Println("error occurs when processing",
  31. data.fullFilename, err)
  32. continue
  33. }
  34. data.fileString = string(fileData)
  35. readToReplace <- data
  36. }
  37. }
  38. func replacer() {
  39. for data := range readToReplace {
  40. data.fileString = strings.Replace(data.fileString,
  41. "http://", "https://", -1)
  42. replaceToWrite <- data
  43. }
  44. }
  45. func writeer() {
  46. for data := range replaceToWrite {
  47. if err := ioutil.WriteFile(data.fullFilename,
  48. []byte(data.fileString), 0644); err != nil {
  49. log.Println("error occurs when processing",
  50. data.fullFilename, err)
  51. return
  52. }
  53. writeToComplete <- data
  54. }
  55. }
  56. func complete() {
  57. for data := range writeToComplete {
  58. log.Println("finish processing", data.fullFilename,
  59. "[", data.thisCount, "]")
  60. }
  61. }
  62. var counter = 0
  63. func producer(dir string) {
  64. const extname = ".html"
  65. files, err := ioutil.ReadDir(dir)
  66. if err != nil {
  67. log.Println("error occurs when readDir", dir, err)
  68. return
  69. }
  70. for _, file := range files {
  71. fullFilename := path.Join(dir, file.Name())
  72. if !file.IsDir() && filepath.Ext(file.Name()) == extname {
  73. counter++
  74. thisCount := counter
  75. log.Println("start processing", fullFilename,
  76. "[", thisCount, "]")
  77. producerToRead <- &payload{
  78. thisCount,
  79. fullFilename,
  80. "",
  81. }
  82. } else if file.IsDir() {
  83. producer(fullFilename)
  84. }
  85. }
  86. }
  87. // 搞四个 worker
  88. func startWorker() {
  89. for i := 0; i < workerCount; i++ {
  90. go reader()
  91. go replacer()
  92. go writeer()
  93. go complete()
  94. }
  95. }
  96. func main() {
  97. startWorker()
  98. producer("/Users/zzz/hzzz.lengzzz.com/")
  99. }
Created with Raphaël 2.1.2golang worker 实现producerproducerreaderreaderreplacerreplacerwriterwritercompletecomplete生成文件名channel读文件channel修改文件channel写文件channel打 log并发

性能比较

分别看一下两个程序打出的 log ,来分析一下哪个程序会运行的更快。

golang 的 log

  1. start processing /1001/index.html [ 1 ]
  2. finish processing /1001/index.html [ 1 ]
  3. start processing /1001/trackback/index.html [ 2 ]
  4. finish processing /1001/trackback/index.html [ 2 ]
  5. start processing /1006/index.html [ 3 ]
  6. finish processing /1006/index.html [ 3 ]
  7. start processing /1006/trackback/index.html [ 4 ]
  8. finish processing /1006/trackback/index.html [ 4 ]
  9. start processing /101/index.html [ 5 ]
  10. finish processing /101/index.html [ 5 ]
  11. start processing /101/trackback/index.html [ 6 ]
  12. finish processing /101/trackback/index.html [ 6 ]
  13. start processing /1010/index.html [ 7 ]
  14. finish processing /1010/index.html [ 7 ]
  15. start processing /1010/trackback/index.html [ 8 ]
  16. finish processing /1010/trackback/index.html [ 8 ]
  17. start processing /1027/index.html [ 9 ]
  18. finish processing /1027/index.html [ 9 ]
  19. start processing /1027/trackback/index.html [ 10 ]
  20. finish processing /1027/trackback/index.html [ 10 ]

特点是挨个抓取,第一个没有抓完不开始抓第二个。

NodeJS 的 log

  1. start processing /index.html [ 0 ]
  2. start processing /blog/index.html [ 1 ]
  3. start processing /blog/wp-login.html [ 2 ]
  4. start processing /blog/1001/index.html [ 3 ]
  5. start processing /blog/1006/index.html [ 4 ]
  6. start processing /blog/101/index.html [ 5 ]
  7. ... 省略
  8. start processing /blog/page/9/index.html [ 736 ]
  9. finish processing /index.html [ 0 ]
  10. start processing /blog/date/2013/08/index.html [ 737 ]

特点是同时抓取网页,谁先抓完先处理谁,谁先处理完谁就保存。不需要等待。

golang 第二版的 log

  1. start processing /blog/1001/index.html [ 1 ]
  2. start processing /blog/1001/trackback/index.html [ 2 ]
  3. start processing /blog/1006/index.html [ 3 ]
  4. start processing /blog/1006/trackback/index.html [ 4 ]
  5. start processing /blog/101/index.html [ 5 ]
  6. start processing /blog/101/trackback/index.html [ 6 ]
  7. start processing /blog/1010/index.html [ 7 ]
  8. start processing /blog/1010/trackback/index.html [ 8 ]
  9. start processing /blog/1027/index.html [ 9 ]
  10. start processing /blog/1027/trackback/index.html [ 10 ]
  11. start processing /blog/1030/index.html [ 11 ]
  12. start processing /blog/1030/trackback/index.html [ 12 ]
  13. start processing /blog/1038/index.html [ 13 ]
  14. start processing /blog/1038/trackback/index.html [ 14 ]
  15. finish processing /blog/101/trackback/index.html [ 6 ]
  16. start processing /blog/1040/index.html [ 15 ]

KFC 和 麻辣烫

可以对比一下 KFC 和 麻辣烫 店的点餐方式,和上面两个程序有异曲同工之妙。

  • KFC:大家排队,前面的餐没全部做出来前不服务下一个客户
  • 麻辣烫:大家分别点餐,然后拿个号码,叫号取餐

谁更快显而易见。在 KFC 里最怕前面点个全家桶,好不容易排到第一个了,结果还不如旁边队列里最后的取餐快。

所以可见,NodeJS 只需要使用一般的写法就能自动获得 性能加成 还是很有吸引力的。

可读性比较

golang 的代码是按照先后顺序很直观的顺下来的,可以看一下几个注释,都在同一个缩进级别。

而 NodeJS 使用了大量回调函数,本身串行的逻辑看起来却像是内嵌的感觉,从代码的缩进就能明显的看出来。大家亲切的成这种代码风格叫做 冲击波

  1. // 冲击波
  2. {
  3. {
  4. {
  5. {
  6. {
  7. {
  8. {
  9. // ============ >
  10. }
  11. }
  12. }
  13. }
  14. }
  15. }
  16. }

golang 使用 goroutine 重新实现之后性能得到了提升,但是可读性也是降低了一些。如果逻辑比较复杂则 channel 不很好设计。

除此之外 NodeJS 的写法还有几个坑:

  • 对循环支持的不好,循环内部的回调函数访问同一个闭包变量(ES2015 的 let 可解决这个问题)
  • 错误处理不友好,只能( almostly )直接跳出事务,没法爽快 try catch (例子:超时重传)

同步的代码 异步的事情

那么有没有一种方法,能同时具备两种写法的优点呢?答案就是前端终极武器 await & async

首先来看一下 await 的使用方法,我给出一个同样功能(爬虫)的示例:

  1. import path from 'path';
  2. import { readDir, stat, readFile, writeFile } from './api_promise';
  3. let counter = 0;
  4. const extname = '.html';
  5. async function handleDir(dir) {
  6. try {
  7. // 读取文件夹列表
  8. let files = await readDir(dir);
  9. // iterate 列表
  10. files.map(async file => {
  11. let fullFilename = path.join(dir, file);
  12. try {
  13. // 检查文件类型
  14. let stats = await stat(fullFilename);
  15. if (stats.isFile() && path.extname(file) == extname) {
  16. let thisCount = counter++;
  17. // 打开始 log
  18. console.log('start processing', fullFilename,
  19. '[', thisCount, ']');
  20. // 读文件
  21. let fileString = await readFile(fullFilename, 'utf-8');
  22. // 改文件
  23. fileString = fileString.replace(/http:\/\//g, 'https://');
  24. // 写文件
  25. await writeFile(fullFilename, fileString);
  26. // 打结束 log
  27. console.log('finish processing', fullFilename,
  28. '[', thisCount, ']');
  29. } else if (stats.isDirectory()) {
  30. handleDir(fullFilename);
  31. }
  32. } catch (err) {
  33. console.error("error occurs when processing", file, err);
  34. }
  35. });
  36. } catch (err) {
  37. console.error("error occurs when readDir", dir, err);
  38. }
  39. }
  40. function main() {
  41. handleDir('/Users/zzz/hzzz.lengzzz.com/');
  42. }
  43. main();

代码清晰了不少,但是性能还和之前一样,异步的读文件,异步的写文件。另外我还加入了 try catch ,来演示 await 对 try catch 的支持。

此外,还需要做的事情是对原生的 API 进行一下包装。使之返回一个 Promise 以支持 await。

如下:

  1. import fs from "fs";
  2. export function readDir(path) {
  3. return new Promise(function (resolve, reject) {
  4. fs.readdir(path, function (err, files) {
  5. if (err) {
  6. reject(err);
  7. return;
  8. }
  9. resolve(files);
  10. })
  11. })
  12. }

这里只举例一个了,这个文件 wrap 了四个 NodeJS API ,都是使用相同的方式包装的。


实现篇

其实,await 只是一个语法糖。下面,分析一下这颗糖底层是怎么实现的。

Iterator

要说 await 不得不先温习一些前置知识,比如 iterator 和协程。

在大部分语言中,要实现一个类型可以被 iterate (既让一个类型 iterable)一般需要实现一个叫 Iterable 的 interface。

  1. class List implements Iterable {
  2. public Iterator iterator() {
  3. // ...
  4. }
  5. }

这个 Iterable 有方法能返回一个 Iterator 循环调用 Iterator 的方法 next() 可以得到下一个元素,调用 hasNext() 可以判断是否结束。

所以 Iterator 可以这样实现:

  1. class List implements Iterable {
  2. // nested class
  3. class ListIterator implements Iterator {
  4. int i = 0;
  5. int max = 10;
  6. public Object next() {
  7. return i++;
  8. }
  9. public boolean hasNext() {
  10. return i > max;
  11. }
  12. }
  13. public Iterator iterator() {
  14. return new ListIterator();
  15. }
  16. }

这样,就可以 iterate 一个 List 了:

  1. List list = new List();
  2. for (Object i : list) {
  3. // ...
  4. }

在 javascript 中也不例外,这样实现一个 Iterable :

  1. var iterable = {
  2. [Symbol.iterator]: function() {
  3. var i = 0;
  4. var iterator = {
  5. next: function () {
  6. var iteratorResult = {
  7. done: i > 10,
  8. value: i++
  9. };
  10. return iteratorResult;
  11. }
  12. };
  13. return iterator;
  14. }
  15. };
  16. for (let item of iterable) {
  17. console.log(item);
  18. }

协程

协程是一种抽象方式,可以让一个函数中途暂停返回一些东西,然后过一段时间后再继续执行。

  1. function routine()
  2. local i = 0
  3. i = i + 1
  4. coroutine.yield(i)
  5. i = i + 1
  6. coroutine.yield(i)
  7. i = i + 1
  8. coroutine.yield(i)
  9. end

调用三次 routine 后分别能得到 1 2 3 。原因是协程执行了一半被暂停后会保存下它自己的上下文,以便下次 resume后数据还在。

Generator

Generator 相当于 javascript 中的协程,在一个 Generator 函数中使用 yield 关键字,可以暂停函数执行,返回一个结果。

  1. // 符号 * 代表 generator
  2. function* routine() {
  3. let i = 0;
  4. yield i++;
  5. yield i++;
  6. yield i++;
  7. }

这段代码和上面的 lua 代码等价。

使用 generator 函数可以方便的实现一个 iterator:

  1. iterable = {
  2. [Symbol.iterator]: function* () {
  3. for (let i = 0; i < 10; ++i) {
  4. yield i;
  5. }
  6. }
  7. };
  8. for (let item of iterable) {
  9. console.log(item);
  10. }

和上面的 iterable 代码等价,但是可以使用 for 循环了,是不是简洁多了?

await & async

可能大家已经想到了,async 函数就是被翻译成了 generator 函数,ya。

  1. async function getArticle () {
  2. var test = $('.test');
  3. var comments = await getComments();
  4. test.append('<p>' + JSON.stringify(comments) + '</p>');
  5. var posts = await getPosts();
  6. test.append('<p>' + JSON.stringify(posts) + '</p>');
  7. }
  8. // 翻译成=====>
  9. function* getArticle () {
  10. var test = $('.test');
  11. var comments = yield getComments();
  12. test.append('<p>' + JSON.stringify(comments) + '</p>');
  13. var posts = yield getPosts();
  14. test.append('<p>' + JSON.stringify(posts) + '</p>');
  15. }

当调用一个 async 函数时,实际上是这样做的:

  1. getArticle();
  2. // 翻译成=====>
  3. runner(getArticle);
  4. function runner(getIterator) {
  5. var iterator = getIterator();
  6. function next(data) {
  7. var result = iterator.next(data);
  8. if (result.done){
  9. return;
  10. }
  11. var promise = result.value;
  12. promise.then(function (data) {
  13. next(data);
  14. });
  15. }
  16. next();
  17. };

拓展篇

1. 在 NodeJS 中避免大规模计算

Javascript 只有一根线程,如果用 for 循环来计算 10000 个数字的和,整个 vm 都非卡死不行。

以前大家都用 setTimeout / setInterval 来把运算拆开来做:

  1. let output = $('.power');
  2. function run2() {
  3. var i = 0, end = 10000;
  4. var cancel = setInterval(function () {
  5. let p = i * i;
  6. output.append(`<p>${p}</p>`);
  7. if (++i >= end) {
  8. clearInterval(cancel);
  9. }
  10. }, 0);
  11. }

现在可以用 await 了

  1. function getPower(x) {
  2. return new Promise(function (resolve, reject) {
  3. setTimeout(function () {
  4. resolve(x * x);
  5. }, 0);
  6. });
  7. }
  8. async function run() {
  9. let output = $('.power');
  10. for (let i = 0; i < 10000; i++) {
  11. let p = await getPower(i);
  12. output.append(`<p>${p}</p>`);
  13. }
  14. }

这两种都不会卡 vm 但是第二种显然直观一些。


代码

https://github.com/zwh8800/analyse-await


发表于 2016-04-20 20:35 PM,最后更新于 2018-12-04 14:24:33 PM。

本文使用 署名 - 非商业性使用 - 相同方式共享 4.0 国际 协议


评论加载中...

首页