单线程的大脑和JavaScript异步

2021-03-08
1940
2300

在ECMAScript 6发布之前,开发人员编写异步代码通常只能使用回调(tiao,第二声)函数的方式来实现,回调也是JavaScript语言中最基础的异步模式。因为它使事件循环"回头调用"的概念更加清晰,队列在异步代码有响应的时候会运行这个callback。

单线程的大脑

我不止一次从自己的身边听到某些人声称自己可以"一心多用"。有时候为了说服"观众",甚至还举了很多例子:比如边写代码边听歌,左手和右手在同一时刻变换不同手势,或伸出不同手指。甚至还有的说自己可以边开车边发微信(当真是不要命)。

但是,我们当真能一心二用甚至多用吗?我们真的能同时执行两个或两个以上的意识以及意识所支配的肢体动作吗?我们自认为最高级的大脑是以多线程并发的方式运行的吗?

答案可能会让你很失望,大脑跟JavaScript一样,都是单线程。也就是说任一时刻,只有一个任务在执行(任一时刻我们只能思考一件事)。

比如你可以试试你是否能在5秒内做到左手伸食指右手伸中指,并随意切换,你会发现即便在自己大脑充分思考并作出足够准备的情况下,依然频频出错。当然,经过多次练习后这种情况会慢慢好转。但这并不能跟大脑处理信息的模式相提并论。

经过刻意练习,一些同时进行的两个或两个以上的动作能做出来,是因为大脑的"反射区"神经元针对某件事得到强化了,但是这种强化依然跟我们的心跳、呼吸、眨眼睛等行为来说有着显著的区别。没听说过憋一会气之后,就忘了怎么呼吸的,也没听说过睡着了之后心脏忘了怎么跳动的情况。但是这些可以训练的"多线程"动作,却可以在几天甚至几小时不做的情况下被大脑忘的干干净净。随着刻意练习的时长增加,神经元被强化的程度也会增加,那么这些多线程动作被忘记所需的时长也随之增加。

大脑的伪多线程/并发

那么大脑到底是如何做到看似在同一时刻执行了"多线程/多任务"呢?实际上跟JavaScript的事件循环机制非常相似,都是在进行极快的"上下文"切换。

执行期上下文是JavaScript程序运行时的重要概念,通俗点说就是某一时刻的代码块所能访问的内存的不同体现方式。

那么大脑的上下文切换是怎样的呢?比如正在和父母通电话的同时还在办公写作。脖子上夹着电话,大脑的上下文迅速切换为:父母最近过的怎样,身体好不好,家里冷不冷等各种琐碎事,大三(我家气质之王:杜宾犬)有没有欺负小朋友等琐碎事中抽出一句优先级最高的事情通过语言系统表达出来:家里冷不冷啊,昨天看天气预报说家里下了好大的雪?然后大脑迅速再切换"上下文":"大脑的假多线程/并发,大脑的伪多线程/多任务,大脑的伪多线程/并发"?同样在这三个或更多个的段落标题中选出优先级最高的那一条之后,大脑同时开始"监听"鼓膜传递过来的"音频"。再迅速切换上下文,大脑发出携带着文本信息的神经信号给手指,手指接到指令后开始啪啪啪的敲打键盘,如此往复下去....

实际上广博复杂的神经学远不止我所说的这么"简单",大脑作为这个世界上最复杂最精密也是最神秘的器官,已经创造出了太多奇迹。世界上很多伟大的产品也都是以人类大脑或神经系统或思维方式作为模型,为这个世界带来了各种便利。

脑中会议

仔细想想,我们无法一直集中注意力在某件事上的原因可能就是因为大脑会一直处于不断切换上下文的状态。所谓的思想开小差,应该就是上下文切换的时候,突然切到了一件特别能触发情绪的上下文,大脑对情绪的处理优先级是几乎最高的,痛苦、悲伤、愤怒、快乐,任意一种情绪都可能让你心猿意马。

