# Blocking Detection in Async Code

## Complete Blocking Function Catalog

### Always blocking (must replace or wrap)

| Module | Function | Async replacement |
|--------|----------|-------------------|
| `time` | `time.sleep(n)` | `await asyncio.sleep(n)` |
| `requests` | `requests.get/post/put/delete/patch/head` | `httpx.AsyncClient` or `aiohttp.ClientSession` |
| `urllib` | `urllib.request.urlopen()` | `httpx.AsyncClient` or `aiohttp.ClientSession` |
| `subprocess` | `subprocess.run/call/check_output/check_call` | `await asyncio.create_subprocess_exec()` |
| `subprocess` | `subprocess.Popen().communicate()` | `await proc.communicate()` after `create_subprocess_exec` |
| `os` | `os.system()`, `os.popen()` | `await asyncio.create_subprocess_shell()` |
| `socket` | `socket.create_connection()`, `.recv()`, `.send()` | `await asyncio.open_connection()`, streams API |
| `sqlite3` | `sqlite3.connect()`, cursor operations | `aiosqlite.connect()` |
| `smtplib` | `SMTP.send_message()` | `aiosmtplib` |
| `ftplib` | `FTP.retrbinary()` etc. | `aioftp` |

### Conditionally blocking (depends on usage)

| Function | When it blocks | Async alternative |
|----------|----------------|-------------------|
| `open()` | Always for files on disk; fast for small files | `aiofiles.open()` for large files, or `run_in_executor` |
| `json.loads/dumps` | CPU-bound for large payloads (>1MB) | `await asyncio.to_thread(json.loads, data)` for large |
| `pickle.loads/dumps` | CPU-bound for large objects | `await asyncio.to_thread(...)` |
| `re.search/match` | CPU-bound for pathological patterns | Usually fine; wrap if profiler shows it's slow |
| `hashlib.*` | CPU-bound for large inputs | `await asyncio.to_thread(...)` |
| `gzip.compress/decompress` | CPU-bound | `await asyncio.to_thread(...)` |

### Database drivers

| Sync driver | Async equivalent |
|-------------|-----------------|
| `psycopg2` | `asyncpg` or `psycopg` (v3, async mode) |
| `pymysql` | `aiomysql` |
| `sqlite3` | `aiosqlite` |
| `pymongo` | `motor` |
| `redis` (py-redis) | `redis.asyncio` (redis-py 4.2+) or `aioredis` |
| `elasticsearch` | `elasticsearch[async]` |

## Detection Methods

### Method 1: Static analysis via grep (compile-time)

Use Grep to find known blocking functions inside `async def` bodies:

- `time\.sleep` — always blocking
- `requests\.(get|post|put|delete|patch|head|request)` — sync HTTP
- `subprocess\.(run|call|check_output|check_call)` — sync subprocess
- `os\.(system|popen)` — sync shell execution
- `open\(` — sync file I/O (for large files)
- `socket\.create_connection` — sync socket
- `sqlite3\.connect` — sync database

Also grep for `@(functools\.)?(cache|lru_cache)` near `async def` to catch the caching-a-coroutine bug.

### Method 2: asyncio debug mode (runtime)

```bash
PYTHONASYNCIODEBUG=1 $RUNNER -X dev script.py 2>&1 | grep "took"
```

This catches blocking calls that static analysis misses:
- Blocking in third-party libraries (no source to analyze)
- Blocking in C extensions
- CPU-bound code that doesn't call a known function
- Dynamic dispatch where the called function varies

### Method 3: Manual code review (architectural)

Static analysis and debug mode catch individual blocking calls. Manual review catches:
- Sync-only libraries used throughout the codebase (needs architectural refactoring)
- Middleware/decorators that add blocking
- Generator/iterator patterns that hide blocking

## Wrapping Strategies

### Simple wrapping with asyncio.to_thread (3.9+)

