Skip to content

並行実行:@task の future オーケストレーション

源码版本1.2.9

役割

@task の実行時セマンティクスは PregelRunner が引き受けます。task 呼び出し自体は即座に実行されるわけではなく、関数と引数を現在の runner に渡し、runner がいつ・どのスレッド/コルーチンで動かすかを決めます。この層は3つの仕事を担います。task 呼び出しを PUSH タスクに変換してキューに積む、同一スーパーステップ (superstep) 内でトリガーされた複数タスクを並行実行する、future で結果を呼び出し元に返す。PregelRunner(PregelRunner:135)がこのオーケストレーションの中心です。

位置としては PregelLoop の下、BackgroundExecutor / AsyncBackgroundExecutor の上です。loop.tick が当ステップで動かすべきタスクを計算し、runner.tick / atick(PregelRunner.tick:176)に渡します。runner は submit で各タスクを executor に投げ、自身は FuturesDict で全 in-flight future を追跡し、done になったら writes を書き戻し、例外をエラー処理ノードにルーティングします。

設計動機

  • task 呼び出し即ち PUSH タスク:entrypoint 関数本体に task_a(); task_b() と書いても、この2つの task に明示的なエッジは要りません。_call_with_options は config から CONFIG_KEY_CALL コールバック(runner が注入した _call / _acall)を取り出し、task を Call データ構造に包んで schedule_taskTASKS チャネルに積みます(schedule PUSH task:713)。次回の prepare_next_tasks が自然にそれを見つけます。
  • 同期と非同期の統一抽象:SyncAsyncFuture(SyncAsyncFuture:253)は concurrent.futures.Future であると同時に __await__ を持つオブジェクトです。その __await__ は単に1回 yield して制御を外側の runner に返します。同期 entrypoint では task().result() でブロックして値を取り、非同期 entrypoint では await task() でコルーチンを譲ります。両者は同一の未来オブジェクトを共有します。
  • next_tick スケジューリング:子 task はスケジュールされた瞬間に動かず、__next_tick__=True(next_tick:768)で executor に「次の tick で動かす」ことを伝えます。これにより現在の tick の writes が先に commit され、stream イベントが先に流れてから子タスクが始まるため、stream の順序がユーザーがコードに書いた呼び出し順と一致します。
  • 再入の重複排除:同一 task が複数回呼ばれたとき、_call はまず futures から同じ id の future を探し、あれば再利用します。task がすでに完了していれば(next_task.writes が非空)、writes から RETURN 値を取り出して完了済み future を構築して返します(dedup already ran:724)。親タスク再試行時に子タスクが重複実行されるのを防ぎます。
  • max_concurrency セマフォ:非同期パスでは AsyncBackgroundExecutorconfig["max_concurrency"] を取り、asyncio.Semaphore を起動します。すべての task coro を gated(semaphore, coro) で1層包み(gated:214)、同一グラフ内で同時に in-flight な task 数の上限を制御します。LLM 呼び出しでイベントループが圧倒されるのを防ぐためです。

主要ファイル

  • PregelRunner:135 — 並行実行器。submit / put_writes / node_error_handler_map / schedule_error_handler を保持。
  • PregelRunner.tick:176 — タスクのバッチを同期的に実行。concurrent.futures.wait(FIRST_COMPLETED) のループで done future を取り出し、writes を commit。
  • PregelRunner.atick:360 — 非同期版。asyncio.wait が同期 wait を置き換える以外はイテレータセマンティクスが同じ。
  • _call:700 — 同期パスの CONFIG_KEY_CALL コールバック。task 呼び出しを PUSH タスクに変換し、重複排除と future のチェーンを行う。
  • _acall:789 — 非同期パスのコールバック。セマンティクスは上と同じで coroutine を扱う。
  • schedule PUSH task:713schedule_task(実体は loop.accept_push / aaccept_push)経由で Call データ構造を TASKS チャネルに積む。
  • SyncAsyncFuture:253 — sync Future でもあり async awaitable でもある橋渡しオブジェクト。__await__ が単一の yield で制御を返す。
  • _call_with_options:276_TaskFunction.__call__ の実装。config から CONFIG_KEY_CALL を取り出して呼ぶ。
  • BackgroundExecutor:40 — 同期スレッドプールのコンテキストマネージャ。submitcopy_context() で子スレッドに contextvar を継承。
  • AsyncBackgroundExecutor:122 — 非同期コンテキストマネージャ。asyncio.get_running_loop() ベース。オプションで max_concurrency セマフォ。
  • gated:214async def gated(semaphore, coro):まず acquire() してから release() し、並行上限を task coro の外に被せる。

