2020-09-08

JavaScriptでバックグラウンドで処理してもらうのはPromiseをawaitしなければよいだけだった

当たり前じゃん、と思うじゃろ? それが他の言語のコンテキストを強く頭に持っているとすぐに発想できんのじゃよ、という話。

やりたいこと

長時間実行するのを前提にした Web アプリ、Web アプリというか単にバッチ処理を PaaS で動かす際に entry point が HTTP に露出しているだけのものがあって、その際、時間の掛かる処理の完了を待たずに 202 Accepted や処理中を意味する status をさっさと返してしまいたい。

前提

  • HTTP request については適切にアクセスが制御されており、不必要な request が大量に来ることはない
  • 長時間掛かるので内部で state を管理する必要があるが、その部分は完成しているものとする

例えばRubyだと

最もカジュアルな方法を、例えば Sinatra っぽい何かと日本語で書くとするとこんな感じになるかな。

post '/' do
  if (state は処理中ではない)
    Thread.new {
      storage に処理中の state を保存
      longrun_process()
      storage に完了の state を保存
    }
    [202, {}, []]
  else
    [412, {}, []]
  end
end

def longrun_process
  ...
end

Thread を使って一応期待通りの挙動になることは確認できた。

これを JavaScript でやりたい。

結論

express っぽい何かと日本語の擬似コードで書くとこんな感じになる。

post('/', async (req, res) => {
  if (state は処理中ではない) {
    await (storage に処理中の state を保存)
    longrunProcess().catch((e) => { throw e }) // <- await を付けない!
    await (storage に完了の state を保存)
    res.status(202).send()
  } else {
    res.status(412).send()
  }
})

ポイントは「待たない処理について await を付けない」これだけ。

ただし、ちゃんとケアしないと UnhandledPromiseRejectionWarning が出るので注意。

間違った考え方

async/await の世界に慣れて、非同期も待てるのが前提の頭になってしまって以下のように考えていたが、これは間違っていた。

  • Thread を使えばよいのか?
  • Fiber を使えばよいのか?
  • え、もう setTimeout({}, 0) でよくない?

JavaScript でのこれらの言葉は Ruby や他の同期的な処理を基本にした言語で考えていた使い方とはだいぶ異なることが分かった。

  • Thread
    • JavaScript の Thread は Worker を形成するための JavaScript のファイルを用意してそこから Worker を立ち上げることから始まる。当然名前空間は完全に丸ごとベツモノになり、呼び出し側で require 済みの module なども何も利用できない。
    • カジュアルさに欠けるし、やりたいことを実現するためのコード量はだいぶ多くなる。
  • Fiber
    • https://github.com/laverdet/node-fibers
    • 名前からしてこれっぽいし、挙動としても完全に期待通りだったが、ドキュメントにはできれば使うなと書かれていて、確かに不要だった。

ちなみに node-fibers を使ったコードはこんな感じになる。

Fiber(async () => {
  // ここにバックグラウンドで動いてほしいコードを書く
}).run()

「await を付けない」よりは明示的でとても嬉しいコードに見える。現在の Promise 前提のコードでも意図的にこういう wrapper を作ることは恐らく可能だと思う。

ちなみに、setTimeout({}, 0) を使う方法でも概ね似た挙動を実現できるんだけど、時間の掛かる処理に入る前にその下に書いたコードが動いてしまうのと、意味合いをつかみにくいのであんまりよくないかなぁということを現時点では考えている。なんとなくイヤなにおいがする。

まとめ

JavaScript はもともと非同期でした。async/await に慣れてほとんどすべての場合で await を付けて処理を書くようになってしまった今こそ、await を付けない挙動を今一度確認し、必要な場合は外しましょう。

おまけ

Worker Thread の上の特徴を理解したうえで完全に CPU bound な処理だけを background に回したい場合は以下の microjob という npm が使える。

https://wilk.github.io/microjob/

データだけは data や ctx で渡せるので、必要な module が何もない場合は以下のように簡単に使える。これはREADME のコードを端折ったもの。

(async () => {
  const { job, start, stop } = require("microjob");

  try {
    // start the worker pool
    await start();

    // this function will be executed in another thread
    const res = await job(() => {
      ..
    });

    console.log(res);
  } catch (err) {
    console.error(err);
  } finally {
    // shutdown worker pool
    await stop();
  }
})();

依存しているものが一切ないことはほとんどないと思うけど、単に await を付けないだけよりは分かりやすく安全なコードを書けそうに見える。

About

例によって個人のなんちゃらです