2.2 顺序的大脑
我非常确定大多数人都听到过别人自称“能一心多用”。人们试图让自己成为多任务执行者的努力有各种方式,包括从搞笑(比如小孩玩的拍脑袋然后揉肚子这样声东击西的游戏招数)到日常生活(边走路边嚼口香糖),再到十分危险的行为(边开车边发短信)。
但是,我们真的能一心多用吗?我们真的能同时执行两个有意识的、故意的动作,并对二者进行思考或推理吗?我们最高级的大脑功能是以并行多线程的形式运行的吗?
答案可能出乎你的意料:很可能并不是这样。
看起来我们的大脑并不是以这样的方式构建起来的。很多人(特别是 A 型人)可能不愿意承认,但我们更多是单任务执行者。实际上,在任何特定的时刻,我们只能思考一件事情。
我这里所说的并不是所有我们不自觉、无意识地自动完成的脑功能,比如心跳、呼吸和眨眼等。对维持生命来说,这些是至关重要的,但我们并不需要有意识地分配脑力来执行这些任务。谢天谢地,当我们忙于在 3 分钟内第 15 次查看社交网络更新时,我们的大脑在后台(多线程!)执行了所有这些重要任务。
我们在讨论的是此时处于意识前端的那些任务。对我来说,此时此刻的任务就是编写本书。就在此刻,我还在执行任何其他更高级的脑功能吗?不,并没有。我很容易分心,并且频繁地分心——写前面几段的时候就分心了几十次!
我们在假装并行执行多个任务时,实际上极有可能是在进行快速的上下文切换,比如与朋友或家人电话聊天的同时还试图打字。换句话说,我们是在两个或更多任务之间快速连续地来回切换,同时处理每个任务的微小片段。我们切换得如此之快,以至于对外界来说,我们就像是在并行地执行所有任务。
这听起来是不是和异步事件并发机制(比如 JavaScript 中的形式)很相似呢?!如果你还没意识到的话,就回头把第 1 章再读一遍吧!
实际上,把广博复杂的神经学简化(即误用)为一种这里我足以讨论的形式就是,我们大脑的工作方式有点类似于事件循环队列。
如果把我打出来的每个字母(或单词)看作一个异步事件,那么在这一句中我的大脑就有几十次机会被其他某个事件打断,比如因为我的感官甚至随机思绪。
我不会在每次可能被打断的时候都转而投入到其他“进程”中(这值得庆幸,否则我根本没法写完本书!)。但是,中断的发生经常频繁到让我觉得我的大脑几乎是不停地切换到不同的上下文(即“进程”)中。很可能 JavaScript 引擎也是这种感觉。
2.2.1 执行与计划
好吧,所以我们的大脑可以看作类似于单线程运行的事件循环队列,就像 JavaScript 引擎那样。这个比喻看起来很贴切。
但是,我们的分析还需要比这更加深入细致一些。显而易见的是,在我们如何计划各种任务和我们的大脑如何实际执行这些计划之间,还存在着很大的差别。
再一次用此书的写作进行类比。此刻,我心里大致的计划是写啊写啊一直写,依次完成我脑海中已经按顺序排好的一系列要点。我并没有将任何中断或非线性的行为纳入到我的写作计划中。然而,尽管如此,实际上我的大脑还是在不停地切换状态。
虽然在执行的层级上,我们的大脑是以异步事件方式运作的,但我们的任务计划似乎还是以顺序、同步的方式进行:“我要先去商店,然后买点牛奶,然后去一下干洗店。”
你会注意到,这个较高层级的思考(计划)过程看起来并不怎么符合异步事件方式。实际上,我们认真思考的时候很少是以事件的形式进行的。取而代之的是,我们按照顺序(A,然后 B,然后 C)仔细计划着,并且会假定有某种形式的临时阻塞来保证 B 会等待 A 完成,C 会等待 B 完成。
开发者编写代码的时候是在计划一系列动作的发生。优秀的开发者会认真计划。“我需要把 z 设为 x 的值,然后把 x 设为 y 的值”,等等。
编写同步代码的时候,语句是一条接一条执行的,其工作方式非常类似于待办任务清单。
// 交换x和y(通过临时变量z) z = x; x = y; y = z;
这三条语句是同步执行的,所以 x = y 会等待 z = x 执行完毕,然后 y = z 等待 x = y 执行完毕。换个说法就是,这三条语句临时绑定按照特定顺序一个接一个地执行。谢天谢地,这里我们不需要处理异步事件的细节。如果需要的话,代码马上就会变得复杂得多!
所以,如果说同步的大脑计划能够很好地映射到同步代码语句,那么我们的大脑在规划异步代码方面又是怎样的呢?
答案是代码(通过回调)表达异步的方式并不能很好地映射到同步的大脑计划行为。
实际上你能想象按照以下思路来计划待办任务吗 ?
“我要去商店,但是路上肯定会接到电话。‘嗨,妈妈。’然后她开始说话的时候,我要在 GPS 上查找商店的地址,但是 GPS 加载需要几秒钟时间,于是我把收音机的音量关小,以便听清妈妈讲话。接着我意识到忘了穿外套,外面有点冷,不过没关系,继续开车,继续和妈妈打电话。这时候安全带警告响起,提醒我系好安全带。‘是的,妈妈,我系着安全带呢。我一直都有系啊!’啊,GPS 终于找到方向了,于是……”
如果我们这样计划一天中要做什么以及按什么顺序来做的话,事实就会像听上去那样荒谬。但是,在实际执行方面,我们的大脑就是这么运作的。记住,不是多任务,而是快速的上下文切换。
对我们程序员来说,编写异步事件代码,特别是当回调是唯一的实现手段时,困难之处就在于这种思考 / 计划的意识流对我们中的绝大多数来说是不自然的。
我们的思考方式是一步一步的,但是从同步转换到异步之后,可用的工具(回调)却不是按照一步一步的方式来表达的。
这就是为什么精确编写和追踪使用回调的异步 JavaScript 代码如此之难:因为这并不是我们大脑进行计划的运作方式。
唯一比不知道代码为什么崩溃更可怕的事情是,不知道为什么一开始它是工作的!这就是经典的“纸牌屋”心理:“它可以工作,可我不知道为什么,所以谁也别碰它!”你可能听说过“他人即地狱”(萨特)这种说法,对程序员来说则是“他人的代码即地狱”。而我深信不疑的是:“不理解自己的代码才是地狱。”回调就是主要元凶之一。
2.2.2 嵌套回调与链式回调
考虑:
listen( "click", function handler(evt){ setTimeout( function request(){ ajax( "http://some.url.1", function response(text){ if (text == "hello") { handler(); } else if (text == "world") { request(); } } ); }, 500) ; } );
你很可能非常熟悉这样的代码。这里我们得到了三个函数嵌套在一起构成的链,其中每个函数代表异步序列(任务,“进程”)中的一个步骤。
这种代码常常被称为回调地狱 (callback hell),有时也被称为毁灭金字塔 (pyramid of doom,得名于嵌套缩进产生的横向三角形状)。
但实际上回调地狱与嵌套和缩进几乎没有什么关系。它引起的问题要比这些严重得多。本章后面的内容会就此类问题的现象和原因展开讨论。
一开始我们在等待 click 事件,然后等待定时器启动,然后等待 Ajax 响应返回,之后可能再重头开始。
一眼看去,这段代码似乎很自然地将其异步性映射到了顺序大脑计划。
首先(现在 )我们有:
listen( "..", function handler(..){ // .. } );
然后是将来 ,我们有:
setTimeout( function request(..){ // .. }, 500) ;
接着还是将来 ,我们有:
ajax( "..", function response(..){ // .. } );
最后(最晚的将来 ),我们有:
if ( .. ) { // .. } else ..
但以这种方式线性地追踪这段代码还有几个问题。
首先,例子中的步骤是按照 1、2、3、4……的顺序,这只是一个偶然。实际的异步 JavaScript 程序中总是有很多噪声,使得代码更加杂乱。在大脑的演习中,我们需要熟练地绕过这些噪声,从一个函数跳到下一个函数。对于这样满是回调的代码,理解其中的异步流不是不可能,但肯定不自然,也不容易,即使经过大量的练习也是如此。
另外,其中还有一个隐藏更深的错误,但在代码例子中,这个错误并不明显。我们另外设计一个场景(伪代码)来展示这一点:
doA( function(){ doB(); doC( function(){ doD(); } ) doE(); } ); doF();
尽管有经验的你能够正确确定实际的运行顺序,但我敢打赌,这比第一眼看上去要复杂一些,需要费一番脑筋才能想清楚。实际运行顺序是这样的:
· doA()
· doF()
· doB()
· doC()
· doE()
· doD()
你第一眼看到前面这段代码就分析出正确的顺序了吗?
好吧,有些人可能会认为我的函数命名有意误导了大家,所以不怎么公平。我发誓,我只是按照从上到下的出场顺序命名的。不过还是让我再试一次吧:
doA( function(){ doC(); doD( function(){ doF(); } ) doE(); } ); doB();
现在,我是按照实际执行顺序来命名的。但我还是敢打赌,即使对这种情况有了经验,也不能自然而然地就追踪到代码的执行顺序 A → B → C → D → E → F 。显然,你需要在代码中不停地上下移动视线,对不对?
但即使你能够很轻松地得出结论,还是有一个可能导致严重问题的风险。你能够指出这一点吗?
如果 doA(..) 或 doD(..) 实际并不像我们假定的那样是异步的,情况会如何呢?啊,那顺序就更麻烦了。如果它们是同步的(或者根据程序当时的状态,只在某些情况下是同步的),那么现在运行顺序就是 A → C → D → F → E → B 。
现在你听到的背景中模糊的声音就是无数 JavaScript 开发者的掩面叹息。
问题是出在嵌套上吗?是它导致跟踪异步流如此之难吗?确实,部分原因是这样。
但是,让我们不用嵌套再把前面的嵌套事件 / 超时 /Ajax 的例子重写一遍吧:
listen( "click", handler ); function handler() { setTimeout( request, 500 ); } function request(){ ajax( "http://some.url.1", response ); } function response(text){ if (text == "hello") { handler(); } else if (text == "world") { request(); } }
这种组织形式的代码不像前面以嵌套 / 缩进的形式组织的代码那么容易识别了,但是它和回调地狱一样脆弱,易受影响。为什么?
在线性(顺序)地追踪这段代码的过程中,我们不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以“查看”流程。而且别忘了,这还是简化的形式,只考虑了最优情况。我们都知道,真实的异步 JavaScript 程序代码要混乱得多,这使得这种追踪的难度会成倍增加。
还有一点需要注意:要把步骤 2、步骤 3 和步骤 4 连接在一起让它们顺序执行,只用回调的话,代价可以接受的唯一方式是把步骤 2 硬编码到步骤 1 中,步骤 3 硬编码到步骤 2 中,步骤 4 硬编码到步骤 3 中,以此类推。如果实际上步骤 2 总会引出步骤 3 是一个固定条件的话,硬编码本身倒不一定是坏事。
但是,硬编码肯定会使代码更脆弱一些,因为它并没有考虑可能导致步骤执行顺序偏离的异常情况。比如,如果步骤 2 失败,就永远不会到达步骤 3,不管是重试步骤 2,还是跳转到其他错误处理流程,等等。
这些问题都可以通过在每个步骤中手工硬编码来解决,但这样的代码通常是重复的,并且在程序中的其他异步流中或其他步骤中无法复用。
尽管我们的大脑能够以顺序的方式(这个,然后这个,然后这个)计划一系列任务,但大脑运作的事件化的本质使得控制流的恢复 / 重试 / 复制几乎不费什么力气。如果你出外办事的时候发现把购物清单落在了家里,那么这一天并不会因为你没有预知到这一点就成为世界末日了。你的大脑很容易就能针对这个小意外做出计划:回家拿清单,然后立刻返回商店就是了。
但是,手工硬编码(即使包含了硬编码的出错处理)回调的脆弱本性可就远没有这么优雅了。一旦你指定(也就是预先计划)了所有的可能事件和路径,代码就会变得非常复杂,以至于无法维护和更新。
这才是回调地狱的真正问题所在!嵌套和缩进基本上只是转移注意力的枝节而已。
如果这还不够的话,我们还没有提及两个或更多回调 continuation 同时发生的情况,或者如果步骤 3 进入了带有 gate 或 latch 的并行回调的分支,还有……不行,我脑子转不动了,你怎么样?!
现在你抓住重点了吗?我们的顺序阻塞式的大脑计划行为无法很好地映射到面向回调的异步代码。这就是回调方式最主要的缺陷:对于它们在代码中表达异步的方式,我们的大脑需要努力才能同步得上。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论