JavaScript不阻塞事件循环 Event Loop 实践

创建于 2024年8月1日修改于 2024年8月1日
JavaScript

Contents

事件循环(Event Loop)

JavaScript在单线程环境中运行,使用事件循环(Event Loop),这种架构非常容易理解。它是一个执行传入工作的连续循环。所说的工作可以为未来安排更多的工作。

while (hasWorkToDo) {
    /* 运行定时器,I/O回调,
       检查传入连接,
       等等... */
    doWork();
}

同步工作立即运行;异步工作在没有同步工作需要执行时运行(或者简单地说,“稍后”)。理想情况下,应用程序的执行 profile 应允许事件循环频繁运行以执行后台工作(例如接受新连接,运行定时器等)。

image

这种设计意味着执行同步工作是一个大问题:在它运行的每一持续时间内,事件循环不能执行任何工作——none!

/* setImmediate在事件循环上
   注册一个回调 */
setImmediate(() => {
   console.log("This will at some point in the future");
});

/* 你永远看不到结尾的
   同步工作 */
findNthPrime(9999999);

image

在服务器上下文中,一个这样的请求可以无限期地阻塞所有其他请求。

/* 如果发送了对 /computePrimes 的请求,
   这个路由将永远不会响应。
   它将超时。 */
app.get("/home", () => {
    return response("Welcome Home!");
});

app.get("/computePrimes", () => {
    /* 你永远看不到结尾的
       同步工作。 */
    return response(findNthPrime(9999999));
});

image

有三种解决这些情景的方法。

  1. 增加更多的节点
  2. findNthPrime 重构为异步工作
  3. findNthPrime 转载(off-loading)到另一个线程

增加更多节点!

“增加更多资源”的行业术语是水平扩展(与之相对的是垂直扩展,游戏规则是“增加更好的资源”)。Node.js的一大特色是通过集群轻松实现水平扩展的内置支持。

总体思路是并行运行多个服务器,这样如果一个服务器忙碌,另一个可以处理传入的请求。这个方法的一个陷阱是,它可以掩盖问题,直到负载赶上来。

在我们的服务器实现中,同步操作完成得很慢。如果只有一个节点,只需一个请求就可以使其失效。增加节点的数量将按相同数量增加这些请求的处理能力。

这个方法实现起来很简单,但并不能避免阻塞事件循环;它只是增加了更多的事件循环。作为一种策略,只要传入请求的速度不超过处理它们所需的时间,它就能奏效。

重构为异步工作

异步工作通常不是CPU绑定的。例如,如果读取文件需要10毫秒,可能不到1毫秒是等待CPU,其余时间都在等待磁盘。

另一方面,计算质数完全是CPU绑定的:只是基本的数学运算。

在事件循环架构中,可以通过将工作分块到事件循环来将长时间运行的算法转换为异步任务。

考虑以下 findNthPrime 实现:

const findNthPrime = num => {
  let i, primes = [2, 3], n = 5;
  const isPrime = n => {
    let i = 1, p = primes[i],
      limit = Math.ceil(Math.sqrt(n));
    while (p <= limit) {
      if (n % p === 0) {
        return false;
      }
      i += 1;
      p = primes[i];
    }
    return true;
  }
  for (i = 2; i <= num; i += 1) {
    while (!isPrime(n)) {
      n += 2;
    }
    primes.push(n);
    n += 2;
  };
  return primes[num - 1];
}

image

这种方法的基本目标是增加同步执行块之间的间隙,允许事件循环在算法执行时运行。你希望这些间隙出现在哪里取决于你想要的性能 profile。如果你的算法阻塞事件循环超过一秒钟,那么在任何地方添加间隙都是值得的。

在这种情况下,isPrime() 在多次迭代中完成大部分工作。它已经方便地隔离在一个函数中,这使它成为在事件循环上延迟的理想候选。

image

Promisify

