※タイトルは釣りです。
この記事は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 = ファイルの送信
という処理をする場合
(そもそも仕様が悪いというのはスルーするとして)
ファイルが存在しなくて送れない、ファイルが古いものを送ってしまう
ということが起こってしまうということです。
関数が割り込みされるかどうか(他の処理が)、
つまりシングルスレッドでも、マルチスレッド程ではないとしても
クリティカルセクション、排他を意識したコーディングが必要になるということです。
どうすればいいの?
いろんなやり方があります。
- 通信Aでflag=true(完了時にflag=false)して、通信Bをflag===trueで失敗にする
- 通信Aでflag=true(完了時にflag=false)して、通信Bでflag===falseになるまで待つ
- 通信A、Bをキューイングして、キューから処理を実行する
- 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も使えますが、個人的には特殊なケース以外では使わないように
使い方で注意したほうがいいでしょう。