Master

Where the boundary sits

A task boundary is a decision. Each boundary costs bookkeeping, and too many boundaries hide the failure inside a task nobody waits on.

What you will see

caught

About 2 min of reading

Quick check

Before this new lesson, one small question about the lesson you just finished. Recall it, do not look it up.

Try It

Check what a boundary does.

1

Sum gathered.

import asyncio async def fetch(n): return n async def main(): r = await asyncio.gather(*[fetch(i) for i in range(3)]) print(sum(r)) asyncio.run(main())

Before you run it, say out loud what it will print. Then open the answer.

What it prints
3
2

Stages chained.

import asyncio async def stage(n): return n + 1 async def main(): a = await stage(1) b = await stage(a) print(b) asyncio.run(main())

Before you run it, say out loud what it will print. Then open the answer.

What it prints
3
3

Group sized.

import asyncio async def one(n): return n async def main(): tasks = [asyncio.ensure_future(one(i)) for i in range(3)] r = await asyncio.gather(*tasks) print(len(r)) asyncio.run(main())

Before you run it, say out loud what it will print. Then open the answer.

What it prints
3
4

Failure caught.

import asyncio async def bad(): raise ValueError("no data") async def main(): try: await asyncio.gather(bad()) except ValueError: print("caught") asyncio.run(main())

Before you run it, say out loud what it will print. Then open the answer.

What it prints
caught

Put it in order

Before you write anything, put these lines back in the right order. Click a line to move it up one step. This support step is easier than writing code from scratch, and it is here to make the last example stick.

Data Flow

Tasks are built, one await collects them, a failure surfaces at that await, and the boundary count is kept small.
1 build the tasks
↓
2 await them once
↓
3 a failure surfaces there
↓
4 keep the count small
Line by line

Read the first example one line at a time. A line that prints shows what appears on screen; the rest set things up quietly.

import asyncio sets nothing on screen
async def fetch(n): sets nothing on screen
return n sets nothing on screen
async def main(): sets nothing on screen
r = await asyncio.gather(*[fetch(i) for i in range(3)]) sets nothing on screen
print(sum(r)) 3
asyncio.run(main()) sets nothing on screen
Why this works
Boundaries cost

Each task is an object, a schedule slot, and a place an error can land. Fewer boundaries with the same overlap is the cheaper shape.

Errors land at awaits

A failure inside a task surfaces where it is awaited. Put the handling at the boundary and the trace stays readable.

Examples

One boundary

import asyncio

async def fetch(n):
    await asyncio.sleep(0)
    return n

async def main():
    r = await asyncio.gather(*[fetch(i) for i in range(3)])
    print(sum(r))

asyncio.run(main())

One boundary per item, awaited by one gather. The failure path is a single place.

A boundary per stage

import asyncio

async def stage(n):
    await asyncio.sleep(0)
    return n + 1

async def main():
    a = await stage(1)
    b = await stage(a)
    print(b)

asyncio.run(main())

A stage that needs the last answer cannot overlap. The boundary here is a wall, not a task.

A fire and forget

import asyncio

async def log(n):
    await asyncio.sleep(0)

async def main():
    asyncio.create_task(log(1))
    await asyncio.sleep(0)

asyncio.run(main())

A task nobody awaits is a task whose errors go nowhere. Keep the handle or await it at the end.

A group awaited once

import asyncio

async def one(n):
    return n

async def main():
    tasks = [asyncio.ensure_future(one(i)) for i in range(3)]
    r = await asyncio.gather(*tasks)
    print(len(r))

asyncio.run(main())

Build the list of tasks first, then one await. That order is what makes the work overlap.

A failure inside

import asyncio

async def bad():
    raise ValueError("no data")

async def main():
    try:
        await asyncio.gather(bad())
    except ValueError:
        print("caught")

asyncio.run(main())

A raise inside a task arrives at the await, not where the task was made. The try belongs at the boundary.

Count the boundaries

stages = ["read", "parse", "save"]
print(len(stages))

Count the awaits in a run. A number far above the number of real dependencies is the number to reduce.

Words this lesson uses

Each one is defined here on this site. Open the entry for the full definition and the other words it points at.

← Reading a stalled run
Previous Lesson
Reading a stalled run
Next Lesson
Designing shared state
Designing shared state →