データフロー

_call は task 呼び出しの中核で、「entrypoint 関数内で task_fn(arg) を呼ぶ」を「runner 内で PUSH タスクをスケジュールする」に変換します:

python
# schedule the next task, if the callback returns one
if next_task := schedule_task(
    task(),
    scratchpad.call_counter(),
    Call(
        func,
        input,
        retry_policy=retry_policy,
        cache_policy=cache_policy,
        callbacks=callbacks,
        timeout=timeout,
    ),
):
    if fut := next(
        (
            f
            for f, t in list(futures().items())
            if t is not None and t == next_task.id
        ),
        None,
    ):
        # if the parent task was retried,
        # the next task might already be running
        pass
    elif next_task.writes:
        # if it already ran, return the result
        fut = concurrent.futures.Future()
        ret = next((v for c, v in next_task.writes if c == RETURN), MISSING)
        # ...
    else:
        # schedule the next task
        fut = submit()(
            run_with_retry,
            next_task,
            retry_policy,
            configurable={
                CONFIG_KEY_CALL: partial(
                    _call,
                    weakref.ref(next_task),
                    futures=futures,
                    # ...
                ),
            },
            __reraise_on_exit__=False,
            __next_tick__=True,
        )
        SKIP_RERAISE_SET.add(fut)
        futures()[fut] = next_task

3つの分岐を順に判定します。(1) 同じ id の task が実行中なら future を再利用。(2) task がすでに実行済み(writes が非空)なら writes から RETURN 値を取り出して完了済み future を構築。(3) 全新の task なら submit()run_with_retryBackgroundExecutor または AsyncBackgroundExecutor に投げ、再帰的な _callCONFIG_KEY_CALL として子タスクの config に注入します。これにより子タスク内でさらに task() を呼んでも同じスケジュール機構を通ります。

PregelRunner.tick 自身の中核は FuturesDictconcurrent.futures.wait(FIRST_COMPLETED) ループです(wait loop:281)。future が done になるたびに対応する task を取り出し、commit でその writes を loop に書き戻します。task にエラー処理ノードが設定されていれば error handler task を submit で追加します。ループは全 in-flight future が完了する(または get_waiter のプレースホルダ future だけが残る)まで回り続け、終わると制御を loop.after_tick に返してチャネルに落とします。

境界と失敗

  • 同期コンテキストで async task を呼ぶとエラー:_call の冒頭で if inspect.iscoroutinefunction(func): raise RuntimeError(sync async mismatch:716)。同期 entrypoint では await できず、非同期 task は動かせません。
  • 子 task の例外は親に伝播:SKIP_RERAISE_SET.add(fut) で子 task の future を「tick 終了時に re-raise しない」マーク付けします。例外は chainFuture で親 future に伝播し、親 task が await したときに初めて見えます(skip reraise:781)。これによりエラー伝播経路が呼び出し階層と一致し、runner が直接例外を投げて tick 全体を中断するのを防ぎます。
  • __next_tick__ は同期モードで遅延実行:BackgroundExecutor.submitnext_tick(ctx.run, fn, ...)(next_tick sync:66)でタスクを次回の tick に並べます。非同期 executor では __next_tick__ は noop で、asyncio タスクがもともと非同期にスケジュールされるためです。
  • max_concurrency はノードレベルの並行度には影響しない:AsyncBackgroundExecutor のセマフォは task レベルの coro(gated)だけを制限し、PregelRunner.tick が同時にいくつのノードをスケジュールするかには影響しません。ノードの並行度は prepare_next_tasks が決め、セマフォは単にスロットルします(semaphore:153)。
  • 親タスク再試行時に子タスクは再実行されない:親タスクが retry すると、_call は「dedup already ran」分岐に入り、既存の future を返すか writes から結果を取り出します(dedup already ran:724)。冪等性を保証します。
  • contextvar の継承:BackgroundExecutor.submitcopy_context()(copy_context:62)で子スレッドに親スレッドの contextvar(RunnableConfig など)を継承させ、子タスクが config を取り損なうのを防ぎます。

まとめ

@task の並行性は PregelRunner の future オーケストレーションに由来します。中核の抽象は SyncAsyncFuture で同期/非同期を橋渡しし、_call / _acall が task 呼び出しを PUSH タスクに変換し、BackgroundExecutor / AsyncBackgroundExecutor が実行スレッド/コルーチンを提供します。PUSH タスクのスケジューリング詳細は /subgraph/send-command を、entrypoint 内での task のセマンティクスは /func/entrypoint-task を参照してください。

公式資料:LangGraph 文档 · README