但是如果按照这个理论分析下去,那我们人类就废了,根本不可能完整的做完某件事,甚至都不能完整的思考。这个时候,大脑中的会议层就诞生了。这个会议的主要目的就是在众多微小琐碎的事物中挑选出优先级最高的几件事,然后各种分析,各种讨论,对这些事务进行排序,排第一的事物最先被处理。当然这个过程可能会产生更多的事务。比如:你发现浴室的洗发水没有了,想去商场买瓶洗发水。可是这个时候发现自己头发太油了,简直没法见人,想洗头又没洗发水。于是新的决策出现了,去理发店,然后去商场买洗发水。不过很有可能理发店出来后,开开心心的商场买了一大堆东西,回到家累成了狗,最后理东西的时候发现洗发水忘买了....

大脑中的"会议"就是为了解决各种上下文切换的时候,无法选择出最重要的那件事而诞生的,"脑会议"的最终解释就是决策权。这也是我们能井井有条的做完一件又一件事情的基础。规划能力强的人则说明"脑会议"的产出质量非常高。对于一个优秀程序员来说,这同样是不可或缺的品质。

编程的本质是什么,其中很重要的一点是对现实世界的抽象能力以及让这些抽象数据能按照自己编写的"意识流"稳步运行。

相比于代码意识流的构建,大脑的上下文切换可能会更加错综复杂,不确定性也高出不少。比如:在早上起床的时候可以井井有条的规划各种事情,先去理发店洗头,然后去商场买洗发水,买了洗发水回家吃饭、学习、下午出去打一场酣畅淋漓的篮球。结果你开车堵了半个小时,这还不算,刚到商场物业打电话来告诉你你家大三(我家气质之王:杜宾犬)想跳窗去找隔壁家那只阿花。那可是8楼啊。于是二话不说一脚油门往回赶....

大脑的会议层虽然把每件事情都给你排出了1,2,3,4。但是无奈现实生活中会迫使我们改变原有计划的事情太多了,有些甚至是蝴蝶效应般恐怖的影响。

回调函数和未来的大三

JavaScript 回调函数(callback)和不确定的大三相比,情况似乎好了不少。callback更像是提前埋好了点,假设编码期望这个回调被调用,这个回调一定会调用(这件事一定会发生),只是时间不确定,至少在未来的某个时间会发生。

尽管看起来我们代码中的"意识流"被我们规划的非常井井有条,但是实际上可能还是会发生各种各样我们意想不到的问题。导致这些问题的最著名原因之一则是"回调地狱"。比如某些任务是链式的,任务A得到结果后才触发任务B,任务B得到结果后触发任务C,持续下去代码将丑陋无比,同时极难维护和管理,容易出错。

当然,还有比回调地狱更可怕的是,代码不知道为什么就崩溃了。这还没完,更加可怕的是,为什么一开始它是好的。

这就是经典的"纸牌屋"效应,它可以工作,可我不知道为什么,所以谁也别碰它。代码行业中流传着一句话:他人即地狱(他人的代码即地狱)。而我深信不疑的是:不理解自己的代码才是地狱!

Promise 期约/承诺

由于大脑对事务的规划是线性的、阻塞的、单线程的,但是回调表达的异步流程方式是非线性的、非顺序的,这便导致了正确推导这些代码的难度很大,一不小心就会出现bug。

我们需要一种更同步、更顺序、更阻塞的的方式来表达异步,就像我们的大脑一样。于是Promise风暴来了。

未来值

设想一下这样的场景:你去一个奶茶店点了一杯奶茶并付了钱,这个时候你并不会立即得到这杯奶茶。你付钱的行为类似于客户端向服务端发送了一个请求,至于什么时候得到响应要看服务端对这次请求的处理情况以及服务器的繁忙程度、负载状况。奶茶店也是如此,你付钱相当于发送了请求一杯奶茶的信号,收银员会给你某个凭证(可能是订单/收据之类的),这代表着未来的奶茶。至于等多久,要看奶茶店员的繁忙程度以及你前面排了多少人。在等待的同时你可以去做一些其他的事情,比如刷刷朋友圈,看看短视频之类的。

