异步功能不必要地杀死您的JavaScript性能
#javascript #性能 #async

使用Promise API提供了快速的提示来增强异步JavaScript性能,但此讨论侧重于简单的程序调整如何导致速度改善。通过优化代码,您可能会实现1.9倍甚至14倍的速度提升。

我相信异步JavaScript功能中未开发的性能潜力是由于V8引擎无法为这些功能提供预期的优化水平;并且有一些关键指标暗示了这种可能性。

语境

您可以根据需要跳过本节,但以下是上下文的简要概述。我一直在使用一个bnf-parser库,该库当前需要一个完整的文件,以将其解析到BNF指定的语法树中。但是,可以重构库以使用可克隆的状态发电机,该发电机会顺序输出文件字符,并允许在特定点复制以稍后恢复阅读。

因此,我尝试在JavaScript中实现它,可以将大 +1GB文件解析为部分语法树来处理大型XML,部分是为了娱乐,部分是因为我也知道很快我需要在某个中实现类似的东西较低的语言,这可能是很好的做法。

案例研究

我的目的是在磁盘上可读的数据流之间创建一个层,并允许迭代地呼吁对有限回溯的小文本部分进行呼叫。我实施了一个迭代的Cursor,将传递的字符作为字符串返回。可以克隆光标,克隆可以独立前进。重要的是,光标 May 需要等待当前流式传输的数据在返回下一个子字符串之前可用。为了最大程度地减少内存使用量,我们将所有这些实施到异步/等待模式中,以避免复杂的回调链或不必要的事件循环阻止。

旁注:我们使用池进行缓存,将每个块从磁盘读取到阵列中,然后操纵数组以免费缓存数据。此方法可降低调整大小的操作和字符串操作。但是,它可能导致NodeJ报告错误的内存使用情况,因为在应用程序域内操纵之前,OS分配的块不计数。

光标具有异步读取调用,异步连接到StreamCache从缓存中读取。多个光标可能试图读取最新的不可用的信息,需要condition variable锁定-PromiseQueue的异步调用用于管理此信息。

100-byte块中读取1GB文件至少通过三个异步调用层导致10,000,000 IOs。问题变成了灾难性的,因为这些功能本质上是回调的语言级抽象,缺乏异步性质带来的优化。但是,我们可以手动实施优化以减轻此问题。

测试

因此,让我们通过基本实现,然后进行一些不同的变化和优化;或者,您可以跳到results,然后如果您愿意的话向后工作。

关于测试方法的快速注意:每个测试从寒冷的状态开始连续10次。第一个结果始终慢,而其他九个几乎相同。这表明nodejs暂时保存运行之间的优化代码,或者NAS智能地缓存文件以更快地访问。后者更有可能,因为冷启动之间的持续时间更长会导致初始执行较慢。

使用的测试文件是here(流式传输为独立XML文件)。

完整的异步

因此,我们有一个光标,我们可以在接下来打电话,将请求转发到StreamCache-然后处理所有实际读取行为。

class Cursor {
  // ...
  async next(highWaterMark = 1): Promise<string> {
    return await this._owner._read(this, highWaterMark);
  }
  // ...
};

然后,我们拥有仅创建StreamCache,添加光标的主文件,并以一种向后的方式向fs.createReadStream进行管道,但这是由于实现了StreamCache的方式,以允许Nodejs nodejs和WebJS可读的流API差异。

在管道之前添加光标,以确保无法将数据的第一个字节读取到缓存中,然后由于任何光标无法访问
而删除。

let stream = new experimental.StreamCache();
let cursorA = stream.cursor();
stream.pipe_node(fstream);

async function main() {
  console.time("duration");

  while (!cursorA.isDone()) {
    let val = await cursorA.next(100);
    read += val.length;
  }

  cursorA.drop();

}
fstream.on('end', ()=>{
  console.timeEnd("duration");
});

main();

包装器优化

在光标中,我们才能看到我们拥有一个异步功能,基本上只是充当包装器,如果您了解异步的抽象,您知道异步函数只是返回诺言,因此创建此额外的额外需要没有实际需求异步函数,而是我们可以从子呼叫中返回创建的函数。 (这具有一定的性能优势,它确实不应该:D)

to:

class Cursor {
  next(highWaterMark = 1): Promise<string> {
    return this._owner._read(this, highWaterMark);
  }
};

嵌入式

在这种情况下,我们假装是一个编译器,并将我们自己的功能插入了,因此我们实际上只是将StreamCache._read的功能嵌入到所谓的位置,这完全破坏了我们的公共私人属性保护函。 «

如果只有一个编译器,例如 typecript 可以安全地为我们插入ð

let stream = new experimental.StreamCache();
let cursorA = stream.cursor();
stream.pipe_node(fstream);

async function main() {
  console.time("duration");

  while (!cursorA.isDone()) {
    if (cursorA._offset < 0) {
      throw new Error("Cursor behind buffer position");
    }

    while (cursorA._offset > stream._total_cache - 100) {
      if (stream._ended) {
        break;
      }

      await stream._signal.wait();
    }

    let loc = stream._offset_to_cacheLoc(cursorA._offset);
    if (loc[0] >= stream._cache.length) {
      return "";
    }

    let out = stream._cache[loc[0]].slice(loc[1], loc[1]+100);
    cursorA._offset += out.length;
    read += out.length;
  }

  cursorA.drop();

}
main();

fstream.on('end', ()=>{
  console.timeEnd("duration");
});

异步与峰值

如果所有其他方法都失败了,请在可能的情况下避免异步。因此,在这种情况下,我添加了一些功能。

