〜synchronous Node.js IPCを強制する


8

fork()IPC を使用して子プロセスを作成するノードサーバーがあります。ある時点で、子は長期実行タスクの一部として約10Hzで結果を親に送り返します。渡されたペイロードprocess.send()が小さい場合、すべてうまくいきます。送信するすべてのメッセージはすぐに受信され、親によって処理されます。

ただし、ペイロードが「大きい」場合(正確なサイズ制限を特定していません)、親がすぐに受信するのではなく、すべてのペイロードが最初に送信されます。子が実行されると、親が長期実行タスクを実行します。メッセージを処理します。

tl; dr visual:

良い(ペイロードが小さい場合):

child:  send()
parent: receive()
child:  send()
parent: receive()
child:  send()
parent: receive()
...

悪い(大きなペイロードで発生):

child:  send()
child:  send()
child:  send()
(repeat many times over many seconds)
...
parent: receive()
parent: receive()
parent: receive()
parent: receive()
...
  1. これはバグですか?(編集:動作はOS Xでのみ発生し、WindowsやLinuxでは発生しません)
  2. IPCペイロードを小さく保つ以外に、これを回避する方法はありますか?

編集2:以下のサンプルコードは、時間カウンターと反復カウンターの両方を使用して、更新を送信するタイミングを選択します。(実際のコードでは、n回の反復の後、またはループが特定の結果を達成した後に更新を送信することもできます。)ループの代わりにsetInterval/ を使用するようにコードを書き直すことは、setTimeout私にとって最後の手段です。機能を削除します。

編集:問題を再現するテストコードを次に示します。ただし、OS Xでのみ複製され、WindowsやLinuxでは複製されません。

server.js

const opts = {stdio:['inherit', 'inherit', 'inherit', 'ipc']};
const child = require('child_process').fork('worker.js', [], opts);

child.on('message', msg => console.log(`parent: receive() ${msg.data.length} bytes`, Date.now()));

require('http').createServer((req, res) => {
   console.log(req.url);
   const match = /\d+/.exec(req.url);
   if (match) {
      child.send(match[0]*1);
      res.writeHead(200, {'Content-Type':'text/plain'});
      res.end(`Sending packets of size ${match[0]}`);
   } else {
      res.writeHead(404, {'Content-Type':'text/plain'});
      res.end('what?');
   }
}).listen(8080);

worker.js

if (process.send) process.on('message', msg => run(msg));

function run(messageSize) {
   const msg = new Array(messageSize+1).join('x');
   let lastUpdate = Date.now();
   for (let i=0; i<1e7; ++i) {
      const now = Date.now();
      if ((now-lastUpdate)>200 || i%5000==0) {
         console.log(`worker: send()  > ${messageSize} bytes`, now);
         process.send({action:'update', data:msg});
         lastUpdate = Date.now();
      }
      Math.sqrt(Math.random());
   }
   console.log('worker done');
}

約8kで問題が発生します。たとえば、http://localhost:8080/15vshttp://localhost:8080/123456

/15
worker: send()  > 15 bytes 1571324249029
parent: receive() 15 bytes 1571324249034
worker: send()  > 15 bytes 1571324249235
parent: receive() 15 bytes 1571324249235
worker: send()  > 15 bytes 1571324249436
parent: receive() 15 bytes 1571324249436
worker done
/123456
worker: send()  > 123456 bytes 1571324276973
worker: send()  > 123456 bytes 1571324277174
worker: send()  > 123456 bytes 1571324277375
child done
parent: receive() 123456 bytes 1571324277391
parent: receive() 123456 bytes 1571324277391
parent: receive() 123456 bytes 1571324277393

Node v12.7とv12.12の両方で経験。


1
ブロッキングループでメッセージをキューに入れる代わりに、setInterval()
Patrick Roberts、

なぜ@PatrickRobertsあなたは尋問されていますrun()持っているwhileことでループを?それをに切り替えるとsetInterval()私の問題が解決することを示唆していますか?質問に答えると思います。whileこの関数はこのワーカープロセスの唯一の目的であるため、ループを使用します。(小さなIPCペイロードを使用する場合)ループが発生しても問題は発生しませんでした。
Phrogz

