Corrutinas en Python

Las corrutinas son un tipo especial de funciones que ceden deliberadamente el control a la persona que llama, pero no finalizan su contexto en el proceso, manteniéndolo en un estado inactivo. En este artículo, nos sumergiremos en las rutinas de Python.

Introducción

Todo programador está familiarizado con las funciones: secuencias de instrucciones agrupadas como una sola unidad para realizar tareas predeterminadas. Admiten un único punto de entrada, son capaces de aceptar argumentos, pueden o no tener un valor de retorno y pueden ser llamados en cualquier momento durante la ejecución de un programa, incluso por otras funciones y por ellos mismos.

Cuando un programa llama a una función, su contexto de ejecución actual se guarda antes de pasar el control a la función y reanudar la ejecución. Luego, la función crea un nuevo contexto; a partir de ahí, los datos recién creados existen exclusivamente durante el tiempo de ejecución de las funciones.

Tan pronto como se completa la tarea, el control se transfiere de nuevo a la persona que llama: el nuevo contexto se elimina y se reemplaza por el anterior.

Corrutinas

Las corrutinas son un tipo especial de función que deliberadamente cede el control a la persona que llama, pero no finaliza su contexto en el proceso, sino que lo mantiene en un estado inactivo.

Se benefician de la capacidad de conservar sus datos durante su vida útil y, a diferencia de las funciones, pueden tener varios puntos de entrada para suspender y reanudar la ejecución.

Las rutinas en Python funcionan de manera muy similar a Generadores. Ambos operan sobre datos, así que mantengamos las principales diferencias simples:

Los generadores producen datos

Las corrutinas consumen datos

El manejo distinto de la palabra clave rendimiento determina si estamos manipulando uno u otro.

Definición de una rutina

Con todos los elementos esenciales fuera del camino, entremos de inmediato y codifiquemos nuestra primera rutina:

1
2
3
def bare_bones():
    while True:
        value = (yield)

Es claro ver el parecido con una función normal de Python. El bloque while True: garantiza la ejecución continua de la rutina mientras recibe valores.

El valor se recopila a través de la declaración yield. Volveremos a esto en unos momentos...

Es claro ver que este código es prácticamente inútil, por lo que lo redondearemos con algunas declaraciones imprimir:

1
2
3
4
5
def bare_bones():
    print("My first Coroutine!")
    while True:
        value = (yield)
        print(value)

Ahora, qué sucede cuando tratamos de llamarlo así:

1
coroutine = bare_bones()

Si esta fuera una función normal de Python, uno esperaría que produjera algún tipo de salida en este punto. Pero si ejecuta el código en su estado actual, notará que no se llama ni un solo print().

Esto se debe a que las corrutinas requieren que se llame primero al método next():

1
2
3
4
5
6
7
8
def bare_bones():
    print("My first Coroutine!")
    while True:
        value = (yield)
        print(value)

coroutine = bare_bones()
next(coroutine)

Esto inicia la ejecución de la rutina hasta que alcanza su primer punto de interrupción: valor = (rendimiento). Luego, se detiene, devolviendo la ejecución a la principal, y permanece inactiva mientras espera una nueva entrada:

1
My first Coroutine!

La nueva entrada se puede enviar con send():

1
coroutine.send("First Value")

Nuestra variable value recibirá la cadena First Value, la imprimirá y una nueva iteración del bucle while True: obligará a la corrutina a esperar una vez más a que se entreguen nuevos valores. Puedes hacer esto tantas veces como quieras.

Finalmente, una vez que haya terminado con la rutina y ya no desee utilizarla, puede liberar esos recursos llamando a close(). Esto genera una excepción GeneratorExit que debe solucionarse:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def bare_bones():
    print("My first Coroutine!")
    try:
        while True:
            value = (yield)
            print(value)
    except GeneratorExit:
        print("Exiting coroutine...")

coroutine = bare_bones()
next(coroutine)
coroutine.send("First Value")
coroutine.send("Second Value")
coroutine.close()

Producción:

1
2
3
4
My first Coroutine!
First Value
Second Value
Exiting coroutine...

