如何使用Web Workers处理CPU密集型任务

作者选择将”Girls Who Code”作为”写作筹款计划”的捐赠对象。

介绍

由于 JavaScript 的执行是按顺序依次在单个线程中进行的,所以它通常被称为单线程语言。如果您在具有多个核心的设备上访问 Web 应用程序,JavaScript 只会使用一个核心。当一个任务在主线程上执行时,所有后续任务都必须等待该任务完成后才能执行。当任务所需时间较长时,它会阻塞主线程,从而阻止剩余的任务执行。大部分阻塞任务往往是占用 CPU 的任务,也称为 CPU 密集型任务,例如处理图形、数学计算以及视频或图像压缩等任务。

除了CPU绑定的任务外,您还有非阻塞的I/O绑定任务。这些I/O绑定任务大部分时间都用于向操作系统(OS)发出请求并等待响应。例如,Fetch API向服务器发出的网络请求。当您使用Fetch API从服务器获取资源时,操作系统接管了任务,Fetch API则等待操作系统的响应。在此期间,Fetch API回调函数被转移到一个队列中,等待操作系统的响应,从而释放主线程,并允许它执行其他后续任务。一旦收到响应,与Fetch API调用相关联的回调函数就会在主线程上执行。因为I/O绑定任务的性能取决于操作系统完成任务所需的时间,所以大多数I/O绑定任务(例如Fetch)实现了Promise,定义了在Promise解析时应该运行的函数;也就是说,当操作系统完成任务并返回响应时。

相比之下,与等待操作系统的I/O限制任务不同,具有CPU限制的任务不会闲置。CPU限制任务会持有CPU直到任务完成,从而阻塞主线程。即使将它们封装在promise中,它们仍然会阻塞主线程。此外,当主线程被阻塞时,用户可能会注意到Web应用的用户界面(UI)可能会冻结,任何使用JavaScript的功能可能无法正常工作。

作为解决这个问题的一个方法,浏览器引入了Web Workers API,在浏览器中提供多线程支持。通过Web Workers,您可以将CPU密集型任务分配给另一个线程,从而释放主线程。主线程在一个设备核心上执行JavaScript代码,而分配的任务在另一个核心上执行。这两个线程可以通过消息传递进行通信和共享数据。

在本教程中,你将创建一个耗用CPU资源的任务,阻塞浏览器的主线程,并观察它对Web应用的影响。然后,你将尝试使用Promises使这个耗用CPU资源的任务变为非阻塞的,但未成功。最后,你将创建一个Web Worker,将耗用CPU资源的任务转移到另一个线程中,以防止它阻塞主线程。

先决条件

为了跟随这个教程,你将需要:

  • A machine with two or more cores with a modern web browser installed.
  • A local Node.js environment on your system, which you can setup with How To Install Node.js on Ubuntu 22.04. On other operating systems, follow the appropriate guide on How To Install Node.js and Create a Local Development Environment.
  • Knowledge of the event loop, callbacks, and Promises, which you can learn by reading Understanding the Event Loop, Callbacks, Promises, and Async/Await in JavaScript.
  • You will also need a basic knowledge of HTML, CSS, and JavaScript, which you can find in our How To Build a Website With HTML series, How To Build a Website With CSS series, and in How To Code in JavaScript.

第一步 – 创建一个不需要Web Workers 的CPU密集型任务

在这一步中,您将创建一个具有阻塞CPU绑定任务和非阻塞任务的Web应用程序。该应用程序将具有三个按钮。第一个按钮将启动阻塞任务,即一个循环,迭代大约五十亿次。第二个按钮将增加网页上的一个值,第三个按钮将改变Web应用程序的背景颜色。用于增加和改变背景的按钮是非阻塞的。

首先,使用mkdir命令创建项目目录。

  1. mkdir workers_demo

 

用cd命令进入目录。

  1. cd workers_demo

 

使用 nano 或你最喜欢的文本编辑器,创建一个 index.html 文件。

  1. nano index.html

 

在你的index.html文件中,添加以下代码来创建按钮和元素,用于显示输出:

