⏱️

Node.jsのEvent LoopとSync系メソッド

2024/03/01に公開

execexecSync

Node.jsのexecSync等のSync系メソッドは非Sync系のexecメソッド等と比べて可読性や書き味が良いと思います。

例えばexecSyncでOSのechoを実行する場合は以下のように書けます。

import { execSync } from 'child_process'

const reslut = execSync('echo hello')
console.log(reslut.toString()) // helloが表示される

しかしexecの場合は以下のようにcallback形式で結果を受け取る必要があり、若干見通しが悪いです。
(stdoutの結果を利用して色々やりたい時に、どんどんcallbackのネストが深くなったりします)

import { exec } from 'child_process'

exec('echo hello', (error, stdout, stderr) => {
  console.log(stdout) // helloが表示される
})

promisifyexecの併用

promisifyを利用すればexecSyncのように、上から下に見通し良く書けるようになりますが、
わざわざpromisifyを併用したり、execa等のパッケージを利用するのが大袈裟な時もあります。

import { promisify } from 'util'
import { exec } from 'child_process'

const execPromisified = promisify(exec)
const result = await execPromisified('echo hello')
console.log(result.stdout) // helloが表示される

execSyncの注意点

こういった理由からexecSyncで問題ない箇所はexecSyncを利用することがありますが、以下のようなケースで思わぬ副作用があったたためメモを兼ねて紹介します。

以下は良くあるPromise.allを利用した並列処理で、ぱっと見はsleep 1を同時に10並列走らせるため1秒程で完了するように見えます。
ですが、実際は完了まで10秒程かかります。

import { execSync } from 'child_process'

const sleepMany = async () => {
  await Promise.all(
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(() => execSync('sleep 1')),
  )
}

execSyncとEvent Loop

この理由はexecSyncNode.jsのEvent Loopをブロックしてしまうためで、公式ドキュメントにもしっかり以下の説明があります。

Synchronous process creation

The child_process.spawnSync(), child_process.execSync (), and child_process.execFileSync() methods are synchronous and will block the Node.js event loop, pausing execution of any additional code until the spawned process exits.
ref: https://nodejs.org/api/child_process.html#child_processexecsynccommand-options

execSyncの用途

なおexecSyncがどのような用途に適しているのかも、公式ドキュメントで丁寧に紹介されています

Blocking calls like these are mostly useful for simplifying general-purpose scripting tasks and for simplifying the loading/processing of application configuration at startup.
ref: https://nodejs.org/api/child_process.html#child_processexecsynccommand-options

補足

今回の例に遭遇したきっかけは、元々は単発での利用が想定されていた以下のような関数を、

const someSimpleFn = () => execSync('sleep 1')

以下のようにより上位の複雑な関数に組み込んだ際に気付いたものでした。

const someOuterComplexFn = async () => {
  await Promise.all(
    [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(() => someSimpleFn()),
  )
}

公式の説明のような箇所でexecSyncを使うのは良さそうですが、複数メンバで開発していたり、将来関数が別の箇所や方法で呼ばれる可能性がある場合は最初からexecを利用した方が無難かもしれません。

Discussion