Concurrencia en Python

Buena gente la de Betabeers. Montan un evento para desarrolladores una vez al mes, y los patos solemos pasarnos por ahí. Se presentan proyectos, hay algo de debate técnico, y luego unas cervecillas (o copichuelas para los más valientes) ejercen de lubricante social. Ahora los hay en casi todas las ciudades del mundo, así que buscad el vuestro :)

Pues andaba yo un día echando un ojo a la lista de correo de Betabeers (me gusta leer en ese tipo de foros pero sin participar, muy en plan voyeur, pero sin tocarme, claro) y me llamó la atención una pregunta que se hacía por ahí. Hablaba alguien de su proyecto, y de que lo había hecho en Java, y tal y cual. Y entonces venía alguien y preguntaba aquello de “¿Java? ¿Por qué no Ruby o Python o Tecnología Cool X?”. Y el primero respondía algo como “necesito concurrencia, y no tengo demasiada idea de como está el tema de la concurrencia en esos lenguajes esotéricos, en Java uso threads y p’alante…”.

Aquello, como decía, me llamó la atención. Y me la llamó porque yo, en buena parte, me dedico a escribir software en Python con un nivel de concurrencia considerable. A eso y a leer foros en plan voyeur, sí. Y tiene sentido que siendo Python o Ruby lenguajes menos conocidos que Java por el gran público, la gente no esté muy al día del panorama por esos lares. Así que respondí en la lista contando lo que sé sobre el estado del arte de la concurrencia en Python, y todo muy bien. Pero uno se hace viejo, y lo que sé hoy se me podría olvidar mañana, y sería una pena que habiendo escrito algo útil perdiese el link para siempre jamás. Así que ahora que ya molo y tengo un blog voy a plasmarlo aquí, para tenerlo controlado.

Muy bien pues, Python y concurrencia iba diciendo. Python es, evidentemente, un lenguaje de programación, y con eso no le enseño nada a nadie. La concurrencia es la ejecución simultanea de varias tareas. Y ala, ya hemos acabado, hasta otro post… que no, que no, que sigo, que chispa tengo… La concurrencia es un tema muy denso, pero voy a centrarme principalmente en la concurrencia de tareas de I/O de red (en castellano sería E/S, ya, pero se me hace muy raro).

La concurrencia en tareas de red es molona porque supone un problema bastante interesante. El I/O de red, por defecto, es bloqueante. Vamos, que si tú le pides a tu software que vaya a comprobar el correo a un servidor SMTP, la ejecución de tu programa se queda ahí esperando a que los bytes vayan y vuelvan por los Internets del mundo. Tu programa cliente, en definitiva, bloquea esperando una respuesta del servidor. Eso es intrínseco al software que hace comunicación de red: hablar con nodos remotos es órdenes de magnitud más lento que cualquier operación relativa a CPU o RAM (o incluso el acceso a un disco local), así que la ejecución se bloquea en el I/O de red. Eso mata toda ilusión de concurrencia: no pueden pasar varias cosas a la vez si cada una de ellas bloquea el programa completo durante un tiempo largo. Mal asunto.

¿Y cómo se logra entonces que ese I/O de red no bloquee todo el hilo de ejecución y aspirar a la tan ansiada concurrencia? Pues hay diversas maneras, mirusté,  y vamos a echarles un ojo a las mismas. Keep it in mind: hablamos de Python, esto puede o no aplicar a tu amado lenguaje X.

Multithreading

La típica y clásica aproximación a todos los problemas de concurrencia del mundo mundial. Si has hecho una ingeniería sabes de que va el asunto. Los threads son hilos de ejecución simultáneos en un mismo proceso. A diferencia de los procesos del sistema operativo, comparten espacio de memoria y otros recursos entre ellos. Eso los hace ligeros y poco costosos de instanciar, y es más sencillo controlarlos desde la aplicación que múltiples procesos. Los threads permiten la concurrencia de red porque cuando uno de ellos ha de bloquear en I/O, el planificador del sistema operativo le otorga el control a otro, y de ese modo la aplicación sigue ejecutándose en vez de bloquearse sin remedio.

El uso de threads suele introducir problemas de sincronización: el orden en que acceden a los recursos compartidos puede hacer que el flujo de ejecución del programa sea imprevisible, y la cosa puede liarse muy parda.