工人示范/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Workers</title>
    <link rel="stylesheet" href="main.css" />
  </head>
  <body>
    <div class="wrapper">
      <div class="total-count"></div>
      <div class="buttons">
        <button class="btn btn-blocking" id="blockbtn">Blocking Task</button>
        <button class="btn btn-nonblocking" id="incrementbtn">Increment</button>
        <button class="btn btn-nonblocking" id="changebtn">
          Change Background
        </button>
      </div>
      <div class="output"></div>
    </div>
    <script src="main.js"></script>
  </body>
</html>

在头部部分,你引用了main.css样式表,其中包含了应用程序的样式。在body标签中,你创建了一个class为total-count的div元素,它将包含一个在点击按钮时递增的值。接下来,你创建了另一个div元素,它有三个按钮元素作为子元素。第一个按钮将开始一个占用CPU资源的任务,该任务是阻塞的。第二个按钮将递增class为total-count的div元素中的值,第三个按钮将触发JavaScript代码来改变背景颜色。这两个任务是非阻塞的。

下一个div元素将包含来自CPU密集型任务的输出,并且最后在body标签结束之前,您会引用main.js文件,其中将包含所有的JavaScript代码。

你可能注意到这些元素都有ID和类。在后面的步骤中,你将使用它们来在JavaScript中引用这些元素。

现在保存并退出文件。

创建并打开 main.css 文件。

  1. nano main.css

 

在你的main.css文件中,添加以下内容来样式化元素:

工人演示/主要.css
body {
  background: #fff;
  font-size: 16px;
}

.wrapper {
  max-width: 600px;
  margin: 0 auto;
}
.total-count {
  margin-bottom: 34px;
  font-size: 32px;
  text-align: center;
}

.buttons {
  border: 1px solid green;
  padding: 1rem;
  margin-bottom: 16px;
}

.btn {
  border: 0;
  padding: 1rem;
}

.btn-blocking {
  background-color: #f44336;
  color: #fff;
}

#changebtn {
  background-color: #4caf50;
  color: #fff;
}

.buttons定义了实心绿边框和轻微的内边距,而阻止任务进程又进一步通过.btn-blocking样式定义了不同的背景颜色。

保存并关闭文件。

既然你已经定义了CSS样式,现在你需要编写JavaScript代码来使HTML元素交互。保存并退出你的文件。

在你的编辑器中创建并打开main.js文件。

  1. nano main.js

 

在你的main.js文件中,添加以下代码以引用DOM元素:

工人演示/主要.js
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");

在前三行中,你使用document对象的getElementByID()方法引用了按钮的ID。在最后两行中,你使用document对象的querySelector()方法引用了div元素的类名。

接下来,定义一个事件监听器,当点击incrementBtn按钮时,它将增加一个div元素的值。

工人演示/主要.js
...
totalCountEl.textContent = 0;

incrementBtn.addEventListener("click", function incrementValue() {
  let counter = totalCountEl.textContent;
  counter++;
  totalCountEl.textContent = counter;
});

首先,你将totalCountEl元素的文本内容设置为0。然后,你使用DOM的addEventListener()方法将一个事件监听器附加到incrementBtn按钮上。该方法接受两个参数:要监听的事件和一个回调函数。在这里,事件监听器监听点击事件,并在点击事件触发时调用incrementValue()回调函数。

在增加值(incrementValue)回调函数中,你从DOM中获取totalCountEl元素的文本内容值,并将其设置为计数器变量。然后,你将该值增加1,并将增加后的值设置为totalCountEl元素的文本内容。

接下来,将以下代码添加到changeColorBtn按钮上,以便在点击按钮时随机更改背景颜色。

工人示例/主要功能.js
...
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
  colors = ["#009688", "#ffc107", "#dadada"];
  const randomIndex = Math.floor(Math.random() * colors.length)
  const randomColor = colors[randomIndex];
  document.body.style.background = randomColor;
});

在先前的代码中,你附加了一个点击事件监听器,当用户点击changeColorBtn按钮时会运行changeBackgroundColor回调函数。在回调函数中,你将colors变量设置为一个包含三个十六进制颜色值的数组。然后你调用了Math.random()方法,并将其结果与数组长度值相乘,以生成一个0到数组长度3之间的随机数。随机值然后使用Math.Floor()方法四舍五入到最近的整数,并存储在randomIndex变量中。

