Hablar de async/await
y asyncio
no es lo mismo. La primera es una construcción fundamental de bajo nivel (corrutinas), mientras que la última es una biblioteca que utiliza estas construcciones. Por el contrario, no existe una única respuesta definitiva.
La siguiente es una descripción general de cómo funcionan las bibliotecas async/await
y asyncio
similares. Es decir, puede haber otros trucos en la parte superior (hay ...) pero son intrascendentes a menos que los construya usted mismo. La diferencia debería ser insignificante a menos que ya sepa lo suficiente como para no tener que hacer esa pregunta.
1. Corutinas versus subrutinas en pocas palabras
Al igual que las subrutinas (funciones, procedimientos, ...), las corrutinas (generadores, ...) son una abstracción de la pila de llamadas y el puntero de instrucción: hay una pila de piezas de código en ejecución, y cada una está en una instrucción específica.
La distinción de def
versus async def
es simplemente para mayor claridad. La diferencia real es return
versus yield
. A partir de esto, await
o yield from
tome la diferencia de llamadas individuales a pilas completas.
1.1. Subrutinas
Una subrutina representa un nuevo nivel de pila para contener variables locales y un solo recorrido de sus instrucciones para llegar a un final. Considere una subrutina como esta:
def subfoo(bar):
qux = 3
return qux * bar
Cuando lo ejecutas, eso significa
- asignar espacio de pila para
bar
yqux
- ejecutar recursivamente la primera declaración y saltar a la siguiente declaración
- una vez a la vez
return
, empuja su valor a la pila de llamadas
- borre la pila (1.) y el puntero de instrucción (2.)
En particular, 4. significa que una subrutina siempre comienza en el mismo estado. Todo lo exclusivo de la función en sí se pierde al finalizar. No se puede reanudar una función, incluso si hay instrucciones después return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Corutinas como subrutinas persistentes
Una corrutina es como una subrutina, pero puede salir sin destruir su estado. Considere una corrutina como esta:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Cuando lo ejecutas, eso significa
- asignar espacio de pila para
bar
yqux
- ejecutar recursivamente la primera declaración y saltar a la siguiente declaración
- una vez a la vez
yield
, empuja su valor a la pila de llamadas pero almacena la pila y el puntero de instrucción
- una vez llamando
yield
, restaure la pila y el puntero de instrucción y envíe argumentos aqux
- una vez a la vez
return
, empuja su valor a la pila de llamadas
- borre la pila (1.) y el puntero de instrucción (2.)
Tenga en cuenta la adición de 2.1 y 2.2: una corrutina se puede suspender y reanudar en puntos predefinidos. Esto es similar a cómo se suspende una subrutina durante la llamada a otra subrutina. La diferencia es que la corrutina activa no está estrictamente ligada a su pila de llamadas. En cambio, una corrutina suspendida es parte de una pila separada y aislada.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Esto significa que las corrutinas suspendidas se pueden almacenar o mover libremente entre pilas. Cualquier pila de llamadas que tenga acceso a una corrutina puede decidir reanudarla.
1.3. Atravesando la pila de llamadas
Hasta ahora, nuestra corrutina solo baja en la pila de llamadas con yield
. Una subrutina puede subir y bajar en la pila de llamadas con return
y ()
. Para que estén completas, las corrutinas también necesitan un mecanismo para subir la pila de llamadas. Considere una corrutina como esta:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Cuando lo ejecuta, eso significa que todavía asigna la pila y el puntero de instrucción como una subrutina. Cuando se suspende, sigue siendo como almacenar una subrutina.
Sin embargo, yield from
hace ambas cosas . Suspende la pila y el puntero de instrucción wrap
y se ejecuta cofoo
. Tenga en cuenta que wrap
permanece suspendido hasta que cofoo
termina por completo. Siempre que se cofoo
suspende o se envía algo, cofoo
se conecta directamente a la pila de llamadas.
1.4. Coroutines hasta el final
Según lo establecido, yield from
permite conectar dos visores a través de otro intermedio. Cuando se aplica de forma recursiva, eso significa que la parte superior de la pila se puede conectar a la parte inferior de la pila.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Tenga en cuenta que root
y coro_b
no se conocen el uno al otro. Esto hace que las corrutinas sean mucho más limpias que las devoluciones de llamada: las corrutinas aún se construyen en una relación 1: 1 como las subrutinas. Las corrutinas suspenden y reanudan toda su pila de ejecución existente hasta un punto de llamada regular.
En particular, root
podría tener un número arbitrario de corrutinas para reanudar. Sin embargo, nunca puede reanudar más de uno al mismo tiempo. Las corrutinas de la misma raíz son concurrentes pero no paralelas.
1.5. Python async
yawait
Hasta ahora, la explicación ha utilizado explícitamente el vocabulario yield
y yield from
de los generadores: la funcionalidad subyacente es la misma. La nueva sintaxis de Python3.5 async
y await
existe principalmente para mayor claridad.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
Las declaraciones async for
y async with
son necesarias porque rompería la yield from/await
cadena con las declaraciones desnudas for
y with
.
2. Anatomía de un bucle de eventos simple
Por sí misma, una corrutina no tiene el concepto de ceder el control a otra corrutina. Solo puede ceder el control a la persona que llama en la parte inferior de una pila de corrutinas. Esta persona que llama puede cambiar a otra corrutina y ejecutarla.
Este nodo raíz de varias corrutinas es comúnmente un bucle de eventos : en suspensión, una corrutina produce un evento en el que desea reanudar. A su vez, el bucle de eventos es capaz de esperar eficientemente a que ocurran estos eventos. Esto le permite decidir qué corrutina ejecutar a continuación o cómo esperar antes de reanudar.
Tal diseño implica que existe un conjunto de eventos predefinidos que el bucle comprende. Varias corrutinas await
entre sí, hasta que finalmente se edita un evento await
. Este evento puede comunicarse directamente con el bucle de eventos mediante el yield
control.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
La clave es que la suspensión de rutina permite que el bucle de eventos y los eventos se comuniquen directamente. La pila de corrutinas intermedia no requiere ningún conocimiento sobre qué bucle lo está ejecutando, ni cómo funcionan los eventos.
2.1.1. Eventos en el tiempo
El evento más simple de manejar es llegar a un punto en el tiempo. Este es un bloque fundamental de código enhebrado también: un subproceso se repite repetidamente sleep
hasta que una condición es verdadera. Sin embargo, una sleep
ejecución de bloques regular por sí sola: queremos que no se bloqueen otras corrutinas. En su lugar, queremos decirle al bucle de eventos cuándo debe reanudar la pila de corrutinas actual.
2.1.2. Definición de un evento
Un evento es simplemente un valor que podemos identificar, ya sea a través de una enumeración, un tipo u otra identidad. Podemos definir esto con una clase simple que almacena nuestro tiempo objetivo. Además de almacenar la información del evento, podemos permitir await
una clase directamente.
class AsyncSleep:
"""Event to sleep until a point in time"""
def __init__(self, until: float):
self.until = until
# used whenever someone ``await``s an instance of this Event
def __await__(self):
# yield this Event to the loop
yield self
def __repr__(self):
return '%s(until=%.1f)' % (self.__class__.__name__, self.until)
Esta clase solo almacena el evento, no dice cómo manejarlo realmente.
La única característica especial es __await__
: es lo que await
busca la palabra clave. Prácticamente, es un iterador pero no está disponible para la maquinaria de iteración regular.
2.2.1. Esperando un evento
Ahora que tenemos un evento, ¿cómo reaccionan las corrutinas? Debemos ser capaces de expresar el equivalente de sleep
por await
ing nuestro evento. Para ver mejor lo que está pasando, esperamos dos veces la mitad del tiempo:
import time
async def asleep(duration: float):
"""await that ``duration`` seconds pass"""
await AsyncSleep(time.time() + duration / 2)
await AsyncSleep(time.time() + duration / 2)
Podemos instanciar y ejecutar directamente esta corrutina. Similar a un generador, el uso coroutine.send
ejecuta la corrutina hasta obtener yield
un resultado.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Esto nos da dos AsyncSleep
eventos y luego una StopIteration
cuando se realiza la corrutina. ¡Tenga en cuenta que el único retraso es time.sleep
el del bucle! Cada uno AsyncSleep
solo almacena un desplazamiento de la hora actual.
2.2.2. Evento + Sueño
En este punto, tenemos dos mecanismos separados a nuestra disposición:
AsyncSleep
Eventos que se pueden generar desde el interior de una corrutina
time.sleep
que puede esperar sin afectar las rutinas
En particular, estos dos son ortogonales: ninguno afecta ni desencadena al otro. Como resultado, podemos idear nuestra propia estrategia sleep
para afrontar el retraso de un AsyncSleep
.
2.3. Un bucle de eventos ingenuo
Si disponemos de varias corrutinas, cada una puede indicarnos cuándo quiere que le despierten. Luego podemos esperar hasta que el primero de ellos quiera reanudarse, luego el siguiente, y así sucesivamente. En particular, en cada punto solo nos preocupamos por cuál es el siguiente .
Esto hace que la programación sea sencilla:
- ordenar las rutinas por la hora deseada para despertarse
- escoge el primero que quiera despertar
- espera hasta este momento
- ejecutar esta corrutina
- repetir desde 1.
Una implementación trivial no necesita conceptos avanzados. A list
permite ordenar las corrutinas por fecha. Esperar es algo habitual time.sleep
. La ejecución de corrutinas funciona igual que antes coroutine.send
.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
# store wake-up-time and coroutines
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting:
# 2. pick the first coroutine that wants to wake up
until, coroutine = waiting.pop(0)
# 3. wait until this point in time
time.sleep(max(0.0, until - time.time()))
# 4. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
Por supuesto, esto tiene un amplio margen de mejora. Podemos usar un montón para la cola de espera o una tabla de despacho para eventos. También podríamos obtener valores de retorno de StopIteration
y asignarlos a la corrutina. Sin embargo, el principio fundamental sigue siendo el mismo.
2.4. Espera cooperativa
El AsyncSleep
evento y el run
ciclo de eventos son una implementación totalmente funcional de eventos cronometrados.
async def sleepy(identifier: str = "coroutine", count=5):
for i in range(count):
print(identifier, 'step', i + 1, 'at %.2f' % time.time())
await asleep(0.1)
run(*(sleepy("coroutine %d" % j) for j in range(5)))
Esto cambia cooperativamente entre cada una de las cinco corrutinas, suspendiendo cada una durante 0,1 segundos. Aunque el ciclo de eventos es síncrono, aún ejecuta el trabajo en 0,5 segundos en lugar de 2,5 segundos. Cada corrutina mantiene el estado y actúa de forma independiente.
3. Bucle de eventos de E / S
Un bucle de eventos que admita sleep
es adecuado para el sondeo . Sin embargo, la espera de E / S en un identificador de archivo se puede hacer de manera más eficiente: el sistema operativo implementa E / S y, por lo tanto, sabe qué identificadores están listos. Idealmente, un bucle de eventos debería admitir un evento explícito "listo para E / S".
3.1. La select
llamada
Python ya tiene una interfaz para consultar el sistema operativo para leer identificadores de E / S. Cuando se llama con identificadores para leer o escribir, devuelve los identificadores listos para leer o escribir:
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Por ejemplo, podemos open
escribir un archivo y esperar a que esté listo:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
Una vez que seleccione las devoluciones, writeable
contiene nuestro archivo abierto.
3.2. Evento de E / S básico
Similar a la AsyncSleep
solicitud, necesitamos definir un evento para E / S. Con la select
lógica subyacente , el evento debe referirse a un objeto legible, digamos un open
archivo. Además, almacenamos cuántos datos leer.
class AsyncRead:
def __init__(self, file, amount=1):
self.file = file
self.amount = amount
self._buffer = ''
def __await__(self):
while len(self._buffer) < self.amount:
yield self
# we only get here if ``read`` should not block
self._buffer += self.file.read(1)
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.file, self.amount, len(self._buffer)
)
Al igual que con la AsyncSleep
mayoría de las veces, solo almacenamos los datos necesarios para la llamada al sistema subyacente. Esta vez, __await__
se puede reanudar varias veces, hasta que amount
se haya leído lo deseado . Además, obtenemos return
el resultado de E / S en lugar de simplemente reanudarlo.
3.3. Aumento de un bucle de eventos con lectura de E / S
La base de nuestro bucle de eventos sigue siendo la run
definida anteriormente. Primero, necesitamos rastrear las solicitudes de lectura. Este ya no es un horario ordenado, solo asignamos solicitudes de lectura a corrutinas.
# new
waiting_read = {} # type: Dict[file, coroutine]
Dado que select.select
toma un parámetro de tiempo de espera, podemos usarlo en lugar de time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Esto nos da todos los archivos legibles; si hay alguno, ejecutamos la corrutina correspondiente. Si no hay ninguno, hemos esperado lo suficiente para que se ejecute nuestra corrutina actual.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Finalmente, tenemos que escuchar las solicitudes de lectura.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Poniendo todo junto
Lo anterior fue un poco simplificado. Necesitamos hacer algunos cambios para no morir de hambre a las corrutinas para dormir si siempre podemos leer. Necesitamos manejar no tener nada que leer o nada que esperar. Sin embargo, el resultado final todavía encaja en 30 LOC.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
waiting_read = {} # type: Dict[file, coroutine]
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting or waiting_read:
# 2. wait until the next coroutine may run or read ...
try:
until, coroutine = waiting.pop(0)
except IndexError:
until, coroutine = float('inf'), None
readable, _, _ = select.select(list(waiting_read), [], [])
else:
readable, _, _ = select.select(list(waiting_read), [], [], max(0.0, until - time.time()))
# ... and select the appropriate one
if readable and time.time() < until:
if until and coroutine:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read.pop(readable[0])
# 3. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension ...
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
# ... or register reads
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
3.5. E / S cooperativa
Las implementaciones AsyncSleep
, AsyncRead
y run
ahora son completamente funcionales para dormir y / o leer. Igual que para sleepy
, podemos definir un ayudante para probar la lectura:
async def ready(path, amount=1024*32):
print('read', path, 'at', '%d' % time.time())
with open(path, 'rb') as file:
result = return await AsyncRead(file, amount)
print('done', path, 'at', '%d' % time.time())
print('got', len(result), 'B')
run(sleepy('background', 5), ready('/dev/urandom'))
Al ejecutar esto, podemos ver que nuestra E / S está intercalada con la tarea en espera:
id background round 1
read /dev/urandom at 1530721148
id background round 2
id background round 3
id background round 4
id background round 5
done /dev/urandom at 1530721148
got 1024 B
4. E / S sin bloqueo
Si bien la E / S en archivos transmite el concepto, no es realmente adecuado para una biblioteca como asyncio
: la select
llamada siempre regresa para los archivos , y ambos open
y read
pueden bloquearse indefinidamente . Esto bloquea todas las corrutinas de un bucle de eventos, lo cual es malo. Bibliotecas comoaiofiles
utilizan subprocesos y sincronización para falsificar eventos y E / S no bloqueantes en el archivo.
Sin embargo, los sockets permiten E / S sin bloqueo, y su latencia inherente lo hace mucho más crítico. Cuando se usa en un bucle de eventos, la espera de datos y el reintento se pueden ajustar sin bloquear nada.
4.1. Evento de E / S sin bloqueo
Similar a nuestro AsyncRead
, podemos definir un evento de suspensión y lectura para sockets. En lugar de tomar un archivo, tomamos un socket, que debe ser sin bloqueo. Además, nuestros __await__
usos en socket.recv
lugar de file.read
.
class AsyncRecv:
def __init__(self, connection, amount=1, read_buffer=1024):
assert not connection.getblocking(), 'connection must be non-blocking for async recv'
self.connection = connection
self.amount = amount
self.read_buffer = read_buffer
self._buffer = b''
def __await__(self):
while len(self._buffer) < self.amount:
try:
self._buffer += self.connection.recv(self.read_buffer)
except BlockingIOError:
yield self
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.connection, self.amount, len(self._buffer)
)
A diferencia de AsyncRead
, __await__
realiza E / S verdaderamente sin bloqueo. Cuando hay datos disponibles, siempre se lee. Cuando no hay datos disponibles, siempre se suspende. Eso significa que el bucle de eventos solo se bloquea mientras realizamos un trabajo útil.
4.2. Desbloquear el bucle de eventos
En lo que respecta al bucle de eventos, nada cambia mucho. El evento para escuchar sigue siendo el mismo que para los archivos: un descriptor de archivo marcado como listo por select
.
# old
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
# new
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
elif isinstance(command, AsyncRecv):
waiting_read[command.connection] = coroutine
En este punto, debería ser obvio que AsyncRead
y AsyncRecv
son el mismo tipo de evento. Podríamos refactorizarlos fácilmente para que sean un evento con un componente de E / S intercambiable. En efecto, el ciclo de eventos, las corrutinas y los eventos separan claramente un programador, un código intermedio arbitrario y la E / S real.
4.3. El lado feo de la E / S sin bloqueo
En principio, lo que deberías hacer en este punto es replicar la lógica de read
as a recv
for AsyncRecv
. Sin embargo, esto es mucho más feo ahora: tienes que manejar los retornos tempranos cuando las funciones se bloquean dentro del kernel, pero te dan el control. Por ejemplo, abrir una conexión en lugar de abrir un archivo es mucho más largo:
# file
file = open(path, 'rb')
# non-blocking socket
connection = socket.socket()
connection.setblocking(False)
# open without blocking - retry on failure
try:
connection.connect((url, port))
except BlockingIOError:
pass
En pocas palabras, lo que queda son unas pocas docenas de líneas de manejo de excepciones. Los eventos y el ciclo de eventos ya funcionan en este punto.
id background round 1
read localhost:25000 at 1530783569
read /dev/urandom at 1530783569
done localhost:25000 at 1530783569 got 32768 B
id background round 2
id background round 3
id background round 4
done /dev/urandom at 1530783569 got 4096 B
id background round 5
Apéndice
Código de ejemplo en github
BaseEventLoop
se implementa CPython : github.com/python/cpython/blob/…