Node が CPU を集中的に使用するタスクを処理する方法は何ですか?次の記事では、Node が CPU 負荷の高いタスクをどのように処理するかを説明します。
私たちは日常業務で次のような言葉を多かれ少なかれ聞いたことがあるでしょう:
ノードは
ノンブロッキング I/O
(ノンブロッキング I/O) およびイベント駆動型
(イベント駆動型)JavaScript 実行環境
(ランタイム) であるため、I/O 集中型の構築に非常に適しています。 Webサービスなどのアプリケーション
同様の言葉を聞いたときに、あなたも私と同じ疑問を抱くのではないでしょうか: シングルスレッド ノードが I/O 集中型アプリケーションの開発に適しているのはなぜですか?
論理的に言えば、マルチスレッドをサポートする言語 (Java や Golang など) の方が、これらのタスクを実行する上でより有利ではないでしょうか?
上記の問題を理解するには、ノードの単一スレッドが何を参照しているのかを知る必要があります。 [関連チュートリアルの推奨事項: nodejs ビデオ チュートリアル ]
実際、ノードがシングルスレッドであると言うときは、スレッド化とは、JavaScript コード
が同じスレッド (メイン スレッド
と呼ぶことができます) で実行されていることを意味するだけであり、ノードで 1 つのスレッドだけが動作しているということではありません## #。実際、Node の最下層は libuv の
マルチスレッド機能 を使用して、一部の
メイン スレッド スレッドで作業の一部 (基本的に I/O 関連の操作) を実行します。タスクが完了すると、結果は
コールバック関数 の形式でメインスレッドの JavaScript 実行環境に返されます。回路図を見てみましょう:
Event Loop(Event Loop) の簡略化されたバージョンです。実際、完全なイベント ループにはタイマーなどのさらに多くのステージがあります。
同時実行モデルは、スレッド (スレッドベース) に基づいています。すべてのネットワーク リクエストの
。##処理する別のスレッド。ただし、Web アプリケーションの場合、主なタスクには
データベースの追加、削除、変更、クエリ、または他の外部サービスや他のネットワーク I/O 操作の要求 が含まれ、これらの操作は最終的にシステム コールに渡されます。
オペレーティング システムの処理が (アプリケーション スレッドの参加なしで) 行われ、非常に遅い (CPU クロック サイクルと比較して) ため、作成されたスレッドはほとんどの場合何もすることがありません。このサービスには、追加の
スレッド切り替えオーバーヘッドも発生します。これらの言語とは異なり、Node はリクエストごとにスレッドを作成しません。
すべてのリクエストの処理はメイン スレッドで発生するため、
スレッド切り替えのオーバーヘッドはなく、処理も行われます。これらの
I/O 操作は
スレッド プール の形式で非同期に実行され、メイン スレッドの実行のブロックを避けるためにイベントの形式でメイン スレッドに結果を伝えます。
理論的には の方が効率的です。ここで注目していただきたいのは、理論上は Node
が高速であると述べたばかりですが、実際には必ずしも高速であるとは限りません。これは、実際には、サービスのパフォーマンスは多くの側面によって影響を受けるためです。ここでは 同時実行モデル
要素のみを考慮しており、ランタイム消費などの他の要素もサービスのパフォーマンスに影響します。たとえば、たとえば、JavaScript
は動的言語であり、データ型は実行時に推論する必要がありますが、Golang
と Java
はどちらも静的言語であり、そのデータも確かに、型はコンパイル時に推論する必要があるため、実際にはより高速に実行され、使用するメモリも少なくなる可能性があります。 ノードは 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 インターフェイスを呼び出そうとします。
/hard_work インターフェイスが次のようになります。大量の
CPU 計算が必要なため、完了するまでに時間がかかるため、行き詰まります。このとき、インターフェイス
/easy_work が影響を与えているかどうかを見てみましょう。
で占有されていることがわかりました。 /hard_work リソースの後、無害な
/easy_work インターフェイスもスタックします。その理由は、
hardWork 関数が Node のメインスレッドをブロックし、
/easy_work のロジックが実行されないためです。ここで言及しておく価値があるのは、この問題が発生するのは Node などのイベント ループに基づくシングルスレッド実行環境だけであり、Java や Golang などのスレッドベースの言語ではこの問題は発生しないということです。では、サービスが本当に
CPU 集中型の タスクを実行する必要がある場合はどうなるでしょうか?言語は変更できませんよね?約束どおり
All in JavaScript についてはどうですか?心配しないでください。##CPU 集中型のタスク
を処理するために、Node は多くのソリューションを用意しています。次に、一般的に使用される 3 つのソリューションを紹介します。それらは: クラスター モジュール
、子プロセス
とワーカースレッド
。
// 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 接続を受信し、それらを別のワーカー プロセスにルーティングします。早速、このノード サービスを実行してみましょう:
/hard_work インターフェイスを再度リクエストします。
他のリクエストがブロックされているという問題を解決したかどうかを確認してください:
9 リクエストを確認できます。結果はスムーズに返されましたが、
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有什么优点:
资源利用率高
:可以充分利用CPU的多核能力
来提升请求处理效率。API设计简单
:可以让你实现简单的负载均衡
和一定程度的高可用
。这里值得注意的是我说的是一定程度的高可用,这是因为Cluster Module的高可用是单机版的
,也就是当宿主机器挂了,你的服务也就挂了,因此更高的高可用肯定是使用分布式集群做的。进程之间高度独立
,避免某个进程发生系统错误导致整个服务不可用。优点说完了,我们再来说一下Cluster Module不好的地方:
资源消耗大
:每一个子进程都是独立的Node运行环境
,也可以理解为一个独立的Node程序,因此占用的资源也是巨大的
。进程通信开销大
:子进程之间的通信通过跨进程通信(IPC)
来进行,如果数据共享频繁是一笔比较大的开销。没能完全解决CPU密集任务
:处理CPU密集型任务时还是有点抓紧见肘
。在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
还提供了诸如exec
和spawn
等函数来启动子进程,并且这些进程可以执行任何的shell
命令而不只是局限于Node脚本,有兴趣的读者后面可以通过官方文档了解一下,这里就不过多介绍了。
最后让我们来总结一下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应用的话,我们只能通过标准输入输出
或者其它方式来进行进程间通信,这是一件很麻烦的事。无论是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 のメリットとデメリットをまとめます。まず第一に、その利点は次のとおりだと思います。
リソース消費量が少ない
: クラスター モジュールや子プロセスのプロセスベースのアプローチとは異なり、ワーカー スレッドはより軽量なプロセスに基づいています。スレッドなので、リソースのオーバーヘッドは 比較的小さいです。ただし、
Sparrow は小規模で設備が充実していますが、各 ワーカー スレッド
には独自の独立した v8 エンジン インスタンス
と イベント ループ
システムがあります。これは、メイン スレッドが停止した場合でも、
ワーカー スレッドは動作し続けることができることを意味します。これに基づいて、実際に多くの興味深いことができるようになります。
父子スレッド通信は便利で効率的です
しかし、ワーカー スレッドは完璧ではありません: Execution なので、特定の子スレッドがハングアップしても他のスレッドに影響が及ぶため、他のスレッドへの影響を防ぐために追加の措置を講じる必要があります。
スレッドデータ共有の面倒な実装
ノード関連の知識の詳細については、nodejs チュートリアル
を参照してください。
以上がCPU を集中的に使用するタスクを処理する Node の方法の簡単な分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。