```python
# Before: blocking in async
async def get_data():
    data = open("big_file.txt").read()  # blocks
    return process(data)

# After: wrapped
async def get_data():
    data = await asyncio.to_thread(open("big_file.txt").read)
    return process(data)

# Better: wrap the whole blocking section
async def get_data():
    def _sync_work():
        data = open("big_file.txt").read()
        return process(data)
    return await asyncio.to_thread(_sync_work)
```

### run_in_executor with custom executor

```python
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# Thread pool for I/O-bound blocking
io_executor = ThreadPoolExecutor(max_workers=20)

# Process pool for CPU-bound work
cpu_executor = ProcessPoolExecutor(max_workers=4)

async def io_bound_task():
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(io_executor, blocking_io_func)

async def cpu_bound_task():
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(cpu_executor, heavy_computation)
```

### Executor sizing

| Executor type | Default workers | When to customize |
|---------------|----------------|-------------------|
| ThreadPoolExecutor | `min(32, os.cpu_count() + 4)` | Increase for many concurrent blocking I/O calls |
| ProcessPoolExecutor | `os.cpu_count()` | Usually fine as default |

## Framework-Specific Guidance

### FastAPI

```python
# async def: runs on event loop — MUST NOT block
@app.get("/async")
async def async_endpoint():
    data = await fetch_from_db()  # OK: async
    return data

# def: automatically runs in thread pool — safe for blocking
@app.get("/sync")
def sync_endpoint():
    data = blocking_db_query()  # OK: runs in thread
    return data
```

**Rule**: If your endpoint only calls sync code, use `def`, not `async def`. FastAPI handles the threading automatically. Use `async def` only when you have actual `await` calls.

### Django (4.1+)

```python
from asgiref.sync import sync_to_async

# Django ORM is synchronous — must wrap
@sync_to_async
def get_user(user_id):
    return User.objects.get(id=user_id)

async def async_view(request):
    user = await get_user(request.user.id)
    return JsonResponse({"name": user.name})

# Or use the decorator directly
async def async_view(request):
    user = await sync_to_async(User.objects.get)(id=request.user.id)
    return JsonResponse({"name": user.name})
```

**Django-specific**: `database_sync_to_async` is an alias for `sync_to_async` with `thread_sensitive=True` — it runs in Django's main thread, which is important for DB operations.

### aiohttp

```python
# Session management
async def init_app():
    app = web.Application()
    # Create session on startup
    app["client_session"] = aiohttp.ClientSession(
        connector=aiohttp.TCPConnector(limit=100)
    )
    app.on_cleanup.append(cleanup)
    return app

async def cleanup(app):
    await app["client_session"].close()

# Use in handlers
async def handler(request):
    session = request.app["client_session"]
    async with session.get("https://api.example.com") as resp:
        data = await resp.json()
    return web.json_response(data)
```

## Detecting Hidden Blocking

Some blocking is not obvious from the function name:

### Third-party libraries

```python
# These may block internally:
result = some_sdk.client.get_data()  # Might use requests internally
parsed = some_parser.parse(content)  # Might do heavy CPU work

# Solution: profile or wrap proactively
result = await asyncio.to_thread(some_sdk.client.get_data)
```

### Property access

```python
class Model:
    @property
    def computed_value(self):
        # This might do heavy computation or I/O
        return expensive_computation(self.data)

# In async code, property access looks innocent:
async def handler():
    val = model.computed_value  # Might block!
```

### Magic methods

```python
class LazyLoader:
    def __getattr__(self, name):
        return load_from_disk(name)  # Blocks!

# In async code:
async def handler():
    value = lazy_obj.some_attr  # Blocks on first access
```

## Testing for Blocking

### Automated test helper

```python
import asyncio
import time

async def assert_no_blocking(coro, max_block_ms=50):
    """Run a coroutine and fail if the event loop was blocked."""
    loop = asyncio.get_running_loop()
    old_debug = loop.get_debug()
    old_duration = loop.slow_callback_duration

    warnings = []
    loop.set_debug(True)
    loop.slow_callback_duration = max_block_ms / 1000

    # Capture warnings (requires custom handler or logging capture)
    try:
        result = await coro
    finally:
        loop.set_debug(old_debug)
        loop.slow_callback_duration = old_duration

    return result
```