峰会告诉我我是否可以不等待阅读,在这种情况下,_skin_read是安全的。


否则返回调用异步方法。

let stream = new experimental.StreamCache();
let cursorA = stream.cursor();
stream.pipe_node(fstream);

async function main() {
  console.time("duration");

  while (!cursorA.isDone()) {
    let val = cursorA._skip_read(100);
    if (cursorA.isDone()) {
      break;
    }
    read += val.length;
    peaked += val.length;

    if (val == "") {
      let val = await cursorA.next(100);
      read += val.length;
    }
  }

  cursorA.drop();
}
main();

fstream.on('end', ()=>{
  console.timeEnd("duration");
});

在这种用例中,这实际上节省了很多时间,因为由于负载块的尺寸如此之大,实际上并不需要等待。

with
通过async 919417
通过峰 1173681200
总计 1174600617

磁盘阅读

与所有良好的测试一样,我们需要一个基线 - 因此,在这种情况下,我们甚至没有一个活跃的光标,我们实际上只让数据尽可能快地流入StreamCache我们的磁盘读取,加上allocfree的开销,当我们添加和删除缓存池时。

let stream = new experimental.StreamCache();
let cursorA = stream.cursor();
stream.pipe_node(fstream);

async function main() {
  console.time("duration");
  cursorA.drop();

}
main();

fstream.on('end', ()=>{
  console.timeEnd("duration");
});

打回来

最后,我们需要一个测试来确保这不是一个耗时的错误,如果我们回到回调地狱日,但是我们是否公平?

注意:我没有重写signal.wait(),因为试图在a循环中创建一个优化的回调系统将是地球上的地狱。
是的,我们确实需要一个时循环,因为可能需要多个块才能完成所需的读取 - 有时会很奇怪且不一致,而且也许您只想立刻一个大块读取。

export class StreamCache {
  async read(cursor: Cursor, size = 1, callback: (str: string) => void): Promise<void> {
    if (cursor._offset < 0) {
      throw new Error("Cursor behind buffer position");
    }

    // Wait for more data to load if necessary
    while (cursor._offset > this._total_cache - size) {
      // The required data will never be loaded
      if (this._ended) {
        break;
      }

      // Wait for more data
      //   Warn: state might change here (including cursor)
      await this._signal.wait();
    }

    // Return the data
    let loc = this._offset_to_cacheLoc(cursor._offset);
    if (loc[0] >= this._cache.length) {
      callback("");
    }

    let out = this._cache[loc[0]].slice(loc[1], loc[1]+size);
    cursor._offset += out.length;
    callback(out);
  }
}
function ittr(str: string) {
  read += str.length;
  if (cursorA.isDone()) {
    cursorA.drop();
    return;
  }

  stream.read(cursorA, 100, ittr);
}

async function main() {
  console.time("duration");
  ittr("");
}
main();

fstream.on('end', ()=>{
  console.timeEnd("duration");
});

结果

案例 持续时间(min) 中值 平均值 max
Full Async 27.742S 28.339S 28.946S 35.203S
Async Wrapper Opt 14.758S 14.977S 15.761S 22.847S
Callback 13.753S 13.902S 14.683S 21.909S
Inlined Async 2.025S 2.048S 3.037S 11.847S
Async w/ Peaking 1.970S 2.085S 3.054S 11.890S
Disk Read 1.970S 1.996S 2.982S 11.850S

这有点可怕,仅更改包装器函数Cursor.next,它表明可以轻松地进行优化改进,加上Inting 13.9x性能改进表明,即使V8也无法实现,还有一些空间某些东西,例如TypeScript当然可以。

同样,如果您查看峰值示例,我们遇到了一个有趣的限制。在这种情况下,仅通过异步函数实现了请求的0.078%,这仅意味着关于11746006请求的9194正在等待加载数据。这意味着我们的CPU几乎可以通过传入的数据完美地喂食。

结论

通过对代码进行简单的调整,可以显着改善异步JavaScript功能的性能。该案例研究的结果证明了通过手动优化的1.9倍至14倍速度提高的潜力。 V8目前缺乏对这些功能的优化,将来有进一步改进的空间。

使用Direct Raw Promise API调用时,可以提出一个有力的论点,即尝试在不可能改变执行行为的情况下优化此行为可能很难实现。但是,当我们使用async/await语法甚至不使用术语Promise时,我们的功能现在以这样的方式编写,您可以使一些非常简单的性能保证可以保证优化。

简单的altering the wrapper call创造了几乎1.9倍的性能,对于使用编译语言的任何人来说,都应该令人恐惧。这是一个简单的函数调用重定向,在大多数情况下可以轻松地优化出现。

我们不需要等待浏览器实现这些优化,诸如打字稿之类的工具已经提供了转移到较旧的ES版本,清楚地表明编译器基础架构对语言行为有深入的了解。长期以来,人们一直在说TypeScript不需要优化您的JavaScript,因为V8已经做得很好,但是这种新的异步语法显然并非如此 - 并且有了一点静态分析单独的javaScript可能会变得更具性能。

带走

目前在V8实施JavaScript中,async只是Promises的抽象,而PromiseS只是回调的抽象,V8似乎没有使用async函数在传统回调中提供任何添加信息有点优化。

虽然大多数主动异步JavaScript代码可能是IO有限的,而不是CPU,但这可能不会影响大多数JavaScript代码。但是,即使您不是从事重型CPU负载的人,您的代码仍然可能受到这些性能特征的限制。有可能根据您如何与给定库进行交互的方式可能会为您提供截然不同的性能特征,具体取决于您是否使用非同步代码,并且根据库的实现详细信息,问题可能会加剧。