1
そのようなブロッキングは有益な目的を果たしません。などの非ブロッキングタイミングメカニズムを使用するとsetInterval()、イベントループが解放され、バックグラウンドでI / Oが実行されます。私はそれがこの問題を確実に解決するとは言っていませんが、できるからといって、あなたのやり方で書くのは奇妙な選択のようです。
Patrick Roberts、

@PatrickRoberts入力ありがとうございます。「できるから」と書いたのではなく、もともとコードがIPCのないコンソールベースだったからです。結果を定期的に出力するwhileループは、当時は妥当なように見えましたが、この問題が発生しています(macOSのみ)。
Phrogz

時間ベースの条件が満たされるまで現在の時間をポーリングするブロッキングループを作成することは、JavaScript期間のアンチパターンです。以前にIPCがあったかどうかは関係ありません。setTimeout()またはを使用して、常にノンブロッキングアプローチを優先しsetInterval()ます。ここでの変更は簡単です。
Patrick Roberts

回答:


3

lノード内のソケットまたはファイル記述子と組み合わせて長時間実行およびブロック中のwhileループがある場合は、常に何かが間違っていることを示しています。

セットアップ全体をテストできなければ、私の主張が本当に正しいかどうかを判断することは困難ですが、短いメッセージはおそらく1つのチャンクでOSに直接渡され、それがOSに渡されます。大きなメッセージで送信がキューに入れられているので、ノードは、より多くのデータを受信できるOSになるまで待つ必要があるだろう、とあなたが持っているようにブロックしwhileながらまで、送信キューでloop終わりました。

だからあなたの質問に、それはバグではありません。

あなたが最新のnodejsバージョンを使用しているので、私はawaitasync代わりにandを使用し、非ブロッキングを作成しますwhile してsleepこの回答のようなますawait場合インターセプトにノードのイベントループを許可するprocessSome約束を保留復帰します。

実際のユースケースを実際に反映していないコードの場合、それを正しく解決する方法を伝えるのは困難です。processSomeI / Oがインターセプトできるように非同期で何もしない場合は、定期的に手動で行う必要がありますawait new Promise(setImmediate);

async function run() {
  let interval = setInterval(() => {
    process.send({action:'update', data:status()});
    console.log('child:  send()');
  }, 1/10)

  while(keepGoing()) {
    await processSome();
  }

  clearInterval(interval)
}

この回答をありがとうございます。質問に対する私の編集によると、私の実際のコードには、更新を送信するための複数の条件があり、そのうちの1つだけが時間に基づいています。processSome()コードをwhileループの外に移動したようです。(または、私は約束に関連する重要な何かを見逃している。)
Phrogz

1
@Phrogz ah ok no、間違って中括弧を間違って読みました。がtrueの場合、および のすべての反復でprocess.send({action:'update', data:status()});実行されるように、答えを更新しました。場合でも、切片にノードのEvenLoopを可能にしなければならない約束を返さないです。しかし、問題の理由は依然としてループがブロックしていることです。every10HzprocessSomewhileawaitprocessSome
t.niese

この回答についての2つのコメントは現状のままです。processSome()がpromiseを返さない場合でも、このアプローチはI / Oをブロックします(このawaitステートメントによって生成される継続のようなマイクロタスクはIOの前に処理されます)。また、これにより、反復ごとにキューに入れられたマイクロタスクが原因で、反復の実行速度が大幅に低下します。
Patrick Roberts、

@PatrickRobertsはい、あなたは正しいです、それは未解決の約束を返さなければなりません。
t.niese

2

最初の質問について

これはバグですか?(編集:動作はOS Xでのみ発生し、WindowsやLinuxでは発生しません)

これは間違いなくバグではなく、Windows 10で再現できました(サイズ123456)。2つの別個のプロセス(分離されていない)がipc記述子を介して通信しているため、これは主に、基盤となるカーネルバッファリングとOSによるコンテキスト切り替えが原因です。

2番目の質問について

IPCペイロードを小さく保つ以外に、これを回避する方法はありますか?

