Un millón de solicitudes por segundo con Python

¿Es posible alcanzar un millón de solicitudes por segundo con Python? Probablemente no hasta hace poco.

Muchas empresas están migrando de Python a otros lenguajes de programación para poder aumentar el rendimiento de sus operaciones y ahorrar en los precios de los servidores, pero en realidad no es necesario. Python puede ser la herramienta adecuada para el trabajo.

La comunidad de Python está haciendo mucho en torno al rendimiento últimamente. CPython 3.6 mejoró el rendimiento general del intérprete con una nueva implementación de diccionario. CPython 3.7 va a ser incluso más rápido, gracias a la introducción de cachés de búsqueda de diccionarios y convenciones de llamadas más rápidos.

Para tareas de procesamiento de números, puede usar PyPy con su compilación de código justo a tiempo. También puede ejecutar el conjunto de pruebas de NumPy, que ahora ha mejorado la compatibilidad general con las extensiones C. A finales de este año, se espera que PyPy alcance la conformidad con Python 3.5.

Todo este gran trabajo me inspiró a innovar en una de las áreas donde Python se usa ampliamente: el desarrollo web y de microservicios.

¡Entra Japronto!

Japronto es un nuevo microestructura adaptado a sus necesidades de microservicios. Sus principales objetivos incluyen ser rápido , escalable y ligero . Te permite hacer programación síncrona y asincrónica gracias a asyncio . Y es descaradamente rápido . Incluso más rápido que NodeJS y Go.

Errata: Como señala el usuario @heppu, el servidor HTTP stdlib de Go puede ser un 12% más rápido de lo que muestra este gráfico cuando se escribe con más cuidado. También hay un servidor fasthttp increíble para Go que aparentemente es solo un 18% más lento que Japronto en este punto de referencia en particular. ¡Increíble! Para obtener más información, consulte //github.com/squeaky-pl/japronto/pull/12 y //github.com/squeaky-pl/japronto/pull/14.

También podemos ver que el servidor WSGI de Meinheld está casi a la par con NodeJS y Go. A pesar de su diseño de bloqueo inherente, tiene un gran rendimiento en comparación con los cuatro anteriores, que son soluciones Python asincrónicas. Así que nunca confíe en nadie que diga que los sistemas asíncronos son siempre más rápidos. Casi siempre son más concurrentes, pero hay mucho más que eso.

Realicé este micro benchmark usando un "¡Hola, mundo!" aplicación, pero demuestra claramente la sobrecarga del marco del servidor para varias soluciones.

Estos resultados se obtuvieron en una instancia AWS c4.2xlarge que tenía 8 CPU virtuales, lanzada en la región de São Paulo con tenencia compartida predeterminada y virtualización HVM y almacenamiento magnético. El equipo ejecutaba Ubuntu 16.04.1 LTS (Xenial Xerus) con el kernel x86_64 genérico de Linux 4.4.0–53. El sistema operativo informaba Xeon® CPU E5–2666 v3 @ 2.90GHz CPU. Usé Python 3.6, que recién compilé a partir de su código fuente.

Para ser justos, todos los concursantes (incluido Go) estaban ejecutando un proceso de un solo trabajador. La carga de los servidores se probó utilizando wrk con 1 subproceso, 100 conexiones y 24 solicitudes simultáneas (canalizadas) por conexión (paralelismo acumulativo de 2400 solicitudes).

La canalización HTTP es crucial aquí, ya que es una de las optimizaciones que Japronto tiene en cuenta al ejecutar solicitudes.

La mayoría de los servidores ejecutan las solicitudes de los clientes de canalización de la misma manera que lo harían de los clientes que no lo hacen. No intentan optimizarlo. (De hecho, Sanic y Meinheld también eliminarán silenciosamente las solicitudes de los clientes de canalización, lo cual es una violación del protocolo HTTP 1.1).

En palabras simples, la canalización es una técnica en la que el cliente no necesita esperar la respuesta antes de enviar solicitudes posteriores a través de la misma conexión TCP. Para garantizar la integridad de la comunicación, el servidor envía varias respuestas en el mismo orden en que se reciben las solicitudes.

Los detalles sangrientos de las optimizaciones

Cuando el cliente canaliza muchas solicitudes pequeñas GET juntas, existe una alta probabilidad de que lleguen en un paquete TCP (gracias al algoritmo de Nagle) en el lado del servidor y luego sean leídas por una llamada al sistema .

Hacer una llamada al sistema y mover datos del espacio del kernel al espacio del usuario es una operación muy costosa en comparación con, digamos, mover la memoria dentro del espacio del proceso. Es por eso que al hacerlo es importante realizar las llamadas al sistema necesarias (pero no menos).

