🌝

ただJSONサイズを80%削減する話

に公開
7

Discussion

あいや - aiya000あいや - aiya000

このJSONの圧縮処理は同期的な処理なので

async functionやPromiseの中で実行をすればいい、という単純な話ではない感じですかね?

mpywmpyw

前提として

  • JavaScript 実行環境は(クライアント側・サーバ側ともに)原則的にはシングルスレッド動作です。つまり単一の CPU コアしか利用できません。
  • Promise は IO バウンドな処理の多重化・平行化(並列化ではありません) に使われるだけです。複数 CPU コアを別々のタスクに割り当てるということはできず、単一 CPU コア上であるタスクの IO の待ち時間に別のことをやってもらう ということしか出来ません。

そのため、今回のような CPU バウンドな処理は Promise ベースの提供になっていないことが殆どで、そういった処理は明示的にプロセスやスレッドを起こさないと複数の CPU コアを利用した真の並列化は実現できません。

あいや - aiya000あいや - aiya000

つまり単一の CPU コアしか利用できません。

この記事ではスクレイピングの文脈とのことで、少なくともNode.jsではデフォルトで(必要に応じて)マルチスレッドが使用されるはずと思っています👀

なんとなくわかりやすそうなページ↓
https://qiita.com/darai0512/items/568ea7d49d2c522b7c45

マルチスレッドはOSによって複数のCPUコアが使われる場合がある認識ですが、ここが間違っているのでしょうか?

Promise は IO バウンドな処理の多重化・平行化(並列化ではありません) に使われるだけです

僕が引用した内容は「JSON圧縮が同期処理である」というもので、多重化・並行並列処理に関しては言及していないつもりです🙋‍♂️

またPromiseで行う処理が同期処理であるか非同期処理か、については非同期処理である、と考えているのですが、そこが間違っていますか?

mpywmpyw

少々語弊があったみたいですね,すみません。

Promise は IO バウンドな処理の多重化・平行化(並列化ではありません) に使われるだけです。

こちらは語弊があったので無視してください。これが正しいかどうかは内包する処理に依存する,というのが正しい説明でした。


以下,仕切り直して説明します。具体例を挙げて,各ケースにおける「並行性」と「並列性」について説明します。 JSON.stringify で大きなペイロードを文字列化することを想定してください。

A. JSON.stringify をラップした JavaScript の同期関数

コード例

const syncStringify = (data) => JSON.stringify(data);

説明

  • 並行性: ❌
  • 並列性: ❌

B. JSON.stringify をラップした JavaScript の Promise 関数

コード例

const concurrentStringify = async () => JSON.stringify(data);
const concurrentStringify = (data) => new Promise((resolve) => {
    resolve(JSON.stringify(data));
});

説明

  • 並行性: 🔺
    • セマンティック的にはありに分類されるが,実際のところは無しに近い。 内部の処理が全て同期実行であるため,一度 concurrentStringify の実行に入ると全ての処理が終わるまでこの関数を抜けることはない。
  • 並列性: ❌

C. JSON.stringifychild_process 上で実行するようにラップした Node.js の Promise 関数

コード例

import { fork } from 'node:child_process';
import path from 'node:path';

const parallelStringify = (data) => new Promise((resolve, reject) => {
    const child = fork(path.resolve(__dirname, 'stringify-worker.js'));
    child.on('message', (result) => resolve(result));
    child.on('error', reject);
    child.send(data);
});

stringify-worker.js:

process.on('message', (data) => {
    const result = JSON.stringify(data);
    process.send(result);
});

説明

  • 並行性: ✅
  • 並列性: ✅
    • child_process を使用することで別のプロセスで処理され,CPUのコアを活用した並列実行が可能

D. fs.readFile をラップした Node.js の Promise 関数

コード例

import * as fs from 'node:fs/promises';

const asyncReadFile = async (filePath) => {
    return await fs.readFile(filePath, 'utf8');
}
import * as fs from 'node:fs';

const asyncReadFile = (filePath) => new Promise((resolve, reject) => {
    fs.readFile(filePath, 'utf8', (err, data) => {
        err ? reject(err) : resolve(data);
    });
});

説明

  • 並行性: ✅
  • 並列性: ✅
    • fs.readFilelibuvのスレッドプールを利用するため,複数の IO 操作を並列で処理可能

もとの話に戻りますが,A から B のように書き換えたところで, 「同期処理として提供されているものを Promise でラップしただけでは何も解決しないよ」 が私の一番いいたいところでした。 Promise でラップするだけで OK というのは, D のように元から並列性が実現されているものを取り扱う場合のみであり,そうなっていない処理の場合は基本的には C のような対応が必要ということです。

  • Node.js であれば child_process など(他にも選択肢あり)を使用します。
  • Web ブラウザであれば WebWorker を使用します。
nawadanawada

1ファイル圧縮するのに10分以上かかる

単純に気になったのですが、10秒じゃなく10分ですか…?ファイルがすごく大きいのでしょうか?

nuko_suke_devnuko_suke_dev

あー、すみません、10分なのはCloud Buildのデフォルトの machineType で実行した結果です!ので実行する環境によってはもっと速くなるかと思います〜
ちなみに10分くらいかかってるファイルのサイズは大体100kB~200kBです。
なお、JSONで記述されている各オブジェクトは、オブジェクトや配列がネストされたような複雑なものでもなく、valueが文字列や数値の単純な形式のオブジェクトです。

簡易的ですがコード例も貼っておきます。

import fs from 'node:fs/promises';
import { compress } from 'compressed-json';
import JSONCrush from 'jsoncrush';

function heavyCompressJsonToString(value) {
  return JSONCrush.crush(JSON.stringify(compress(value)));
}

export async function writeDataToLocalFile(exportDir, data) {
  const t0 = performance.now();
  await fs.writeFile(exportDir, heavyCompressJsonToString(data));
  console.log(`Write ${performance.now() - t0} ms`);
}