Context Managers Asíncronos: Protocolos y Casos Reales

Cuando llevas tiempo usando with para abrir archivos, llega un momento en que te das cuenta de que el patrón es mucho más potente que un simple “abrir y cerrar”. Los context managers son un mecanismo de composición: encapsulan setup y teardown de tal manera que el cuerpo del bloque with queda completamente aislado de esa responsabilidad. En código síncrono esto ya es valioso; en código asíncrono se vuelve casi imprescindible.

Vamos a ver qué hay bajo el capó antes de tocar código asíncrono.

El protocolo y su extensión asíncrona

El protocolo síncrono (__enter__ / __exit__) ya lo conoces. El protocolo asíncrono es su espejo directo: __aenter__ y __aexit__. La diferencia es que ambos son coroutines —se declaran con async def— y por tanto deben ser awaiteados por el runtime cuando ejecutas async with. No hay magia adicional; es el mismo patrón de delegación que __iter__ / __aiter__.

Esto importa porque un async with no puede usarse en código síncrono aunque el objeto tenga también __enter__. Son protocolos separados. Si defines solo __aenter__, el objeto solo funciona en contextos async.

¿Cuándo necesitas el protocolo async? Cada vez que el setup o el teardown involucra I/O: abrir una conexión de red, iniciar una transacción en base de datos, adquirir un lock asíncrono. Un __enter__ síncrono que haga I/O bloquearía el event loop; __aenter__ le devuelve el control mientras espera.

El riesgo de ignorar esto es sutil: si usas un context manager síncrono que hace I/O bloqueante dentro de una coroutine, tu aplicación asíncrona deja de ser concurrente exactamente en ese punto —y el bug no lanza ninguna excepción, simplemente degrada el rendimiento de forma invisible.

Sintaxis de múltiples contextos y locks

Antes del primer bloque asíncrono, dos patrones síncronos que vale la pena tener cristalizados.

with open(a) as f1, open(b) as f2: es azúcar sintáctico estricto para context managers anidados: f1 se abre primero, f2 después, y se cierran en orden inverso. Si f2 falla al abrir, f1 ya se cierra limpiamente. Es la misma semántica que anidar los bloques with, sin el nivel extra de indentación.

Los locks de threading son el caso donde el teardown garantizado de __exit__ importa más que en archivos. Si llamas a lock.acquire() manualmente y tu código lanza una excepción antes de lock.release(), el thread queda bloqueado para siempre. with lock: hace que __exit__ llame a release() incluso si vuela una excepción, lo que convierte un deadlock potencial en un error manejable.

Código completo

import asyncio
import threading
from contextlib import asynccontextmanager
from typing import AsyncIterator

import aiosqlite  # pip install aiosqlite


# ── 1. Context manager asíncrono con clase ──────────────────────────────

class ManagedConnection:
    """Wrapper sobre aiosqlite que expone el protocolo __aenter__/__aexit__."""

    def __init__(self, db_path: str) -> None:
        self.db_path = db_path
        self._conn: aiosqlite.Connection | None = None

    async def __aenter__(self) -> aiosqlite.Connection:
        self._conn = await aiosqlite.connect(self.db_path)
        await self._conn.execute("PRAGMA journal_mode=WAL")
        return self._conn

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
        if self._conn:
            if exc_type is not None:
                # Hubo excepción: rollback implícito en SQLite al cerrar
                await self._conn.rollback()
            else:
                await self._conn.commit()
            await self._conn.close()
        return False  # No suprimimos excepciones


# ── 2. Context manager asíncrono con asynccontextmanager ───────────────

@asynccontextmanager
async def timed_section(label: str) -> AsyncIterator[None]:
    """Mide el tiempo de ejecución de cualquier bloque async."""
    start = asyncio.get_event_loop().time()
    try:
        yield  # aquí se ejecuta el cuerpo del bloque `async with`
    finally:
        # finally garantiza que medimos aunque el bloque lance excepción
        elapsed = asyncio.get_event_loop().time() - start
        print(f"[{label}] {elapsed:.4f}s")


# ── 3. Lock síncrono para demostrar el patrón con threading ────────────

_counter = 0
_lock = threading.Lock()


def increment_safely(n: int) -> None:
    global _counter
    for _ in range(n):
        with _lock:  # release() garantizado aunque haya excepción interna
            _counter += 1


# ── 4. Múltiples contextos async en un solo bloque ─────────────────────