Cuando Japronto recibe datos y analiza con éxito varias solicitudes, intenta ejecutar todas las solicitudes lo más rápido posible, pega las respuestas en el orden correcto y luego escribe en una llamada al sistema . De hecho, el kernel puede ayudar en la parte de pegado, gracias a las llamadas al sistema de E / S de dispersión / recopilación, que Japronto aún no usa.

Tenga en cuenta que esto no siempre es posible, ya que algunas de las solicitudes pueden tardar demasiado y esperarlas aumentaría innecesariamente la latencia.

Tenga cuidado al ajustar la heurística y considere el costo de las llamadas al sistema y el tiempo esperado de finalización de la solicitud.

Además de retrasar las escrituras para clientes canalizados, existen otras técnicas que emplea el código.

Japronto está escrito casi en su totalidad en C. Los objetos analizador, protocolo, segador de conexión, enrutador, solicitud y respuesta se escriben como extensiones C.

Japronto se esfuerza por retrasar la creación de contrapartes de Python de sus estructuras internas hasta que se le solicite explícitamente. Por ejemplo, no se creará un diccionario de encabezados hasta que se solicite en una vista. Todos los límites del token ya están marcados antes, pero la normalización de las claves de encabezado y la creación de varios objetos str se realiza cuando se accede a ellos por primera vez.

Japronto se basa en la excelente biblioteca picohttpparser C para analizar la línea de estado, los encabezados y un cuerpo de mensaje HTTP fragmentado. Picohttpparser emplea directamente las instrucciones de procesamiento de texto que se encuentran en las CPU modernas con extensiones SSE4.2 (casi cualquier CPU x86_64 de 10 años de antigüedad las tiene) para hacer coincidir rápidamente los límites de los tokens HTTP. La E / S es manejada por el súper impresionante uvloop, que en sí mismo es un envoltorio de libuv. En el nivel más bajo, este es un puente a la llamada del sistema epoll que proporciona notificaciones asincrónicas sobre la preparación de lectura y escritura.

Python es un lenguaje de recolección de basura, por lo que se debe tener cuidado al diseñar sistemas de alto rendimiento para no aumentar innecesariamente la presión sobre el recolector de basura. El diseño interno de Japronto intenta evitar los ciclos de referencia y hacer las pocas asignaciones / desasignaciones necesarias. Lo hace mediante la asignación previa de algunos objetos en las llamadas arenas. También intenta reutilizar los objetos de Python para solicitudes futuras si ya no se hace referencia a ellos en lugar de desecharlos.

Todas las asignaciones se realizan como múltiplos de 4 KB. Las estructuras internas están cuidadosamente distribuidas para que los datos que se usan juntos con frecuencia estén lo suficientemente cerca en la memoria, minimizando la posibilidad de pérdidas de caché.

Japronto intenta no copiar innecesariamente entre búferes y realiza muchas operaciones en el lugar. Por ejemplo, decodifica porcentualmente la ruta antes de hacer coincidir en el proceso del enrutador.

Colaboradores de código abierto, me vendría bien su ayuda.

He estado trabajando en Japronto de forma continua durante los últimos 3 meses, a menudo durante los fines de semana, así como en días laborales normales. Esto solo fue posible gracias a que me tomé un descanso de mi trabajo de programador habitual y puse todo mi esfuerzo en este proyecto.

Creo que es hora de compartir el fruto de mi trabajo con la comunidad.

Actualmente, Japronto implementa un conjunto de características bastante sólido:

  • Implementación HTTP 1.x con soporte para cargas fragmentadas
  • Soporte completo para canalización HTTP
  • Conexiones Keep-Alive con reaper configurable
  • Soporte para vistas sincrónicas y asincrónicas
  • Modelo maestro-multi-trabajador basado en bifurcación
  • Soporte para recarga de código en cambios
  • Enrutamiento simple

Me gustaría analizar Websockets y transmitir respuestas HTTP de forma asincrónica a continuación.

Hay mucho trabajo por hacer en términos de documentación y pruebas. Si está interesado en ayudar, comuníquese conmigo directamente en Twitter. Aquí está el repositorio del proyecto GitHub de Japronto.

Además, si su empresa está buscando un desarrollador de Python que sea un fanático del rendimiento y también realice DevOps, estoy dispuesto a escuchar eso. Voy a considerar puestos en todo el mundo.

Ultimas palabras

Todas las técnicas que he mencionado aquí no son realmente específicas de Python. Probablemente podrían emplearse en otros lenguajes como Ruby, JavaScript o incluso PHP. Yo también estaría interesado en hacer ese trabajo, pero lamentablemente esto no sucederá a menos que alguien pueda financiarlo.

Me gustaría agradecer a la comunidad de Python por su continua inversión en ingeniería de rendimiento. A saber, Victor Stinner @VictorStinner, INADA Naoki @methane y Yury Selivanov @ 1st1 y todo el equipo de PyPy.

Por el amor de Python.