Tasks and the Event Loop

The Course
•
September 18, 2026
•
9 min read

gather starts a batch of coroutines and waits for all of them. That’s often exactly what you want. But sometimes you want to start something now and check on it later, or deal with each result the moment it arrives. For that, you need tasks.

Tasks: started now, awaited later

asyncio.create_task(coroutine) hands a coroutine to the event loop, which starts running it as soon as the current coroutine next pauses. You get back a Task, which you can await later for the result:

create_task Python
import asyncio
import time


async def fetch_log(server, delay):
    print(f"  {time.perf_counter() - START:.1f}s  {server}: started")
    await asyncio.sleep(delay)
    print(f"  {time.perf_counter() - START:.1f}s  {server}: done")
    return f"{server} log"


async def main():
    auth = asyncio.create_task(fetch_log("auth", 0.3))       # both start running...
    search = asyncio.create_task(fetch_log("search", 0.1))
    print(f"  {time.perf_counter() - START:.1f}s  main: tasks created, doing other things")
    await asyncio.sleep(0.2)
    print(f"  {time.perf_counter() - START:.1f}s  main: now I need the results")
    print(await auth, "and", await search)                   # ...and are awaited later


START = time.perf_counter()
asyncio.run(main())

Both fetches began when main reached its first await, and they kept running while main did other things. When main finally awaited search, it had already finished, so the result came back immediately.

gather is built on this. It wraps each coroutine in a task, then waits for them all.

Results as they arrive

gather hands back everything at the end, in the order you asked. When you’d rather act on each result as soon as it’s ready, for example to show progress, use asyncio.as_completed:

as_completed Python
import asyncio

DELAYS = {"auth": 0.3, "payments": 0.2, "search": 0.1}


async def fetch_log(server):
    await asyncio.sleep(DELAYS[server])
    return server, [f"{server}: ok"] * 3


async def main():
    for next_done in asyncio.as_completed([fetch_log(s) for s in DELAYS]):
        server, lines = await next_done
        print(f"{server} arrived with {len(lines)} lines")


asyncio.run(main())

The loop runs once per coroutine, in the order they finish. The coroutines don’t come back out of as_completed in order, so fetch_log returns the server’s name alongside its lines. Otherwise you couldn’t tell which result is whose.

Structured concurrency: TaskGroup

A task you create and then forget keeps running in the background, and if it fails, nobody hears about it. Since Python 3.11, asyncio.TaskGroup makes the lifetime of tasks explicit:

async with asyncio.TaskGroup() as group:
    auth = group.create_task(fetch_log("auth"))
    search = group.create_task(fetch_log("search"))
# Here, every task in the group has finished.
print(auth.result(), search.result())

The async with block doesn’t end until every task in it has finished, so nothing can outlive the code that started it. It’s the async version of Day 4’s lesson: resources, tasks included, should have a clear end. Lesson 3 covers what a TaskGroup does when one of its tasks fails.

Bug hunt: the timeout that never fired

A service fetches data and then does some heavy work on it. The caller allows it 0.3 seconds. A heartbeat task ticks every 0.1 seconds, the way a progress display would. The heavy work is even written as an async def:

A 0.3 second limit Python
import asyncio
import time


async def crunch(n):
    """Stand-in for slow, ordinary work: parsing a huge log, compressing a file..."""
    return sum(i * i for i in range(n))    # an async def, without a single await


async def fetch_and_crunch(n):
    await asyncio.sleep(0.05)          # fetching: waiting on the network
    return await crunch(n)             # crunching: pure work, no waiting


async def heartbeat(beats):
    """Tick every 0.1s, the way a progress display would."""
    while True:
        beats.append(round(time.perf_counter() - START, 1))
        await asyncio.sleep(0.1)


async def main():
    beats = []
    ticker = asyncio.create_task(heartbeat(beats))
    try:
        async with asyncio.timeout(0.3):
            result = await fetch_and_crunch(15_000_000)
        print("finished:", result)
    except TimeoutError:
        print("gave up at the 0.3s timeout")
    ticker.cancel()
    print(f"took {time.perf_counter() - START:.1f}s")
    print("heartbeats at", beats)


START = time.perf_counter()
asyncio.run(main())

This playground is an experiment, and its timings are observations. The same amount of work takes different times on different computers. If yours finishes in under 0.3 seconds, add a zero to 15_000_000 and run it again.

Reproduce. It took well over 0.3 seconds, and it finished rather than timing out. The limit was simply ignored.

Inspect. The heartbeat is the state to look at. It should have ticked every 0.1 seconds throughout. It ticked once, at the very start, and then not again until the work was over.

Before you open this, explain why neither the heartbeat nor the timeout could run, even though crunch is an async def. Then try a fix. Read the reveal either way, even if your fix works: it introduces await asyncio.sleep(0), and a way to test that a loop stays responsive without timing anything.

Show the diagnosis, a test and the fix