第一步是将要移动到事件循环的代码部分隔离到Promise中:

  const isPrime = n => new Promise(
    resolve => {
      let i = 1, p = primes[i],
        limit = Math.ceil(Math.sqrt(n));
      while (p <= limit) {
        if (n % p === 0) {
          return resolve(false);
        }
        i += 1;
        p = primes[i];
      }
      return resolve(true);
    }
  )
  // ...
  while (!await isPrime(n)) {
  //...

将同步代码转换为Promise并不会使代码异步。要使代码异步,必须从事件循环调用它。setImmediate 接受一个回调来实现这一点:

  const isPrime = n => new Promise(
    resolve => setImmediate(() => {
      let i = 1, p = primes[i],
        limit = Math.ceil(Math.sqrt(n));
      while (p <= limit) {
        if (n % p === 0) {
          return resolve(false);
        }
        i += 1;
        p = primes[i];
      }
      return resolve(true);
    }
  ))

完整实现

const asyncInterval = setInterval(() => {
  console.log("Event loop executed");
  exCount++;
}, 1);
const findNthPrimeAsync = async num => {
  let i, primes = [2, 3], n = 5;
  const isPrime = n => new Promise(
    resolve => setImmediate(() => {
      let i = 1, p = primes[i],
        limit = Math.ceil(Math.sqrt(n));
      while (p <= limit) {
        if (n % p === 0) {
          return resolve(false);
        }
        i += 1;
        p = primes[i];
      }
      return resolve(true);
    }
  ));
  for (i = 2; i <= num; i += 1) {
    while (!await isPrime(n)) {
      n += 2;
    }
    primes.push(n);
    n += 2;
  };
  return primes[num - 1];
}

为了证明代码现在确实在事件循环上,我们可以尝试在事件循环上安排任务,看看它们是否被执行:

console.log("Calculating Sync Prime...")
let syncCount = 0;
const syncInterval = setInterval(() => {
  console.log("Event loop executed");
  exCount++;
}, 1);

const sync = findNthPrime(nth);
console.log("Sync Prime is", sync)
clearInterval(syncInterval);
console.log("Intervals on event loop:", syncCount)

console.log("Calculating Async Prime...")
let asyncCount = 0;
const asyncInterval = setInterval(() => {
  console.log("Event loop executed");
  asyncCount++;
}, 1);

findNthPrimeAsync(nth)
  .then(n => console.log("Async Prime is", n))
  .then(() => clearInterval(asyncInterval))
  .then(() => console.log("Intervals on event loop:", asyncCount));

输出:

Calculating Sync Prime...
Sync Prime is 541
Intervals on event loop: 0
Calculating Async Prime...
Event loop executed
Event loop executed
Event loop executed
Event loop executed
Event loop executed
Event loop executed
Async Prime is 541
Intervals on event loop: 6

从视觉上看,执行 profile 如下所示:

image

转载到另一个线程

处理同步作业而不阻塞主线程的最后一种方法是将其完全卸载到另一个线程。工作池进一步优化了这一策略。

前提是让主线程分派一个工作线程:

const nth = 100;

const findNthPrimeWorker = num => new Promise(resolve => {
  const worker = new Worker(require.resolve('./worker.js'), {
    workerData: num
  });

  worker.on("message", d => resolve(d));
})

findNthPrimeWorker(nth)

工作线程执行计算并发送结果:

// worker.js

const findNthPrime = num => {
  // ...
}

parentPort.postMessage(findNthPrime(workerData));

image

工作线程的局限性

工作线程非常适合将长时间运行的、CPU绑定的任务移出主线程,但它们不是万能的。它们的主要限制是可以发送给它们的数据。限制记录在port.postMessage()

工作线程 != 魔法!

工作线程的一个重要说明是,通过将它们专用于CPU绑定任务,它们受到可用线程数量的限制。如果你的服务器有八个线程,运行超过八个工作线程不会使它们运行得更快。

工作线程的好处不是无限并行,而是主线程通过转载不太快速的工作来保证可始终自由地执行快速的工作。

原文:https://www.bbss.dev/posts/eventloop/