Paso de argumentos {#paso de argumentos}

Al igual que las funciones, las corrutinas también pueden recibir argumentos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def filter_line(num):
    while True:
        line = (yield)
        if num in line:
            print(line)

cor = filter_line("33")
next(cor)
cor.send("Jessica, age:24")
cor.send("Marco, age:33")
cor.send("Filipe, age:55")

Producción:

1
Marco, age:33

Aplicación de varios puntos de interrupción

Se pueden secuenciar varias declaraciones yield juntas en la misma corrutina individual:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def joint_print():
    while True:
        part_1 = (yield)
        part_2 = (yield)
        print("{} {}".format(part_1, part_2))

cor = joint_print()
next(cor)
cor.send("So Far")
cor.send("So Good")

Producción:

1
So Far So Good

La excepción StopIteration

Después de cerrar una corrutina, llamar a send() nuevamente generará una excepción StopIteration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def test():
    while True:
        value = (yield)
        print(value)
try:
    cor = test()
    next(cor)
    cor.close()
    cor.send("So Good")
except StopIteration:
    print("Done with the basics")

Producción:

1
Done with the basics

Corrutinas con decoradores

¡Todo esto está muy bien! Pero cuando se trabaja en proyectos más grandes, iniciar todas y cada una de las rutinas manualmente puede ser un gran lastre.

No te preocupes, solo es cuestión de explotar el poder de Decorators{target="_blank"} para que ya no necesitemos usar next() método:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def coroutine(func):
    def start(*args, **kwargs):
        cr = func(*args, **kwargs)
        next(cr)
        return cr
    return start

@coroutine
def bare_bones():
    while True:
        value = (yield)
        print(value)

cor = bare_bones()
cor.send("Using a decorator!")

Ejecutar este fragmento de código producirá:

1
Using a decorator!

Construcción de tuberías {#construcción de tuberías}

Una canalización es una secuencia de elementos de procesamiento organizados de modo que la salida de cada elemento sea la entrada del siguiente.

Los datos se empujan a través de la tubería hasta que finalmente se consumen. Cada canalización requiere al menos una fuente y un sumidero.

Las etapas restantes de la tubería pueden realizar varias operaciones diferentes, desde filtrar hasta modificar, enrutar y reducir datos:

tubería

Las corrutinas son candidatas naturales para realizar estas operaciones, pueden pasar datos entre sí con operaciones send() y también pueden servir como consumidor de punto final. Veamos el siguiente ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def producer(cor):
    n = 1
    while n < 100:
        cor.send(n)
        n = n * 2

@coroutine
def my_filter(num, cor):
    while True:
        n = (yield)
        if n < num:
            cor.send(n)

@coroutine
def printer():
    while True:
        n = (yield)
        print(n)

prnt = printer()
filt = my_filter(50, prnt)
producer(filt)

Producción:

1
2
3
4
5
6
1
2
4
8
16
32

Entonces, lo que tenemos aquí es el producer() actuando como la fuente, creando algunos valores que luego se filtran antes de ser impresos por el sink, en este caso, la corrutina printer().

my_filter(50, prnt) actúa como el único paso intermedio en la canalización y recibe su propia rutina como argumento.

Este encadenamiento ilustra perfectamente la fuerza de las corrutinas: son escalables para proyectos más grandes (todo lo que se requiere es agregar más etapas a la canalización) y fáciles de mantener (los cambios en una no obligan a reescribir completamente el código fuente).

Similitudes con objetos {#similitudes con objetos}

Un programador perspicaz podría darse cuenta de que las rutinas contienen una cierta similitud conceptual con los objetos de Python. Desde la definición previa requerida hasta la declaración y gestión de instancias. Surge la pregunta obvia de por qué uno usaría corrutinas sobre el paradigma probado y verdadero de la programación orientada a objetos.

Bueno, aparte del hecho obvio de que las rutinas requieren solo una definición de función única, también se benefician de ser significativamente más rápidas. Examinemos el siguiente código:

1
2
3
4
5
6
7
8
class obj:
    def __init__(self, value):
        self.i = value
    def send(self, num):
        print(self.i + num)

inst = obj(1)
inst.send(5)
1
2
3
4
5
6
7
8
9
def coroutine(value):
    i = value
    while True:
        num = (yield)
        print(i + num)

cor = coroutine(1)
next(cor)
cor.send(5)

Así es como estos dos se enfrentan entre sí, cuando se ejecutaron a través del módulo timeit, 10,000 veces:

Corrutina de objeto


0.791811 0.6343617 0.7997058 0.6383156 0.8579286 0.6365501 0.838439 0.648442 0.9604255 0.7242559

Ambos realizan la misma tarea servil, pero el segundo ejemplo es más rápido. La velocidad gana advenimiento de la ausencia de las búsquedas “auto” del objeto.

Para tareas más exigentes del sistema, esta característica es una razón convincente para usar rutinas en lugar de los objetos de controlador convencionales.

Precaución al usar corrutinas

El método send() No es seguro para subprocesos

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import threading
from time import sleep

def print_number(cor):
    while True:
        cor.send(1)

def coroutine():
    i = 1
    while True:
        num = (yield)
        print(i)
        sleep(3)
        i += num

cor = coroutine()
next(cor)

t = threading.Thread(target=print_number, args=(cor,))
t.start()

while True:
    cor.send(5)

Debido a que send() no se sincronizó correctamente, ni tiene protección inherente contra errores de llamada relacionados con subprocesos, se generó el siguiente error: ValueError: el generador ya se está ejecutando.

La combinación de rutinas con concurrencia debe hacerse con extrema precaución.

No es posible hacer un bucle de rutinas

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def coroutine_1(value):
    while True:
        next_cor = (yield)
        print(value)
        value = value - 1
        if next_cor != None:
            next_cor.send(value)

def coroutine_2(next_cor):
    while True:
        value = (yield)
        print(value)
        value = value - 2
        if next != None:
            next_cor.send(value)

cor1 = coroutine_1(20)
next(cor1)
cor2 = coroutine_2(cor1)
next(cor2)
cor1.send(cor2)

El mismo ValueError muestra su cara. A partir de estos ejemplos simples, podemos inferir que el método send() crea una especie de pila de llamadas que no regresa hasta que el objetivo alcanza su declaración yield.

Por lo tanto, el uso de rutinas no es todo sol y arcoíris, se debe pensar cuidadosamente antes de la aplicación.

Conclusión

Las corrutinas proporcionan una poderosa alternativa a los mecanismos habituales de procesamiento de datos. Las unidades de código se pueden combinar, modificar y reescribir fácilmente, al mismo tiempo que se benefician de la persistencia variable a lo largo de su ciclo de vida.

En manos de un programador habilidoso, las corrutinas se convierten en herramientas nuevas y significativas al permitir un diseño y una implementación más simples, al mismo tiempo que brindan mejoras significativas en el rendimiento.

Descomponer las ideas en procesos sencillos ahorra tiempo y esfuerzo al programador, al mismo tiempo que evita llenar el código con objetos superfluos que no hacen más que tareas elementales.

Licensed under CC BY-NC-SA 4.0