ホームページ  >  記事  >  ウェブフロントエンド  >  CPU を集中的に使用するタスクを処理する Node の方法の簡単な分析

CPU を集中的に使用するタスクを処理する Node の方法の簡単な分析

青灯夜游
青灯夜游転載
2022-09-13 19:25:522066ブラウズ

Node が CPU を集中的に使用するタスクを処理する方法は何ですか?次の記事では、Node が CPU 負荷の高いタスクをどのように処理するかを説明します。

CPU を集中的に使用するタスクを処理する Node の方法の簡単な分析

私たちは日常業務で次のような言葉を多かれ少なかれ聞いたことがあるでしょう:

ノードは ノンブロッキング I/O(ノンブロッキング I/O) および イベント駆動型 (イベント駆動型) JavaScript 実行環境 (ランタイム) であるため、I/O 集中型の構築に非常に適しています。 Webサービスなどのアプリケーション

同様の言葉を聞いたときに、あなたも私と同じ疑問を抱くのではないでしょうか: シングルスレッド ノードが I/O 集中型アプリケーションの開発に適しているのはなぜですか? 論理的に言えば、マルチスレッドをサポートする言語 (Java や Golang など) の方が、これらのタスクを実行する上でより有利ではないでしょうか?

上記の問題を理解するには、ノードの単一スレッドが何を参照しているのかを知る必要があります。 [関連チュートリアルの推奨事項: nodejs ビデオ チュートリアル ]

ノードはシングルスレッドではありません

実際、ノードがシングルスレッドであると言うときは、スレッド化とは、JavaScript コード が同じスレッド (メイン スレッド と呼ぶことができます) で実行されていることを意味するだけであり、ノードで 1 つのスレッドだけが動作しているということではありません## #。実際、Node の最下層は libuv の マルチスレッド機能 を使用して、一部の メイン スレッド スレッドで作業の一部 (基本的に I/O 関連の操作) を実行します。タスクが完了すると、結果は コールバック関数 の形式でメインスレッドの JavaScript 実行環境に返されます。回路図を見てみましょう:

CPU を集中的に使用するタスクを処理する Node の方法の簡単な分析

注: 上の図は、Node

Event Loop(Event Loop) の簡略化されたバージョンです。実際、完全なイベント ループにはタイマーなどのさらに多くのステージがあります。

Node は I/O 集中型のアプリケーションに適しています

上記の分析から、Node は libuv のマルチスレッド機能を通じてすべての I/O 操作を分散させることがわかります。別のスレッドで実行し、他の操作はメイン スレッドで実行します。では、なぜこのアプローチが Java や Golang などの他の言語よりも I/O 集中型のアプリケーションに適しているのでしょうか? Web サービスの開発を例に挙げると、Java や Golang などの主流のバックエンド プログラミング言語の

同時実行モデルは、スレッド (スレッドベース) に基づいています。すべてのネットワーク リクエストの 。##処理する別のスレッド。ただし、Web アプリケーションの場合、主なタスクには データベースの追加、削除、変更、クエリ、または他の外部サービスや他のネットワーク I/O 操作の要求 が含まれ、これらの操作は最終的にシステム コールに渡されます。 オペレーティング システムの処理が (アプリケーション スレッドの参加なしで) 行われ、非常に遅い (CPU クロック サイクルと比較して) ため、作成されたスレッドはほとんどの場合何もすることがありません。このサービスには、追加の スレッド切り替えオーバーヘッドも発生します。これらの言語とは異なり、Node はリクエストごとにスレッドを作成しません。すべてのリクエストの処理はメイン スレッドで発生するため、スレッド切り替えのオーバーヘッドはなく、処理も行われます。これらの I/O 操作は スレッド プール の形式で非同期に実行され、メイン スレッドの実行のブロックを避けるためにイベントの形式でメイン スレッドに結果を伝えます。 理論的には の方が効率的です。ここで注目していただきたいのは、理論上は Node が高速であると述べたばかりですが、実際には必ずしも高速であるとは限りません。これは、実際には、サービスのパフォーマンスは多くの側面によって影響を受けるためです。ここでは 同時実行モデル 要素のみを考慮しており、ランタイム消費などの他の要素もサービスのパフォーマンスに影響します。たとえば、たとえば、JavaScript は動的言語であり、データ型は実行時に推論する必要がありますが、GolangJava はどちらも静的言語であり、そのデータも確かに、型はコンパイル時に推論する必要があるため、実際にはより高速に実行され、使用するメモリも少なくなる可能性があります。 ノードは CPU を集中的に使用するタスクには適していません

