generator函数与async
提出问题
为了解决多线程带来的各种问题(例如耗费上下文切换的资源,竞态条件等),Node.js 等平台使用了异步 IO 的方式,主工作现场只使用单线程来处理工作。然而单线程异步 IO 带来了一个严重问题—回调地狱(callback hell)。为此,出现了 Promise 等技术来解决书写回调地狱的麻烦问题。
比如以前这样的代码:
1 | a(function () { |
现在可以写成:
1 | a().then(b).then(c).then(d). |
这样,a、b、c、d 四项异步任务就会依次发生。使用 Promise 除了链式写法,还可以:
1 | var aok = a(); |
技术的发展总是无止境的,开发方式开始越来越往把 程序员当傻子
的方向发展,于是现在,有一种叫做 Generator
的技术,可以直接将异步任务书写为同步的代码,这必然彻底解决了回调地狱的问题;其封装后的语法糖 async、await
彻底简化了异步代码编写困难的问题。而 Generator 的理论实际上是一种 协程
技术的应用,协程技术本来的目标是可以实现一种并发编程操作。
回顾概念
线程与进程
进程(Process)是系统资源分配和调度的单元。一个运行着的程序就对应了一个进程。一个进程包括了运行中的程序和程序所使用到的内存和系统资源。如果是单核 CPU 的话,在同一时间内,CPU 上有且只有一个进程在运行。但是,单核 CPU 也能实现多任务同时运行,比如你边听网易云音乐的每日推荐歌曲,边在网易有道云笔记上写博文。这算开了两个进程(多进程),那运行的机制就是一会儿播放一下歌,一会儿响应一下你的打字,但由于 CPU 切换的速度很快,你根本感觉不到,以至于你认为这两个进程是在同时运行的。进程之间是资源隔离的。
线程(Thread)是进程下的执行者,一个进程至少会开启一个线程(主线程),也可以开启多个线程。比如网易云音乐一边播放音频,一边显示歌词。多进程的运行其实也就是通过进程中的线程来执行的。一个进程下的线程是共享资源的,所以多线程比多进程实现起来要简单而且要消耗更少的资源。当多个线程同时操作同一个资源的时候,就出现资源争抢的问题,所以这种并行编程一旦涉及到访问同一个资源就要小心进行编程上的控制,这又是另外一个大的问题了这里不再赘述。
chrome 浏览器之所以占内存大,就是因为他采用了新开 tap 是新开进程的方式。每个进程都是独立的资源,所以比较耗内存。
并行与并发
并行(Parallelism)是指程序的运行状态,在同一个时间内有几件事情并行在处理。由于一个线程在同一时间只能处理一件事情,所以并行需要多个线程在同一时间执行多件事情。
而并发(Concurrency)是指程序的设计结构,在同一时间内多件事情能被交替地处理。重点是,在某个时间内只有一件事情在执行。比如单核 CPU 能实现多线程多任务运行的过程就是并发的。比如总体上来看,服务器上有限的线程来处理成千上万的 Request 请求,总体看也是并发的(不可能是并行处理几千万个请求)。
所以,并发应该是在描述一种事务请求资源的状态。而并行是在说做任务的方法。比如你开了 5 个进程或线程来进行数据分片计算,这应该叫并行处理任务。 你的 5 个线程来响应外网一千万个请求,这些请求对你的服务器或者 CPU 来说是并发的。 如果你的服务器是单核,但开启了 100 个线程,那么这些线程对你的服务器 CPU 来说,也是叫并发的;而如果是四核 CPU 开了 4 个线程同时处理任务,这个就可以叫做并行了。
浏览器单线程机制与事件循环的原理
由于本文要讨论的问题,跟异步、线程有很大关系,不由会思考浏览器中的单线程机制和事件循环,请参考我的另外一篇博文:浏览器事件循环
协程
协程就是在一个线程里像多线程一样,执行多个任务。
官方概念:协程(Coroutine)是一种轻量级的用户态线程。简单来说,进程(Process), 线程(Thread)的调度是由操作系统负责,线程的睡眠、等待、唤醒的时机是由操作系统控制,开发者无法精确的控制它们。使用协程,开发者可以自行控制程序切换的时机,可以在一个函数执行到一半的时候中断执行,让出 CPU,在需要的时候再回到中断点继续执行。因为切换的时机是由开发者来决定的,就可以结合业务的需求来实现一些高级的特性。更多资料
在一个 cpu 时,多线程也无法真正的并行执行任务;因此协程这种跳跃式执行不同位置代码的方式表面上的效果是有些类似多线程的。只不过 多线程是内核提供的功能,线程切换实际上要涉及内核态切换,还是消耗性能;而协程唤做用户态线程(协程),用户态线程就是程序自己控制状态切换,进程不用陷入内核态,开发者可以按照程序的特性来选择更适合的调度算法,协程属于语言级别的调度算法实现。
所以协程我是这样理解的: 协程在一个线程里做出多线程那样并行的效果,其实本质也无法并行(只是像多线程一样起到了并发效果),毕竟只在一个线程内。但从表面上看你的代码可以随时跳来跳去执行, 好像并不是传统的顺序执行。最大的特点是: 一个函数竟然可以执行到某个地方保存现场,然后程序就跑到另外一个地方去执行了。 等到合适的时机,再跳回来恢复现场并执行。 但从表面看,我们可以认为这是一种对一个线程的并发行为。更多资料。关于协程, 是一个比较大的课题。可以参考一些文章,例如 并发之痛 Thread,Goroutine,Actor
协程配合多线程和异步
我们知道多线程和异步写的代码不好管理,比如异步代码里太多回调。而如果协程来封装多线程的操作,可以更好的管理多线程;用协程来封装异步,也可以更好的书写异步代码。 在 JavaScript 中,一般用协程来管理异步 IO 的调用和回调,从而编写出同步样式的代码
. 这正是本文要讲的内容。
我为什么非要编写同步形式的代码?
假设一个逻辑是:先去服务器拿到本机 ip,再去调用另外一个服务去把 ip 换成城市名,然后再打印。
回调写法:
1 | // 拉取ip 的归属地 |
多少有点丑吧。
假设异步函数已经实现成 promise 版本,那接下来咱们用 promise 改造改造:
1 | // 拉取ip 的归属地 |
startLoadAddress 里面看起来少了一层嵌套,但是那俩回调函数倒是“一点都没少”,依然是写了俩回调函数。不过从嵌套角度,由于少了一层嵌套,看起来舒服多了。外部调用者调用 startLoadAddresss 之后,依然要靠 then+回调函数来等结果,跟 callback 也差不多。。。
那我们再看 es6 发明的终极方案 async:
1 | async function startLoadAddress() { |
此时,startLoadAddress 代码中没有任何一个回调。外部调用者也可以不需要写回调,就像写“同步代码”一样去等待“异步结果”。
Generator
终于来到本文的重点 Generator 了。generator 是一种协程的具体语言实现,其实 generator 还不能是完全的协程,他只实现了让一个函数可以暂停,也称之为非对称协程(semi-coroutine). 所以说 Generator 应该是半协程这个概念的语言实现。
generator 函数是一个状态机,内部有自己的状态以及可以变更其状态. 通过 yield 表达式,可以定义 generator 函数暂停的位置。
1 | function* f() { |
要想再次让函数执行到下一个 yield 表达式的位置,必须调用遍历器对象的 next 方法,使得其内部指针移向下一个状态。
1 | var g = f(); |
也就是说,每次调用 next 方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式(或 return 语句)为止。换言之,Generator 函数是分段执行的,yield 表达式是暂停执行的标记,而 next 方法可以恢复执行.
哇,仿佛,我们的 js 函数具有了状态记忆的功能。(这体现在 yield 表达式)。不过,为了更容易理解,我们可以用 迭代器
的思路来学习 Generator。比如,我们可以认为执行 f 函数后,就返回一个迭代器(实际上确实是一个迭代器),迭代器内包含了函数的每一个步骤,通过这个迭代器可以去执行函数的每一个步骤。
next 的返回值
执行 next 方法是有返回值的。next 方法返回一个对象,它的 value 属性就是当前 yield 表达式中 yield 后面跟着的那个值(右值),done 属性的值若是 false,则表示遍历还没有结束。如上文的例子,我们不断调用 g.next()
1 | var g = f(); |
其结果 a,b,c,d 分别是 {value: 1, done: false}
, {value: 2, done: false}
, {value: 3, done: false}
, {value: undefined, done: true}
也就是说,当函数运行到 return 或者函数末尾时,Generator 函数就已经运行完毕,此时会把 return 的东西当做 value 返回最后一次且 done 属性为 true。 以后再执行 next 方法返回对象的 value 属性为 undefined,done 属性为 true。再以后,无论多少次调用 next 方法,返回的都是这个值。
从另一个角度看 Generator,其实发现它的结果就是: 生成了一系列的值
,这也就是它的名称的来历(英语中,generator 这个词是“生成器”的意思)。
yield 的返回值
generator 函数内的 yield 表达式是没有返回值的,所以函数内部的这种赋值语句,将会永远拿到的是 undefined
1 | var reset = yield i; |
然而,它虽然没有返回给左侧,但会返回给外部调用 next 函数的左侧。另外,可以通过调用 generator 生成的函数,来向已经运行的 generator 函数中注入值。传入的值会被赋值给 yield 左侧的 reset。所以,也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数的行为。比如:
1 | function* f() { |
generator 函数里面写了一个无限循环的函数,每次调用 next 都会让 i 加 1,并让循环进入下一个迭代中,并暂停在下一个 yield 那里。 由于 reset 是 false,所以 i=-1
永远不会执行,循环还会继续。
而通过外部 next调用
时传入 true,就让 yield 有了返回值,从而 var reset = yield i;
这一句里的 reset 就不是 undefined 了,而是 true,所以可以让这次循环的迭代时将 i 置为 -1
Generator 总结
总结一下,生成器不是线程,在支持线程的语言中,多段代码可以同时运行,通常导致竞态条件和非确定性,不过同时也带来不错的性能。生成器则完全不同。当生成器运行时,它和调用者处于同一线程中,拥有确定的连续执行顺序,永不并发。调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的 next 方法,就会返回一个有着 value 和 done 两个属性的对象。value 属性表示当前的内部状态的值,是 yield 表达式后面那个表达式的值;done 属性是一个布尔值,表示是否遍历结束。
Generator 与异步操作
其实了解了 Generator 之后,会有一个很困惑的问题,就是这玩意怎么执行,通过不断调用 next 来执行函数中的一部分代码,有啥子作用呢? 这跟我们自己去运行多个函数来完成一个任务也差不多啊?
其实 Generator 的类似“断点”执行函数的特性(这不就是操作系统底层的中断?),那我如果有个总控操作系统能控制他啥时候中断啥时候再启用是不是就行了。这个玩意非常适合用来封装那些异步操作的 API。
generator 本质上并不能将异步代码做成同步的,仅仅能够控制代码的执行顺序,要实现异步代码执行起来像是同步的,需要类似 co 这样的库。co 的根本目的:将上一个 yield
函数的回调返回值作为下一个 next
函数的入参传递,从而将一个 Generator 自己运行起来,封装后就实现了让 异步代码看起来像同步的
。
使用 co 这个库,就可以将 Promise 和 Generator 结合起来,改成 同步的代码
。瞅一下:
1 | co(function* getData() { |
需要确保 yield 后面是个 Promise (或 thunk 函数)。
使用 Generator 也方便我们去捕获异步的异常。我们知道,promise 的异常需要使用 p.catch 这样的语法来捕获。而使用了 Generator,完全可以使用 try…catch 捕捉异步的异常。
1 | co(function* getData() { |
对于 co 来说,他实现为一个 Promise 的返回。因此,如果你的业务代码中不去 try…catch,则 co 会触发 Promise reject。你可以在 co 函数之后捕获这个错误。
对于 Promise 的 reject 异常,如果你没用自己 catch,则只能在全局 catch,在 Node.js 中可以通过监听 unhandleRejection
来处理:
1 | process.on("unhandledRejection", function (err) { |
注意了: 大家说熟知的 Node 中的 uncaughtException,以及浏览器中的 window.onerror 都是无法捕获 Promise 的 reject 异常的。
co 封装 Generator 的原理
简单的代码模拟如下
1 | // 模拟一个异步回调函数1 (yibu1其实就是一个Thunk函数) |
其核心就是通过 mynext 函数重复递归调用,实现异步任务完成后就再次触发 generator,并把结果传入 generator(同时发起下一个异步操作)
async,await
async,await 是 ES7 对 generator 使用方法的包装,使其使用起来更简洁(尤其是用在 generator 做异步 IO 操作的时候)。使用 async 来修改上面的代码,可以这样写:
1 | async function getData() { |
async,await 就是 generator+自动执行器的语法糖。
更多参考,可以查看阮一峰老师的 ES6 标准教程(在线有开源版本),其中详细讲解了 async,await 语法糖的来源。值得有时间时进行详细解读,大概从中可以明白,async 是对 generator 封装后的语法糖,实现了之前用 co 库才能做到的 generator 的自动 run 执行器。
async 可以认为就是一个包含了很多异步操作的函数(一个 generator 函数)。而 await 你就可以看做后面是个 promise 异步任务(相当于 generator 里的 yield),而这个语法糖让后面异步 promise 的结果会自动赋值给前面的变量。
async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。
async 函数返回的 Promise 对象,必须等到内部所有 await 命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到 return 语句或者抛出错误。也就是说,只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数。
await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中
例子:
1 | function timeout(ms) { |
通过例子可以看出来,虽 async 实现了同步代替异步,但仅仅是语法层面的。JavaScript 单线程本质没变,异步下面一行代码必须等到其他同步代码执行完,才会打印。因此,即使学习了 async,但思维本质上我们要跟以前 Promise 等异步的理解方式保持一致的哦。
async 函数返回一个 promise,其实也类似于 co 函数执行后返回一个 promise。因此,我们可以用 then 方法来指定异步完成后的回调。
另外一点是,多个 await 语句实际上是串行执行的,有时候我们需要并行执行两个异步操作。这个时候可以这样来拼装 2 个异步操作:
1 | // 写法一 |