# Asyncio Basics — Part 1: Coroutines, `await`, and Running Them

## 1. `async def` Creates a Coroutine Function

```python
async def greet(name):
    return f"hello, {name}"

result = greet("ana")
print(result)   # <coroutine object greet at 0x...> — NOT "hello, ana"
```

Calling an `async def` function does **not** run its body. It returns a **coroutine object** — a paused, not-yet-started computation, conceptually similar to how calling a generator function returns a generator without running any of its code (see the Generators and Iterators topic). Something has to actually *drive* that coroutine forward for its body to execute at all.

## 2. `asyncio.run()` — the Entry Point

```python
import asyncio

async def greet(name):
    return f"hello, {name}"

result = asyncio.run(greet("ana"))
print(result)   # "hello, ana"
```

`asyncio.run(coro)` creates a fresh event loop, drives the given coroutine to completion, returns its result, and tears the loop down. It's meant to be called **once**, at the top level of a program, to kick off the whole async call stack — never called from inside an already-running coroutine (that raises `RuntimeError: asyncio.run() cannot be called from a running event loop`).

## 3. `await` — Suspending Until a Coroutine Finishes

```python
import asyncio

async def fetch_user(user_id):
    await asyncio.sleep(1)          # simulates a network call — yields control for 1s
    return {"id": user_id, "name": "Ana"}

async def main():
    user = await fetch_user(1)       # suspends main() here until fetch_user finishes
    print(user)

asyncio.run(main())
```

`await` can only appear inside an `async def` function. It hands control back to the event loop while the awaited coroutine is pending, letting the loop run other ready work in the meantime, then resumes exactly where it left off once the awaited thing completes — with its return value as the expression's result.

## 4. `asyncio.sleep()` Is Not `time.sleep()`

```python
import asyncio, time

async def bad():
    time.sleep(1)          # BLOCKS the entire event loop — nothing else can run for 1s
    return "done (blocking)"

async def good():
    await asyncio.sleep(1)  # yields control — other coroutines can run during this second
    return "done (non-blocking)"
```

`time.sleep()` blocks the operating system thread the event loop runs on — since asyncio is single-threaded cooperative concurrency (Part 2 covers this model in depth), one blocking call stalls *everything* scheduled on that loop, not just the coroutine that called it. `asyncio.sleep()` instead tells the event loop "wake me up after N seconds" and immediately yields control, letting other pending coroutines make progress during the wait.

## 5. Running Things Concurrently with `asyncio.gather()`

```python
import asyncio, time

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

async def sequential():
    start = time.perf_counter()
    a = await fetch("a", 1)
    b = await fetch("b", 1)
    print(time.perf_counter() - start)   # ~2.0s — one after another

async def concurrent():
    start = time.perf_counter()
    a, b = await asyncio.gather(fetch("a", 1), fetch("b", 1))
    print(time.perf_counter() - start)   # ~1.0s — both run "at the same time"
```

Awaiting two coroutines back-to-back (`sequential`) runs them one after another — the second doesn't even start until the first fully finishes. `asyncio.gather(*coros)` instead schedules all of them onto the event loop up front and waits for all to finish, returning their results in the same order they were passed in. This is where asyncio's concurrency actually pays off: many I/O-bound waits overlap instead of stacking.

## 6. Coroutines Must Be Awaited (or Scheduled) — Never Just Called

```python
async def fetch_user(user_id):
    await asyncio.sleep(1)
    return {"id": user_id}

async def main():
    fetch_user(1)   # BUG: creates a coroutine object and immediately discards it —
                     # the body never runs, and Python prints a
                     # "coroutine 'fetch_user' was never awaited" RuntimeWarning
```

Forgetting `await` (or `asyncio.gather`/`asyncio.create_task`, covered in Part 2) is one of the most common asyncio mistakes: the call *looks* like it does something, silently returns a coroutine object instead, and the intended work never runs. Python detects this specific pattern and emits a `RuntimeWarning` at garbage-collection time as a safety net — but it's easy to miss in noisy output.

## 7. Common Pitfalls

1. **Calling an `async def` function and expecting it to run immediately** — it returns a coroutine object; something must `await` it or schedule it on the loop.
2. **Using `time.sleep()` (or any blocking call) inside a coroutine** — blocks the entire single-threaded event loop, defeating the whole point of using asyncio; use the non-blocking equivalent (`asyncio.sleep`, an async HTTP client, etc.) instead.
3. **Awaiting coroutines one at a time when they don't depend on each other** — turns what could be concurrent I/O-bound waits into a slower sequential chain; use `asyncio.gather()` (or `asyncio.create_task`, Part 2) when they're independent.
4. **Forgetting `await` on a coroutine call** — silently does nothing except produce an unawaited-coroutine warning; the work never happens.
5. **Calling `asyncio.run()` from inside code that's already running on an event loop** — raises `RuntimeError`; `asyncio.run()` is for the single top-level entry point only.

Continue to `py_asyncio_basics_theory_2.md` for the event loop model itself, tasks vs. coroutines, and cancellation.