终于我听到了服务员在喊"9527 杨枝甘露好了",于是你拿着凭证(value-promise)换取奶茶。当然还可能有另一种情况,叫到你的时候,杨枝甘露卖完了。如此我们可以看到未来值的一个重要特性:它可能成功也可能失败!

代码中往往要比现实情况还要糟糕,比如服务器崩溃了、宕机了,你的请求虽然发出去了,但是得到的结果可能是无响应(404、502等错误)。更悲催的可能是 while(true) { ... },即便返回的是错误,那也是"结果",怕就怕什么都不返回,这意味着你的代码将永远处于未决议状态。用现实类比就好像是你付了钱,收银员给了你订单号,但是这辈子都没叫过你,任你等到地老天荒。

Promise 状态机

期约是一个有状态的对象,可能处于如下 3 种状态之一:

  • 待定(pending)
  • 兑现(fulfilled,有时候也称为“解决/完成”,resolved)
  • 拒绝(rejected)

待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现 (fulfilled)状态,或者代表失败的拒绝(rejected)状态。无论落定为哪种状态都是不可逆的。只要从待 定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组 织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该具有恰当的行为。

对于这三种状态,在更深入了解 Promise 之前,还有一些模糊之处需要澄清。先来研究一下构造器 Promise(..):

const p = new Promise((a, b) => { 
  // a()用于完成
  // b()用于拒绝 
})

你没有看错,就是这么简单的一句代码就创建了一个 Promise 对象,这里提供了两个回调(称为 a 和 b)。第一个通常用于标识 Promise 已经完 成,第二个总是用于标识 Promise 被拒绝。这个“通常”是什么意思呢?对于这些参数的 精确命名,这又意味着什么呢?

追根究底,这只是你的用户代码和标识符名称,对JavaScript引擎而言没有意义。所以从技术上说, 这无关紧要,foo(..) 或者 bar(..) 还是同样的函数。但是,你使用的文字不只会影响你对 这些代码的看法,也会影响团队其他开发者对代码的认识。错误理解精心组织起来的异步 代码还不如使用一团乱麻的回调函数。

所以事实上,命名还是有一定的重要性的。所以下面的代码将是最佳实践:

const p = new Promise((resolve, reject) => {
  // resolve() 完成
  // reject() 拒绝
})

reject(...) 就是拒绝这个 Promise 的原因;但是resolve(...)则既有可能是拒绝也可能是完成,要根据传入的参数而定(通常情况下,开发者期待的是一个完成值)。如果传给 resolve 的是一个非 Promise、非 thenable 的立即值,这个 Promise 就会用这个值完成。

但是,如果传给resolve的是一个真正的Promisethenable值,这个值就会被展开,并且这个Promise将取用其最终的完成值状态。

快捷的创建完成或拒绝值

const p1 = new Promise((resolve, reject) => reject('拒绝原因'))
// 等价于下面这种写法
const p2 = Promise.reject('拒绝原因')

同理,Promise.resolve(...)常被用于创建一个完成值。需要注意的是:如果传递给resolve(...)的是一个真正的Promise,那么它将什么都不做直接将这其返回,这个过程是不存在任何开销的。

Promise链式流

Promise 并不只是一个单步执行 this-then-that 操作的 机制。当然,那是构成部件,但是我们可以把多个 Promise 连接到一起以表示一系列异步步骤。

每次你对 Promise 调用 then(..),它都会创建并返回一个新的 Promise,我们可以将其链接起来。正是这种链式结构,刚好解决了可怕的"回调地狱"问题。

reject 的链式传递

默认拒绝处理函数只是把错误重新抛出,从本质上说,这使得错误可以继续沿着 Promise 链传播下去,直到遇到显式定义的拒绝处理函数。

尽管链式流程控制是有用的,但是对其最精确的看法是把它看作 Promise 组合到一起的一 个附加益处,而不是主要目的。Promise 规范化了异步, 并封装了时间相关值的状态,使得我们能够把它们以这种有用的方式链接到一起。

then(...) 、catch(...)、finally(...)

