本文最初发布于 acco.io 网站,经原作者授权由 InfoQ 中文站翻译并分享。
我在 2013 年编写了自己的第一个 Node 程序。(它是用 CoffeeScript 写的。)
那个时候,Node 的优势主要体现在三个方面:
第一个是“无处不在的 JavaScript”。这句话一开始的意思是“前端使用 JavaScript,后端也使用 JavaScript”,我一直觉得这个理由没那么强势。(后来它演变成了“强大就是正义”,将 JavaScript 视为一种通用语言,这样看起来就更有说服力了。)
第二个优势来自于 Node 所没有的部分。当时业界的潮流是反对过去的大一统理念的,像 Ruby on Rails 和 ASP.NET 这样的单体框架逐渐失宠。这条理由也不是很站得住脚,毕竟 Ruby 的服务条款也没强迫人们用 Rails(看看 Stripe)。
第三个优势是到目前为止最重要的。当时硅谷的主流框架(Ruby on Rails)还没有足够的并发能力,而 Node 却有着相当稳固的并发特性基础。大家都会用 JavaScript,而且回调的并发门槛比当时许多线程模型要低很多。
Node 的并发特性让它如虎添翼,迅速流行于世。但我记得就算在很早的时候,有些东西也感觉不对劲。
当时我们有一个服务,它会看上去随机地突然在 1-2 秒内莫名其妙地丢弃数百个请求。经过我对 Node 的第一次深入探索,我发现了原因所在:一个未捕获的异常杀掉了服务器上的单个进程。在那段 1-2 秒的空档期里,什么东西都没回来。
我们把过错归结为自己过早用上了新技术。但我不知道过去的几年中这种事情是否发生了很大变化。
我在 Deno v1 版本中发现了这段话,背后颇有深意:
一个 hello-world Deno HTTP 服务器每秒大约处理 25,000 个请求,最大延迟为 1.3 毫秒;对应的 Node 程序每秒处理 34,000 个请求,最大延迟在 2 到 300 毫秒之间。
我想起了自己从数据结构中学到的一课。
数据结构
我曾经和一位好友 Sacha 一起从事一个软件项目。那时他非常沉迷数据结构。当时我们在西贡,收拾好笔记本电脑去了一个偏远的柬埔寨小岛,象岛。我们和世界的联系被切断了,岛上我们住的海滩平房没有互联网接入。
那些日子我们会在白天独立工作,并在日落时分在附近的小屋中共进晚餐。在第一个晚上,我想谈论架构和算法,而 Sacha 想说的只有数据结构。
第二天晚上前,我已经开始着手打造项目的几个工作流了。晚饭时见到 Sacha,看起来好像他几乎没合过眼。他告诉我他熬了一宿,四处踱步、发散思维、画图、做实验。早晨他做了点瑜伽,然后一个白天很快就过去了。到最后,他终于在数据结构上取得了一些突破。
我当然要问了:“Sacha,为什么总要关注数据结构呢?”他的回答引用了一句名言,后来我知道是 Rob Pike 说过的话:
数据是关键。如果你选择了正确的数据结构并组织得当,那么算法往往就能自然体现出来。数据结构才是编程的核心,算法并不是。
从那一刻起,我在哪里都能体会到这个道理。如果我觉得自己的程序变得太复杂或太难读懂,那问题基本都来自于数据结构。从那时起,每次我被其他程序员的代码打动的时候,都不是因为代码用了聪明的技巧或者算法,而是因为我从代码中能看出程序员对程序数据应有结构的独到眼光。
这一原则将数据结构视为大厦的基础。如果你打下了坚实的地基,那么房子盖起来就不费吹灰之力。如果地基是垃圾堆上的一滩软泥,那么盖房子的时候就会有大麻烦在前面等着你了。
从广义上来说,这条原则适用于各种工具。你希望尽可能减少挥动大锤时用的力气,因此设计锤子时应该让锤身比手柄重很多,这样就可以发挥杠杆作用。如果你的设计是反过来的,用起来费的力气就要大很多了。
虽然并发是 Node 迅速风靡业界的核心动力,但 Node 的并发一直存在一些根本缺陷。它是一把大锤,但锤子的设计却是反过来的。
回调从来都不是最优选项,我对这一论点很有自信,因为几乎没有人在全新的领域中使用它们。
我们也可以这么说 Promise,因为 async/await 是专门用来抽象它们的。
但房子的第二层迟早会盖好的,async/await 也迟早会被抽象掉。它很反直觉,就算你习惯了也是如此。我认为一个不错的观点是红蓝函数的理念。在 JavaScript 中,红色函数(异步)可以调用蓝色函数(同步),但反过来是不行的。这两种调用的语法也不同。当引入一个红色函数时,它会在你的代码库中流血,染红许多二级和三级函数。
Async/await 和事件循环是一个奇怪的范式。很难向新手程序员解释清楚其中的机制。而且这种机制简直就像是程序员的基础没打好的时候会引入的那种算法缺陷。
认知开销
人们在研究抽象概念时,为了方便理解会把它们映射到近似的物理实体上。经过漫长的进化,我的大脑可以想象出遥远的丛林中浆果的大致数量和颜色。它的进化路线上并没有软件编程的份儿。但事实证明,大脑可以使用原本打算用在野外生存的那套神经来很好地完成编程任务。在我的脑海中,我的程序处于一个 3 维平面上,“在这里”的一个文件里的函数会调用“在那里”的一个文件中的函数。现实情况会有很大差异,但是我们创建的抽象(从文件系统到编译器再到显示内容)打通了这一桥梁。
谈到并发性时,对我的大脑而言,任务就是最优雅的映射。任务可以是 Elixir 中的一个进程,或者是 Go 中的一个 Goroutine。人脑很容易想象出一个 worker 执行一个任务的画面:
我想同时请求这个 API 的前五页,然后将结果打包在一起交付给客户端。因此我让五个小伙伴来做事,每个小伙伴都发出一个请求,向他们每个人分配一个要抓取的页面——这样就可以了。现在我只要坐下来等待每个小伙伴回来报告结果就行。
但对我来说,回调或 Promise 的想法总是需要一些额外的 CPU 资源。就像光子击中了半镀银的镜子一样:程序被拆分成两条世界线。在一条线中,控制流继续运作;在另一条线中,在未来的某个不确定的时间点,程序会执行一个回调或 promise。
Async/await 是一种折叠范式,让它更容易理解的尝试。它让你的程序在某些层面“感觉”上更同步。但这种抽象并不完美,并且放在了错误的堆栈层上。
查询数据库
在 Node 中,假设你要在一个 REPL 里查询数据库,如下所示:
这种事情总有点让人感到不爽。从理性上讲,我可以接受:没有损失,没有收获。如果我想坐上 Node 的异步火箭登陆月球,我必须接受这类情况下的反人性机制。我得到了一个 promise,而不是一个结果,所以我需要添加其他逻辑来处理这个 promise 并获得结果。
生活要是那么轻松就好了:
哎,没辙,我们都已经习惯了“这里应该用新办法做事”。
目前,由于 Async/await 的泛滥,我已经想不起 Promise 实例的 API 怎么用了。所以我只能一路回到回调上。还好回调还能用,因为 JavaScript 的“不抛弃任何人”原则会确保到我孙子的那一代,回调还能得到很好的支持:
还是没有结果。抓耳挠腮五分钟以后,我终于意识到——感谢冯·诺伊曼——我忘了调用 client.connect()。如果你在调用 client.query()之前不调用 client.connect(),pg 客户端会以静默的方式将这个查询推送到一个内部队列。这里不太容易理解,而且非常容易让人烦躁——记住我们的基础是有缺陷的。
所以到最后我调用了 connect(),然后是 query(),然后终于得到一个结果(夹在这里面……):
在我这么多年只编写 Node 程序的日子里,我永远不会忘记当我第一次在 Elixir 的 REPL,iex 中做一个 SQL 查询的那一刻。
我启动了一个与远程 Postgres 数据库的连接:
我输入查询内容:
我按下了回车键:
你知道 REPL 接下来做了什么吗?它卡了。零点几秒的时间,这只美丽的小虫子就卡在那里。我永远不会忘记,它看起来像这样,持续了几毫秒:
我感觉很难喘上气,然后我的结果出来了:
Elixir 怎么能避免它呢?像这样的 I/O 操作不就是你用到 async 的地方吗?我是否以某种方式在 REPL 中关闭了异步?难道 Elixir 不是异步的吗?
不,Elixir 可以避免这种情况,因为它是建立在 Erlang/OTP 之上的,而 Erlang/OTP 具有很好的并发性。
从一开始,并发及支持它的流程就已经成为 OTP 的一部分。不是作为一个特性,而是其存在的一部分。
当我运行上面的 Postgrex.start_link 时,这个函数会向我返回一个 pid,我将其存储在变量 conn 中。pid 是一个地址。在这里,Postgrex 启动了一个进程,该进程管理与我的 Postgres 数据库的连接。这个进程在后台某处运行,pid 是指向该进程的指针。
当我运行 Postgrex.query(conn, statement)时,我传递给 query/2 的第一个参数是连接进程的 pid。REPL——也就是我正在输入的进程——将 statement 作为一条消息发给连接进程。
这里很容易想象出两个朋友之间互相发消息的画面。对我这个程序员来说,很重要的一点是 REPL 会高兴地等待,直到它从连接进程中收到回复。对 query/2 的调用是同步的,之所以是同步的,是因为它可以是同步的。
实际上,每当一个进程执行任何操作时,它始终是同步的。在本地级别,Elixir/Erlang 程序员一直都在考虑同步、功能简化。在向其他进程发送和接收消息时也是一样。(而且完全用不着红色/蓝色函数二分法。)
在 Elixir 和 Erlang 中,并发不是在函数层发生,而是在模块层发生。你可以将模块实例化为一个进程,现在它与其他进程并发运行。每个进程都保持自己的状态,并且可以与其他进程来回传递消息。
你最后得到的并发范式是人们可以轻松与现实映射并理解的。我感到自己是在正确的堆栈层理解并发的。
实际上,对于 Elixir/Erlang 程序员而言,正确地建模进程模块与正确地建模数据结构是一样重要的。我认为这就是为什么这么多的人将这些语言描述为“乐在并发中”的原因所在。这就是当你在数据结构方面取得突破,干掉 400 行的复杂逻辑时获得的那种喜悦。
创造 vs 进化
思考一下 Elixir 的历史:首先是由 Joe Armstrong 和他的团队发明的 Erlang,旨在解决电信行业带来的巨大并发挑战。Erlang 经过了多年的实战测试,然后 José Valim 和他的团队创建了 Elixir,其唯一目标是通过大量借鉴有史以来最人性化的那些语言的优点,来做出一种更为人性化的产物。
当然,你最后会得到一些用起来非常愉快的独特体验。
像 Elixir 和 Ruby 之类的语言都是创造的行为。例如 Ruby 就只有一位创造者和设计师(Matz)。你从第一次接触该语言时就可以感受到他的感情。它很友好,用起来很舒服,什么内容都适得其所,有理有据。Ruby 的最小惊讶原则让一切都井井有条。
JavaScript 恰恰相反:JavaScript 是一种进化。Node 在每个层面,对所有人而言都充满惊奇的事情。JavaScript 总能在细微之处找到破坏你、羞辱你的方法。它不是一个人的设计作品,只是自然选择的冷酷结果。它到处都是神秘的进化怪癖。不管是好是坏,它都是完全民主的结果,是人民的语言。
JavaScript 的历史是复杂而深刻的,也许有一天世界会坍缩为一个奇点,我等不及看到那一天的来临了。
但强大并不等于正义,我很高兴自己终于摆脱它的束缚了。
评论 8 条评论