私が問題を正しく理解している場合、httpリクエストごとに、ワーカーがチャンクをサーバーに送り返すたびに、次のチャンクを取得する前にサーバーで処理する必要があります。 あなたが同期処理と言ったときそれが私が理解する方法です

promiseを使用する方法はありますが、ワーカーでジェネレーターを使用したいと思います。サーバーとワーカー間のフローを調整する方が良い

フロー:

  1. サーバーは、httpリクエストから取得した整数をワーカーに送信します
  2. 次に、ワーカーはジェネレーターを作成して実行し、最初のチャンクを送信します
  3. チャンクを送信した後のワーカーの譲歩
  4. サーバーの追加リクエスト
  5. サーバーがより多くを要求したため、ワーカーはより多くを生成します(利用可能な場合のみ)
  6. これ以上ない場合、ワーカーはチャンクの終わりを送信します
  7. サーバーはワーカーが完了したことをログに記録するだけで、それ以上要求しません

server.js

const opts = {stdio:['inherit', 'inherit', 'inherit', 'ipc'], detached:false};
const child = require('child_process').fork('worker.js', [], opts);

child.on('message', (msg) => {
   //FLOW 7: Worker is done, just log
   if (msg.action == 'end'){
      console.log(`child ended for a particular request`)
   } else {
      console.log(`parent: receive(${msg.data.iter}) ${msg.data.msg.length} bytes`, Date.now())
      //FLOW 4: Server requests for more
      child.send('more')
   }   

});

require('http').createServer((req, res) => {
   console.log(req.url);
   const match = /\d+/.exec(req.url);   
   if (match) {
      //FLOW 1: Server sends integer to worker
      child.send(match[0]*1);
      res.writeHead(200, {'Content-Type':'text/plain'});
      res.end(`Sending packets of size ${match[0]}`);
   } else {
      res.writeHead(404, {'Content-Type':'text/plain'});
      res.end('what?');
   }
}).listen(8080);

worker.js

let runner
if (process.send) process.on('message', msg => {   
   //FLOW 2: Worker creates and runs a generator to send the first chunk
   if (parseInt(msg)) {
      runner = run(msg)
      runner.next()
   }
   //FLOW 5: Server asked more, so generate more chunks if available
   if (msg == "more") runner.next()

});

//generator function *
function* run(messageSize) {
   const msg = new Array(messageSize+1).join('x');
   let lastUpdate = Date.now();
   for (let i=0; i<1e7; ++i) {
      const now = Date.now();
      if ((now-lastUpdate)>200 || i%5000==0) {
         console.log(`worker: send(${i})  > ${messageSize} bytes`, now);
         let j = i         
         process.send({action:'update', data:{msg, iter:j}});
         //FLOW 3: Worker yields after sending the chunk
         yield
         lastUpdate = Date.now();
      }
      Math.sqrt(Math.random());
   }
   //FLOW 6: If no more, worker sends end signal
   process.send({action:'end'});
   console.log('worker done');
}

正確なユースケースがわかっている場合、それをプログラムするより良い方法があるかもしれません。 これは、元のソースコードの多くを保持している子プロセスを同期する方法の1つにすぎません。


1

次のメッセージを送信する前にメッセージの受信を保証する必要がある場合は、マスターが受信を確認するまで待つことができます。これは当然次のメッセージの送信を遅らせますが、ロジックはメッセージを送信するかどうかを決定するために時間と反復数の両方に依存しているので、それはあなたのケースで大丈夫かもしれません。

実装では、各ワーカーが送信されるメッセージごとにプロミスを作成し、プロミスを解決する前にマスターからの応答を待つ必要があります。これは、同時に複数のメッセージまたはワーカーがある場合、メッセージIDまたは一意の何かに基づいて、どのメッセージが確認応答されるかを識別する必要があることも意味します。

これが修正されたコードです

server.js

const opts = {stdio:['inherit', 'inherit', 'inherit', 'ipc']};
const child = require('child_process').fork('worker.js', [], opts);

child.on('message', msg =>  {
    console.log(`parent: receive() ${msg.data.length} bytes`, Date.now())
    // reply to the child with the id
    child.send({ type: 'acknowledge', id: msg.id });
});

...

worker.js

const pendingMessageResolves = {};