然后,您使用随机索引从数组中选择一个值,然后将文档对象的 body.style.background 属性设置为该颜色。

现在您已经实现了两个触发非阻塞任务执行的按钮,您将为剩下的按钮附加一个事件监听器,以启动一个占用CPU资源较多的任务。循环将重复五十亿次,并将结果保存在DOM中。

仍然在你的main.js文件中,添加以下代码来为一个按钮添加点击事件监听器,该按钮将启动一个阻塞任务。

工人示例/主.js
...
blockingBtn.addEventListener("click", function blockMainThread() {
  let counter = 0;
  for (let i = 0; i < 5_000_000_000; i++) {
    counter++;
  }
  output.textContent = `Result: ${counter}`;
});

在前面的代码中,你附加了一个点击事件监听器,它运行blockMainThread()回调函数。在函数内部,你将计数器设置为0,然后创建了一个循环,循环迭代五十亿次。在每次迭代期间,计数器的值增加1。循环结束后,计算的结果被设置为输出元素的值。

现在完整的文件将与以下内容相匹配。

工人_demo/main.js
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;

incrementBtn.addEventListener("click", function incrementValue() {
  let counter = totalCountEl.textContent;
  counter++;
  totalCountEl.textContent = counter;
});

changeColorBtn.addEventListener("click", function changeBackgroundColor() {
  colors = ["#009688", "#ffc107", "#dadada"];
  const randomIndex = Math.floor(Math.random() * colors.length)
  const randomColor = colors[randomIndex];
  document.body.style.background = randomColor;
});

blockingBtn.addEventListener("click", function blockMainThread() {
  let counter = 0;
  for (let i = 0; i < 5_000_000_000; i++) {
    counter++;
  }
  output.textContent = `Result: ${counter}`;
});

一旦您输入代码完成,保存并退出文件。

为了避免在第三步开始使用Web Workers时出现跨域资源共享(CORS)错误,您需要为应用程序创建一个Web服务器。运行以下命令创建服务器:

  1. npx serve .

 

输入y以确认,控制台将输出“正在提供服务!”的消息来确认服务器正在运行。

Output

┌─────────────────────────────────────────────────────┐ │ │ │ Serving! │ │ │ │ – Local: http://localhost:3000 │ │ – On Your Network: http://your_ip_address:3000 │ │ │ │ Copied local address to clipboard! │ │ │ └─────────────────────────────────────────────────────┘

打开你偏爱的网络浏览器,访问 http://localhost:3000/index.html。

Note

注意:如果您正在远程服务器上进行教程,您可以使用端口转发在浏览器中查看index.html文件。
在当前终端中,输入以下命令启动一个web服务器:
npx serve 。

在提示时,输入y以继续。
您的控制台可能会加载以下错误,但这不会影响您访问web服务器的能力:
OutputERROR:无法将服务器地址复制到剪贴板:找不到“xsel”二进制文件,且回退操作无效。在Debian/Ubuntu上,您可以使用以下命令安装xsel:sudo apt install xsel。

┌─────────────────────────────────────────────────────┐
│ │
│ 服务已启动! │
│ │
│ – 本地: http://localhost:3000 │
│ – 在您的网络中: http://your_ip_address:3000 │
│ │
│ 已将本地地址复制到剪贴板! │
│ │
└─────────────────────────────────────────────────────┘

在本地机器上打开第二个终端,然后输入以下命令:
ssh -L 3000:localhost:3000 your_non_root_user@your_server_ip

返回浏览器并访问http://localhost:3000/index.html以访问您的应用程序主页。

当页面加载时,它将显示一个带有“阻止任务”、“递增”和“改变背景”按钮的主页。递增计数器将从0开始,因为您还没有按下递增计数的按钮。

Screencapture of the homepage with the

首先,多次点击增加按钮,以便在每次点击时更新页面上的数字。

Screencapture of the homepage with the number incremented to seven after clicking the

其次,点击几次“更改背景”按钮来改变页面的背景颜色。

Screencapture of the homepage with background color changed to green after clicking the

最后,点击阻塞任务按钮,然后随机点击增加按钮和更改背景按钮。页面将变得无响应,按钮将无效。这个冻结是因为阻塞任务按钮开始了一个消耗CPU的任务,导致主线程被阻塞,其他代码直到主线程空闲才会执行。过一段时间后,当CPU密集任务完成时,页面将显示结果:5000000000。此时,如果点击其他按钮,它们将恢复正常工作。

