17611538698
info@21cto.com

TypeScript 迁移到 Go:性能提升 10 倍的真正原因是什么?

图片

导读:微软的海尔斯伯格将TypeScript编译器从JS迁移到Go语言,这里面的本质原因是什么。

大概在一个月前,微软宣布将把 TypeScript 编译器从 JavaScript 移植到 Go,并承诺性能将提升惊人的 10 倍这一消息迅速在技术社区传播开来,从 TypeScript 的拥趸到语言之争的参与者,纷纷发表了自己的看法。

这份公告以令人印象深刻的业绩数据和雄心勃勃的目标开篇,但一些引人入胜的细节仍未揭晓。我们今天就来探讨这些问题。为什么是今天而不是一月前?嗯,我们不想仓促发布标题党文章,对吧?我特意等到风头过去后再发布。

在这些数据背后,隐藏着一个值得深入探讨的故事,其中涉及设计选择、性能权衡以及开发者工具的演变。

即使你对编译器不感兴趣,也能从中汲取一些对系统设计有益的经验教训,例如:

  • 超越表面声明

  • 将技术与问题领域相匹配

  • 了解你的运行时模型。

  • 随着项目的发展,需要重新考虑项目的基础架构。


让我们一步一步来解决这个问题,首先从微软的文章标题开始。

“速度提升 10 倍的 TypeScript”——其实,并非如此。首先,让我们澄清一下微软公告标题中可能令人困惑的地方:

“速度提升 10 倍的 TypeScript。”

真的会更快吗?微软发布用 Go 重写的新代码后,你用 TypeScript 编写的应用程序速度会提升 10 倍吗?

并非真正的 GIF | Tenor


实际上速度提升的是 TypeScript 编译器,而不是 TypeScript 语言本身或 JavaScript 的运行时性能。你的 TypeScript 代码编译速度会更快,但它在浏览器或 Node.js 中的执行速度不会突然提升 10 倍。

这有点像在说:

“我们让你的车速提升了10倍!”

然后他们澄清说,他们只是加快了生产流程——汽车本身的行驶速度并没有改变。这仍然很有价值,尤其如果你已经等了好几个月才拿到车的话,但与标题所暗示的并不完全一样。

超越“速度提升10倍”的标题


安德斯·海尔斯伯格公布的数据令我们印象深刻:


图片


如此惊人的数据值得深入分析,因为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 性能的关键:


  1. V8 引擎:使用即时 (JIT) 编译技术将 JavaScript 编译成机器代码

  2. libuv:处理异步 I/O 操作的 AC 库

  3. 核心库:很多核心库为了提升性能,都是用 C/C++ 编写的。这也是为什么 Node.js、Go 和 Rust 中使用的数据库连接器性能差异不大的原因。

  4. JavaScript API :对这些原生实现的轻量级封装


人们谈论 Node.js 时,往往没有意识到他们谈论的是一个关键操作由高度优化的 C/C++ 代码执行的系统。你的 JavaScript 代码通常只是负责协调对这些原生实现的调用。

有趣的是:Node.js 多年来一直是速度最快的 Web 服务器技术之一。它刚问世时,在基准测试中就超越了许多传统的线程式 Web 服务器,尤其是在高并发、低计算量的工作负载方面。这并非偶然,而是精心设计的。

内存密集型与 CPU 密集型


Node.js 是专门为 Web 服务器和网络应用程序设计的,这些应用程序主要受内存和 I/O 限制,而不是 CPU 限制:


内存密集型操作包括移动数据、转换数据或存储/检索数据。例如:


  • 解析 JSON 有效负载

  • 转换数据结构

  • 路由 HTTP 请求

  • 格式化响应数据


I/O密集型操作涉及等待外部系统:

  • 数据库查询

  • 网络请求

  • 文件系统操作

  • 外部 API 调用


对于典型的 Web 应用程序,大部分时间都花费在等待这些 I/O 操作完成上。典型的请求流程可能是:

  1. 接收 HTTP 请求(内存密集型)

  2. 解析请求数据(内存密集型)

  3. 查询数据库(I/O密集型,大部分时间处于等待状态)

  4. 处理结果(内存密集型)

  5. 格式化响应(内存密集型)

  6. 发送HTTP响应(I/O密集型)


