はじめに
Expressでhttpサーバーを作っている中で、重い処理を実行したい場面があります。
例えば、別のサーバーからhttpサーバー内へ何かをダウンロードするとか。
そのリクエストに対して、即座に「ダウンロードを開始しました」と返し、裏でダウンロードを走らせる。またダウンロード中にまた同じリクエストが来たとき、即座に「今ダウンロードしてます」と返したい。
ダウンロード処理を非同期化すれば、できるんでしょと思いきや、意外とすんなりできなかったので、その問題点、原因、対策を共有します。
問題点:うまくいかなかった方法
重い処理のリクエストがあったら、グローバルのisLoading
フラグを立て、asyncで定義した関数を呼び出す。終わったら、フラグを倒す。
こんな感じ。
let isProcessing = false;
app.get("/heavyProcess", (req, res) => {
console.log("/heavyProcessが呼び出されました");
// 既に実行中の場合
if (isProcessing) {
res.status(409).json({
message: "重い処理が既に実行中です",
status: "loading"
});
return;
}
// 重い処理を開始
isProcessing = true;
heavyProcess1("hogehoge", "fugafuga")
.then(() => {
console.log("重い処理が完了しました");
isProcessing = false;
})
.catch(error => {
console.error("重い処理でエラーが発生しました:", error);
isProcessing = false;
})
;
console.log("重い処理、始めました");
// レスポンスを返す
res.json({
message: "重い処理を開始しました",
status: "started"
});
});
こうすると、サーバーでは"重い処理を始めました"が出て、クライアントにも返ります。
ですがその処理中に、もう一度同じURLを呼ぶと、まったくなにも始まらない。"/heavyProcessが呼び出されました"も出ません。クライアント側は、レスポンス待ちでクルクルします。
これでは、1度目のリクエストは非同期の恩恵を受けますが、2度目のリクエストは同期処理のようなもの。
ラーメン屋の注文でいうと、1度目の人は注文を出して席に座ってスマホをいじれますが、2度目の人は注文すら聞いてもらえず立って待っている感じ。
そういう問題です。
原因
Node.jsのシングルスレッド特性により、重い処理がメインスレッドで実行されています。その結果、他のリクエスト処理がブロックされていました。
そのため、isProcessing
とかのハンドリング以前に、app.get("/heavyProcess"~
自体を聞いてもらえない状態でした。
対策
worker_threads
を使って、重い処理を別スレッドで実行。メインスレッドでは、Expressサーバーの処理のみを担当。
具体的には、workerの呼び出し部分だけ切り出します。
import path from "path";
import { fileURLToPath } from "url";
import { Worker } from "worker_threads";
// ~~省略~~
app.get("/heavyProcess", (req, res) => {
// ~省略~
// 重い処理を開始
isProcessing = true;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const worker = new Worker(path.join(__dirname, "./worker.js"));
worker.on("message", (message) => {
if (message.type === "complete") {
console.log("重い処理が完了しました");
isLoading = false;
} else if (message.type === "error") {
console.error("重い処理でエラーが発生しました:", message.error);
isLoading = false;
}
});
worker.on("error", (error) => {
console.error("Workerエラー:", error);
isLoading = false;
});
worker.postMessage({
type: "heavyProcess1",
param1: "hogehoge",
param2: "fugafuga",
});
// ~省略~
次にworker.tsの記述。
他の重い処理も実行できるように、data.type
で呼び出す関数をハンドリングできるようにしてあります。今は1つですが。
import { parentPort } from 'worker_threads';
import { heavyProcess1 } from "./heavyProcess1.js";
parentPort?.on('message', async (data) => {
try {
if (data.type === "heavyProcess1") {
await heavyProcess1(data.param1, data.param2);
parentPort?.postMessage({ type: 'complete' });
}
} catch (error) {
parentPort?.postMessage({ type: 'error', error });
}
});
この結果、2度目のリクエストがあったとき、"重い処理が既に実行中です"と、サラっとかわされるようになりました。
ラーメン屋の注文でいうと、1度目の人は注文を出して席に座ってスマホをいじれ、2度目の人は注文を出すと「今作ってんだよ、座って待ってな!」と言われる感じ。ラーメン屋と違って、2人とも同じ"重い処理"を待つということが、微妙に違うたとえで恐縮ですが。
まとめ
"Node.jsのシングルスレッド特性"という言葉は、どこかで聞いたような気がしますが、初めて真に受けました。今後もご挨拶することがあるでしょう。覚えておきたい方法です。
チャットエージェントの話
この問題は、ChatGPTやPerplexityを使っても、なかなか解決に至りませんでした。
非同期処理を呼び出す方法は正しくて、1つ目のリクエストは正しくハンドリングされており問題はない。2つ目が待ってしまう状況の把握と対応、というためでしょう。
AIに尋ねると、
- 1つ目のリクエストの問題解決(うまくいっているのに改悪)
- 1つ目を速く返す方法(heavyProcessのチューニング)
- 2つ目を速く返す方法(timeoutを短くする)
といった回答に振り回されました。都度「そうじゃなくて」って軌道修正しないといけない。私のプロンプトの出し方にも問題があるだろうし、聞く側が問題を正しく理解しないと正しい解決策が出ないということを思い知りました。
今の段階では人間は伝え方を練習しないといけないし、そのうちAIが賢くなることで解決できるようになるかもしれません。
では、ゆっくり充実した生活を!