Narrow. Everything in asyncio runs on one thread, and the event loop can only switch tasks at an await that actually pauses. async def makes crunch a coroutine, but that alone doesn’t make it cooperative. It gives up the thread only at an await, and it contains none. Once it starts, it keeps the thread until it’s finished. The heartbeat can’t tick. And the timeout is also just a timer the event loop has to run, so it can’t fire either. By the time the loop gets control back, the work is already done.

A coroutine doesn’t get interrupted. It gives up control, at each await. Code that never awaits never gives it up, whatever keyword it was defined with.

Pin it. Resist writing a test with a timeout in it. A fixed amount of work takes a different time on every computer, and an event loop that’s briefly busy can make a timeout fire for the wrong reason. Test the property itself instead: other tasks get turns while crunch runs. A ticker counts its turns, and the test looks at how many it got during the crunch:

def test_other_tasks_run_while_crunching(self):
    turns = 0

    async def ticker():
        nonlocal turns
        while True:
            turns += 1
            await asyncio.sleep(0)

    async def attempt():
        ticking = asyncio.create_task(ticker())
        await asyncio.sleep(0)                    # let the ticker start
        before = turns
        await crunch(1_000_000)
        ticking.cancel()
        return turns - before

    self.assertGreater(asyncio.run(attempt()), 0)

A crunch that never gives up the thread allows the ticker no turns at all, on any computer, fast or slow. So the test fails now, and it will pass once crunch cooperates.

Fix. Give the loop a chance to run between pieces of the work. Split it into chunks, and await asyncio.sleep(0) after each one:

async def crunch(n, chunk=20_000):
    """Slow, ordinary work, done in chunks so other tasks get a turn between them."""
    total = 0
    for start in range(0, n, chunk):
        total += sum(i * i for i in range(start, min(start + chunk, n)))
        await asyncio.sleep(0)         # a chance for timers and other tasks to run
    return total

asyncio.sleep(0) waits for no time at all, but it is an await, so the loop gets to run anything that’s ready: a heartbeat, or a timeout cancelling this very task. Swap it in and run the experiment again. The heartbeat keeps ticking, and the timeout fires.

Blocking calls in async code

The same freeze happens with any blocking call, like time.sleep, requests.get, or a big synchronous database query, inside a coroutine on your own machine. They wait without letting the loop do anything else. (This browser’s time.sleep happens to be special and does let other tasks run, which is why this page uses real work instead.) On your machine, the usual cures are an async library, such as httpx instead of requests, or await asyncio.to_thread(blocking_function, ...), which runs the call on a separate thread while the loop carries on. For heavy calculation, chunking as above, or a separate process, is the answer, because threads don’t make Python code calculate faster.

Your turn

Exercise

Progress as logs arrive

Write fetch_all(servers, on_arrival). It fetches every server’s log at once, calls on_arrival(server) as each one arrives, and returns a dict of {server: lines} in the order servers were given.

import asyncio
import sys
import unittest

DELAYS = {"auth": 0.3, "payments": 0.2, "search": 0.1}


async def fetch_log(server):
    await asyncio.sleep(DELAYS[server])
    return server, [f"{server}: line {i}" for i in range(3)]


async def fetch_all(servers, on_arrival):
    raise NotImplementedError("your code here")


class TestFetchAll(unittest.TestCase):
    def test_progress_is_reported_as_logs_arrive(self):
        arrived = []
        asyncio.run(fetch_all(list(DELAYS), arrived.append))
        self.assertEqual(arrived, ["search", "payments", "auth"])

    def test_results_come_back_in_the_order_asked(self):
        logs = asyncio.run(fetch_all(["auth", "search"], lambda server: None))
        self.assertEqual(list(logs), ["auth", "search"])
        self.assertEqual(logs["search"][0], "search: line 0")


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestFetchAll)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

Two different orders, answering two different questions. The callbacks happen in the order logs arrive, which is the order asyncio.as_completed hands them to you. The returned dict is in the order the servers were given. So collect everything as it arrives, and build the result in servers order at the end.

Hint 2: a first step

Loop with for next_done in asyncio.as_completed([...]): as in Results as they arrive, and inside it server, lines = await next_done and on_arrival(server). Store each log as it comes; worry about the order of the returned dict last.

Show one solution
async def fetch_all(servers, on_arrival):
    logs = {}
    for next_done in asyncio.as_completed([fetch_log(server) for server in servers]):
        server, lines = await next_done
        on_arrival(server)
        logs[server] = lines
    return {server: logs[server] for server in servers}

as_completed decides the order of the callbacks. The final dict comprehension decides the order of the result. They’re two different questions, and it’s worth keeping them apart. A caller reading the dict shouldn’t get a different order depending on how fast the network was today.

on_arrival is a callback, like Day 4’s on_error. The fetching code doesn’t know or care whether it’s printing progress, updating a display, or, as in the test, appending to a list.

What you can now do

  • Start work with create_task and collect it later.
  • Handle results in the order they finish with as_completed.
  • Keep tasks’ lifetimes explicit with TaskGroup.
  • Recognise a coroutine that blocks the loop, and give control back with await, or move blocking calls off the loop on your own machine.

Next: timeouts on purpose, and what to do when one source out of three fails.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.