在这种工作流程中,实际的 CPU 密集型计算量极少。大多数 Web 应用程序 80-90% 的时间都花在等待 I/O 操作完成上。

Node.js 正是针对这种情况进行了优化,原因如下:

  1. 非阻塞 I/O :在等待 I/O 操作期间,Node.js 可以处理其他请求。

  2. C++ 基础:内存操作委托给高效的 C++ 实现

  3. 事件循环效率:能够以最小的开销协调多个并发操作。


很多人忽略了一点:Node.js 中的 I/O 密集型操作速度几乎可以媲美 C 语言。在 Node.js 中,当你发起网络请求或读取文件时,本质上就是在调用带有轻量级 JavaScript 封装的 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 workswhile (thereAreEvents()) {  const event = getNextEvent();processEvent(event);  // If this takes a long time,   // everything else waits}

当一个 CPU 密集型任务运行时,它会独占这个唯一的线程。在此期间,Node.js 无法处理其他事件、处理新的请求,甚至无法响应现有的请求。它实际上会被阻塞,直到计算完成。

这就是为什么在 Node.js 中运行复杂的算法会导致整个 Web 服务器无响应——事件循环忙于计算,无法处理传入的请求。

事件循环


在 JavaScript 中编写高效的 CPU 密集型代码需要理解并遵循事件循环。代码必须结构化,以便让出控制权,从而允许其他操作周期性地进行:


//////////////////////////////////////////////// Naive approach - blocks the event loop/////////////////////////////////////////////function processLargeData(data) {for (let i = 0; i < data.length; i++) {    // Heavy computation that might take secondsprocessItem(data[i]);  }  return results}//////////////////////////////////// Event-loop friendly approach//////////////////////////////////async function processLargeDataChunked(data) {  const results = []  const CHUNK_SIZE = 1000for (let i = 0; i < data.length; i += CHUNK_SIZE) {    const chunk = data.slice(i, i + CHUNK_SIZE)    // Process one chunkfor (const item of chunk) {      results.push(processItem(item))    }    // Yield to the event loop before processing the next chunkawait new Promise(resolve => setTimeout(resolve, 0))  }  return results}

这种“分块”方法虽然有效,但会增加复杂性,并从根本上改变代码的结构。它与原生支持多线程的语言截然不同,在原生支持多线程的语言中,你可以编写直接的 CPU 密集型代码,而无需担心阻塞其他操作。

对于像 TypeScript 编译器这样复杂的应用程序来说,随着代码库的增长,与事件循环的这种交互变得越来越难以管理。

编译器:CPU密集型巨兽


编译器几乎是 CPU 密集型工作负载的典型代表。它需要:


  1. 将源代码解析为词法单元和抽象语法树

  2. 执行复杂的类型检查和推断。

  3. 应用变换和优化

  4. 生成输出代码


这些操作涉及复杂的算法、庞大的内存结构和大量的计算——这正是对 JavaScript 执行模型构成挑战的那种工作。

具体到 TypeScript 而言,随着语言本身日趋复杂强大,编译器必须处理日益复杂的类型检查、类型推断和代码生成。这种发展自然而然地挑战了 JavaScript 运行时的效率极限。

线程模型至关重要:事件循环与原生并发


JavaScript 和 Go 实现之间的性能差距不仅仅是语言速度的差异,从根本上说,它还与线程模型以及它们如何与问题领域相匹配有关。


如前所述,Node.js 采用事件循环模型:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘


这种单线程方法意味着,对于编译 TypeScript 这类 CPU 密集型任务,你需要编写不会独占线程的代码。实际上,这意味着将任务分解成更小的块,以便将控制权交还给事件循环。

对于编译器而言,这带来了重大的设计挑战:

  1. 人工碎片化:编译器阶段的自然流程(解析→分析→转换→生成)需要分解成可以产生结果的小步骤。

  2. 复杂的状态管理:由于处理过程在事件循环迭代中是碎片化的,因此必须在 yield 之间仔细管理和保留编译器状态。

  3. 局部性破坏:当事件循环在编译器操作之间处理不相关的任务时,CPU 缓存局部性优势丧失,从而损害性能。

  4. 依赖性挑战:编译器组件之间存在复杂的相互依赖关系。为了适应事件循环而打破自然顺序的进程通常需要复杂的协调逻辑。


Go:使用 Goroutine 实现原生并发


相比之下,Go 提供了 goroutine——由 Go 运行时管理的轻量级线程:


┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Goroutine│ │Goroutine│ │Goroutine│ │Goroutine│ ...  more
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
     │           │           │           │
     └───────────┴───────────┴───────────┘
                       │
              ┌────────┴────────┐
              │   Go Scheduler  │
              └────────┬────────┘
                       │
                ┌──────┴──────┐
                │  OS Threads │
                └─────────────┘

该模型允许编译器各阶段自然地并行化,且协调开销极小:

  1. 自然并行性:可以同时解析和类型检查不同的文件。

  2. 直接线程访问:CPU密集型操作可以直接在线程上运行,无需让出线程。

  3. 高效协调:Go 的通道和同步原语旨在协调并发工作。

  4. 内存效率:与操作系统线程(每个线程占用数兆字节)相比,Goroutine 使用的内存极少(每个线程占用几 KB)。

在这种模型中,文件解析、类型检查和代码生成可以同时进行,无需显式的让步点。代码结构可以更自然地遵循编译器各阶段的逻辑流程。

相同的代码,不同的执行模型


Anders Hejlsberg 表示,他们评估了多种语言,最终发现 Go 是最容易移植代码库的语言。TypeScript团队显然开发了一个可以生成 Go 代码的工具,使得移植后的代码在很多地方几乎是逐行等效的。这可能会让你误以为代码“功能相同”,但这是一种误解。


由于执行模型不同,代码看起来可能相同,但在不同语言中行为可能截然不同。

在 JavaScript 中:

  • 所有代码都在带有事件循环的单线程上运行,

  • 长时间运行的操作需要被拆分或委托给工作线程执行。

  • 并发执行需要谨慎处理,以避免阻塞事件循环。


在 Go 语言中:

  • 代码自然地在多个 goroutine(轻量级线程)中运行,

  • 长时间运行的操作可以在不阻塞其他工作的情况下执行,

  • 该语言和运行时环境是为并发执行而设计的。

当你在不改变代码结构的情况下将 JavaScript 代码移植到 Go 时,你实际上改变了代码的执行方式。在 JavaScript 中会阻塞事件循环的操作,在 Go 中只需极少的改动即可并发执行。

这可以得出这样的结论:当直接移植能够带来显著的性能提升时,这可能表明原始实现并没有针对 JavaScript 的执行模型进行充分优化

编写真正高性能的 JavaScript 代码意味着要接受其异步特性和事件循环限制——这在像编译器这样的复杂代码库中变得越来越具有挑战性。

那么工作线程呢?


你们当中有些人可能会想:

“Node.js 现在不是已经有工作线程了吗?他们为什么不用工作线程,而要迁移到 Go 呢?”

这是一个很合理的问题。Node.jsworker_threads在 v10 版本中引入了工作线程模块,作为一项实验性功能,并在 v12 版本(2019 年年中)中成为稳定版本。工作线程在 Node.js 中实现了真正的并行性,允许 CPU 密集型任务在单独的线程上运行,而不会阻塞主事件循环。

Node.js 中工作线程的工作原


与大多数 Node.js 应用程序采用的单线程事件循环不同,工作线程允许 JavaScript 并行执行:

// main.jsconst { Worker } = require('worker_threads')function runWorker(workerData) {  return new Promise((resolve, reject) => {const worker = new Worker('./worker.js', { workerData })    worker.on('message', resolve)    worker.on('error', reject)    worker.on('exit'code => {      if (code !== 0) {        reject(new Error(`Worker stopped with exit code ${code}`))      }    })  })}// Run multiple CPU-intensive tasks in parallelPromise.all([  runWorker({ chunk: data.slice(0, middleIndex) }),  runWorker({ chunk: data.slice(middleIndex) })]).then(results => {  // Combine results})// worker.jsconst { parentPort, workerData } = require('worker_threads')// Perform CPU-intensive workconst result = processData(workerData.chunk)// Send the result back to the main threadparentPort.postMessage(result)

每个工作线程都在独立的 V8 实例中运行,拥有独立的 JavaScript 堆,从而实现真正的并行处理。工作线程通过发送和接收消息与主线程(以及彼此之间)通信,其中可能包括转移某些数据类型的所有权以避免数据复制。

为什么工作线程不是 TypeScript 的解决方案?


那么,TypeScript 团队为什么选择使用 Go 而不是对现有代码库进行改造以支持工作线程呢?我们不得而知,但有几个合理的解释:


  1. 遗留代码库的挑战:TypeScript 编译器已经开发了十多年。将一个大型、成熟的、专为单线程执行而设计的代码库改造为多线程架构,通常比从头开始编写要复杂得多。工作线程主要通过消息传递进行通信。重构编译器以使其以这种方式运行,需要从根本上重新构想组件之间的交互方式。视频显示,即使是单线程的 Go 也更快,因此代码可能仍然需要修改,以更好地处理事件循环的特性。

  2. 数据共享复杂性:工作进程共享内存的能力有限。编译器需要处理复杂且相互关联的数据结构(抽象语法树、类型系统等),这些数据结构无法被清晰地分割成独立的块进行并行处理。

  3. 性能开销:虽然工作线程提供了并行性,但它们也带来了一定的开销。每个工作线程都有自己的 V8 实例和独立的内存,线程间传递的数据通常需要进行序列化和反序列化。它们不如线程或 goroutine 轻量级。

  4. 时间线不匹配:TypeScript 编译器设计和实现时(大约在 2012 年),Node.js 中还没有工作线程。早期的架构决策是基于单线程模型,这使得后续的并行化更加困难。

  5. 死胡同评估:团队可能已经得出结论,即使使用工作线程,JavaScript 仍然会对其特定的工作负载施加根本性的限制,最终又会成为瓶颈。

  6. 技能匹配:该决定可能部分反映了组织专业知识以及与其他开发工具的战略一致性。


Go 的方法与工作线程的比较


对于编译器工作负载而言,Go 的并发处理方式相比 Node.js 的工作线程具有以下几个优势:

  1. 轻量级 Goroutine :与工作线程(需要单独的 V8 实例)相比,Goroutine 非常轻量级(内存占用约为 2KB),使得细粒度并行性更加实用。

  2. 共享内存模型:Go 允许 goroutine 之间通过同步原语直接共享内存,从而更容易处理复杂、相互关联的数据结构。

  3. 语言级并发:Go 语言通过 goroutine 和 channel 内置了并发功能,使得编写和理解并行代码更加自然。

  4. 更低的通信开销:goroutine 之间的通信效率远高于工作线程通信所需的序列化/反序列化。

  5. 成熟的调度器:Go 的运行时包含一个成熟、高效的调度器,用于管理可用 CPU 核心上的数千个 goroutine。


此外,我认为迁移到 Go 不仅仅是“多线程与单线程”的问题,而是采用了一种将并发作为一等概念、深度集成到语言和运行时中的编程模型。

进化的问题


TypeScript 于 2012 年创立之初,团队根据当时的实际情况做出了合理的技术选择:

  • TypeScript 是微软的一个项目,它扩展了 JavaScript,所以使用 JavaScript 是合理的。

  • 最初的规模和复杂程度要小得多。

  • 像 Go 和 Rust 这样的替代语言当时还处于早期阶段。

  • 性能要求相对温和。


随着时间的推移,TypeScript 从 JavaScript 的一个相对简单的超集演变为一种拥有高级类型特性、泛型、条件类型等功能的复杂语言。编译器也随之发展壮大,但其基础仍然是为解决更简单的问题而设计的。

这是一个典型的例子,说明成功的软件往往会面临早期设计中未预料到的扩展性挑战。随着 TypeScript 编译器变得越来越复杂,并被应用于更大的代码库,其 JavaScript 基础架构的限制也越来越大。

难道你自己不知道吗?遗留代码是如何产生的?日复一日地产生。

未决问题和未来考虑


虽然 Go 语言迁移带来的性能提升令人印象深刻,但微软的公告中仍有几个重要问题尚未得到充分解答:


浏览器支持情况如何?


TypeScript 不仅运行在服务器和开发机器上,还可以通过各种 Playground 实现和浏览器内 IDE 直接在浏览器中使用。由于 Go 语言无法在浏览器中原生运行,微软将如何应对这种使用场景?


有几种可能的方法:

  1. WebAssembly (WASM) :将 Go 实现编译成 WebAssembly 格式可以使其在浏览器中运行。虽然 WASM 的性能已显著提升,但与原生 Go 相比仍然存在一些开销。

  2. 双重实现:微软可能会同时维护一个用于浏览器的 JavaScript 版本和一个用于其他所有平台的 Go 版本。这将给功能对等性和维护带来挑战。

  3. 浏览器特定替代方案:他们可能会创建一个精简的浏览器特定实现,减少功能,针对常见的测试场景进行优化。

  4. 云编译:基于浏览器的工具可能会将代码发送到运行 Go 编译器的云端点,而不是在本地执行编译。


该公告并未阐明他们的做法,而这是一个会影响 TypeScript 生态系统的重要细节。

功能对等与性能权衡


值得注意的是,性能提升往往需要权衡取舍。一种常见的做法是减少功能范围或降低其复杂性。虽然微软声称他们保持了功能完全一致,但我们应该密切关注是否存在任何细微的行为变化,或者某些极端情况的处理方式是否有所不同。


其他语言迁移的历史案例表明,要实现100%的行为一致性是很难的。以下是一些值得思考的问题:

  • 所有现有的 TypeScript 错误信息都会保持不变吗?

  • 类型推断中的每个极端情况都会表现得完全相同吗?

  • 编译选项和标志会产生相同的效果吗?

  • 性能优化将如何影响类型系统的极端情况?


TypeScript 团队在向后兼容性方面有着良好的记录,但从头开始重写必然会带来一些细微的行为变化风险。

可扩展性和插件生态系统


TypeScript 拥有丰富的插件和工具生态系统,这些插件和工具可以扩展编译器的功能。迁移到 Go 引发了人们对这个生态系统未来走向的疑问:


  • 插件API是否会保持兼容?

  • 基于 JavaScript/TypeScript 的插件是否需要用 Go 重写?

  • 这将如何影响创建 TypeScript 工具的准入门槛?


这些因素的影响将不仅仅局限于编译性能,还会波及更广泛的 TypeScript 生态系统。

为什么这不仅仅关乎 TypeScript?


本案例研究对技术选择具有更广泛的意义:


  1. 根据问题领域选择合适的技术。像编译器这类 CPU 密集型任务,适合使用专为计算和原生线程设计的语言。而像 Web 服务器这类 I/O 密集型任务,通常与事件循环模型配合良好。

  2. 随着项目的发展,需要重新审视基础架构。小型项目适用的方案,在大规模项目中可能成为瓶颈。要勇于重新审视基本的架构决策。大胆尝试,比如必要时重写代码,并没有错。但在行动之前,请务必先阅读相关文档

  3. 不要只看标题所宣称的性能提升。“10倍提升”往往受多种因素影响,而不仅仅是技术栈的改变。

  4. 了解你的运行时模型。无论你使用 Node.js、Go、Rust 还是任何其他环境,了解代码的执行方式对于性能优化至关重要。


期待


作为 TypeScript 用户,Emmett 和 Pongo 的构建是件好事。如果能“免费”提升编译器的运行速度,那就太棒了。但另一方面,我不明白为什么我们要大张旗鼓地宣传,用标题党的方式来吸引开发者社区的关注。

只关注 10 倍而没有提供足够的背景信息只会造成摩擦(我很庆幸没有听到 C# 开发人员的“为什么不用 C#?!”的叫喊声……)。

这就是为什么我想扩展这个用例的原因,因为它提供了关于技术、语言选择、性能优化以及成功项目演变的宝贵经验。

从 JavaScript 迁移到 Go 不应被视为“Node.js 速度慢”的佐证。更恰当的理解是,这表明不同的问题需要不同的工具。JavaScript 和 Node.js 依然擅长它们最初的设计用途:处理高并发需求的 I/O 密集型 Web 应用。

当然,如果微软能更详细地解释一下,而不是发表博眼球的言论,那就更好了,但这就是我们所处的世界。

欢迎在评论区分享你的想法!

作者:场长

评论

我要赞赏作者

请扫描二维码,使用微信支付哦。

分享到微信