Nebenläufige Ausführung: Future-Orchestrierung bei @task
Verantwortung
Die Laufzeitsemantik von @task wird vom PregelRunner übernommen — der Task-Aufruf selbst wird nicht sofort ausgeführt, sondern Funktion und Argumente gehen an den aktuellen runner, der entscheidet, wann und in welchem Thread / welcher Coroutine er läuft. Diese Schicht ist für drei Dinge zuständig: Task-Aufrufe in PUSH-Tasks übersetzen und in eine Queue legen, mehrere Tasks innerhalb desselben Superstep (superstep) gleichzeitig ausführen und die Ergebnisse über ein future an den Aufrufer zurückgeben. PregelRunner (PregelRunner:135) ist das Zentrum dieser Orchestrierung.
Die Position liegt unterhalb von PregelLoop und oberhalb von BackgroundExecutor / AsyncBackgroundExecutor. loop.tick berechnet, welche Tasks in diesem Schritt laufen sollen, und übergibt sie an runner.tick / atick (PregelRunner.tick:176); der runner reicht jeden Task per submit an den executor weiter, verfolgt alle in-flight futures in einem FuturesDict und schreibt, sobald sie done sind, die writes zurück und leitet Ausnahmen an den Fehlerbehandlungs-Knoten weiter.
Entwurfsmotivation
- Task-Aufruf ist PUSH-Task: Wenn du im entrypoint-Rumpf
task_a(); task_b()schreibst, brauchen diese beiden Tasks keine explizite Kante —_call_with_optionsholt denCONFIG_KEY_CALL-Callback aus dem config (das ist das vom runner injizierte_call/_acall), verpackt den Task in eineCall-Datenstruktur und schiebt ihn perschedule_taskin denTASKS-Kanal (schedule PUSH task:713). Die nächste Rundeprepare_next_tasksfindet ihn von selbst. - Einheitliche Abstraktion für synchron und asynchron:
SyncAsyncFuture(SyncAsyncFuture:253) ist zugleich einconcurrent.futures.Futureund ein Objekt mit__await__— sein__await__ist ein einzelnesyield, das die Kontrolle an den äußeren runner abgibt. In einer synchronen entrypoint blockierttask().result()bis das Ergebnis da ist; in einer asynchronen entrypoint gibtawait task()die Coroutine ab; beide nutzen dasselbe future-Objekt. - next_tick-Scheduling: Ein Unter-Task wird beim Schedulen nicht sofort ausgeführt, sondern mit
__next_tick__=True(next_tick:768) dem executor mitgegeben — «erst im nächsten tick laufen». So werden zuerst die writes des aktuellen tick committet und stream-Events ausgegeben, bevor der Unter-Task beginnt. Die Stream-Reihenfolge bleibt damit konsistent mit der Aufrufreihenfolge im Code. - Reentrant-Deduplizierung: Wird derselbe Task mehrfach aufgerufen, sucht
_callzuerst infuturesnach einem future mit derselben id und verwendet es erneut. Ist der Task schon abgeschlossen (next_task.writesnicht leer), wird direkt aus den writes derRETURN-Wert geholt und ein bereits abgeschlossenes future zurückgegeben (dedup already ran:724) — das verhindert, dass Unter-Tasks beim Wiederholen eines Eltern-Tasks erneut ausgeführt werden. max_concurrency-Semaphor: Im asynchronen Pfad liestAsyncBackgroundExecutorconfig["max_concurrency"]und erzeugt einasyncio.Semaphore; jede Task-Coroutine wird ingated(semaphore, coro)gehüllt (gated:214), um die Obergrenze gleichzeitig in-flight befindlicher Tasks innerhalb eines Graphen zu begrenzen und zu verhindern, dass LLM-Aufrufe den Event-Loop überlasten.
Schlüsseldateien
PregelRunner:135— Nebenläufigkeits-Executor; hältsubmit/put_writes/node_error_handler_map/schedule_error_handler.PregelRunner.tick:176— Führt synchron einen Batch von Tasks aus; eineconcurrent.futures.wait(FIRST_COMPLETED)-Schleife holt done futures und committet writes.PregelRunner.atick:360— Asynchrone Variante;asyncio.waitersetzt das synchronewait, Iterator-Semantik bleibt gleich._call:700— DerCONFIG_KEY_CALL-Callback im synchronen Pfad; übersetzt Task-Aufrufe in PUSH-Tasks, dedupliziert und verkettet futures._acall:789— Asynchroner Pfad-Callback; gleiche Semantik, verarbeitet Coroutines.schedule PUSH task:713— Schiebt dieCall-Datenstruktur perschedule_task(tatsächlichloop.accept_push/aaccept_push) in denTASKS-Kanal.SyncAsyncFuture:253— Brückenobjekt, das zugleich ein sync Future und ein async awaitable ist;__await__gibt mit einem einzelnenyielddie Kontrolle ab._call_with_options:276— Implementierung von_TaskFunction.__call__; holtCONFIG_KEY_CALLaus dem config und ruft es auf.BackgroundExecutor:40— Synchroner Threadpool-Context-Manager;submitverwendetcopy_context(), um contextvar im Unter-Thread zu vererben.AsyncBackgroundExecutor:122— Asynchroner Context-Manager auf Basis vonasyncio.get_running_loop(); optionalesmax_concurrency-Semaphor.gated:214—async def gated(semaphore, coro): erstacquire()dannrelease(); legt die Nebenläufigkeitsobergrenze um die Task-Coroutine.
Datenfluss
_call ist der Kern des Task-Aufrufs; es übersetzt «in der entrypoint-Funktion task_fn(arg) aufrufen» in «im runner einen PUSH-Task schedulen»:
# 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_taskDie drei Zweige nacheinander: (1) Task mit derselben id läuft bereits — future wiederverwenden; (2) Task ist schon gelaufen (writes nicht leer) — aus writes den RETURN-Wert holen und ein abgeschlossenes future konstruieren; (3) neuer Task — submit() gibt run_with_retry an den BackgroundExecutor oder AsyncBackgroundExecutor ab und injiziert das rekursive _call als CONFIG_KEY_CALL in den config des Unter-Tasks, sodass ein task()-Aufruf innerhalb des Unter-Tasks denselben Scheduling-Pfad nimmt.
Der Kern von PregelRunner.tick selbst ist ein FuturesDict plus eine concurrent.futures.wait(FIRST_COMPLETED)-Schleife (wait loop:281). Sobald ein future done ist, wird der zugehörige Task herausgeholt und per commit werden seine writes in den loop zurückgeschrieben; falls der Task einen Fehlerbehandlungs-Knoten konfiguriert hat, wird ein weiterer error-handler-Task per submit eingereiht. Die Schleife läuft, bis alle in-flight futures erledigt sind (oder nur noch der get_waiter-Platzhalter-future übrig ist), und gibt die Kontrolle an loop.after_tick zurück, wo die Kanäle geschrieben werden.
Grenzen und Fehler
- Sync-Kontext mit async-Task wirft Fehler:
_callprüft am Anfangif inspect.iscoroutinefunction(func): raise RuntimeError(sync async mismatch:716) — in einer synchronen entrypoint ist keinawaitmöglich, und der asynchrone Task kann nicht laufen. - Ausnahmen von Unter-Tasks blieben zum Eltern-Task durch:
SKIP_RERAISE_SET.add(fut)markiert das future des Unter-Tasks mit «nicht am Ende des tick re-raisen»; die Ausnahme wird perchainFuturean das Eltern-future gekettet und wird erst beimawaitdes Eltern-Tasks sichtbar (skip reraise:781). So folgt die Fehlerweitergabe der Aufrufhierarchie, statt vom runner direkt geworfen zu werden und den gesamten tick abzubrechen. __next_tick__ist im synchronen Modus verzögert:BackgroundExecutor.submitverwendetnext_tick(ctx.run, fn, ...)(next_tick sync:66), um die Task in den nächsten tick einzureihen; im asynchronen executor ist__next_tick__ein noop, weil asyncio-Tasks ohnehin asynchron schedulen.max_concurrencybetrifft nicht die Knoten-Nebenläufigkeit: Das Semaphor desAsyncBackgroundExecutorschränkt nur Task-Coroutines ein (gated), nicht wie viele KnotenPregelRunner.tickgleichzeitig schedul darf — Knoten-Nebenläufigkeit wird durchprepare_next_tasksbestimmt, das Semaphor drosselt nur (semaphore:153).- Unter-Task wird bei Wiederholung des Eltern-Tasks nicht erneut ausgeführt: Wenn der Eltern-Task wiederholt wird, läuft
_callin den Zweig «dedup already ran» und gibt entweder das bestehende future zurück oder holt das Ergebnis auswrites(dedup already ran:724) — idempotent. - contextvar wird vererbt:
BackgroundExecutor.submitverwendetcopy_context()(copy_context:62), damit der Unter-Thread die contextvar des Eltern-Threads (RunnableConfigusw.) übernimmt und der Unter-Task nicht ohne config dasteht.
Zusammenfassung
Die Nebenläufigkeit von @task stammt aus der future-Orchestrierung des PregelRunner; die zentralen Abstraktionen sind SyncAsyncFuture als Brücke zwischen synchron/asynchron, _call / _acall als Übersetzung von Task-Aufruf in PUSH-Task sowie BackgroundExecutor / AsyncBackgroundExecutor als Ausführungsthreads bzw. -coroutinen. Scheduling-Details der PUSH-Tasks stehen in /subgraph/send-command; die Semantik von Tasks in entrypoint in /func/entrypoint-task. Siehe offizielle Dokumentation: LangGraph 文档 · README。