电子很快,因此如何优化电子应用程序性能
#javascript #性能 #rust #electron

电子是使用同一代码库为不同系统构建桌面应用程序的流行框架。

但是,我们经常听到它很慢,消耗了很多内存,并产生了多个过程,从而减慢整个系统。一些非常受欢迎的应用程序是使用电子构建的,包括:

  • Microsoft团队(但他们正在迁移到Edge WebView2),
  • 信号,
  • WhatsApp。

并非所有这些都是完美的,但是有一些非常好的示例,例如Visual Studio代码。我们可以说这很慢吗?根据我们的经验,相反的表现和反应迅速。

在本文中,我们将向您展示如何减少电子应用中的瓶颈并使其快速!提出的方法可以应用于基于node.js的应用程序,例如API服务器或其他需要高性能的工具。

基于电子的游戏发射器是我们的测试主题

我们的项目是基于电子的游戏启动器。如果您玩游戏,则可能在计算机上安装了其中一些。大多数启动器下载游戏文件,安装更新和验证文件,因此游戏可以启动而不会出现任何问题。

我们可以加快速度的部分取决于连接速度,但是在验证下载或修补的文件时,这是一个不同的故事,如果游戏很大,那么整个过程可能会花费大量时间。这是我们的情况。

我们的应用程序负责下载文件,如果有资格,则应用二进制补丁。完成此操作后,我们必须确保没有任何损坏。造成腐败的原因无关紧要,我们的用户想玩游戏,我们必须使其成为可能。

现在,让我给你一些数字。我们的游戏由44个总尺寸约为4.7GB的文件组成。

下载游戏或更新后,我们必须验证所有内容。我们使用https://www.npmjs.com/package/crc计算每个文件的CRC并根据清单文件进行验证,让我们看看这种方法的性能,某些基准测试的时间。

运行Electron App绩效优化基准测试

所有基准测试均在2021 MacBook Pro14âm1Pro。

上运行

首先,我们需要一些文件来验证。我们可以使用命令

创建一些

mkfile -n 200m test_200m_1

但是,如果我们看内容,我们将看到所有零!

➜  /tmp cat test_200m_1 | xxd | tail -n 10
0c7fff60: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fff70: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fff80: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fff90: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fffa0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fffb0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fffc0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fffd0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7fffe0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0c7ffff0: 0000 0000 0000 0000 0000 0000 0000 0000  ................

这可能会给我们带来偏斜的结果。相反,我们将使用此命令

dd if=/dev/urandom of=test_200m_1 bs=1M count=200

我将创建10个文件,每个文件200MB,并且由于它们中的数据是随机的,所以它们应该具有不同的校验和。

基准代码:

import crc32 from "crc/crc32";
import { createReadStream } from "fs";

const calculate = async (path) => {
 return new Promise((resolve, reject) => {
   let checksum = null;
   const readStream = createReadStream(path);

   readStream.on("data", (chunk) => {
     checksum = crc32(chunk, checksum);
   });

   readStream.on("end", () => {
     resolve(checksum);
   });

   readStream.on("error", () => {
     resolve(false);
   });
 });
};

const then = new Date().getTime();
await calculate("test_200m_1");
const now = new Date().getTime();
const elapsedTimeInMs = now - then;

console.log(elapsedTimeInMs);

创建读取流并逐步计算校验和计算大约需要800毫秒。我们更喜欢流,因为我们可以负担得起将大文件加载到系统内存中。如果我们一一计算所有文件的CRC32,则结果是〜16700ms。它在第三文件后放慢脚步。

如果我们使用 Promise.All 同时运行它们,这会更好吗?好吧…这是测量误差的极限。它在〜16100ms左右变化。

所以,这是我们到目前为止的结果:

中的10个文件
单文件 10个文件一个 Promise.All
〜800ms 〜16700ms 〜16100ms

优化电子应用程序性能的可能方法

在优化电子应用时,您可以采取许多路径,但我们对:

  1. NodeJS Worker Threads
  2. Node-API
  3. Neon
  4. Napi-rs
  5. 其他可在本地工作的JS库

nodejs工作线程

Worker线程需要周围的一些样板代码。另外,如果您的代码基库在打字稿中,则可能是有问题的,它是可行的,但需要其他工具,例如TS节点或配置。我们不想产生谁知道有多少工人线程也会效率低下。性能问题在其他地方。无论我们在何处,它都会很慢。

结论:产卵工具线程将使我们的应用程序更慢,因此Nodejs Worker线程不适合我们。

节点-API

如果我们想要快速,Node-API看起来像是一个完美的解决方案。用C/C ++编写的库必须很快。如果您希望在C上使用C ++,则node-addon-api可以提供帮助。这可能是可用的最佳解决方案之一,尤其是因为它得到了Node.js团队的正式支持。它是一旦建造的超级稳定,但是在开发过程中可能会很痛苦。错误通常远非易于理解,因此,如果您不是C的专家,它可能很容易踢屁股。

结论:我们没有C技能来解决错误,因此Node-api不适合我们。

霓虹灯结合

现在它变得有趣,霓虹灯绑定。 Node.js中的Rust听起来很棒,这是另一个流行语,但这只是一个流行语吗?霓虹灯说,它是由1Password和Signal https://neon-bindings.com/docs/example-projects之类的流行应用程序使用的,但让我们看一下基于Rust的其他选项,即NAPI-RS。

结论:霓虹灯绑定看起来很有希望,但让我们看看它与我们的最后一个选择的比较。

每日r

如果我们查看文档,NAPI-RS的文档看起来比Neon的文档好得多。该框架是由行业中一些知名人士赞助的。大品牌的广泛文档和支持是我们使用NAPI-R而不是霓虹灯绑定的充分理由。

结论:NAPI-RS比可比的霓虹灯提供了更好的文档,因此做出更安全的选择。

使用NAPI-RS优化电子应用程序性能

为了优化我们的电子应用,我们将使用NAPI-RS,将Rust与Node.js混合在一起。由于其性能,记忆安全,社区和工具(货物,生锈的安分析仪),Rust是Node.js的有吸引力的补充。难怪它是最喜欢的语言之一,为什么越来越多的公司将其模块重写为生锈。

使用NAPI-RS,我们需要构建一个包含https://crates.io/crates/crc32fast的库,以非常快地计算CRC32。 NAPI-RS为我们提供了构建NPM软件包的出色现成工作流程,因此将其构建并与该项目集成在一起很容易。也支持预先构建,因此您根本不需要生锈环境来使用它,将下载和使用正确的构建。无论您使用Windows,Linux还是MacOS(Apple M1机器也在列表中。)

使用CRC32Fast库,我们将使用Hasher实例从读取流中更新校验和,如JS实现:

// Spawn and run the thread, it starts immediately
   let handle = thread::spawn(move || {
     // Has to be equal to JS implementation, it changes the checksum if different
     const BUFFER_LEN: usize = 64 * 1024;
     let mut buffer = [0u8; BUFFER_LEN];

     // Open the file, if it fails it will return -1 checksum.
     let mut f = match File::open(path) {
       Ok(f) => f,
       Err(_) => {
         return -1;
       }
     };

     // Hasher instance, allows us to calculate checksum for chunks
     let mut hasher = Hasher::new();

     loop {
       // Read bytes and put them in the buffer, again, return -1 if fails
       let read_count = match f.read(&mut buffer[..]) {
         Ok(count) => count,
         Err(_) => {
           return -1;
         }
       };

       // If this is the last chunk, read_count will be smaller than BUFFER_LEN.
       // In this case we need to shrink the buffer, we don't want to calculate the checksum for a half-filled buffer.
       if read_count != BUFFER_LEN {
         let last_buffer = &buffer[0..read_count];
         hasher.update(&last_buffer);
       } else {
         hasher.update(&buffer);
       }

       // Stop processing if this is the last chunk
       if read_count != BUFFER_LEN {
         break;
       }
     }

     // Calculate the "final" checksum and return it from thread
     let checksum = i64::from(hasher.finalize());
     checksum

运行Electron App绩效优化基准测试

听起来像是假或无效的结果,但单个文件仅75ms!它比JS实施快十倍。当我们一一处理所有文件时,它大约是730ms,因此它的扩展也更好。

但这不是全部。我们可以进行另外一个非常简单的优化。我们可以让它接受路径数组并为每个文件衍生一个线程。

记住:RUST对线程的数量没有限制,因为这些是由系统管理的OS线程。这取决于系统,因此,如果您知道要产生多少个线程并且不是很高,那么您应该安全。否则,我们建议将限制和处理文件或在块中进行计算。

让我们的计算将每个文件的线程放在线程中,然后一次返回所有校验和

// Vector of threads, to be "awaited" later
let mut threads = Vec::<std::thread::JoinHandle<i64>>::new();

for path in paths.into_iter() {
 // Spawn and run the thread, it starts immediately
 let handle = thread::spawn(move || {
   // ... code removed for brevity
 });

 // Push handle to the vector
 threads.push(handle);
}

// Prepare an empty vector for checksums
let mut results = Vec::<i64>::new();

// Go through every thread and wait for it to finish
for task in threads.into_iter() {
 // Get the checksum and push it to the vector
 let result = task.join().unwrap();
 results.push(result);
}

// Return vector(array) of checksums to JS
Ok(results)

用一系列路径调用本机函数需要多长时间?

进行所有计算?

只有150ms,是的,这很快。可以肯定的是,我们重新启动了MacBook并进行了另外两项测试。

第一次运行:

Rust took 463ms Checksums [
  2918571326,  644605025,
   887396193, 1902706446,
  2840008691, 3721571342,
  2187137076, 2024701528,
  3895033490, 2349731754
]
JS promise.all took 16190ms Checksum [
  2918571326,  644605025,
   887396193, 1902706446,
  2840008691, 3721571342,
  2187137076, 2024701528,
  3895033490, 2349731754
]

第二次运行:

Rust took 197ms Checksums [
  2918571326,  644605025,
   887396193, 1902706446,
  2840008691, 3721571342,
  2187137076, 2024701528,
  3895033490, 2349731754
]
JS promise.all took 16189ms Checksum [
  2918571326,  644605025,
   887396193, 1902706446,
  2840008691, 3721571342,
  2187137076, 2024701528,
  3895033490, 2349731754
]

让我们将所有结果汇总在一起,看看它们如何比较。

js Rust
单个文件 〜800ms 〜75ms
10个文件一个 〜16700ms 〜730ms
10个文件Promise.All 〜16100ms -
线程中的10个文件 - 〜200ms

值得注意的是,用空阵列调用本机功能为124584纳米秒为0.12ms,因此开销很小。

记住要使电子应用打开包装

一开始,所有这些都适用于Web API,CLI工具和电子。基本上,使用node.js的所有内容。但是有了电子,还有一件事要记住。电子将应用程序捆绑到一个名为app.asar的档案中。必须解开某些节点模块才能由运行时加载。大多数像电子构建器或Forge这样的捆绑器会自动将这些模块保存在存档文件之外,但是可能会发生我们的库将留在ASAR文件中。如果是这样,则应指定应保留哪些库。它不是强制性的,但会减少解开和加载这些。节点文件的开销。

我们的建议:尝试使用Rust和C进行尝试以提高您的电子应用程序性能

您可以看到,有多种方法可以加快电子应用程序的一部分,尤其是在进行大量计算方面。幸运的是,开发人员可以从不同的语言和策略中进行选择以涵盖各种用例。

在我们的应用程序中,验证文件只是整个启动器过程的一部分。对于大多数玩家来说,最慢的部分是下载文件,但不能超越您的互联网服务提供商提供的内容。另外,有些玩家的机器上有HDD磁盘,其中IO可能是瓶颈,而不是CPU。

但是,如果我们可以以合理的成本来改进并提高性能,那么我们应该为此而努力。如果您的应用程序中有任何功能或模块可以在Rust或C中重写,为什么不尝试实验呢?这种优化可以大大改善您的应用程序的整体性能。