I/O 関連の操作を除いて、ノードの他の操作はメイン スレッドで実行されることを上で述べました。したがって、ノードがいくつかの CPU 集中型 タスクを処理するときに、メインスレッドがブロックされます。 CPU を大量に使用するタスクの例を見てみましょう:

// node/cpu_intensive.js

const http = require('http')
const url = require('url')

const hardWork = () => {
  // 100亿次毫无意义的计算
  for (let i = 0; i  {
  const urlParsed = url.parse(req.url, true)

  if (urlParsed.pathname === '/hard_work') {
    hardWork()
    resp.write('hard work')
    resp.end()
  } else if (urlParsed.pathname === '/easy_work') {
    resp.write('easy work')
    resp.end()
  } else {
    resp.end()
  }
})

server.listen(8080, () => {
  console.log('server is up...')
})

上記のコードでは、2 つのインターフェイスを持つ HTTP サービスを実装しています: /hard_workこのインターフェイスは、hardWork This を呼び出すため、CPU 集中型のインターフェイスです。 CPU 負荷の高い 関数ですが、/easy_work インターフェイスは非常にシンプルで、クライアントに文字列を直接返すだけです。 hardWork 関数は CPU を大量に消費すると言われているのはなぜですか?これは、I/O 操作を実行せずに、CPU の 演算ユニット 内の i に対して算術演算を実行するためです。 Node サービスを開始した後、/hard_word インターフェイスを呼び出そうとします。

CPU を集中的に使用するタスクを処理する Node の方法の簡単な分析

/hard_work インターフェイスが次のようになります。大量の CPU 計算が必要なため、完了するまでに時間がかかるため、行き詰まります。このとき、インターフェイス /easy_work が影響を与えているかどうかを見てみましょう。

CPU を集中的に使用するタスクを処理する Node の方法の簡単な分析

CPU が

で占有されていることがわかりました。 /hard_work リソースの後、無害な /easy_work インターフェイスもスタックします。その理由は、hardWork 関数が Node のメインスレッドをブロックし、/easy_work のロジックが実行されないためです。ここで言及しておく価値があるのは、この問題が発生するのは Node などのイベント ループに基づくシングルスレッド実行環境だけであり、Java や Golang などのスレッドベースの言語ではこの問題は発生しないということです。では、サービスが本当に CPU 集中型の タスクを実行する必要がある場合はどうなるでしょうか?言語は変更できませんよね?約束どおり All in JavaScript についてはどうですか?心配しないでください。##CPU 集中型のタスク を処理するために、Node は多くのソリューションを用意しています。次に、一般的に使用される 3 つのソリューションを紹介します。それらは: クラスター モジュール子プロセスワーカースレッド

クラスター モジュール

コンセプトの紹介

Node は非常に早い段階 (v0.8 バージョン) でクラスター モジュールを起動しました。このモジュールの機能は、親プロセスから子プロセスのグループを開始する

ことでネットワーク リクエストの負荷を分散することです。記事の長さの制限のため、クラスター モジュールの API については詳しく説明しません。興味のある読者は後で公式ドキュメントを読むことができます。ここでは、クラスター モジュールを使用して上記の CPU 負荷の高い処理を最適化する方法を直接見ていきます。シナリオ:

// node/cluster.js

const cluster = require('cluster')
const http = require('http')
const url = require('url')

// 获取CPU核数
const numCPUs = require('os').cpus().length

const hardWork = () => {
  // 100亿次毫无意义的计算
  for (let i = 0; i  {
    console.log(`worker ${worker.process.pid} is online`)
  })

  cluster.on('exit', (worker, code, signal) => {
    // 某个工作进程挂了之后,我们需要立马启动另外一个工作进程来替代
    console.log(`worker ${worker.process.pid} exited with code ${code}, and signal ${signal}, start a new one...`)
    cluster.fork()
  })
} else {
  // 工作进程启动一个HTTP服务器
  const server = http.createServer((req, resp) => {
    const urlParsed = url.parse(req.url, true)
  
    if (urlParsed.pathname === '/hard_work') {
      hardWork()
      resp.write('hard work')
      resp.end()
    } else if (urlParsed.pathname === '/easy_work') {
      resp.write('easy work')
      resp.end()
    } else {
      resp.end()
    }
  })
  
  // 所有的工作进程都监听在同一个端口
  server.listen(8080, () => {
    console.log(`worker ${process.pid} server is up...`)
  })
}
上記のコードでは、cluster.fork 関数を使用して、CPU コアの数に基づいて同じ数の

ワーカー プロセス を作成します。現在のデバイスであり、これらのワーカー プロセスはすべて 8080 ポートでリッスンしています。これを見て、すべてのプロセスが同じポートでリッスンしている場合に問題があるのではないかと疑問に思うかもしれませんが、実際には、Cluster モジュールの最下層が何らかの作業を行うため、ここでは問題はありません。最終的なリッスン ポートは になります。ポート 8080メイン プロセス であり、メイン プロセスは すべてのトラフィックの入口です。これは HTTP 接続を受信し、それらを別のワーカー プロセスにルーティングします。早速、このノード サービスを実行してみましょう:

#上記の出力から、クラスターは Web リクエストを処理するために 10 個のワーカーを開始しました (私のコンピューターには 10 コアがあります)。この時点で、

/hard_workCPU を集中的に使用するタスクを処理する Node の方法の簡単な分析 インターフェイスを再度リクエストします。

このリクエストはまだスタックしていることが判明したので、再試行します。 Cluster モジュールが

他のリクエストがブロックされているという問題を解決したかどうかを確認してくださいCPU を集中的に使用するタスクを処理する Node の方法の簡単な分析:

以前の

9 リクエストCPU を集中的に使用するタスクを処理する Node の方法の簡単な分析を確認できます。結果はスムーズに返されましたが、

10 番目のリクエスト

でインターフェイスが停止しました。これはなぜですか?その理由は、合計 10 個のワーカー プロセスを開いたためです。子プロセスにトラフィックを送信するときにメイン プロセスによって使用されるデフォルトの負荷分散戦略は ラウンドロビン (ターン) であるため、10 番目のリクエスト (実際には、11 番目のもの (最初のhard_work リクエストが含まれているため) は最初のワーカーに戻るだけで、このワーカーは hard_work タスクの処理を完了していないため、easy_work タスクがスタックしています。クラスターの負荷分散アルゴリズムは、cluster.schedulingPolicy を通じて変更できます。興味のある読者は、公式ドキュメントを参照してください。 <p>从上面的结果来看Cluster Module似乎<code>解决了一部分我们的问题,可是还是有一些请求受到了影响。那么Cluster Module在实际开发里面能不能被用来解决这个CPU密集型任务的问题呢?我的意见是:看情况。如果你的CPU密集型接口调用不频繁而且运算时间不会太长,你完全可以使用这种Cluster Module来优化。可是如果你的接口调用频繁并且每个接口都很耗时间的话,可能你需要看一下采用Child Process或者Worker Thread的方案了。

Cluster Module的优缺点

最后我们总结一下Cluster Module有什么优点:

  • 资源利用率高:可以充分利用CPU的多核能力来提升请求处理效率。
  • API设计简单:可以让你实现简单的负载均衡一定程度的高可用。这里值得注意的是我说的是一定程度的高可用,这是因为Cluster Module的高可用是单机版的,也就是当宿主机器挂了,你的服务也就挂了,因此更高的高可用肯定是使用分布式集群做的。
  • 进程之间高度独立,避免某个进程发生系统错误导致整个服务不可用。

优点说完了,我们再来说一下Cluster Module不好的地方:

  • 资源消耗大:每一个子进程都是独立的Node运行环境,也可以理解为一个独立的Node程序,因此占用的资源也是巨大的
  • 进程通信开销大:子进程之间的通信通过跨进程通信(IPC)来进行,如果数据共享频繁是一笔比较大的开销。
  • 没能完全解决CPU密集任务:处理CPU密集型任务时还是有点抓紧见肘

Child Process

在Cluster Module中我们可以通过启动更多的子进程来将一些CPU密集型的任务负载均衡到不同的进程里面,从而避免其余接口卡死。可是你也看到了,这个办法治标不治本,如果用户频繁调用CPU密集型的接口,那么还是会有一大部分请求会被卡死的。优化这个场景的另外一个方法就是child_process模块。

概念介绍

Child Process可以让我们启动子进程来完成一些CPU密集型任务。我们先来看一下主进程master_process.js的代码:

// node/master_process.js

const { fork } = require('child_process')
const http = require('http')
const url = require('url')

const server = http.createServer((req, resp) => {
  const urlParsed = url.parse(req.url, true)

  if (urlParsed.pathname === '/hard_work') {
    // 对于hard_work请求我们启动一个子进程来处理
    const child = fork('./child_process')
    // 告诉子进程开始工作
    child.send('START')
    
    // 接收子进程返回的数据,并且返回给客户端
    child.on('message', () => {
      resp.write('hard work')
      resp.end()
    })
  } else if (urlParsed.pathname === '/easy_work') {
    // 简单工作都在主进程进行
    resp.write('easy work')
    resp.end()
  } else {
    resp.end()
  }
})

server.listen(8080, () => {
  console.log('server is up...')
})

在上面的代码中对于/hard_work接口的请求,我们会通过fork函数开启一个新的子进程来处理,当子进程处理完毕我们拿到数据后就给客户端返回结果。这里值得注意的是当子进程完成任务后我没有释放子进程的资源,在实际项目里面我们也不应该频繁创建和销毁子进程因为这个消耗也是很大的,更好的做法是使用进程池。下面是子进程(child_process.js)的实现逻辑:

// node/child_process.js

const hardWork = () => {
  // 100亿次毫无意义的计算
  for (let i = 0; i  {
  if (message === 'START') {
    // 开始干活
    hardWork()
    // 干完活就通知子进程
    process.send(message)
  }
})

子进程的代码也很简单,它在启动后会通过process.on的方式监听来自父进程的消息,在接收到开始命令后进行CPU密集型的计算,得出结果后返回给父进程。

运行上面master_process.js的代码,我们可以发现即使调用了/hard_work接口,我们还是可以任意调用/easy_work接口并且马上得到响应的,此处没有截图,过程大家脑补一下就可以了。

除了fork函数,child_process还提供了诸如execspawn等函数来启动子进程,并且这些进程可以执行任何的shell命令而不只是局限于Node脚本,有兴趣的读者后面可以通过官方文档了解一下,这里就不过多介绍了。

Child Process的优缺点

最后让我们来总结一下Child Process的优点有哪些:

  • 灵活:不只局限于Node进程,我们可以在子进程里面执行任何的shell命令。这个其实是一个很大的优点,假如我们的CPU密集型操作是用其它语言实现的(例如c语言处理图像),而我们不想使用Node或者C++ Binding重新实现一遍的话我们就可以通过shell命令调用其它语言的程序,并且通过标准输入输出和它们进行通信从而得到结果。
  • 细粒度的资源控制:不像Cluster Module,Child Process方案可以按照实际对CPU密集型计算的需求大小动态调整子进程的个数,做到资源的细粒度控制,因此它理论上是可以解决Cluster Module解决不了的CPU密集型接口调用频繁的问题。

不过Child Process的缺点也很明显:

  • 资源消耗巨大:上面说它可以对资源进行细粒度控制的优点时,也说了它只是理论上可以解决CPU密集型接口频繁调用的问题,这是因为实际场景下我们的资源也是有限的,而每一个Child Process都是一个独立的操作系统进程,会消耗巨大的资源。因此对于频繁调用的接口我们需要采取能耗更低的方案也就是下面我会说的Worker Thread
  • 进程通信麻烦:如果启动的子进程也是Node应用的话还好办点,因为有内置的API来和父进程通信,如果子进程不是Node应用的话,我们只能通过标准输入输出或者其它方式来进行进程间通信,这是一件很麻烦的事。

Worker Thread

无论是Cluster Module还是Child Process其实都是基于子进程的,它们都有一个巨大的缺点就是资源消耗大。为了解决这个问题Node从v10.5.0版本(v12.11.0 stable)开始就支持了worker_threads模块,worker_thread是Node对于CPU密集型操作轻量级的线程解决方案

概念介绍

Node的Worker Thread和其它语言的thread是一样的,那就是并发地运行你的代码。这里要注意是并发而不是并行并行只是意味着一段时间内多件事情同时发生,而并发某个时间点多件事情同时发生。一个典型的并行例子就是React的Fiber架构,因为它是通过时分复用的方式来调度不同的任务来避免React渲染阻塞浏览器的其它行为的,所以本质上它所有的操作还是在同一个操作系统线程执行的。不过这里值得注意的是:虽然并发强调多个任务同时执行,在单核CPU的情况下,并发会退化为并行。这是因为CPU同一个时刻只能做一件事,当你有多个线程需要执行的话就需要通过资源抢占的方式来时分复用执行某些任务。不过这都是操作系统需要关心的东西,和我们没什么关系了。

上面说了Node的Worker Thead和其他语言线程的thread类似的地方,接着我们来看一下它们不一样的地方。如果你使用过其它语言的多线程编程方式,你会发现Node的多线程和它们很不一样,因为Node多线程数据共享起来实在是太麻烦了!Node是不允许你通过共享内存变量的方式来共享数据的,你只能用ArrayBuffer或者SharedArrayBuffer的方式来进行数据的传递和共享。虽然说这很不方便,不过这也让我们不需要过多考虑多线程环境下数据安全等一系列问题,可以说有好处也有坏处吧。

接着我们来看一下如何使用Worker Thread来处理上面的CPU密集型任务,先看一下主线程(master_thread.js)的代码:

// node/master_thread.js

const { Worker } = require('worker_threads')
const http = require('http')
const url = require('url')

const server = http.createServer((req, resp) => {
  const urlParsed = url.parse(req.url, true)

  if (urlParsed.pathname === '/hard_work') {
    // 对于每一个hard_work接口,我们都启动一个子线程来处理
    const worker = new Worker('./child_process')
    // 告诉子线程开始任务
    worker.postMessage('START')
    
    worker.on('message', () => {
      // 在收到子线程回复后返回结果给客户端
      resp.write('hard work')
      resp.end()
    })
  } else if (urlParsed.pathname === '/easy_work') {
    // 其它简单操作都在主线程执行
    resp.write('easy work')
    resp.end()
  } else {
    resp.end()
  }
})

server.listen(8080, () => {
  console.log('server is up...')
})

在上面的代码中,我们的服务器每次接收到/hard_work请求都会通过new Worker的方式启动一个Worker线程来处理,在worker处理完任务之后我们再将结果返回给客户端,这个过程是异步的。接着再看一下子线程(worker_thead.js)的代码实现:

// node/worker_thread.js

const { parentPort } = require('worker_threads')

const hardWork = () => {
  // 100亿次毫无意义的计算
  for (let i = 0; i  {
  if (message === 'START') {
    hardWork()
    parentPort.postMessage()
  }
})

在上面的代码中,worker thread在接收到主线程的命令后开始执行CPU密集型操作,最后通过parentPort.postMessage的方式告知父线程任务已经完成,从API上看父子线程通信还是挺方便的。

Worker Thread のメリットとデメリット

最後に Worker Thread のメリットとデメリットをまとめます。まず第一に、その利点は次のとおりだと思います。

  • リソース消費量が少ない : クラスター モジュールや子プロセスのプロセスベースのアプローチとは異なり、ワーカー スレッドはより軽量なプロセスに基づいています。スレッドなので、リソースのオーバーヘッドは 比較的小さいです。ただし、Sparrow は小規模で設備が充実していますが、各 ワーカー スレッド には独自の独立した v8 エンジン インスタンス イベント ループ システムがあります。これは、メイン スレッドが停止した場合でも、ワーカー スレッドは動作し続けることができることを意味します。これに基づいて、実際に多くの興味深いことができるようになります。 父子スレッド通信は便利で効率的です
  • : 前の 2 つの方法とは異なり、ワーカー スレッドは IPC を介して通信する必要がなく、すべてのデータはプロセス内で共有および転送されます。
  • しかし、ワーカー スレッドは完璧ではありません:

スレッドの分離性が低い
    : 子スレッドが
  • 独立した環境にないためExecution なので、特定の子スレッドがハングアップしても他のスレッドに影響が及ぶため、他のスレッドへの影響を防ぐために追加の措置を講じる必要があります。 スレッドデータ共有の面倒な実装
  • : 他のバックエンド言語と比較すると、Node のデータ共有はさらに面倒ですが、これにより、実際にはマルチ環境下で多くのデータセキュリティを考慮する必要がなくなりました。 -スレッド化が問題です。
  • 概要

この記事では、Node が I/O 集中型のアプリケーションには適しているものの、CPU 集中型のタスクの処理が難しい理由を紹介しました。理由を説明し、実際の開発で CPU を集中的に使用するタスクを処理するための 3 つのオプションを提供します。実際、各ソリューションには長所と短所があり、実際の状況に応じて選択する必要があります。特定のテクノロジーを使用するためだけに、特定のソリューションを採用する必要はありません。

ノード関連の知識の詳細については、nodejs チュートリアル を参照してください。

以上がCPU を集中的に使用するタスクを処理する Node の方法の簡単な分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.cnで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。