每个 Promise 实例都有 then(...) 和 catch(...) 方法,通过这两个方法可以为这个 Promise 注册完成和拒绝(异常)处理函数。Promise 完成后,会立即调用二者之一,但不会两个都调用,且总是异步调用。

Promise.prototype.then() 是为期约实例添加处理程序的主要方法。这个 then()方法接收最多 两个参数:resolve 处理程序和 reject 处理程序。这两个参数都是可选的,如果提供的话, 则会在期约分别进入"解决"和"拒绝"状态时执行。

// 完成时调用
function onResolved(msg) {
  console.log(msg)
}

// 拒绝时调用
function onRejected(msg) {
  console.log(msg)
}

// 一个完成的 Promise
new Promise((resolve, reject) => {
    setTimeout(resolve('完成'), 1000)
}).then(onResolved, onRejected) // 完成

// 一个拒绝的 Promise
new Promise((resolve, reject) => {
    setTimeout(reject('拒绝'), 1000)
}).then(onResolved, onRejected) // 拒绝

传统的try { } catch(err) { }将无法捕获到 Promise 拒绝(异常),因为同步的方式将不适应异步创建的代码异常捕获。

try {
  Promise.reject('拒绝原因')
} catch (err) {
  // 永远也到不了这里
  console.log('啊!我捕捉到了异常:' + err)
}

// 下面这种将会被正常捕获
Promise.reject('拒绝原因!').catch(err => console.log('抓到了:' + err))
// 抓到了:拒绝原因!

其实 Promise.prototype.catch() 就是个语法糖,等同于 Promise.prototype.then(null, onRejected)

Promise.prototype.finally()方法用于给期约添加 onFinally 处理程序,这个处理程序在期 约转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolved 和 onRejected 处理程序中出 现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用 于添加清理代码。

new Promise((resolve, reject) => {
  setTimeout(resolve('完成'), 1000)
}).then(msg => console.log(msg)).finally(() => console.log('不管完成还是拒绝都会到这里'))
// 不管完成还是拒绝都会到这里

Promise.all([...])

实际开发过程中经常有一种需求是要一次性等待2个及以上的耗时行为完成后,才能继续另一件事。Promise.all便是很好的解决方案。

对 Promise.all([ .. ]) 来说,只有传入的所有 promise 都完成,返回 promise 才能完成。 如果有任何 promise 被拒绝,返回的主 promise 就立即会被拒绝(抛弃任何其他 promise 的 结果)。如果完成的话,你会得到一个数组,其中包含传入的所有 promise 的完成值。对于 拒绝的情况,你只会得到第一个拒绝 promise 的拒绝理由值。

Promise.race([...])

另一种少见的情况则是,某几个耗时行为哪个先有结果,就以某个结果为准进行下一步操作。对于Promise.race来说,只有第一个完成的Promise(完成或拒绝)的值被拿到,其他的都将被丢弃。

const p1 = Promise.resolve('Hello World'),
      p2 = Promise.resolve('JavaScript'),
      p3 = Promise.reject(100)

Promise.race([p1, p2, p3]).then(msg => console.log(msg)) // Hello World
Promise.all([p1, p2, p3]).catch(err => console.log(err)) // 100
Promise.all([p1, p2]).then(msgs => console.log(msgs)) // ['Hello World', 'JavaScript']

注意:若向 Promise.all([ .. ]) 传入空数组,它会立即完成,但 Promise. race([ .. ]) 接受空数组则永远也无法得到结果,哪怕是错误都得不到,它将永远处于等待状态(Pendding)。

Promise真的摆脱回调了吗

其实 Promise 也没有真正摆脱回调,只是改变了传递回调的位置。我们不再是把回调传递给foo(...)而是从foo(...)得到某个未来值。

而且,Promise 只能有一个完成值或拒绝值。在简单的需求下这不是什么问题,但是在更复杂的场景中,这可能就成为一种局限了。如果要在 Promise 链中的每一步都进行封装和解封,代码就变得十分丑陋笨重了。

无法取消的 Promise

一旦创建了一个 Promise 并为其注册了完成和 / 或拒绝处理函数,如果出现某种情况使得 这个任务悬而未决的话,你也没有办法从外部停止它的进程。设置超时时间将是唯一的妥协方案:

new Promise((resolve, reject) => {
    setTimeout(() => resolve('8s后成功'), 8000)
    setTimeout(() => reject('3s后失败'), 3000)
}).then()
// Uncaught (in promise) 3s后失败

异步函数 async / await

ES8的 async/await 以一种更加阻塞更加顺序的方式解决了异步代码组织问题。

async

async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo() {}
const bar = async function () {}
const baz = async () => {}
class Client {
  async request() {}
}

使用 async 关键字可以让函数拥有异步特征,但是却是同步阻塞的方式求值的。得益于这一点,开发者可以按照更加顺序的思维方式构建异步代码。

async 函数在参数和闭包方面仍然具有普通函数的正常行为,如下代码中 foo 函数仍然会在后面的代码之前求值:

async function foo() {
  console.log(1)
}
foo()
console.log(2)
// 1
// 2

async 函数如果使用 return 关键字返回了值,这个值将会被 Promise.resolve() 包装成一个期约对象,如果没有 return 则默认返回 undefined(undefined也将被 Promise.resolve() 包装)。所以 async 函数始终返回一个期约对象,在外部调用这个异步函数将得到它所返回的期约:

async function foo() {
  console.log(1)
  return 3
  // return Promise.resolve(3) // 直接返回一个期约也是一样的
}
foo().then(console.log)
console.log(2)
// 1
// 2
// 3

异步函数的返回值期待(但实际上并不要求)一个实现 thenable 接口的对象,常规的值也可以。 如果返回的是实现 thenable 接口的对象,则这个对象可以由提供给 then() 的处理程序"解包"。如果 不是,则返回值就被当作已经解决的期约。

async function foo() {
  const objWithThenable = {
    then(callback) { callback && callback('解包得到了我') }
  }
  return objWithThenable
}
foo().then(console.log) // 解包得到了我

与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约:

async function foo() {
  console.log(1)
  throw 3
}
foo().catch(console.log)
console.log(2)
// 1
// 2
// 3

不过,拒绝期约的错误不会被异步函数捕获:

async function foo() {
  console.log(1)
  Promise.reject(3)
}
foo().catch(console.log)
console.log(2)
// 1
// 2
// Uncaught (in promise): 3

await

由于异步函数主要针对的是不会马上完成的任务,所以暂停和恢复执行的能力就显得格外重要了。使用 await 关键字可以暂停异步函数中的代码,等待期约来解决。

async function foo() {
  const p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 'OK'))
  console.log(await p)
}
foo()
// OK

await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。await 关键字同样是尝试"解包"对象的值,然后将这 个值传给表达式,再异步恢复异步函数的执行。

await 关键字用法跟 JavaScript 一元操作符一样,它可以单独使用,也可以在表达式中使用。

// 异步打印"foo"
async function foo() {
 console.log(await Promise.resolve('foo'))
}
foo()
// foo
// 异步打印"bar"
async function bar() {
 return await Promise.resolve('bar')
}
bar().then(console.log)
// bar
// 1000 毫秒后异步打印"baz"
async function baz() {
 await new Promise((resolve, reject) => setTimeout(resolve, 1000))
 // 这里将阻塞后面这句代码 1000 毫秒
 console.log('baz')
}
baz()
// baz(1000 毫秒后)

await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如 果是实现 thenable 接口的对象,则这个对象可以由 await 来"解包"。如果不是,则这个值就被当作 已经解决的期约。

前面的例子中,异步函数中的 Promise.reject() 不会被异常捕获函数捕获,而会直接抛出未捕获错误。但是对其使用 await 之后将可以将拒绝的期约返回,以便正常捕获错误。

async function foo() {
  console.log(1)
  await Promise.reject(3)
  console.log(4) // 这句将不会被执行
}
foo().catch(console.log)
console.log(2)
// 1
// 2
// 3

await 的限制

未完待续...

1940

文章版权所有:PORK's BLOG,采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。

欢迎分享,转载务必保留出处及原文链接