Skip to content

How to run tasks concurrently

Call a task to run it now and wait. Submit it to run it in the background and get a Future. Map it to submit one task run per element.

Submit and map

from cereyan import flow, task

@task
def fetch(source: str) -> int:
    return len(source)

@task
def combine(sizes: list[int]) -> int:
    return sum(sizes)

@flow
def gather() -> int:
    futures = fetch.map(["orders", "customers", "products"])
    return combine([f.result() for f in futures])

assert gather() == 6 + 9 + 8

submit(*args, **kwargs) returns a Future at once; result() blocks until the task run ends and re-raises its exception on failure, done() polls, wait() blocks without returning the value, and exception() returns what was raised. map(iterable, **static) submits one run per element and passes the static keyword arguments to each.

Pass futures between tasks

A future given as an argument, at any depth inside lists, tuples, sets, or dicts, is resolved before the receiving task starts, and the dependency is recorded for the timeline:

from cereyan import flow, task

@task
def extract() -> list[int]:
    return [1, 2, 3]

@task
def load(rows: list[int]) -> int:
    return sum(rows)

@flow
def pipeline() -> int:
    rows = extract.submit()
    return load.submit(rows).result()

assert pipeline() == 6

Use wait_for=[future, ...] on a call or submission to order tasks that share no data:

from cereyan import flow, task

order = []

@task
def first() -> None:
    order.append("first")

@task
def second() -> None:
    order.append("second")

@flow
def ordered() -> None:
    f = first.submit()
    second(wait_for=[f])

ordered()
assert order == ["first", "second"]

Choose a runner

The flow's runner decides where submitted tasks execute.

Runner Use for Notes
ThreadRunner(max_workers) (default) I/O-bound work: HTTP (fetching from an API), databases, files Shares the process; the run context propagates. A task that waits on a child while every worker is busy gets a warning and a temporary extra worker, so nested waits cannot deadlock.
ProcessRunner(max_workers) CPU-bound work Each task run is a spawned process; arguments and results must be picklable and the task must be importable from a module (not defined in __main__ or a notebook). A timeout terminates the worker.
from cereyan import flow, task, ThreadRunner

@task
def work(i: int) -> int:
    return i * 2

@flow(runner=ThreadRunner(max_workers=8))
def wide() -> list[int]:
    return [f.result() for f in work.map(range(10))]

assert wide() == [i * 2 for i in range(10)]

max_workers defaults to the CPU count. Concurrency inside a run is separate from concurrency between runs, which resources and caps govern.

Timeouts and retries still apply

Each submitted task run has its own retries and timeout_seconds; a failure surfaces when you call result().

Related: Tasks.