大约两周前,微软宣布将 TypeScript 编译器从 JavaScript 移植到 Go,并承诺将性能提高 10 倍。这则消息迅速传遍了科技社区,从 TypeScript 粉丝到语言战争爱好者,所有人都纷纷加入进来。
这份公告以令人印象深刻的性能数据和雄心勃勃的目标为开端,不过一些有趣的细节仍未得到探索。
在标题数字的背后隐藏着一个值得解读的关于设计选择、性能权衡和开发工具的演变的故事。
即使您对编译器不感兴趣,也可以从中获得适合您系统设计的经验教训,例如:
- 超越标题绩效声明
- 将您的技术与您的问题领域相匹配
- 了解您的运行时模型。
- 随着项目的发展重新考虑基础。
“速度快 10 倍的 TypeScript”——其实并非如此
首先,让我们澄清一下微软公告标题中可能引起混淆的一些内容:
“速度提高 10 倍的 TypeScript。”
它真的更快吗?一旦 MS 发布用 Go 重写的新代码,你用 TypeScript 编写的应用程序是否会快 10 倍?
实际上,速度变快的是 TypeScript 编译器,而不是 TypeScript 语言本身或 JavaScript 的运行时性能。您的 TypeScript 代码将编译得更快,但它在浏览器或 Node.js 中的执行速度不会突然提高 10 倍。
这有点像说,
“我们让你的车速提高 10 倍!”
然后澄清说他们只是加快了制造过程——汽车本身仍然以相同的速度行驶。它仍然很有价值,特别是如果你已经等了好几个月才拿到车,但并不完全像标题所暗示的那样。
超越“10 倍速度”的标题
Anders Hejlsberg 的声明展示了令人印象深刻的数字:
展示了不同代码库的性能对比。表格包含以下列:
- Codebase:代码库的名称。
- Size (LOC):代码库的规模,以行数(Lines of Code,LOC)表示。
- Current:当前代码库的执行时间,以秒(s)为单位。
- Native:原生代码的执行时间,以秒(s)为单位。
- Speedup:相对于原生代码的性能提升倍数。
- VS Code:代码规模为1,505,000行,当前执行时间为77.8秒,原生执行时间为7.5秒,性能提升为10.4倍。
- Playwright:代码规模为356,000行,当前执行时间为11.1秒,原生执行时间为1.1秒,性能提升为10.1倍。
- TypeORM:代码规模为270,000行,当前执行时间为17.5秒,原生执行时间为1.3秒,性能提升为13.5倍。
- date-fns:代码规模为104,000行,当前执行时间为6.5秒,原生执行时间为0.7秒,性能提升为9.5倍。
- tRPC (server + client):代码规模为18,000行,当前执行时间为5.5秒,原生执行时间为0.6秒,性能提升为9.1倍。
- rxjs (observable):代码规模为2,100行,当前执行时间为1.1秒,原生执行时间为0.1秒,性能提升为11.0倍。
如此惊人的数据值得深入研究,因为 10 倍的加速有多种因素。这不仅仅是“Go 比 JavaScript 快”这么简单。
说实话:每当你看到“快 10 倍”的说法时,你都应该保持健康的怀疑态度。我的第一反应是,“好吧,他们之前做的哪些事情不够理想?”因为 10 倍的改进不是凭空而来的——它们通常表明某些事情一开始就做得不够好。无论是实施还是某些设计选择。
人们普遍认为“Node.js 很慢”,但这更像是一种刻板印象,而不是事实。在某些情况下,这可能是真的,但这不是普遍的说法。
如果有人说“Node.js 很慢”,这几乎就像他们说 C 和 C++ 很慢一样。为什么?
Node.js 的架构
Node.js 建立在 Google 的V8 JavaScript 引擎之上,该引擎与 Chrome 的高性能引擎相同。V8 引擎本身是用 C++ 编写的,而 Node.js 本质上围绕它提供了一个运行时环境。这种架构是理解 Node.js 性能的关键:
- V8 引擎:使用即时 (JIT) 编译技术将 JavaScript 编译为机器代码
- libuv:处理异步 I/O 操作的 C 库
- 核心库:许多库都用 C/C++ 编写,以提高性能。这也是为什么 Node.js、Go 和 Rust 中使用的数据库连接器的性能几乎没有显著差异的原因。
- JavaScript API :这些本机实现的薄包装器
有趣的事实:多年来,Node.js 一直是速度最快的 Web 服务器技术之一。它首次出现时,在基准测试中的表现优于许多传统的线程式 Web 服务器,尤其是在高并发、低计算工作负载方面。这并非偶然,而是经过精心设计。
内存受限与 CPU 受限
Node.js 是专门为 Web 服务器和网络应用程序设计的,它们主要受内存和 I/O 限制,而不是受 CPU 限制:
内存绑定操作涉及移动数据、转换数据或存储/检索数据。想想:
- 解析 JSON 负载
- 转换数据结构
- 路由 HTTP 请求
- 格式化响应数据
- 数据库查询
- 网络请求
- 文件系统操作
- 外部 API 调用
- 接收 HTTP 请求(内存限制)
- 解析请求数据(内存受限)
- 查询数据库(I/O 密集型,大部分时间等待)
- 处理结果(受内存限制)
- 格式化响应(受内存限制)
- 发送 HTTP 响应(I/O 绑定)
Node.js 正是针对这种场景进行了优化,因为:
- 非阻塞 I/O :在等待 I/O 操作时,Node.js 可以处理其他请求
- C++ 基础:内存操作委托给高效的 C++ 实现
- 事件循环效率:擅长以最小的开销协调许多并发操作
这种架构使 Node.js 成为 Web 服务器领域的革命性产品。如果使用得当,单个 Node.js 进程可以高效处理数千个并发连接,对于典型的 Web 工作负载,其性能通常优于每个请求一个线程的模型。
它本质上源于这样一种观点:像人工任务一样,多任务处理并不总是处理任务的最佳方式。同步多个任务和上下文切换会增加开销,而且并不总是能带来更好的结果。这完全取决于任务是否可以拆分成可以同时完成的较小部分。
Node.js 面临的挑战:CPU 密集型操作
Node.js 真正的性能挑战是 CPU 密集型任务 — — 例如(再次欢迎!)编译 TypeScript。这些工作负载与 Node.js 优化的 Web 服务器场景具有根本不同的特征。
CPU 密集型操作涉及大量计算且等待时间最短:
- 复杂的算法和计算
- 解析和分析大文件
- 图像/视频处理
- 编译代码
单线程限制
JavaScript 采用单线程事件循环模型设计。此模型最适合处理并发 I/O(大部分时间都花在等待上),但对于 CPU 密集型操作来说会出现问题:
// Pseudocode of how the Node.js event loop works |
当 CPU 密集型任务运行时,它会独占这个单线程。在此期间,Node.js 无法处理其他事件、处理新请求,甚至无法响应现有请求。它实际上被阻塞,直到计算完成。
这就是为什么在 Node.js 中运行复杂算法会导致整个 Web 服务器无响应的原因——事件循环忙于计算,无法处理传入的请求。
事件循环
用 JavaScript 编写高效的 CPU 密集型代码需要理解并遵循事件循环。代码必须结构化以产生控制权,从而允许其他操作定期进行:
////////////////////////////////////////////// |
这种“分块”方法有效,但会带来复杂性,并从根本上改变代码结构。这是一种与具有本机线程的语言截然不同的编程模型,在这种语言中,您可以编写简单的 CPU 密集型代码,而不必担心阻塞其他操作。
对于像 TypeScript 编译器这样的复杂应用程序,随着代码库的增长,与事件循环的这种互动变得越来越难以管理。
另请参阅:
编译器:CPU 密集型野兽
编译器实际上是 CPU 密集型工作负载的典型代表。它需要:
- 将源代码解析为标记和抽象语法树
- 执行复杂的类型检查和推断
- 应用转换和优化
- 生成输出代码
尤其是对于 TypeScript,随着语言多年来变得越来越复杂和强大,编译器必须处理越来越复杂的类型检查、推断和代码生成。这一发展自然突破了 JavaScript 运行时效率的极限。
线程模型很重要:事件循环与本机并发
JavaScript 和 Go 实现之间的性能差距不仅仅在于原始语言速度——它从根本上在于线程模型以及它们如何匹配问题域。
如上所述,Node.js 在事件循环模型上运行:
┌───────────────────────────┐ |
这种单线程方法意味着,对于像编译 TypeScript 这样的 CPU 密集型工作,您需要编写不独占线程的代码。实际上,这涉及将工作分解为较小的块,以便将控制权交还给事件循环。
对于编译器来说,这带来了重大的设计挑战:
- 人为碎片化:编译器阶段(解析→分析→转换→生成)的自然流程需要分解为可以产生的小步骤。
- 复杂的状态管理:由于处理在事件循环迭代之间是分散的,因此必须在收益之间仔细管理和保存编译器状态。
- 局部性破坏:当事件循环在编译器操作之间处理不相关的任务时,CPU 缓存局部性优势就会丢失,从而损害性能。
- 依赖性挑战:编译器的组件之间存在复杂的相互依赖关系。打破自然顺序的过程以适应事件循环通常需要复杂的协调逻辑。
Go:使用 Goroutines 实现本机并发
相比之下,Go 提供了 goroutines——由 Go 运行时管理的轻量级线程:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ |
该模型允许编译器阶段以最小的协调开销自然并行化:
- 自然并行:可以同时解析和检查不同的文件。
- 直接线程访问:CPU 密集型操作可以直接在线程上运行而无需让步。
- 高效协调:Go 的通道和同步原语旨在协调并发工作。
- 内存效率:与 OS 线程(每个 MB)相比,Goroutines 使用的内存最少(每个几 KB)。
更多内容请阅读:
相同的代码,不同的执行模型
Anders Hejlsberg 表示,他们评估了多种语言,而 Go 是最容易将代码库移植到的语言。TypeScript团队显然已经创建了一个生成 Go 代码的工具,因此移植到 Go 代码时,在许多地方几乎是逐行等效的。
这可能会让您认为代码“在做同样的事情”,但这是一种误解。
代码可能看起来相同,但由于执行模型的不同,不同语言的行为可能会有很大差异。
在 JavaScript 中:
- 所有代码都在一个带有事件循环的单线程上运行,
- 长时间运行的操作需要分解或委托给工作线程,
- 并发执行需要小心处理以避免阻塞事件循环。
- 代码自然地跨多个 goroutine(轻量级线程)运行,
- 长时间运行的操作可以在不阻塞其他工作的情况下执行,
- 该语言和运行时是为并发执行而设计的。
由此可以得出这样的结论:当直接移植带来显著的性能提升时,这可能表明原始实现并未针对 JavaScript 的执行模型进行完全优化。
编写真正高性能的 JavaScript 意味着接受其异步性和事件循环约束——这在像编译器这样的复杂代码库中变得越来越具有挑战性。
但是工作线程怎么办?
你们中有些人可能会想:
“Node.js 现在没有工作线程了吗?难道他们不能使用这些而不是迁移到 Go 吗?”
这个问题问得好。
Node.js 工作线程worker_threads在 v10 中引入了该模块,因为一项实验性功能在 v12(2019 年中)中变得稳定。工作线程在 Node.js 中提供了真正的并行性,允许 CPU 密集型任务在单独的线程上运行,而不会阻塞主事件循环。
Node.js 中的工作线程如何工作
与大多数 Node.js 应用程序的单线程事件循环不同,工作线程允许 JavaScript 并行执行:
// main.js |
每个工作线程都在自己的 V8 实例中运行,并拥有自己的 JavaScript 堆,从而实现真正的并行。
工作线程通过发送和接收消息与主线程(以及彼此)进行通信,这可以包括转移某些数据类型的所有权以避免复制。
为什么工作线程不是 TypeScript 的解决方案?
那么为什么 TypeScript 团队会选择使用 Go 而不是工作线程来改造现有的代码库呢?
我们不知道,但有几个合理的理由:
- 遗留代码库挑战:TypeScript 编译器已经开发了十多年。用多线程架构改造为单线程执行设计的大型成熟代码库通常比从头开始更复杂。工作者主要通过消息传递进行通信。重构编译器以以这种方式运行需要从根本上重新设想组件的交互方式。视频显示,即使是单线程 Go 也更快,因此代码可能仍需要修改以更好地处理事件循环特性。
- 数据共享复杂性:工作者共享内存的能力有限。编译器操纵复杂、相互关联的数据结构(抽象语法树、类型系统等),这些数据结构无法整齐地划分为独立的块以进行并行处理。
- 性能开销:虽然工作线程提供了并行性,但它们也会带来开销。每个工作线程都有自己的 V8 实例和单独的内存,并且线程之间传递的数据通常需要序列化和反序列化。它们不像线程或 goroutine 那样轻量级。
- 时间线不匹配:当 TypeScript 编译器被设计和实现时(大约在 2012 年),Node.js 中还不存在工作线程。早期做出的架构决策会假设单线程模型,这会使后期的并行化更加困难。
- 死胡同评估:团队可能已经得出结论,即使有了工作线程,JavaScript 仍然会对其特定工作负载施加根本的限制,而这些限制最终会再次成为瓶颈。
- 技能组合协调:该决定可能部分反映了组织的专业知识和与其他开发工具的战略协调。
Go 的方法与工作线程
对于编译器工作负载而言,Go 的并发方法比 Node.js 工作线程有几个优势:
- 轻量级 Goroutines :与工作线程(需要单独的 V8 实例)相比,Goroutines 非常轻量(从约 2KB 内存开始),使细粒度并行更加实用。
- 共享内存模型:Go 允许使用同步原语在 goroutine 之间直接共享内存,从而更容易处理复杂、互连的数据结构。
- 语言级并发:Go 语言级别内置了并发功能,包括 goroutines 和 channel,使得并行代码更易于编写和推理。
- 更低开销的通信:goroutines 之间的通信比工作线程通信所需的序列化/反序列化效率高得多。
- 成熟的调度程序:Go 的运行时包含一个成熟、高效的调度程序,用于管理可用 CPU 核心上的数千个 goroutine。
进化问题
2012 年 TypeScript 刚开始的时候,团队根据当时的背景做出了合理的技术选择:
- TypeScript 是 Microsoft 的一个扩展 JavaScript 的项目,因此使用 JavaScript 是合理的
- 最初的范围和复杂性要小得多
- Go 和 Rust 等替代方案仍处于早期阶段
- 性能要求比较适中
这是一个典型的例子,说明成功的软件经常面临早期设计时未曾预料到的扩展挑战。
随着 TypeScript 编译器变得越来越复杂并应用于更大的代码库,其 JavaScript 基础变得越来越严格。
未决问题和未来考虑
虽然 Go 迁移带来的性能改进令人印象深刻,但微软的公告中尚未完全解决几个重要问题:
浏览器支持情况如何?
TypeScript 不仅在服务器和开发机器上运行,还可通过各种游乐场实现和浏览器内 IDE 直接在浏览器中使用。由于 Go 无法在浏览器中本地运行,微软将如何解决这一用例?
有几种潜在的方法:
- WebAssembly (WASM) :将 Go 实现编译为 WebAssembly 可以使其在浏览器中运行。虽然 WASM 性能已大幅提升,但与原生 Go 相比仍有开销。
- 双重实现:微软可能会为浏览器维护一个 JavaScript 版本,同时为其他所有版本维护 Go 版本。这将给功能对等性和维护带来挑战。
- 特定于浏览器的替代方案:他们可能会创建一个精简的特定于浏览器的实现,并减少针对常见游乐场场景进行优化的功能。
- 云编译:基于浏览器的工具可能会将代码发送到运行 Go 编译器的云端点,而不是在本地执行编译。
功能对等与性能权衡
值得注意的是,实现性能改进通常需要权衡利弊。一种常见的方法是减少功能范围或复杂性。虽然微软声称他们保持了完整的功能对等性,但我们应该仔细观察是否有任何细微的行为发生变化,或者某些边缘情况是否以不同的方式处理。
其他语言迁移的历史案例表明,100% 相同的行为很难实现。以下问题值得考虑:
- 所有现有的 TypeScript 错误消息是否保持完全相同?
- 类型推断中的每个边缘情况都会表现相同吗?
- 编译选项和标志会有相同的效果吗?
- 性能优化将如何影响类型系统的特殊情况?
可扩展性和插件生态系统
TypeScript 拥有丰富的插件和工具生态系统,可以扩展编译器。
迁移到 Go 引发了人们对这个生态系统未来的担忧:
- 插件 API 是否保持兼容?
- 基于 JavaScript/TypeScript 的插件是否需要用 Go 重写?
- 这将如何影响创建 TypeScript 工具的进入门槛?
为什么这在 TypeScript 之外如此重要
本案例研究对于技术选择具有更广泛的意义:
- 将您的技术与您的问题领域相匹配。像编译器这样的 CPU 密集型任务受益于专为计算和本机线程设计的语言。像 Web 服务器这样的 IO 密集型任务通常与事件循环模型配合良好。
- 随着项目的发展,重新考虑基础。对小型项目有效的方法可能会成为大规模项目的制约因素。愿意重新审视基本的架构决策。大胆的举动没有错,比如必要时重写。但在你这样做之前,请先阅读。
- 别只看标题中的性能声明。“10 倍的提升”通常有多种因素,而不仅仅是技术堆栈的变化。
- 了解运行时模型。无论您使用 Node.js、Go、Rust 还是任何其他环境,了解代码的执行方式对于性能优化至关重要。
期待
作为 TypeScript 用户,构建 Emmett 和 Pongo 是个好消息。如果我可以“免费”让编译器运行得更快,那就太好了。但另一方面,我不明白我们为什么要大肆宣传,并使用点击诱饵将其带入开发社区。
只关注 10x 而不提供足够的背景信息只会产生摩擦(我仁慈地跳过了 C开发人员的呼喊“为什么不是 C#?!”...)。
这就是为什么我想扩展这个用例,因为它提供了有关技术、语言选择、性能优化和成功项目发展的宝贵经验教训。
从 JavaScript 转向 Go 不应被视为“Node.js 很慢”的证明。最好将其视为对不同问题需要不同工具的认可。JavaScript 和 Node.js 继续擅长于它们设计的目的:具有高并发需求的 IO 密集型 Web 应用程序。
当然,如果微软能够更详细地解释这一点,而不是提出点击诱饵声明,那就更好了,但这就是我们生活的世界。
另请查看先前版本的性能:
你怎么看?你在项目中是否遇到过类似技术演进的挑战?你是如何应对的?你是否曾获得过 10 倍的惊人改进?你是如何应对的?