正如你所经历的,一个阻塞任务对用户来说是立即可见的,并且会损害你的应用用户体验。

现在你已经创建了一个通过主线程冻结应用程序的阻塞任务的应用程序,你将使用 promises 将 CPU 密集型任务转换为非阻塞任务。

第二步-使用Promise来卸载CPU密集型任务。

使用Fetch API或其他基于Promise的方法处理I/O任务有时会给人一种错误的印象,即将一个CPU密集型任务包装在Promise中可以使其变为非阻塞。如介绍所述,I/O任务之所以是非阻塞的,是因为它们由操作系统处理,操作系统在完成任务时会通知JavaScript引擎。当操作系统执行I/O任务时,与I/O任务相关的回调会在一个队列中等待操作系统的响应。在它们等待的同时,主线程可以处理所有后续任务。当操作系统发送响应时,回调在主线程中执行,回调之间没有并行执行。

为了证明承诺不能使CPU绑定任务变为非阻塞状态,您将在此步骤中将CPU密集型任务包装在一个承诺中。

在你的文字编辑器中,打开main.js文件。

  1. nano main.js

 

在你的main.js文件中,添加这段代码来创建一个calculateCount()函数,它将一个耗费大量CPU的任务封装在一个promise中。

工人演示/主要.js
...
function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 5_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

blockingBtn.addEventListener("click", function blockMainThread(){
  ....
})

calculateCount()函数返回一个Promise。在这个函数中,你使用new Promise语法初始化了一个promise,该promise接受一个回调函数作为参数,该回调函数包含resolve和reject参数。这些参数处理回调中操作的成功或失败。回调中包含了一个耗费CPU的循环,循环迭代五亿次。循环结束后,你使用resolve方法传递结果。

现在你在calculateCount()函数中有一个受CPU限制的任务,删除高亮显示的代码。

工人演示/主要.js
...
blockingBtn.addEventListener("click", function blockMainThread() {
  let counter = 0;
  for (let i = 0; i < 5_000_000_000; i++) {
    counter++;
  }
  output.textContent = `Result: ${counter}`;
});

在移除了代码之后,你将在blockMainThread()函数中调用calculateCount()函数。由于该函数返回一个promise,你需要使用async/await语法来消耗这个promise。

将下面的高亮代码添加到blockMainThread()函数中,使其异步并调用calculateCount()函数。

工人展示/主要.js
...
blockingBtn.addEventListener("click", async function blockMainThread() {
  const counter = await calculateCount();
  output.textContent = `Result: ${counter}`;
});

在上述代码中,你在blockMainThread()函数前加上async关键字以使其异步执行。在函数内部,你用await关键字前缀calculateCount()函数并调用该函数。await操作符等待Promise对象的解析。一旦解析完成,计数器变量被设置为返回的值,并且输出div元素被设置为CPU密集型任务的结果。

现在,您的完整文件将与以下内容匹配:

工人演示/主要.js
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;

incrementBtn.addEventListener("click", function incrementValue() {
  let counter = totalCountEl.textContent;
  counter++;
  totalCountEl.textContent = counter;
});

changeColorBtn.addEventListener("click", function changeBackgroundColor() {
  colors = ["#009688", "#ffc107", "#dadada"];
  const randomIndex = Math.floor(Math.random() * colors.length)
  const randomColor = colors[randomIndex];
  document.body.style.background = randomColor;
});

function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 5_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

blockingBtn.addEventListener("click", async function blockMainThread() {
  const counter = await calculateCount();
  output.textContent = `Result: ${counter}`;
});

完成更改后,保存并退出您的文件。

当您的服务器仍在运行时,在浏览器中刷新http://localhost:3000/index.html。点击增加和更改背景的按钮。之后,点击阻塞任务按钮,然后点击其他按钮。当运行一个占用CPU的任务时,其他按钮仍然无响应,这证明将一个占用CPU的任务包装在一个Promise中并不会使任务非阻塞。

既然你已经尝试过使用承诺(promises)来减轻CPU负载并注意到其失败,那么你将使用Web Workers使CPU密集型任务变得非阻塞。