if (process.send) process.on('message', msg => { 
    if (msg.type === 'acknowledge') {
        // call the stored resolve function
        pendingMessageResolves[msg.id]();
        // remove the function to allow the memory to be freed
        delete pendingMessageResolves[msg.id]
    } else {
        run(msg) 
    }
});

const sendMessageAndWaitForAcknowledge = (msg) => new Promise(resolve => {
    const id = new uuid(); // or any unique field
    process.send({ action:'update', data: msg, id });
    // store a reference to the resolve function
    pendingMessageResolves[id] = resolve;
})

async function run(messageSize) {
    const msg = new Array(messageSize+1).join('x');
    let lastUpdate = Date.now();
    for (let i=0; i<1e7; ++i) {
        const now = Date.now();
        if ((now-lastUpdate)>200 || i%5000==0) {
            console.log(`worker: send()  > ${messageSize} bytes`, now);
            await sendMessageAndWaitForAcknowledge(msg); // wait until master replies
            lastUpdate = Date.now();
        }
        Math.sqrt(Math.random());
    }
    console.log('worker done');
}

PS私はコードをテストしなかったので、多少の調整が必要になるかもしれませんが、アイデアは保持されるはずです。


1

最適な解決策は、子プロセスが各ループの終わりに自発的に制御を放棄し、バッファーフラッシュプロセスを実行できるようにすることであると他の人に同意しますが、ほぼ同期する簡単/迅速/汚い修正があります動作、つまり、子send呼び出しをブロックすることです。

server.js以前と同じように、ほとんど同じように使用して、worker.js1行だけ追加します。

worker.js

if (process.send) process.on('message', msg => run(msg));

// cause process.send to block until the message is actually sent                                                                                
process.channel.setBlocking(true);

function run(messageSize) {
   const msg = new Array(messageSize+1).join('x');
   let lastUpdate = Date.now();
   for (let i=0; i<1e6; ++i) {
      const now = Date.now();
      if ((now-lastUpdate)>200 || i%5000==0) {
         console.error(`worker: send()  > ${messageSize} bytes`, now);
         process.send({action:'update', data:msg});
         lastUpdate = Date.now();
      }
      Math.sqrt(Math.random());
   }
   console.log('worker done');
}

出力:

/123456
worker: send()  > 123456 bytes 1572113820591
worker: send()  > 123456 bytes 1572113820630
parent: receive() 123456 bytes 1572113820629
parent: receive() 123456 bytes 1572113820647
worker: send()  > 123456 bytes 1572113820659
parent: receive() 123456 bytes 1572113820665
worker: send()  > 123456 bytes 1572113820668
parent: receive() 123456 bytes 1572113820678
worker: send()  > 123456 bytes 1572113820678
parent: receive() 123456 bytes 1572113820683
worker: send()  > 123456 bytes 1572113820683
parent: receive() 123456 bytes 1572113820687
worker: send()  > 123456 bytes 1572113820687
worker: send()  > 123456 bytes 1572113820692
parent: receive() 123456 bytes 1572113820692
parent: receive() 123456 bytes 1572113820696
worker: send()  > 123456 bytes 1572113820696
parent: receive() 123456 bytes 1572113820700
worker: send()  > 123456 bytes 1572113820700
parent: receive() 123456 bytes 1572113820703
worker: send()  > 123456 bytes 1572113820703
parent: receive() 123456 bytes 1572113820706
worker: send()  > 123456 bytes 1572113820706
parent: receive() 123456 bytes 1572113820709
worker: send()  > 123456 bytes 1572113820709
parent: receive() 123456 bytes 1572113820713
worker: send()  > 123456 bytes 1572113820714
worker: send()  > 123456 bytes 1572113820721
parent: receive() 123456 bytes 1572113820722
parent: receive() 123456 bytes 1572113820725
worker: send()  > 123456 bytes 1572113820725
parent: receive() 123456 bytes 1572113820727

ブロッキングステートメントをソースコードで直接定義するのは賢い考えです。修正できないボトルネックが発生します。その理由は、ソースコードがハードドライブに保存されるため、ルールエンジンを使用して動作をその場で変更することが困難になるためです。
Manuel Rodriguez、
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.