Python 3 asyncio vs hilos vs procesos: descargar páginas web
Este es el primero de una serie de artículos sobre asyncio, la librería que viene de serie con Python 3.4, y sobre cómo se compara con las dos formas clásicas de hacer varias cosas a la vez: hilos y procesos.
Quiero poner a los tres uno al lado del otro en rendimiento, en lo cómodo que es programar con cada uno, y en qué te da y qué te cuesta cada cual. Mismo problema, tres soluciones, números encima de la mesa.
La serie, tal como la tengo planeada:
- Descargar páginas web (este artículo)
- Acceso a base de datos
- Tareas lentas
- Acceso a disco
Resumen rápido: ventajas e inconvenientes de cada método
Antes de los números, la versión corta de lo que te da cada uno y de dónde duele:
| Método | Ventajas | Inconvenientes |
|---|---|---|
| Hilos | Flujo de ejecución secuencial. Sencillo de programar. | No es multitarea real. Bloqueante. Rendimiento bajo. |
| Procesos | Multiproceso real. Flujo de ejecución secuencial. Sencillo de programar. | Carga el sistema. No usa hilos: levanta procesos del sistema operativo. |
| asyncio | Usa corrutinas para mejorar el rendimiento. No bloqueante por diseño. No carga el sistema. | Solo a partir de Python 3.4. El código no es lineal. |
Descargar páginas web
Hablar con un servidor web es una de las cosas más bloqueantes que hace un programa. La CPU se queda parada mientras la red contesta. Por eso es un buen primer caso: cualquier forma de “ejecutar varias cosas a la vez” debería ayudar aquí, y así vemos cuál ayuda más.
Para la prueba escribí tres programas pequeños que hacen el mismo trabajo, uno con hilos, otro con procesos y otro con corrutinas.
Cómo lo he probado
La lista de URL se construye en tiempo de ejecución a base de búsquedas en Bing, una por número. Las dos variables globales las fija el bucle de pruebas en cada pasada:
# Configuración global
MAX_CONCURRENCE = 0 # lo fija el bucle de pruebas: 5, 10 y 15
WEB_PAGES_PRELOADED = [] # lo rellena el bucle de pruebas: 50, 100 y 200 URL
BASE_URL = "http://www.bing.com/search?q=%s&go=&qs=n&form=QBLH&filt=all&pq=hello&sc=8-1&sp=-1&sk=&cvid=3c6b1fd5cbe0456b8c2370b57dc7ad38"
Lo que mido es cómo se porta cada método al combinar el número de URL con el número de conexiones permitidas a la vez. Los tres tienen el mismo tope: 5, 10 y 15 conexiones simultáneas, sobre 50, 100 y 200 URL. El mismo trabajo, hecho concurrente de tres maneras distintas, y los tiempos comparados.
Estos son los imports que comparten las tres versiones:
import timeit
import asyncio
import urllib.error
import urllib.request as req
import aiohttp
from multiprocessing import Pool
from threading import Thread, Semaphore
Y aquí va el código de cada método. El bucle que los lanza todos y los cronometra está al final.
Código con hilos
# --------------------------------------------------------------------------
# Hilos
# --------------------------------------------------------------------------
def download_threads(url, sem_threads):
try:
response = req.urlopen(url)
data = response.read()
except urllib.error.URLError:
print("Thread error in URL: ", url)
sem_threads.release()
def test_threads():
sem_threads = Semaphore(MAX_CONCURRENCE)
th = []
th_append = th.append
for page in WEB_PAGES_PRELOADED:
sem_threads.acquire()
t = Thread(target=download_threads, args=(page, sem_threads))
t.start()
th_append(t)
# Espera a que acaben los hilos
for x in th:
x.join()
Código con procesos
# --------------------------------------------------------------------------
# Procesos
# --------------------------------------------------------------------------
def download_processes(url):
try:
response = req.urlopen(url)
data = response.read()
except urllib.error.URLError:
print("Process error in URL: ", url)
def test_processes():
mp = Pool(MAX_CONCURRENCE)
mp.map(download_processes, WEB_PAGES_PRELOADED)
Código con asyncio
# --------------------------------------------------------------------------
# Corrutinas de Python 3
# --------------------------------------------------------------------------
@asyncio.coroutine
def download_coroutine(url, sem_coroutines):
with (yield from sem_coroutines):
response = yield from aiohttp.request('GET', url)
data = (yield from response.read())
def test_coroutines():
sem_coroutines = asyncio.Semaphore(MAX_CONCURRENCE)
f = asyncio.wait([download_coroutine(page, sem_coroutines) for page in WEB_PAGES_PRELOADED])
asyncio.get_event_loop().run_until_complete(f)
El bucle que lo cronometra todo
# --------------------------------------------------------------------------
# A cronometrar
# --------------------------------------------------------------------------
if __name__ == '__main__':
testing_cases = {
"Threads": "test_threads",
"Python 3 coroutines": "test_coroutines",
"Processes": "test_processes",
}
print("[*] Starting test")
for requests in [50, 100, 200]:
print(" " * 3, "- Requesting %s URLs:" % requests)
for concurrence in [5, 10, 15]:
print(" " * 5, "+ concurrence %s:" % concurrence)
WEB_PAGES_PRELOADED = [BASE_URL % w for w in range(requests)]
MAX_CONCURRENCE = concurrence
for case_name, case_function in testing_cases.items():
print(" " * 8, "> ", case_name, "time: ",
timeit.timeit("%s()" % case_function,
setup="from __main__ import %s" % case_function,
number=1),
"seconds")
print("[*] Tests end")
Resultados
Esto es lo que imprimió la ejecución:
cr0hn.com # python3.4 network_coroutines.py
[*] Starting test
- Requesting 50 URLs:
+ concurrence 5:
> Threads time: 5.643185321998317 seconds
> Python 3 coroutines time: 6.230320422007935 seconds
> Processes time: 5.842057971982285 seconds
+ concurrence 10:
> Threads time: 2.679791122995084 seconds
> Python 3 coroutines time: 2.809677056997316 seconds
> Processes time: 3.4920346940052696 seconds
+ concurrence 15:
> Threads time: 1.9668658949958626 seconds
> Python 3 coroutines time: 2.065839316986967 seconds
> Processes time: 2.3969717799918726 seconds
- Requesting 100 URLs:
+ concurrence 5:
> Threads time: 10.728528372012079 seconds
> Python 3 coroutines time: 10.180934254982276 seconds
> Processes time: 12.539949495985638 seconds
+ concurrence 10:
> Threads time: 6.375440713018179 seconds
> Python 3 coroutines time: 5.942036010994343 seconds
> Processes time: 6.644756149005843 seconds
+ concurrence 15:
> Threads time: 3.7005897909984924 seconds
> Python 3 coroutines time: 3.8714171170140617 seconds
> Processes time: 4.932254218001617 seconds
- Requesting 200 URLs:
+ concurrence 5:
> Threads time: 21.58786587699433 seconds
> Python 3 coroutines time: 20.815504188009072 seconds
> Processes time: 23.112755888025276 seconds
+ concurrence 10:
> Threads time: 11.149541911989218 seconds
> Python 3 coroutines time: 10.273655647004489 seconds
> Processes time: 13.324604407011066 seconds
+ concurrence 15:
> Threads time: 7.853176967008039 seconds
> Python 3 coroutines time: 7.413613215001533 seconds
> Processes time: 9.253623016993515 seconds
[*] Tests end
Saltan dos cosas a la vista. Hilos y corrutinas van codo con codo: con 50 URL los hilos sacan unas décimas, con 100 está empatado, y con 200 las corrutinas ganan en las tres configuraciones, por hasta medio segundo. Los procesos quedan últimos siempre, y la distancia crece con el número de URL: con 200 URL y 15 conexiones necesitan 9,2 segundos frente a los 7,4 de las corrutinas. Arrancar un proceso cuesta bastante más que arrancar un hilo o una corrutina, y con tantas peticiones cortas se nota.
Subir la concurrencia ayuda a los tres más o menos igual. Pasar de 5 a 15 conexiones deja el tiempo en más o menos un tercio elijas el método que elijas, que es lo que cabe esperar cuando el cuello de botella es esperar a la red y no la CPU.
Así que para descargar páginas sin más no hay un ganador claro entre hilos y asyncio en velocidad pura. La diferencia está en cómo se escribe el código y en lo que cada uno le cuesta al sistema, y para eso está el resto de la serie. El siguiente: acceso a base de datos.