Aplicando lo aprendido con gevent para dar valor a los usuarios

Este artículo fue publicado originalmente el 16 de octubre de 2020 en eng.lyft.com por Shiv Toolsidass y fue traducido por Fernando A. López P.

Panorama

En 2020, comenzó a permitir la comparación entre los modos de bicicletas y scooters y los modos de rideshare y de transporte público. El objetivo de este proyecto fue dar a los usuarios toda la información necesaria para planear un viaje en bicicleta o en scooter, incluyendo tiempos estimados de llegada, ruteo y precios. Tras lanzar la funcionalidad, notamos un grave problema en la experiencia de usuario — los modos de bicicleta y scooter aparecían y desaparecían de la lista, lo cual era confuso.

Nota cómo las “ofertas” de bicicletas y scooters al inicio no aparecen, haciendo que la experiencia de usuario sea confusa.

Esta publicación relata cómo usamos nuestros aprendizajes con gevent de las publicaciones 1, 2 y 3 para encontrar la causa raíz del problema y arreglarla. Esta publicación es relevante a cualquiera que esté usando microservicios de Python sensibles a la latencia y que tienen una mezcla de cargas de trabajo de CPU y de E/S.

Una vista de pájaro del problema

Cuando un usuario de Lyft introduce un origen y un destino en la aplicación, los clientes móviles hacen una solicitud a nuestros servicios de backend, pidiéndoles que generen un conjunto de “ofertas” con todas las opciones de transporte que Lyft ofrece en la región donde está el usuario. Una “oferta” incluye detalles de un modo de transporte pertinente al viaje del usuario, incluyendo el costo, el tiempo de llegada y la ruta.

Nuestros servicios de backend computan “ofertas” para cada modo de Lyft en paralelo — ponemos un límite máximo de 500 ms al tiempo que toma generar cada oferta para asegurarnos de que la experiencia del usuario sea rápida. Cuando una oferta no puede ser generada en ese tiempo, es ignorada; las ofertas generadas con éxito se retornan a los usuarios. Los clientes solicitan nueva información de los servidores con cierta frecuencia para que la lista de ofertas siempre esté actualizada.

Nos percatamos de que la generación de ofertas para bicicletas y scooters estaba tomando más de 500 ms en algunos casos, lo que significó que en algunas cargas de información, los servicios de backend regresaban ofertas de bicicletas y scooters, pero en las siguientes cargas, las ofertas de bicicletas y scooters no se generaban exitosamente dentro del límite de tiempo y eran ignoradas.

Para darte una idea de qué tan malo era este problema, el 0.5% de las ofertas de bicicletas y scooters no eran generadas porque se les acababa el tiempo, lo cual nos ponía en 2.5 nueves de confiabilidad — no lo mejor para una de las experiencias de usuario con mayor valor en la aplicación de Lyft.

Grado de éxito (%) de generación de ofertas de bicicletas y scooters

Nuestras latencias p95 andaban bien, registrando valores consistentemente menores a 450 ms.

Latencias p95 (en ms) de generación de ofertas de bicicletas y scooters

Sin embargo, nuestras latencias p99 eran casi de un segundo en algunas regiones de servicio de Lyft (el doble de nuestras latencias p95).

Latencias p99 (en ms) de generación de ofertas de bicicletas y scooters

Las latencias p999 de generación de ofertas de bicicletas y scooters llegaban al pasmoso tiempo de 1.5 segundos (más del triple de nuestra latencia p95).

Latencias p999 (en ms) de generación de ofertas de bicicletas y scooters

Tras excavar en los datos, la pregunta fue “¿por qué hay tanta variabilidad entre nuestras latencias p95, p99 y p999?”

Para responder a esta pregunta, veamos más detenidamente cómo se generan las ofertas de bicicletas y scooters. El servicio de backend responsable de generar las ofertas de Lyft (llamémosle serviciodeofertas) computa cada oferta en paralelo. Para computar el precio de las ofertas de bicicletas y scooters, serviciodeofertas, que es un microservicio en Go, hace una solicitud al serviciodeprecios, un microservicio en Python.

He aquí un diagrama de datos en texto de cómo se generan las ofertas de bicicletas y scooters:

Cliente móvil (iOS/Android) -> serviciodeofertas (Go) -> serviciodeprecios (Python) -> servicios adicionales (Python + Go)

Te preguntarás por qué estamos mencionando en qué lenguajes están escritos nuestros microservicios de backend. Este es un detalle importante a recordar y pronto será evidente el por qué.

Encontrando la causa raíz

Al revisar las latencias de los servicios adicionales que son llamados por serviciodeprecios, notamos que esos servicios adicionales reportaban que algunas de sus llamadas tomaban alrededor de 110 ms, pero la instrumentación de serviciodeprecios reportaba latencias de hasta 5.3 segundos, una discrepancia de alrededor de 35x.

Discrepancia de alrededor de 35x entre las latencias reportadas por la instrumentación de serviciodeprecios y la de los servicios llamados por éste

Claramente, el delta entre estos dos conjuntos de latencias no había sido considerado y necesitábamos saber en qué se estaba consumiendo. Hicimos uso de trazos distribuidos para debuguear dónde se consumía ese diferencial de tiempo (para aprender más sobre cómo Lyft configuró trazos distribuidos, puedes revisar esta plática [en inglés]).

Usando nuestros trazos distribuidos, encontramos que las solicitudes a otros servicios desde serviciodeprecios estaban siendo insertadas en una fila y esperando ahí por largo tiempo. Sospechamos que esto tal vez tendría que ver con cómo el servidor web de serviciodeprecios estaba agendando solicitudes.

