# Concurrency Patterns for asyncio

## asyncio.gather — Concurrent Execution of Known Coroutines

The workhorse for running multiple coroutines concurrently:

```python
import asyncio

# Basic gather
results = await asyncio.gather(
    fetch_profile(user_id),
    fetch_orders(user_id),
    fetch_preferences(user_id),
)
profile, orders, preferences = results
```

### Key behaviors

- **Order preserved**: Results are in the same order as the input coroutines, regardless of completion order.
- **First exception cancels**: By default, if one coroutine raises, the others are cancelled and the exception propagates.
- **return_exceptions=True**: Exceptions become values in the result list instead of propagating. Useful but dangerous — you must explicitly check each result.

```python
# With return_exceptions — must check results manually
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
    if isinstance(r, Exception):
        logger.error(f"Task failed: {r}")
```

### When to use

- Known, fixed set of coroutines
- All results needed before proceeding
- Python < 3.11 (no TaskGroup)

## asyncio.TaskGroup (3.11+) — Structured Concurrency

Preferred over gather on Python 3.11+:

```python
async with asyncio.TaskGroup() as tg:
    task1 = tg.create_task(fetch_profile(user_id))
    task2 = tg.create_task(fetch_orders(user_id))
    task3 = tg.create_task(fetch_preferences(user_id))

# All tasks guaranteed complete here
profile = task1.result()
orders = task2.result()
preferences = task3.result()
```

### Advantages over gather

- **Automatic cancellation**: If any task fails, all others are cancelled immediately
- **ExceptionGroup**: All exceptions collected into a single ExceptionGroup (not just the first)
- **Scope guarantee**: All tasks complete (or are cancelled) before the `async with` block exits
- **No silent failures**: Unlike `gather(return_exceptions=True)`, failures always raise

### Collecting results from dynamic tasks

```python
results = {}

async with asyncio.TaskGroup() as tg:
    for i, item in enumerate(items):
        async def process(idx=i, it=item):
            results[idx] = await handle(it)
        tg.create_task(process())

ordered_results = [results[i] for i in range(len(items))]
```

## asyncio.Semaphore — Bounded Concurrency

Controls the maximum number of concurrent operations:

```python
semaphore = asyncio.Semaphore(20)  # Max 20 concurrent

async def bounded_fetch(url):
    async with semaphore:
        return await fetch(url)

# All 1000 URLs will be fetched, but only 20 at a time
results = await asyncio.gather(*(bounded_fetch(url) for url in urls))
```

### Choosing the bound

| Scenario | Suggested bound | Rationale |
|----------|----------------|-----------|
| HTTP to external API | 5-50 | API rate limits, politeness |
| HTTP to internal service | 50-200 | Connection pool size |
| Database queries | Pool size | Match the connection pool |
| File I/O | 10-50 | OS file descriptor limits |
| CPU-bound (executor) | `os.cpu_count()` | One per core |

### BoundedSemaphore

`BoundedSemaphore` raises ValueError if released more times than acquired. Use it to catch bugs:

```python
sem = asyncio.BoundedSemaphore(10)  # Raises if release() called without acquire()
```

## asyncio.Queue — Producer-Consumer

For streaming data between coroutines with backpressure:

```python
queue = asyncio.Queue(maxsize=100)  # Backpressure: producer blocks when full

async def producer():
    for url in urls:
        await queue.put(url)  # Blocks if queue is full
    # Signal completion
    for _ in range(num_consumers):
        await queue.put(None)

async def consumer():
    while True:
        url = await queue.get()
        if url is None:
            break
        result = await fetch(url)
        process(result)
        queue.task_done()

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(producer())
        for _ in range(10):
            tg.create_task(consumer())
```

### When to use Queue vs gather

| Use case | Pattern |
|----------|---------|
| All items known upfront, all results needed | `gather` + semaphore |
| Items arrive over time (streaming) | `Queue` producer-consumer |
| Want to process results as they arrive | `asyncio.as_completed()` |
| Need backpressure (slow consumer) | `Queue(maxsize=N)` |