@asynccontextmanager
async def temp_table(conn: aiosqlite.Connection, name: str) -> AsyncIterator[str]:
    """Crea una tabla temporal y la elimina al salir, pase lo que pase."""
    await conn.execute(f"CREATE TEMP TABLE IF NOT EXISTS {name} (id INTEGER, val TEXT)")
    try:
        yield name
    finally:
        await conn.execute(f"DROP TABLE IF EXISTS {name}")


# ── 5. Orquestación principal ───────────────────────────────────────────

async def run_pipeline(db_path: str) -> None:
    async with (
        ManagedConnection(db_path) as conn,
        timed_section("pipeline"),  # múltiples async with en un bloque (Python 3.10+)
    ):
        async with temp_table(conn, "staging") as tbl:
            await conn.execute(
                f"INSERT INTO {tbl} VALUES (1, 'hello'), (2, 'world')"
            )
            async with conn.execute(f"SELECT * FROM {tbl}") as cursor:
                rows = await cursor.fetchall()

        print(f"Rows procesados: {len(rows)}")


if __name__ == "__main__":
    # Threading demo
    threads = [threading.Thread(target=increment_safely, args=(10_000,)) for _ in range(4)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print(f"Counter final: {_counter}")  # siempre 40000

    # Async demo
    asyncio.run(run_pipeline(":memory:"))

Desglose del código

ManagedConnection implementa el protocolo async con clase explícita porque necesita estado entre __aenter__ y __aexit__: la conexión abierta. La decisión de hacer rollback o commit en __aexit__ según exc_type es el núcleo de la separación setup/teardown que mencionamos: el cuerpo del async with no sabe nada de transacciones, solo trabaja con la conexión. Eso es arquitectura limpia.

@asynccontextmanager es el equivalente async de @contextmanager. El generador hace yield exactamente una vez; lo que está antes es __aenter__, lo que está después es __aexit__. El try/finally en timed_section garantiza que siempre se imprime el tiempo, incluso si el bloque lanza. Sin finally, una excepción en el cuerpo saltaría el bloque de limpieza —el mismo error que ocurre con __exit__ cuando devuelves True por accidente.

temp_table demuestra el patrón de arquitectura: el caller no gestiona el ciclo de vida de la tabla. La creación y el DROP están encapsulados. Si el bloque interno falla a mitad de inserción, la tabla temporal desaparece igualmente. El finally hace el trabajo que en otros lenguajes harías con RAII.

run_pipeline usa la sintaxis de paréntesis (async with (A, B):) disponible desde Python 3.10, que es equivalente a context managers anidados pero sin incrementar la indentación. Fíjate en el anidamiento de tres context managers: conexión → tabla temporal → cursor. Cada capa tiene responsabilidad única y no se mezclan entre sí.

El lock de threading puede parecer fuera de lugar en un artículo sobre código async, pero ilustra el punto más importante: with lock: no es solo conveniencia sintáctica. Es una garantía de correctitud. Un lock.acquire() manual combinado con cualquier ruta de código que pueda lanzar excepción es un bug latente. El context manager lo hace estructuralmente imposible.

Errores que debes conocer

Error: Usar @contextmanager en vez de @asynccontextmanager para una función que tiene await dentro, lo que hace que la función devuelva una coroutine en lugar de un context manager utilizable con async with.

from contextlib import contextmanager  # ❌ módulo incorrecto

@contextmanager
async def bad_ctx():
    await asyncio.sleep(0)
    yield

# ✅ Right
from contextlib import asynccontextmanager

@asynccontextmanager
async def good_ctx():
    await asyncio.sleep(0)
    yield

asynccontextmanager entiende que la función es una coroutine y construye el objeto que implementa __aenter__/__aexit__ correctamente; contextmanager no sabe manejar coroutines y produce un objeto roto en silencio.


Error: Retornar True desde __aexit__ sin intención, lo que suprime todas las excepciones que ocurran en el bloque.

async def __aexit__(self, exc_type, exc_val, exc_tb):
    await self._conn.close()
    return True  # ❌ suprime cualquier excepción del bloque

# ✅ Right
async def __aexit__(self, exc_type, exc_val, exc_tb):
    await self._conn.close()
    return False  # propaga la excepción al caller

__aexit__ suprime la excepción si y solo si devuelve un valor truthy; devolver False (o None) deja que la excepción siga su camino normal, que es el comportamiento correcto en el 99% de los casos.

124

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio