一、写在开始
协程在前端领域似乎没什么存在感,甚至很多前端同学和我一样在很长的时间内根本就不知道这个概念 。本文就尝试探讨协程在前端领域的应用,当你读完这篇文章你会发现其实在我们的开发过程中经常有意或者无意的用到了协程。
概念
在了解协程这个概念之前,我们或许应该对进程、线程进行一个回顾,如果你对这些概念还不太了解,我推荐你阅读这篇文章,但我们依然简单回顾一下这几个很重要的概念。
当我们谈论进程、线程、和协程时我们必须提前认识内存,应用程序和内存的关系就像鱼和水一样。应用程序离开了内存就是一堆毫无意义的文件而已,只有跑在内存上的应用程序才有意义。
-
进程
进程是操作系统分配资源的最小单位,进程为一台计算机可以跑多个应用程序提供了可能。从浏览器的角度来讲,现代浏览器就是多进程架构的,每一个tab基本上就是一个新的进程。我们可以在浏览器的进程管理上见证这一点。
每一个进程就是由操作系统负责调度和分配资源的,所谓的资源其实就是内存资源、IO资源、信号处理等等。
-
线程
线程是cpu分配资源的最小单位,每一个进程都至少会有一个线程,线程的异常退出会导致所在进程的整体崩溃,线程和其他兄弟线程可以共享进程的内存空间,而不同进程间的内存空间是相互隔离的。线程的创建和调度同样是由操作系统来接管的。
javascript 是一门单线程的语言仅仅代表js引擎在解析和执行js时是单线程的,并不意味着渲染进程整体是单线程的。
-
协程
协程可以理解为用户级的轻量线程 ,它和线程最大的区别就是它的执行流切换是发生在用户态 的,根本不需要操作系统的介入,而线程的切换则需要操作系统进行介入,操作系统介入就肯定会有内核态和用户态的转换,因此线程的切换的代价会大一些。
理解协程
由于本文是探讨javascript中的协程,所以我们回顾了基本概念的时候我们首先来好好深入理解一下协程。
协程的出现其实是比较早的,甚至比线程出现的时间更早,只不过由于线程的出现导致协程变的没落了,但是对于性能极致追求的程序员们不满足于线程提供的能力。不满足的点在于哪里呢?
首先是在高并发的场景下,多线程的确可以解决大部分的问题,但是对于大部分IO密集型 的问题,就有些捉襟见肘了,大部分CPU的时间根本没有得到完全利用,而是浪费在了切换线程的身上。线程的切换是一件费力的事情,尽管它已经比进程的切换代价小很多很多。线程的切换是由操作系统进行的,因此绕不开内核态 和用户态的转换。
因此有人提出,那就干脆把切换线程的时机和权限交给程序员自己行不行,于是程序员在幻想有一天,自己手上握着一个单线程的执行流,并且自己可以决定调度任意一个任务,并且切换后原先任务的相关信息依然可以恢复。想想虽然麻烦一点,但是性能的确提升了。语言的实现者也觉得可以考虑,开始实现这个运行时,于是协程诞生了。
因此,协程的本质其实是将执行流的调度权限进行了转让,程序员或许会做更多的事情,但却换来了性能的提升。
其实简而言之,协程就是函数层面的轻量级"线程"(可以把线程理解为硬件层面或者进程层面的东西),某个函数执行到一半的时候,可以通过某些机制保存当前现场然后暂停执行,之后可以通过外部代码重新恢复执行。
二、javascript协程体验
在javascript中协程的实现其实就是generator函数 ,generator函数最重要的就是它的yield关键字。
js
function* gen() {
yield 1;
return 2;
}
let g = gen();
g.next(); // 1
g.next(); // 2
当程序来到yeild关键字的时候,执行流会停止执行当前协程,执行流回到了主线程,继续执行g.next(),然后执行流又回到了协程中继续执行,直到程序结束。
我们画一个图来展示一下:
我们还可以对比一下协程和普通函数的另外一个很重要的区别------保存栈信息
js
// 协程函数
function* genFun() {
let a = 1;
yield a = a + 1;
yield a = a + 1;
}
// 主线程
const gen = genFun();
gen.next() // 2
gen.next() // 3
js
// 普通函数
function normalFun() {
let a = 1;
a = a + 1;
a = a + 1;
}
const result = normalFun();
对于协程函数来讲,当genFun()调用完成之后,只是创建了一个协程的空间,用来存储协程相关的信息,但是再次回到主线程的时候依然没有被释放,而普通函数一旦调用完成就被释放了,虽然可以通过再次调用来执行,但是那就是新的一个函数栈了,而协程的每一次调用都是回到原来的函数中,栈都是保存好了的,没有被清除或释放。
三、应用
状态机
在javascript中,Promise就是一种状态机,它反映的实际上是一种状态信息(成功、失败、进行)。通过普通函数实现一个状态机是这样的
js
let status = true;
const state = function() {
if (status)
console.log('A');
else
console.log('B');
status = !status;
}
我们额外需要定义一个全局变量来存储状态,这样会造成状态是暴露在外部来,安全性并不高。但是通过generator就比较方便了:
js
const state = function* () {
while (true) {
console.log('A');
yield;
console.log('B');
yield;
}
};
const genState = state();
genState.next();
通过在外部不断调用协程实例就可以获取到不同的状态,而且外部无法拿到内部的状态,也就没有恶意更改的风险。
异步操作用同步的方式表达
在开发中,我们需要面对很多异步场景,写出下面的代码也就很正常了。
js
const doSomeThing1 = (props)=> { ... }
const doSomeThing2 = (props)=> { ... }
const doSomeThing3 = (props)=> { ... }
const doSomeThing4 = (props)=> { ... }
doSomeThing1((props1)=>{
doSomeThing2((props2)=>{
doSomeThing3((props3)=>{
doSomeThing4((props4)=>{
})
})
})
})
采用 Promise 改写上面的代码。
scss
Promise.resolve(doSomeThing1)
.then(doSomeThing2)
.then(doSomeThing3)
.then(doSomeThing4)
.then(function (props) {
// 成功了
}, function (error) {
// 失败了
})
.done();
上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise 的语法。Generator 函数可以进一步改善代码运行流程。
csharp
function* longRunningTask(value1) {
try {
var value2 = yield doSomeThing1(value1);
var value3 = yield doSomeThing2(value2);
var value4 = yield doSomeThing3(value3);
var value5 = yield doSomeThing4(value4);
// Do something with value4
} catch (e) {
// 错误
}
}
然后,使用一个函数,按次序自动执行所有步骤。
scss
scheduler(longRunningTask(initialValue));
function scheduler(task) {
var taskObj = task.next(task.value);
// 如果Generator函数未结束,就继续调用
if (!taskObj.done) {
task.value = taskObj.value
scheduler(task);
}
}
Iterator 接口
众所周知,for...of在javascript中可以遍历数组,但是不能遍历对象,那是因为数组上部署了迭代器的接口,但是对象中没有,如果我们希望让对象也能够被for...of遍历,其实我们在上面部署一个迭代器的接口就好了。
利用 Generator 函数,可以在任意对象上部署 Iterator 接口。
ini
function* iter(obj) {
let keys = Object.keys(obj);
for (let i=0; i < keys.length; i++) {
let key = keys[i];
yield [key, obj[key]];
}
}
let obj = { foo: 3, bar: 7 };
for (let [key, value] of iter(obj)) {
console.log(key, value);
}
// foo 3
// bar 7
上述代码中,obj
是一个普通对象,通过iter
函数,就有了 Iterator 接口。也就是说,可以在任意对象上部署next
方法。