Skip to content

Instantly share code, notes, and snippets.

@CarterLi CarterLi/async.md
Last active May 23, 2016

Embed
What would you like to do?

异步 与 Generator

讨论会上谈到了这两者的关系,自认为有些许心得,却羞于开口,于是有了这篇博文。

引题

……

什么是异步(Asynchrony)

按照维基百科上的解释:独立于主控制流之外发生的事件就叫做异步。比如说有一段顺序执行的代码

void function main() {
  fA();
  fB();
}();

fA => fB 是顺序执行的,永远都是 fAfB 的前面执行,他们就是 同步 的关系。加入这时使用 setTimeout 将 fB 延后

void function main() {
  setTimeout(fA, 1000);
  fB();
}();

这时,fA 相对于 fB 就是异步的。main 函数只是声明了要在一秒后执行一次 fA,而并没有立刻执行它。这时,fA 的控制流就独立于 main 之外。

并发(Concurrent)和 并行(Parallel)

异步有两种实现方式:并发和并行。区别在于:

  • 并行是两段代码在 同时 执行
  • 并发是两段代码在 交替 执行。

比如上面的例子,浏览器会在 main 执行完并渲染 UI,等待 1s 后执行 fA。执行 fA 时会阻断 UI 线程(fA 本身就在UI线程上执行),换句话说,执行 fA 时并不能同时执行别的代码,所以说 fA 相对于 fB 乃至浏览器消息队列是 并发 执行的。由于执行 fA 时会阻断整个工作线程,所以 并发 并不能缩短整体代码的执行时间。

再比如一个异步的 xhr 请求,当 response 回来浏览器仍然会在 UI 线程上执行 onreadystatechange 事件处理函数,Ajax 也是 并发 执行的。

很久以来,由于 JavaScript 没有多线程的概念,所以说 JavaScript 不存在并行。注意这里只是指 JavaScript 脚本本身不存在并行执行,即两段 JavaScript 代码不能同时被执行。当然,不同 window 下的 JavaScript 代码是并行执行的;在 HTML5 引入 Web WorkerService Worker 等技术之后,JavaScript 也有了多线程的概念,但这已经是比较后来的事情,通常情况下也不会使用。所以说“JS 不存在并行”虽然并不严谨,但并不能算错,这里不展开讨论。

并发的作用

避开 * Worker 不谈。JavaScript 实现异步只有 并发 一条路。有人会问:既然并发不能节省代码运行的时间,那并发有什么用?

一个最重要的作用:减少 UI 阻塞。

前端最注重的是交互。回想一下以前我们用 Windows 的时候,一个程序不知道怎么的就卡在那里,窗口变白屏,多点两下系统弹出一个提示:“该程序已停止相应,是否强制关闭?”用户并没有多少耐心等待,而通常会认为程序已经死掉并选择 Ctrl + Shift + Delete。简单学过操作系统消息处理机制的同学都会知道,每一个窗口的底层都是一个消息循环,程序必须在一定时间内把控制权交回操作系统以处理其他用户事件。但有时候某段逻辑真的特别复杂用时很长怎么办?这时候就有必要将代码拆分成多个小块分段执行。

比如有一段计算 π = 4 - 4/3 + 4/5 - 4/7 + ... 问题,有代码如下

var pi = 0;
for (var i = 0; i <= 100000000; ++i) {
  pi += (i % 2 ? -4 : 4) / (1 + 2 * i);
}
console.log(pi);

把代码实际丢到浏览器的 Console 里运行,结果能出来但是要等几秒,期间这个页面整个死掉点什么都没反应。能否优化一下呢?我们可以将这段大循环拆分成多个小循环分段执行。

var next = function gen() {
  var pi = 0, step = 0;
  return function next() {
    for (var beg = step * 100000, end = (step + 1) * 100000; beg < end; ++beg) {
      pi += (beg % 2 ? -4 : 4) / (1 + 2 * beg);
    }
    ++step;
    return {
      done: step >= 1000,
      value: pi
    }
  };
}();

void function step() {
  var result;
  if (!(result = next()).done) {
    setTimeout(step);
  } else {
    console.log(result.value);
  }
}();

代码中将 1e7 的大循环拆分成了 1000 个 1e5 的没有大到那么离谱的循环。虽说整体用时反而比之前更多,然而对于终端用户却较为友好。可以测试,在结果出来之前 UI 扔接受鼠标事件响应(比如页面上的文字仍然可以选中)。

可以看到,setTimeout 并非只是简单的把当前控制流阻断一段时间。在等待执行 next 的这段时间里,控制权被交回了浏览器本身,浏览器可以继续渲染 UI、响应用户事件,以及做其他操作。这时,浏览器的 UI 渲染与 计算π那段 JavaScript 代码形成了 并发

当然,前面说到 并发 并不能节省代码的整体执行时间,客户端 JavaScript 代码中通常也不会做大量非常耗时的工作(通常页面卡顿多是由 UI 渲染引起),所以实际工作中使用异步操作减少 UI 阻塞的情形并不多见。但是在其他领域却有普遍的应用。在现代多进程操作系统中,通常运行中的进程数是电脑 CPU 核心数的数倍,如此多的进程无法做到完全并行,操作系统通常会使用划分时间片的形式让各个活动进程轮流执行一段时间,以达到近似并行的效果。