## asyncio.wait — Fine-Grained Control

```python
tasks = {asyncio.create_task(fetch(url)) for url in urls}

# Wait for the first one to complete
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
    result = task.result()
    # Process first result, maybe cancel the rest

# Wait for all with timeout
done, pending = await asyncio.wait(tasks, timeout=5.0)
for task in pending:
    task.cancel()  # Cancel tasks that didn't finish in time
```

### asyncio.as_completed — Process Results as They Arrive

```python
tasks = [asyncio.create_task(fetch(url)) for url in urls]

for coro in asyncio.as_completed(tasks):
    result = await coro  # Returns results in completion order
    process(result)
```

## asyncio.shield — Protect from Cancellation

```python
# Without shield: if parent is cancelled, this task is also cancelled
await asyncio.wait_for(important_task(), timeout=5.0)

# With shield: the task continues even if wait_for times out
try:
    result = await asyncio.wait_for(asyncio.shield(important_task()), timeout=5.0)
except asyncio.TimeoutError:
    # important_task is still running — it wasn't cancelled
    pass
```

Use sparingly — shielded tasks can leak if not tracked.

## asyncio.timeout (3.11+) — Scoped Timeout

```python
async with asyncio.timeout(5.0):
    result = await fetch(url)
    processed = await transform(result)
# Both operations must complete within 5 seconds total
```

Cleaner than `wait_for` for timeouts that span multiple awaits.

## Pattern: Rate Limiter

```python
class RateLimiter:
    """Token bucket rate limiter for async code."""
    def __init__(self, rate: float, burst: int = 1):
        self.rate = rate  # tokens per second
        self.burst = burst
        self._tokens = burst
        self._last: float | None = None
        self._lock = asyncio.Lock()

    async def acquire(self):
        async with self._lock:
            now = asyncio.get_running_loop().time()
            if self._last is None:
                self._last = now
            self._tokens = min(self.burst, self._tokens + (now - self._last) * self.rate)
            self._last = now
            if self._tokens < 1:
                wait = (1 - self._tokens) / self.rate
                await asyncio.sleep(wait)
                self._tokens = 0
            else:
                self._tokens -= 1

# Usage
limiter = RateLimiter(rate=10, burst=5)  # 10 req/s, burst of 5

async def rate_limited_fetch(url):
    await limiter.acquire()
    return await fetch(url)
```

## Pattern: Retry with Exponential Backoff

```python
async def retry(coro_fn, *args, max_retries=3, base_delay=1.0, **kwargs):
    for attempt in range(max_retries + 1):
        try:
            return await coro_fn(*args, **kwargs)
        except Exception as e:
            if attempt == max_retries:
                raise
            delay = base_delay * (2 ** attempt)
            await asyncio.sleep(delay)
```

## Anti-Patterns to Avoid

### Fire-and-forget tasks without tracking

```python
# BAD: task may fail silently, and exception is lost
asyncio.create_task(background_job())

# GOOD: track tasks and handle errors
task = asyncio.create_task(background_job())
task.add_done_callback(lambda t: t.result() if not t.cancelled() else None)

# BEST (3.11+): use TaskGroup
async with asyncio.TaskGroup() as tg:
    tg.create_task(background_job())
```

### Mixing sync and async in loops

```python
# BAD: sync sleep blocks the event loop
async def poll():
    while True:
        data = await fetch_data()
        process(data)
        time.sleep(5)  # BLOCKS!

# GOOD
async def poll():
    while True:
        data = await fetch_data()
        process(data)
        await asyncio.sleep(5)  # yields control
```

### Creating tasks without a reference

```python
# BAD: task can be garbage collected before completion
for url in urls:
    asyncio.create_task(fetch(url))

# GOOD: keep references
tasks = [asyncio.create_task(fetch(url)) for url in urls]
results = await asyncio.gather(*tasks)
```