Para evitar los problema de sincronización, se tomó una medida drástica al implementar el intérprete de Python: el GIL, o Global Interpreter Lock. La idea es básicamente que cuando a un thread le toca ejecutarse, obtiene un lock a nivel de intérprete que no permite la ejecución simultanea de otros threads. Bien, así ya no hay que preocuparse por la sincronización, pero… mal por otras muchas razones. Entre ellas, que eso hace que el intérprete corra en una única CPU, aunque el sistema sea multi-CPU (y ahora todos lo son, ¡diantre!), y que esa contención (los threads se quedan ahí esperando unos a otros durante un lapso de tiempo dado) hace que el rendimiento de la aplicación caiga con cada nuevo thread añadido. Una gran explicación sobre por qué pasa eso aquí.

En resumen, en Python trata de evitarse el uso de threads en cualquier aplicación mediana o grande. Y bien que se hace. El tema es un poco una mierda, la verdad.

Multiprocessing

Bien, en Python usar threads es una castaña, en gran parte porque su uso evita el aprovechamiento de las múltiples CPUs del sistema. Que faena, con la pasta que han valido esos cores de más. Nada, siempre hay alguien listo con soluciones para este tipo de problemas. En el caso de Python esa solución (al menos en parte) es el módulo de multiprocessing.

La idea es sencilla: en vez de usar threads, usamos procesos enteros, y arreglado lo del GIL, las múltiples CPUs y demás. Procesos independientes corriendo en tantas CPUs como haga falta, regocijaos. Las APIs emulan las del módulo estándar de threading, con lo que las aplicaciones se escriben sin cambios a penas respecto al modelo anterior. El módulo implementa comunicación entre procesos, colas de tareas y toda la magia de sincronización que provee el módulo de threading.

Lanzar un proceso, claro, es siempre más costoso que iniciar un thread, aunque en Linux (no sé como será la cosa en otro unices) es razonablemente rápido. Mucha gente está pasando del tema de threads para echar mano de esta opción.

Y ahora, una nota sobre asincronía

Tanto threads como procesos son métodos de concurrencia genéricos: no son soluciones específicas para I/O de red, sirven para todo tipo de concurrencia. Su poca especialización los hace elecciones pobres para aplicaciones complejas, soluciones de demasiado bajo nivel. Vamos a echar ahora un ojo a los dos modelos de programación asíncrona que facilitan la concurrencia en aplicaciones de red.

¿Y qué es eso de la asincronía? Pues sencillo: no bloquear esperando a que termine al I/O, sino seguir haciendo otras cosas, y sólo cuando termine el I/O continuar ejecutando esa parte del código que se había bloqueado. Buf, a ver si con un ejemplo lo dejo más claro.

Volvamos al tema de antes de ir a buscar la lista de mensajes de correo a un SMTP. Quiero ir al servidor, recuperar la lista de mensajes, y mostrársela al usuario. En el modelo bloqueante, hago la petición al servidor, bloqueo mientras espero, cafelito, pam!, recibo el resultado y lo muestro. ¿Qué ocurre en el modelo asíncrono? Hago la petición al servidor, en vez de bloquear sigo haciendo otras cosillas, continúo mi ejecución, y cuando recibo la respuesta del servidor entonces vuelvo al código que iba a mostrar los mails y lo ejecuto. Esa es la gracia de la asincronía, las cosas se ejecutan cuando les toca, no en orden secuencial. Toma ya.

Event loop + callbacks (Twisted)

El siempre controvertido Twisted. Lo odias o lo amas, pero no deja a nadie indiferente. Twisted es un framework para desarrollo de aplicaciones de red asíncronas. Para su modelo de asincronía tira del reactor pattern. La idea es fácil: el reactor es un loop de eventos, vamos, el tío se ejecuta de manera infinita y comprueba si las peticiones de red que hemos hecho están listas. Cuando una está lista, reacciona llamando a una función de callback registrada para ese evento.

Volviendo al ejemplo del correo, le digo al reactor “oye, le he pedido a un servidor SMTP que me envíe cosillas, cuando esa conexión tenga una respuesta, llama a la función de callback mostrar_resultados()“. El reactor usa los mecanismos que ofrezca el sistema operativo para detectar actividad en ese socket, y cuando llegan datos llama a mi función mostrar_resultados() pasando la respuesta del servidor como parámetro. Chulo, ¿verdad? Mientras tanto la aplicación ha seguido ejecutándose sin bloquear. Si el servidor tarda 5 minutos en responder, pues nada, durante 5 minutos hemos seguido haciendo cosas.

Ya tenemos concurrencia de I/O de red: podemos hacer cantidad de llamadas de red a la vez, el sistema operativo se encarga de gestionar sockets, y mientras tanto la aplicación no se ha bloqueado y sigue respondiendo a eventos y registrando nuevos.