异步的相对性

前面的解释已经多次提到 相对于 这个词,显而易见,所谓同步、异步是相对而言的。最前面的这段代码

void function main() {
  setTimeout(fA, 1000);
  fB();
}();

fA 相对于 fB 异步执行,fB 也相对于 fA 异步执行,甚至 setTimeout 也是相对于 fA 异步执行,而相对于 fB 同步执行。我们通常省略说 fA 是异步执行的,是相对于浏览器的主消息循环而言。

Generator

什么是 Generator

MDN 上的解释:Generator 是一种可以中途退出之后重入的函数。他们的函数上下文在每次重入后会被保持。

简而言之,Generator 与普通 Function 最大的区别就是:Generator 自身保留上次调用的状态。其类似于上例的闭包变量 sumstep

上例可以用 Generator 改写如下

function *gen() {
  var pi = 0;
  for (var step = 0; step < 1000; ++step) {
    for (var beg = step * 100000, end = (step + 1) * 100000; beg < end; ++beg) {
      pi += (beg % 2 ? -4 : 4) / (1 + 2 * beg);
    }
    yield pi;
  }
  return pi;
}

var iter = gen();
void function step() {
  var result;
  if (!(result = iter.next()).done) {
    setTimeout(step);
  } else {
    document.writeln(result.value);
  }
}();

代码的执行顺序是这样:

  1. 请求 gen,得到一个迭代器 iter。注意此时并未真正执行 gen 的函数体。
  2. 调用 iter.next(),初次执行 gen 的函数体,第一次执行外层循环。step 值为 0。
  3. 进行一遍内部 100000 次的循环后,遇到 yield 语句,将此时的 piiter.next() 的返回值返回。yield 语句表示 gen 尚未执行完毕(返回对象中 donefalse)。
  4. 函数 step 中 检测 iter.donefalsesetTimeout 递归 step 自己,第二次调用 iter.next()
  5. 回到 gen 内部,从上次返回时的 yield pi 语句开始执行,即第二轮外层循环,此时 step 自增值为 1。
  6. 执行一遍内部 100000 此的循环后,遇到 yield 语句,将此时的 pi 值作为 iter.next() 的返回值返回
  7. 函数 step 中 检测 iter.donefalsesetTimeout 递归 step 自己,第三次调用 iter.next()
  8. 回到 gen 内部,从上次返回时的 yield sum 语句开始执行,即第二轮外层循环,此时 step 自增值为 2。
  9. ……
  10. 直到 gen 里的 step 变量到了 1000,循环结束,遇到 return 语句。return 语句表示 gen 执行完毕(返回对象中 donetrue
  11. 函数 step 中 检测 iter.donetrue,输出返回值 iter.value

似乎有个循环并不好理解,我们举个更简单的例子。

function *gen() {
  yield 1;
  yield 2;
  return 3;
}

void function main() {
  var iter = gen();
  console.log(iter.next().value);
  console.log(iter.next().value);
  console.log(iter.next().value);
}();

代码的执行顺序是这样:

  1. 请求 gen,得到一个迭代器 iter。注意此时并未真正执行 gen 的函数体。
  2. 调用 iter.next(),执行 gen 的函数体。
  3. 遇到 yield 1,将 1 返回,iter.next() 的返回值即为 { done: false, value: 1 },输出 1
  4. 调用 iter.next()。从上次 yield 出去的地方继续往下执行 gen
  5. 遇到 yield 2,将 2 返回,iter.next() 的返回值即为 { done: false, value: 2 },输出 2
  6. 调用 iter.next()。从上次 yield 出去的地方继续往下执行 gen
  7. 遇到 return 3,将 3 返回,return 表示整个函数已经执行完毕。iter.next() 的返回值即为 { done: true, value: 3 },输出 3

你可以使用 for ... of 遍历一个 iterator,例如

for (var i of gen()) console.log(i);

输出 1 2,最后 return 3 的结果不算在内。想用 Generator 的各项生成一个数组也很简单,Array.from(gen()) 或直接用 [...gen()] 即可,生成 [1, 2] 同样不包含最后的 return 3

Generator 是异步的吗

既是也不是。前面提到,异步是相对的,例如上面的例子

function *gen() {
  yield 1;
  yield 2;
  return 3;
}

void function main() {
  var iter = gen();
  console.log(iter.next().value);
  console.log(iter.next().value);
  console.log(iter.next().value);
}();

我们可以很直观的看到,gen 的方法体与 main 的方法体在交替执行,所以可以肯定的说,gen 相对于 main 是异步(并发)执行的。然而此段过程中,整个控制流都没有交回给浏览器,所以说 gen 和 main 相对于浏览器消息循环

首先个人观点

  1. Generator 本身是同步的,Generator 不应该与 Promiseasync 等概念相提并论
  2. Generator 可以用于简化异步编程,但 Generator 产生的初衷不是为了实现异步

那么 Generator 是干什么的呢?我我们看它的名字 “Generator(生成器)”,顾名思义,它本来是用于“Generate(生成)”一个值的。 比如说实现一个简单的伪随机数算法

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.