# Asyncio Basics — Part 2: the Event Loop, Tasks, and Cancellation

## 1. Tasks vs. Coroutines — Scheduling Eagerly

```python
import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    task_a = asyncio.create_task(fetch("a", 1))   # scheduled NOW, starts running immediately
    task_b = asyncio.create_task(fetch("b", 1))   # also scheduled now — both run concurrently
    result_a = await task_a   # just waits for something already in progress
    result_b = await task_b
    print(result_a, result_b)
```

`asyncio.create_task(coro)` wraps a coroutine in a `Task` and immediately schedules it on the event loop — the coroutine starts making progress the moment the current coroutine next yields control (e.g. at its own next `await`), not only once someone `await`s the task. This is the key difference from a bare coroutine object, which does *nothing* until awaited. `asyncio.gather()` (Part 1) is actually built on top of this same task-scheduling mechanism internally.

## 2. The Event Loop Is a Single-Threaded Scheduler

```
                 ┌───────────────────────────────────┐
                 │           EVENT LOOP               │
                 │  (one OS thread, one call stack)   │
                 └───────────────────────────────────┘
                       │ picks the next READY task
                       ▼
        ┌───────────┐     ┌───────────┐     ┌───────────┐
        │  Task A   │     │  Task B   │     │  Task C   │
        │ (paused   │     │ (running  │     │ (paused,  │
        │  at await)│     │  now)     │     │  waiting) │
        └───────────┘     └───────────┘     └───────────┘
```

![Three tasks interleave on one loop thread: each runs until its next await, the loop waits in the OS selector when nothing is ready, and a blocking call would freeze every task](diagrams/01-event-loop-tasks.svg)

There is exactly one event loop, running on exactly one OS thread, executing exactly one piece of Python code at any instant — asyncio concurrency is **cooperative**, not parallel. A running coroutine keeps the CPU until it hits an `await` on something not yet ready (I/O, a timer, another task), at which point it voluntarily yields control back to the loop, which then picks another ready task to run. Nothing preempts a coroutine mid-statement the way an OS thread scheduler can preempt a thread — control only changes hands at `await` points.

This is fundamentally different from the `threading` module: threads achieve concurrency via OS-level preemptive context switching across (potentially) multiple cores for I/O-bound work, or contend for the GIL for CPU-bound work; asyncio achieves concurrency on a **single** thread by having coroutines explicitly yield at `await` points. Neither approach gives true CPU parallelism in CPython for pure-Python code (the GIL prevents that for threads too) — asyncio's win is specifically for I/O-bound work with many concurrent waits, where cooperative switching has far lower overhead than OS threads.

## 3. Why a Blocking Call Anywhere Stalls Everything

```python
import asyncio, time

async def worker(n):
    print(f"worker {n} starting")
    time.sleep(2)   # BLOCKS the one and only OS thread the loop runs on
    print(f"worker {n} done")

async def main():
    await asyncio.gather(worker(1), worker(2), worker(3))
    # prints all three "starting" lines, then a 2s pause, then all three "done" lines —
    # NOT interleaved concurrently, because time.sleep() never yields to the loop
```

Because there is only one thread and control only changes hands at `await`, any call that blocks *without* awaiting (a synchronous `time.sleep()`, a synchronous DB driver, CPU-heavy pure-Python work) freezes the entire loop — every other task, no matter how "concurrent" it looks in the code, simply cannot run until that blocking call returns. This is the single most common asyncio production bug: mixing a synchronous, blocking library into async code and silently losing all the concurrency the code appears to have.

## 4. Cancellation

```python
import asyncio

async def long_task():
    try:
        await asyncio.sleep(10)
        return "finished"
    except asyncio.CancelledError:
        print("cleanup: long_task was cancelled")
        raise   # re-raising is the convention — swallowing it can break the caller

async def main():
    task = asyncio.create_task(long_task())
    await asyncio.sleep(0.1)
    task.cancel()                 # requests cancellation — does NOT stop it instantly
    try:
        await task
    except asyncio.CancelledError:
        print("main: confirmed task was cancelled")
```

`task.cancel()` doesn't interrupt the task immediately — it arranges for a `CancelledError` to be raised at the task's **next** suspension point (its current or next `await`). This means a task deep inside a long CPU-bound stretch between `await`s won't actually be cancelled until it reaches one. Code that needs to clean up on cancellation (closing a file, releasing a lock) should catch `CancelledError`, do the cleanup, and **re-raise it** — swallowing it silently marks the task as successfully completed instead of cancelled, which usually surprises whatever is awaiting that task.

## 5. Under the Hood: What `await` Actually Does to a Coroutine

A coroutine object, like a generator, has its own internal frame (locals, instruction pointer) that persists across suspensions — this is the same suspend/resume machinery covered in the Generators and Iterators topic; `async def`/`await` were in fact built on top of generator-based coroutines historically and still share the core mechanism. When code hits an `await some_coroutine`, the event loop doesn't "wait" in the sense of blocking — it registers a callback to resume the current coroutine's frame once `some_coroutine` (or, at the bottom of the chain, an actual OS-level event like a socket becoming readable) is ready, then returns control all the way up to the loop's own run-loop, which is now free to pick literally any other ready task's frame to resume next. Every `await` boundary is a checkpoint where the loop's scheduler gets to choose what runs next — this is precisely why blocking synchronous code is so damaging: it holds the one thread through what *should* have been a checkpoint, denying the scheduler any opportunity to switch.

## 6. Common Pitfalls

1. **Mixing a synchronous blocking call into async code** — stalls the entire event loop for every task, not just the one making the call (see section 3); use an async-native library or run blocking calls in a thread pool executor instead.
2. **Assuming `task.cancel()` stops execution immediately** — it only raises `CancelledError` at the task's next suspension point; a task not currently at an `await` keeps running until it reaches one.
3. **Swallowing `CancelledError` without re-raising it** — silently converts a cancelled task into a successfully completed one from the caller's perspective, hiding the cancellation.
4. **Creating a task with `asyncio.create_task()` and never awaiting or storing it** — the task can be garbage-collected mid-flight with no warning beyond a logged message; always keep a reference and eventually await it.
5. **Expecting asyncio concurrency to speed up CPU-bound work** — cooperative single-threaded scheduling helps overlap I/O waits, not CPU computation; CPU-bound work still needs multiprocessing or a thread pool (see the Concurrency-adjacent topics) to actually parallelize.

## 7. Quick Reference

```python
async def f(): ...                      # defining a coroutine function
f()                                       # calling it returns a coroutine object — does NOT run the body

asyncio.run(main())                        # top-level entry point, creates+drives+tears down the loop
await coro                                   # suspend until coro finishes, get its return value
await asyncio.sleep(n)                         # non-blocking wait — yields control to the loop

asyncio.gather(c1, c2, ...)                       # run several coroutines concurrently, wait for all
asyncio.create_task(coro)                           # schedule NOW, eagerly, independent of await timing

task.cancel()                                         # request cancellation at next suspension point
except asyncio.CancelledError: ... ; raise               # correct cleanup pattern — always re-raise

# single OS thread, cooperative scheduling — a blocking call anywhere stalls every task
```