第三步 – 使用Web Workers来卸载CPU密集型任务

在这一步中,您将创建一个专用的工作线程,通过将 CPU 限制的任务移入一个 worker.js 文件来卸载 CPU 限制的任务。在 main.js 文件中,您将使用 worker.js 文件的路径来实例化一个专用的 Web Worker。一旦 Web Worker 被初始化,CPU 限制的任务将会被卸载到一个独立的线程,而主线程将会自由地处理剩下的任务。

首先,创建一个worker.js文件。

  1. nano worker.js

 

在您的worker.js文件中,添加以下代码以在文件中添加CPU密集型任务。

工人演示/工作者.js
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
  counter++;
}

前面的代码块包含了迄今为止你一直在使用的CPU绑定任务。现在这段代码将在一个单独的线程中运行。

为了确保主线程可以获取计算结果,你需要使用Worker接口的postMessage()方法发送包含数据的消息。

在你的worker.js文件中,添加突出显示的行以将数据发送到主线程。

工人演示/工人.js
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
  counter++;
}
postMessage(counter);

在此行代码中,您使用包含 CPU 绑定任务计算结果的 counter 变量调用 postMessage() 方法。

保存并关闭文件。

既然你把占用CPU的任务转移到了worker.js文件中,打开main.js文件。

  1. nano main.js

 

请删除主要.js文件中包含CPU任务的突出显示行。

工人演示/主要.js
...
function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 5_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

blockingBtn.addEventListener("click", async function blockMainThread() {
  const counter = await calculateCount();
  output.textContent = `Result: ${counter}`;
});

在blockMainThread回调中,添加以下突出显示的代码来初始化工作线程并监听工作线程的消息。

工人示范/主要.js
blockingBtn.addEventListener("click", function blockMainThread() {
  const worker = new Worker("worker.js");
  worker.onmessage = (msg) => {
    output.textContent = `Result: ${msg.data}`;
  };
});

首先,你使用之前创建的worker.js文件的路径创建了一个Worker实例。其次,你将Worker接口的onmessage属性附加到工作线程上,以便监听来自工作线程的任何消息。如果有消息进来,就会触发消息事件,调用回调函数并将消息数据msg作为参数传递。在回调函数中,你使用来自Web Worker的消息修改输出文本的内容。

完整的文件现在将与以下代码块相匹配。

工人展示/主要.js
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;

incrementBtn.addEventListener("click", function incrementValue() {
  let counter = totalCountEl.textContent;
  counter++;
  totalCountEl.textContent = counter;
});

changeColorBtn.addEventListener("click", function changeBackgroundColor() {
  colors = ["#009688", "#ffc107", "#dadada"];
  const randomIndex = Math.floor(Math.random() * colors.length)
  const randomColor = colors[randomIndex];
  document.body.style.background = randomColor;
});

blockingBtn.addEventListener("click", function blockMainThread() {
  const worker = new Worker("worker.js");
  worker.onmessage = (msg) => {
    output.textContent = `Result: ${msg.data}`;
  };
});

保存并退出文件。

在服务器运行时,返回到您的网络浏览器并访问http://localhost:3000/index.html。页面将成功从服务器加载。

首先,点击递增和更改背景按钮几次。其次,点击阻塞任务按钮来启动需要大量CPU资源的任务,然后继续点击其他的按钮。尽管CPU密集型任务仍在执行,但这些按钮现在能正常工作。

您现在可以使用一个专门的Web Worker来卸载一个CPU密集型任务,使之成为非阻塞的。

总结

在这个教程中,你创建了一个启动一个CPU绑定任务并阻塞主线程的应用,并尝试不成功地使用Promises使CPU绑定任务非阻塞。最后,你使用了一个专用的Web Worker将CPU绑定任务转移到另一个线程,使其变得非阻塞。

作为下一步,您可以访问 Web Workers API,全面了解专用 Web Workers。除了专用 Web Workers,Web Workers API 还包含 Shared Workers 和 Service Workers,可以用于提供离线访问和提升性能。

如果您使用Node.js,您可以学习如何在《如何使用Node.js进行多线程编程》中使用工作线程。

广告
将在 10 秒后关闭
bannerAds