Este mismo modelo de programación está disponible en otros lenguajes: en Ruby con EventMachine, o en Javascript con node.js, por ejemplo.

Cooperative preemption usando corutinas (gevent)

Menudo nombre, pero no uso los palabros en castellano para que no suene aún más raro. Aquí el objetivo es también ser asíncronos y no bloquear, pero la estrategia es distinta. También hay un loop de eventos, pero, a diferencia de Twisted, no se interactúa directamente con él. Está ahí, en la sombra, y la librería (gevent) hace uso de él por nosotros. La idea es que cuando una función necesite hacer I/O de red, le pase el control a otra, y así no bloquear. Un poco como tener threads, sólo que no los gestiona el sistema operativo sino la propia aplicación. Y al ser funciones y no procesos son más ligeros.

Ejemplo del SMTP de nuevo, como no. Llego a mi función que va a ir a pedir datos, y en un punto de la misma voy a hacer I/O de red, gevent se da cuenta y lo que hace es darle el control de la ejecución a otra función. Cuando el loop de eventos le avise de que los datos están listos, retomará la función por donde se había quedado, en el punto de ir a por datos, y seguirá su ejecución. En este caso, pintando en la pantalla del usuario.

¿Cómo se lo monta gevent para resumir la función por donde iba? ¡Pues usando corutinas! Las corutinas no son muy distintas de los generadores en Python: se puede “congelar” el estado de una función y luego retornar a su ejecución en ese mismo punto, con el añadido de poder retomar dicha ejecución pasando nuevos parámetros a la función. Y ahí está la magia. Para que gevent pueda hacer su trabajo con cualquier I/O de red, ha de parchearse el módulo de socket en Python, cosa que la propia librería soporta.

El uso de gevent oculta en gran parte el funcionamiento asíncrono de la aplicación, cuando en Twisted es explícito. Las funciones se escriben de manera secuencial de toda la vida, sin callbacks ni otras magias, y simplemente se llaman wrappeadas con utilidades de gevent. Esa capacidad para ocultar la complejidad inherente al código asíncrono ha hecho de este método el más popular en los últimos tiempos. Ruby con sus fibers Y Go con sus goroutines usan principios similares.

Conclusión

Pues… nada, eso es lo que tengo. Se puede escribir software de red de manera muy seria en Python. Sin esos mecanismos para ofrecer concurrencia, sería un asunto más bien turbio. En Ducksboard hacemos unos cuantos millones de peticiones diarias a servicios web, imaginad el cachondeo que sería el asunto si tuviésemos que bloquear en cada ocasión…

Advertisements

5 thoughts on “Concurrencia en Python

  1. Genial post, me he topado con el buscando información sobre Gevent.
    Y tengo una pregunta, por si puedes darme algo mas de info, ya que soy relativamente nuevo en python.
    Estoy creando un webservice y una web, para una app movil (ios y android), y la escribiré en python (usando django), en ella tendre algo como un chat, digo algo, por que es mas como un servicio de mensajeria (PM).
    Me tope con un proyecto “gevent-socketio” que eso me facilitaría a la hora de crear el chat, ya que la v1 que tengo hecha esta usando nodejs+socketIO. consideras que es buena idea hacerlo en python?? es decir, aguantaría cientos sde peticiones el gevent-socketio??

    Ya que quisiera centralizar todo el codigo en una sola aplicación, y no mantener 2 (python y luego nodejs)

    Saludos,

    1. No vas a tener problema alguno con Python, no te preocupes. Si bien es cierto que node.js (gracias a V8) es superior a cualquier solución Python en los benchmarks de requests/segundo, los números no son tan distintos como para que tengan que preocuparte a menos que planees una concurrencia muy muy bestia.

      Si te vas a mover en los cientos de conexiones concurrentes como comentas, vas muy sobrado con cualquier de las soluciones. Si te movieses en los muchos miles te tocaría medir muy bien y optimizar mucho, pero como no es el caso, usa lo que te resulte más cómodo porque a penas vas a notar diferencia.

  2. Pingback: Orden y concierto | Take it easy

  3. Gracias por la explicacion, todo me queda mas claro ahora, pero todavia tengo una duda, en mi caso necesito hacer un servidor que recibira entre 1000 y 2000 conexiones simultaneas de red para recepcion de tramas que envian equipos de GPS, he leido sobre el modulo asyncore de python y me gustaria saber cual es su recomendacion, si utilizar asyncore , gevent o twisted… Y si estoy todo perdido por el mundo entonces dimelo y ya se que la cosa no va por aqui para lo que necesito. Gracias nuevamente.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s