如果你还在想办法理解协程是什么,那么就让我们玩一玩分手厨房。分手厨房(overcooked),是一款多人烹饪游戏,玩家需要在特定的时间内做出尽可能多的订单。协程 (coroutine)有些人花了很多时间并不一定能理解它,而游戏,却很容易理解。
1、如何玩?先让我们来看看分手厨房的玩法。
玩家们分别控制着带厨师帽的小人,没有厨师帽的是一些NPC(no player character),如上图,我们一共有四个玩家,对应着四个厨师。
游戏开始后,左上角会不停地出现订单,而玩家们通过不停地完成这些订单得分。 时间截止后,分数达到一定,即可进入下一关。
(1)每个订单会标明需要什么材料,比如灰色的是蘑菇,红色的代表西红柿,棕色的是洋葱等
(2)每个订单也标注着自己的制作流程,比如先把切碎西红柿,接着放在灶台上煮,当煮好后,将菜盛到盘子里递给顾客
现在我们已经学会了怎么玩分手厨房,但是要怎么样才进入下一关呢?每一关有积分要求,制作完成的订单越多,得分越多,也就可以通关。
那么怎么样才能制作更多的订单呢?小伙伴要分工明确,并且密切地配合,快马加鞭地做菜。比如同学小张负责切菜,拿食材,同学小丁负责煮,上菜等等,很多时候能者还要多劳。
实际操作就会发现简单的规则,但是操作和配合却很难控制。长时间不能完成的订单就会销毁(可能顾客等不及退单了),然后被扣分。食物煮的太久就会着火,着火就要救火。两个人走位不对就会撞在一起,而且容易冲突起来,该拿的食材没有拿。
这些困难对应着协程的理解,可实际上理解协程很简单,因为你只需要明白订单本身就可以了。
2、协程是什么?
协程可以让计算机程序在IO密集型的场景下,支持更多的请求,而且比多线程的方式,节省更多地资源,性能更优。
那什么是协程呢?维基百科是这么定义的:
协程是计算机程序的一类组件,推广了协作式多任务的子程序,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。
定义里面的关键词是协作式,挂起和恢复。是不是有点难理解?实际上它们就真真切切地存在分手厨房里面
(1)协作式,玩家们不停地协作制作订单
(2)挂起,当食材切好,放在锅里煮着的时候,这个订单就被“挂起”了
(3)当菜煮好的时候,你就去盛它,这个订单就被恢复了
但是协程比分手厨房奇妙的地方在于,站在前人的肩膀上,我们不用像游戏里的小人一样忙手忙手,不知所措,只需编写订单,计算机就会充当游戏里的小人们把订单完成得漂漂亮亮的。
这些订单就是我们的代码,但是它们又不同于常见的代码,它们是借助于async、await构造的协程coroutine,看起来就像是同步的代码,但是计算机会用异步的方式去执行。
3、异步是什么?
类比分手厨房,订单本身是同步编写的:先切,后煮,上菜。而订单的实际制作是异步的方式:
(1)当出现一个订单的时候,我们有空的时候,就会去拿食材,然后切菜。
(2)但是如果其他菜已经做好了,我们就会放下当前的订单,去处理其他的订单。
所以订单们不是从一开始制作,不停地烹饪,直到一个订单完成,才去制作其他订单,而是中间穿插了多个不同订单的制作。这就是异步方式地烹饪菜肴。
所以异步地执行是指做事情仍按照顺序,但是并不要求顺序在时间上相连,只要按照逻辑的顺序即可。这些代码看起来跟同步方式执行的代码没有区别,所以叫用同步编写的代码。
协程和线程对比起来更容易理解,因为他俩实在太像了。
1、线程(英语thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。
一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
2、那么什么是进程呢?
进程是程序运行的一个实例。一个程序是静态的二进制文件,是没有灵魂的,当我们启动程序时就会开启一个进程,这个时候操作系统开始读取程序的二进制文件,并且向系统申请一些资源。
简言之,进程就是一个程序运行的时候系统环境变量和用到的资源以及本身代码的集合,其特点是每个 CPU 核心任何时间内仅能运行一项进程,即同一时刻执行的进程数不会超过核心数,这对支持更高并发是个阻碍,并且为了解决进程阻塞的问题,操作系统遍引入了更轻量的线程。
线程是操作系统能够进行运算调度的最小单位。可以这么理解:
在进程这个大圈子中,存在着各种资源:
在没有线程时,这些资源全部由一个可执行代码调配,这有点像单线程进程。
当引入线程之后,一个进程下可以有很多线程,相当于一个可执行代码被分成了很多段,这些片段可以单独执行,并且使用所在进程内的资源。
3、时间片轮转
当然,要使一个应用程序完整运行起来就必须要把这些细分的线程全都执行起来,于是便需要时间片轮转。
操作系统为每一个线程分配 CPU 执行时间(通常为几百毫秒),当运行这个线程的时间超过分配的执行时间时,系统会强制 CPU 去执行下一个等待的线程(补充一下,线程和进程都是有状态的,这里这个正在”等待“的线程应该是”中断“状态),如此快速的不断切换线程便实现了并发。同时程序运行的时候也只会出现线程阻塞,而不是整个进程阻塞,如此便解决了上面的问题。
4、并发是指一段时间内执行多个程序(线程算是一个进程的子程序)
5、协程
线程是为了解决阻塞和并发的问题,在一段时间内执行更多的程序,类似的,协程也是为了在一段时间运行更多的“程序”(应该说是函数)并且避免线程阻塞。有了之前的铺垫,类比起来讲协程就很容易了。
线程和协程解决的并发问题不是一个问题:线程是为了让操作系统并发运行程序,以达到“同时”(实际是时间片轮转 - 交替执行)运行更多程序的目的,而协程是为了让一个线程内的程序并发服务更多内容。
这里不太好解释,一个直观的例子就是一个单线程的服务器程序同时服务多个用户,如何做到服务更多用户?想想线程是怎么来的,我们只需要把这个线程中的程序继续细分,然后像时间片轮转一样不断的去执行这些细分的“子程序”。即使一个这样的“子程序”执行发生阻塞,也不会导致整个线程阻塞,在这个“子程序”阻塞的时候切换到其他“子程序”继续服务,既解决了阻塞的问题,也实现了并发。大概理解了吧,协程就是线程中可以交替运行的代码片段。
线程切换是由操作系统的时间片轮转控制的,而协程是程序自己实现的,让协程不断轮流执行,所以实现协程还必须要有一个类似于时间片的结构。
不同于线程的时间片切换,协程的切换不是按照时间来算的,而是按照代码既定分配,就是说代码运行到这一行才启动协程,协程是可以由我们程序员自己操控的。
1、协程如何展现?
在 JavaScript 中,协程是怎样的呢?其实 es6 里的生成器函数就是协程的展现。协程,就是一个生成器,生成器本身是一个函数,也就是说在 JavaScript 中协程是由一个生成器函数实现的。
2、协程如何切换?
协程本身是个函数,协程之间的切换,本质是函数执行权的转移。
生成器函数的yield
关键字可以交出函数的执行权,挂起自身,然后JS引擎去执行这个函数后面的语句。
使用 yield
和 next()
方法就能不断的交出和恢复函数的执行权。
3、可以把生成器函数的执行权交给普通函数(你也可以把非协程看做是一个协程整体),也可以在一个协程中调用另一个协程,实现协程之间的切换
function* anotherGenerator(i) { yield i + 1; yield i + 2; yield i + 3; } function* generator(i) { yield i; yield* anotherGenerator(i); // 移交执行权 yield i + 10; } var gen = generator(10); console.log(gen.next().value); // 10 console.log(gen.next().value); // i=10,传入anotherGenerator协程,为 11 console.log(gen.next().value); // 12 console.log(gen.next().value); // 13 console.log(gen.next().value); // 20
第 9 行使用 yield*
将执行权交给另一个生成器函数,接下来要等到这个生成器函数anotherGenertor()
执行完毕,执行权才会回到generator
函数。这和普通函数表现一致,都是后进先出,符合JS事件循环机制(Event Loop)
1、异步
实现异步的关键就是把会阻塞线程函数的执行权交出去,让这个函数等待恢复执行,等待的时间内请求(或者其他异步任务)也该执行完了,这时候再来继续执行这个函数。
通过前面对协程的运行方式的讲解我们很容易就能想到用协程来解决这个问题,利用 yield
挂起这个阻塞线程函数,然后继续执行后面的语句,等这个函数不再阻塞了,再回到这个函数继续执行。
那么问题来了,应该什么时候继续执行这个挂起的函数呢?你可能想到大概估计一下阻塞时间,设定时间再回来执行,这个方案有点牵强。
2、Promise
这时候 Promise 就派上用场了,Promise 本质是一个状态机,用于表示一个异步操作的最终完成 (或失败)及其结果值。它有三个状态:
最终 Promise 会有两种状态,一种成功,一种失败,当 pending 变化的时候,Promise 对象会根据最终的状态调用不同的处理函数。
根据 Promise 的特点,他是一个状态机,在yield
之后可以用 Promise 来表示异步任务是否执行完毕(是否是pending状态),并且还能够自动判别异步任务成功与否(fulfilled 还是 rejected)并执行处理函数。
如此看来用协程+Promise 可以完美实现异步,让我们来根据上面的理论实现一下:
// 模拟阻塞2s事件 function resolveAfter2Seconds(val) { return new Promise((resolve) => { setTimeout(() => { resolve(val); }, 2000); }); } // 实现生成器 function* coroutineFunc(val) { yield resolveAfter2Seconds(val); } let doIt = coroutineFunc("OK"); // 生成器创建协程 let value = doIt.next().value; // 执行 // value是Promise对象 value.then((res) => { console.log(res); }); // 模拟后面被阻塞的语句 for (let i = 0; i < 10; i++) { console.log(i); }
这段代码的输出顺序是 0=>1=>2=>...=>9,两秒之后输出'OK',从输出顺序来看我们已经实现了异步。其执行过程和之前说的一样,挂起会阻塞运行的函数,继续执行后面的语句,等待 Promise 改变状态并自动执行处理函数。
3、使用 Generator、Promise 组合和直接使用 Promise 的区别
实际上下面这段代码运行顺序的结果和上面一模一样:
function resolveAfter2Seconds(val) { return new Promise((resolve) => { setTimeout(() => { resolve(val); }, 2000); }); } resolveAfter2Seconds("OK").then((res) => { console.log(res); });
但是我们为什么要使用上面那种复杂的写法呢?原因有3:
(1)为了简化问题,便于理解,我已经简化了代码,在前一个例子中,生成器函数内,yield
行后面完全可以写更多的代码,这些代码一定是在异步获取到数据之后才执行的。
(2)如果直接使用 Promise 需要把这些代码放在 then 代码块里边才能保证在异步获取到值之后执行,
(3)那么当有多个异步事件的时候问题就来了——可怕的嵌套!
4、Async、Await
ECMAscript2017 中提供了更高级的协程控制语法,其被看做是对 Generator 和 Promise 组合的封装,使异步函数看起来更像同步函数,减轻开发者的痛苦。上面的例子改写:
async function f1() { var x = await resolveAfter2Seconds(10); console.log(x); // 10 }
可以看出 Async、Await 实现了 Generator 的自动迭代,不需要手动使用next()
方法来继续执行。