Async/Aways is Not All You Need

async/await パターンは、C#、C++、Dart、Kotlin、Rust、Python、TypeScript/JavaScript、Swift など多くのプログラミング言語の機能となっています。これにより、非同期の非ブロッキング関数を、通常の同期関数に似た形で構造化することができます。

しかし、このパターンは複数の非同期タスクを同時に実行するには適していません。

たとえば、次の TypeScript コードは、TaskA と TaskB が独立しているにもかかわらず、順次実行されます。

const TaskRunner = async () => {
  const a = await TaskA();
  const b = await TaskB();
  const c = await TaskC(a, b);
}

TaskA と TaskB を同時に実行するには、Promise.all を使用する必要があります。

const TaskRunner = async () => {
  const [a, b] = await Promise.all(TaskA(), TaskB());
  const c = await TaskC(a, b);
}

この方法は単純なケースでは問題ありませんが、次のような複雑なケースでは難しくなります(経験豊富な TypeScript 開発者であれば、続きを読む前に最適化を試してみてください)

const TaskRunner = async () => {
  const a = await TaskA();
  const b = await TaskB();
  const c = await TaskC();
  const d = await TaskD(a, b);
  const e = await TaskE(b, c);
  return TaskF(d, e);
};

このクイズを X やいくつかの開発者フォーラムで開発者に試したところ、多くの開発者、特に経験豊富な開発者でも、次のような答えを出してきました

const TaskRunner = async () => {
  const [a, b, c] = await Promise.all([TaskA(), TaskB(), TaskC()]);
  const [d, e] = await Promise.all([TaskD(a, b), TaskE(b, c)]);
  return TaskF(d, e);
};

これは元のコードよりもかなり性能が向上しますが、最適ではありません。TaskD は TaskC を待つ必要がないのに待ち、TaskE は TaskA を待つ必要がないのに待つことになります。

この問題を指摘したところ、一人の開発者が次のような答えを出しました。TaskD と TaskE の両方が TaskB の完了を待つ必要があることに気付いたのです。

const TaskRunner = async () => {
  const promiseA = TaskA();
  const promiseC = TaskC();
  const b = await TaskB();
  const AthenD = async () => {
    const a = await promiseA;
    return TaskD(a, b);
  }
  const CthenE = async () => {
    const c = await promiseC;
    return TaskE(b, c);
  }
  const [d, e] = await Promise.all([AthenD(), CthenE()]);
  return TaskF(d, e);
}

これは完全に最適化されていますが、このスタイルのコードは非常に読みづらく、スケールしません。何十もの非同期タスクがある場合、最適なコードを書くのは不可能です。

この問題を解決するために、「データフロープログラミング」を提案します。タスクを非循環データフローグラフのノードとして扱い、ノード間の依存関係を記述します。

データフロープログラミングスタイルでは、コードは次のようになります

import { computed } from '@receptron/graphai_lite';

const ExecuteAtoF = async () => {
  const nodeA = computed([], TaskA);
  const nodeB = computed([], TaskB);
  const nodeC = computed([], TaskC);
  const nodeD = computed([nodeA, nodeB], TaskD);
  const nodeE = computed([nodeB, nodeC], TaskE);
  const nodeF = computed([nodeD, nodeE], TaskF);
  return nodeF;
};

computed() は Promise.all の薄いラッパーで(@receptron/graphai_liteで定義されています)、入力ノードの配列と非同期関数から「computed node」を作成します。

const nodeD = computed([nodeA, nodeB], TaskD); は、nodeDtaskD を表すノードであり、nodeAnodeB からのデータを必要とすることを示しています。

このスタイルでは、実行順序を指定する必要はありません。ノード間のデータ依存関係を指定するだけで、システムが適切な順序を自動的に判断し、独立したタスクを同時に実行します。