Como ya mencionamos, serviciodeprecios es un microservicio de Python que usa Flask como framework de aplicación web. Gunicorn es el servidor web usado por Flask y gunicorn usa gevent para agendar múltiples peticiones a la vez.

Recordemos que el candado de interpretación global (global interpreter lock) de Python efectivamente hace que cualquier programa en Python ejecutado en el CPU tenga un solo hilo. ¿Cómo hacen entonces gunicorn y gevent para servir miles de solicitudes entrantes por segundo bajo esta restricción? gevent inicia un greenlet (también conocido como green thread, hilo verde) por cada solicitud recibida por el microservicio. Estos greenlets son agendados para correr usando un ciclo de eventos bajo el framework de multitarea cooperativa del que hablamos en la parte 1.

Un greenlet típico atendiendo una solicitud (llamémosle greenlet A) puede hacer algo de trabajo en el CPU, hacer una llamada a otro servicio (lo cual involucra E/S bloqueante) y finalmente hacer algo más de trabajo en CPU. Mientras espera a que el bloqueo de E/S termine, en este caso la llamada de red, el greenlet cederá el control al ciclo de eventos.

En ese momento Gevent puede buscar otro greenlet (llamémosle greenlet B) para agendar en su ciclo de eventos. Cuando el greenlet B termina, si el greenlet A ya terminó su bloqueo de E/S, el ciclo de eventos agenda al greenlet A para que corra de nuevo. El greenlet A completa su trabajo de CPU y es extraído del ciclo de eventos. El ciclo de eventos ahora busca qué otro greenlet correrá.

Lo importante aquí es que cuando el greenlet hace una llamada de red, no solo esperamos a que la llamada termine, sino también a que el ciclo de eventos vuelva a correr nuestro greenlet. Si corremos demasiados greenlets a la vez, el trabajo de CPU de otras solicitudes puede “bloquear” que nuestra solicitud corra de nuevo.

Ilustremos esto con un ejemplo (imaginemos que cada celda representa una fracción de tiempo de 10 ms cada una). Nuestra infraestructura de red mediría la latencia de la llamada de red de la Solicitud 7 como 70 ms; sin embargo, la aplicación la mediría como 180 ms porque esperamos 110 ms adicionales para que el ciclo de eventos la agendara de nuevo. El trabajo de CPU de otras solicitudes “bloqueó” que nuestra solicitud corriera.

En el ejemplo anterior, correr tres solicitudes concurrentes funciona casi a la perfección:

Ahora bien, ¿cómo se relaciona esto a serviciodeprecios y los altos tiempos de respuesta que vimos antes?

serviciodeprecios hospeda a cientos de puntos finales diferentes que tienen diversos niveles de trabajo de CPU y de E/S. Algunos puntos finales requieren mucho trabajo de CPU, mientras que otros (como el punto final que calcula los precios de las ofertas de bicicletas y scooters) requieren mucho trabajo de E/S (porque necesita llamar a varios otros servicios).

Los greenlets para los puntos finales que requieren mucho del CPU en serviciodeprecios estaban bloqueando a que corrieran los greenlets que requieren muchas E/S. Esto hacía que los greenlets de los puntos finales que calculan los precios de bicicletas y scooters quedaran en la cola, esperando más tiempo para volver a ser agendados por el ciclo de eventos.

Para resolver este problema, decidimos correr este punto final en procesos dedicados de servicio de aplicación, de manera que tuviéramos procesos dedicados de gunicorn para ocuparse de nuestras solicitudes de precios de bicicletas y scooters, dado que requieren de mucho trabajo de E/S. Estas solicitudes y sus greenlets ya no tendrían que competir con los greenlets que requieren mucho del CPU y que vienen de otros tipos de solicitudes.

Usando herramientas construidas por nuestros equipos de infraestructura, pudimos correr nuestros puntos finales de precios de bicicletas y scooters en procesos dedicados y apuntamos los servicios que llaman a estos puntos finales para que las solicitudes hacia estos puntos finales fueran hechas a dichos huéspedes dedicados. ¡Una vez que liberamos estos cambios, obtuvimos exactamente los resultados que deseábamos!

Nuestra tasa de éxito (%) saltó de ~99.5 a ~99.995.

Una gran baja en el número de solicitudes cuyo tiempo se vencía (timeouts).

Las solicitudes a nuestro punto final de precios de bicicletas y scooters inmediatamente dejaron de descartarse por falta de tiempo, lo que llevó a que los precios dejaran de aparecer y desaparecer en la aplicación. La confiabilidad de las solicitudes de precios de bicicletas y scooters saltó de un promedio de 2.5 a 4.5 nueves — ¡una mejora significativa!

Aprendizajes

El aprendizaje más importante para el lector es que correr un servicio en Python de alto rendimiento (que aprovecha a gevent) y que tiene algunas cargas de trabajo que requieren mucho CPU y otras que requieren mucho E/S puede llevar a que las solicitudes fuertes en CPU ahoguen a las que requieren mucho E/S. Recomendamos usar trazado distribuido para examinar el ciclo de vida de las solicitudes y aprovechar las recomendaciones de esta serie de publicaciones sobre gevent para mejorar las latencias y la confiabilidad de tu servicio de Python.

Esperamos que hayas encontrado utilidad en esta serie de cuatro partes sobre gevent y que puedas aprovechar estas enseñanzas para dar más valor a tus usuarios así como nosotros lo hicimos.

Esta publicación no habría sido posible sin la ayuda de Roy Williams, David Quaid y Justin Phillips. Adicionalmente, un gran agradecimiento a Garrett Heel, Ilya Konstantinov, Daniel Metz, y Jatin Chopra por sus consejos y por revisar esta publicación.

¡Lyft está contratando! Si te interesa mejorar la vida de la gente con el mejor transporte del mundo, ¡únete a nosotros!

--

--