LoginSignup
11
4

More than 1 year has passed since last update.

Node.jsはシングルスレッド、排他は必要ない

Last updated at Posted at 2021-11-23

※タイトルは釣りです。
この記事はNode.js初心者がasync/await実装をマルチスレッドと誤解して
混乱するのを解消するために書きました。

Node.jsのコードはシングルスレッドで動きます。

(厳密に言うとWorkerとかモジュール内部とかマルチスレッドな部分はありますが、ユーザーが普通に書くコードとしては)

結論

  • あなたがasyncと書いても、コードが2箇所で同時に実行されることはありません。
  • 実行されるコードは常に1箇所のみです。
  • awaitと書くとコードの実行が止まって、他のコードに処理が移ることがあります。
  • awaitではないコードで、処理が他のコードに切り替わることはありません。
  • あなたが作りたいものによって、排他が必要になることがあります。

質問

外部から通信を受け付けるプログラムを書いています。
通信Aを処理している最中に、通信Bが届いたとき、何が起こりますか?

「通信Aの処理が終わるまで、通信Bの受信処理は実行されない」
「通信Aの処理の最中に、通信Bの受信処理が割り込む」

回答

  • (作り方によって)どっちもありうる

awaitの処理が割り込まれない例

//時間がかかる処理
async function heavyOperation(){
  console.log('heavyOperation');
  let x = 0;
  for(let i=0;i<10000000000;i++){
      x += i / 10000000000;
  }
}


async function callback(){
  console.log('callback');
}

async function exec(){

  console.log('exec');

  setTimeout(callback,1);

  const startTime = Date.now();
  console.log('heavy operation started.');
  await heavyOperation();
  console.log('heavy operation finished.', Date.now() - startTime, 'ms');

}

exec();
% node settimeout.js
exec
heavy operation started.
heavyOperation
heavy operation finished. 12254 ms
callback

heavyOperationの実行には12秒ほどかかっていますが、
setTimeout(1ミリ秒)によるcallback実行は後回しにされました。
関数callbackにasyncがついている/ついていないで違いはありません。

awaitの処理が割り込まれる例

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}


async function callback(){
  console.log('callback');
}

async function exec(){

  console.log('exec');

  setTimeout(callback,1);

  const startTime = Date.now();
  console.log('sleep started.');

  await sleep(3000);
  console.log('sleep finished.', Date.now() - startTime, 'ms');

}


exec();
% node sleep.js 
exec
sleep started.
callback
sleep finished. 3000 ms

await sleep(1000ミリ秒)の最中にsetTimeoutによる関数callbackの呼び出しが行われています。

つまりawaitする関数の中身によって、他の処理に切り替わるかどうかが変わります。

処理が割り込むか、割り込まないか、どうやったらわかるの?

const fs = require("fs");

async function readFile1(){
  return (await fs.promises.readFile('./test.txt')).toString();
}

async function readFile2(){
  return fs.readFileSync('./test.txt').toString();
}

「よく考えればわかります。処理がイベントキューに積まれることを意識するべきだ!」

とか言っていると見落としがちです。わかりにくいんです。

この例では、readFile1は割り込みますが、readFile2は割り込みません。
readFile1ではawaitを書かないでPromiseのままreturnすることもできるので
中身でawaitを使っているかどうか、は判断として使えません。
fs.promises.readFileはわかりやすい名前ですが、関数名で見分けがつくとも限りません。

async/awaitで説明しましたが、コールバックでも似たような状況
(Concurrent safeではない)が作れます。

質問の例に戻ると

通信Aを処理している最中に、通信Bが届いたとき、何が起こりますか?

通信A = ファイルの書き込み
通信B = ファイルの送信

という処理をする場合

(そもそも仕様が悪いというのはスルーするとして)
ファイルが存在しなくて送れない、ファイルが古いものを送ってしまう
ということが起こってしまうということです。

関数が割り込みされるかどうか(他の処理が)、

つまりシングルスレッドでも、マルチスレッド程ではないとしても
クリティカルセクション、排他を意識したコーディングが必要になるということです。

どうすればいいの?

いろんなやり方があります。

  1. 通信Aでflag=true(完了時にflag=false)して、通信Bをflag===trueで失敗にする
  2. 通信Aでflag=true(完了時にflag=false)して、通信Bでflag===falseになるまで待つ
  3. 通信A、Bをキューイングして、キューから処理を実行する
  4. async-lockを使う

3について想像が難しい場合は、マルチスレッドパターンですが、Active Object、Proactor、Reactor、Worker threadとか調べるといい説明がでてくるかもしれません。

4のasync-lockはスター数253のライブラリで、Lockという名前がついていますがキューのような動作をしていて、lock.acquireはpromiseを返すので、async/awaitな実装と一緒に使いやすいです。

async-await

const AsyncLock = require('async-lock');
const lock = new AsyncLock();


async function sleep(ms) {
  return lock.acquire('mylock', async ()=>{
    await new Promise(resolve => setTimeout(()=>{
      console.log('sleep resolved');
      resolve();
    }, ms));
  });
}


async function callback(){
  return lock.acquire('mylock', async()=>{
    console.log('callback');
  });
}

async function exec(){

  console.log('exec');

  setTimeout(callback,1);

  const startTime = Date.now();
  console.log('sleep started.');

  await sleep(3000);
  console.log('sleep finished.', Date.now() - startTime, 'ms');

}


exec();
% node sleep2.js
exec
sleep started.
sleep resolved
callback
sleep finished. 3001 ms

'callback'はおおよそ3秒後に表示されます。
sleep関数のlockを抜けた直後にcallback関数のlock内処理が実行されます。
sleep finishedより先にcallbackが表示されることに注意して下さい。

  • lock.acquireは、他のlock.acquireと同時に実行されないことが保証できる
  • lock.acquireを呼んだ順に実行される

注意すること

return lock.acquire('mylock', async()=>{
  return lock.acquire('mylock', async()=>{
    console.log("hi");
  });
});

このように入れ子になると、デッドロックのように止まって何も実行されなくなります。
関数を跨いで入れ子になる場合でも同様なので注意が必要です。
async-lockにはreentrantなlockも使えますが、個人的には特殊なケース以外では使わないように
使い方で注意したほうがいいでしょう。

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4