
Hola Habr!
A menudo escribimos y hablamos sobre el rendimiento de PHP:
cómo lo tratamos en general,
cómo ahorramos $ 1 millón al cambiar a PHP 7.0, y también
traducimos varios materiales sobre este tema. Esto se debe al hecho de que la audiencia de nuestros productos está creciendo y la ampliación del backend de PHP con hierro está cargada de costos significativos: tenemos 600 servidores con PHP-FPM. Por lo tanto, invertir tiempo en la optimización es beneficioso para nosotros.
Antes, hablamos principalmente sobre las formas habituales y ya establecidas de trabajar con la productividad. ¡Pero la comunidad PHP está en alerta! JIT aparecerá en PHP 8, la precarga aparecerá en PHP 7.4 y se desarrollarán marcos fuera del marco de desarrollo de PHP central, que suponen que PHP funciona como un demonio. Es hora de experimentar con algo nuevo y ver qué nos puede dar.
Dado que el lanzamiento de PHP 8 todavía está muy lejos, y los marcos asincrónicos no son adecuados para nuestras tareas (por qué, lo diré a continuación), hoy nos centraremos en la precarga, que aparecerá en PHP 7.4, y el marco para demonizar PHP, RoadRunner.
Esta es la versión de texto de mi informe con
Badoo PHP Meetup # 3 . Video de todos los discursos que
hemos recopilado en esta publicación .
PHP-FPM, Apache mod_php y formas similares de ejecutar scripts PHP y procesar solicitudes (que son ejecutadas por la gran mayoría de sitios y servicios; por simplicidad, los llamaré PHP "clásico") funcionan sobre la base de
nada compartido en el sentido amplio del término:
- el estado no se revuelve entre los trabajadores de PHP;
- el estado no se revuelve entre varias solicitudes.
Considere esto con un ejemplo de un script simple:
Para cada solicitud, el script se ejecuta desde la primera hasta la última línea: a pesar de que la inicialización, muy probablemente, no diferirá de la solicitud a la solicitud y puede ejecutarse una vez (ahorrando recursos), aún debe repetirla para cada solicitud. No podemos simplemente tomar y guardar variables (por ejemplo,
$app
) entre solicitudes debido a las peculiaridades de cómo funciona el PHP "clásico".
¿Cómo sería si fuéramos más allá del alcance del PHP "clásico"? Por ejemplo, nuestro script podría ejecutarse independientemente de la solicitud, inicializarse y tener un ciclo de consulta dentro, dentro del cual esperaría el siguiente, procesarlo y repetir el ciclo sin limpiar el entorno (en adelante llamaré a esta solución "PHP como demonio" ").
Pudimos no solo deshacernos de la inicialización repetida para cada solicitud, sino también guardar la lista de ciudades una vez en la variable
$cities
y usarla desde varias solicitudes sin acceder a ningún lugar excepto a la memoria (esta es la forma más rápida de obtener datos).
El rendimiento de dicha solución es potencialmente significativamente mayor que el del PHP "clásico". Pero, por lo general, el aumento de la productividad no se da de forma gratuita: debe pagar un precio por ello. Veamos qué puede ser en nuestro caso.
Para hacer esto, complicaremos un poco nuestro script y, en lugar de mostrar la variable
$name
, llenaremos la matriz:
- $name = $cities[$req->getCookie('city_id')]; + $names[] = $cities[$req->getCookie('city_id')];
En el caso de PHP "clásico", no surgirán problemas: al final de la consulta, la variable
$name
se destruirá y cada solicitud posterior funcionará como se esperaba. En el caso de iniciar PHP como demonio, cada solicitud agregará otra ciudad a esta variable, lo que conducirá a un crecimiento incontrolado de la matriz hasta que la memoria se agote en la máquina.
En general, no solo puede terminar la memoria, sino que pueden producirse otros errores que conducirán a la muerte del proceso. Con tales problemas, PHP "clásico" maneja automáticamente. En el caso de iniciar PHP como demonio, necesitamos monitorear de alguna manera este demonio, reiniciarlo si falla.
Los errores de este tipo son desagradables, pero existen soluciones efectivas para ellos. Es mucho peor si, debido a un error, el script no cae, pero cambia de manera impredecible los valores de algunas variables (por ejemplo, borra la matriz
$cities
). En este caso, todas las solicitudes posteriores funcionarán con datos incorrectos.
Para resumir, es más fácil escribir código para PHP "clásico" (PHP-FPM, Apache mod_php y similares): nos libera de una serie de problemas y errores. Pero por esto pagamos con rendimiento.
De los ejemplos anteriores, vemos que en algunas partes del código, PHP gasta recursos que no podrían haberse gastado (o desperdiciado una vez) en el procesamiento de cada solicitud del "clásico". Estas son las siguientes áreas:
- conexión de archivo (incluir, requerir, etc.);
- inicialización (framework, bibliotecas, contenedor DI, etc.);
- solicitar datos del almacenamiento externo (en lugar de almacenarlos en la memoria).
PHP ha existido durante muchos años e incluso puede haberse vuelto popular gracias a este modelo de trabajo. Durante este tiempo, se desarrollaron muchos métodos de diversos grados de éxito para resolver el problema descrito. Mencioné algunos de ellos en mi
artículo anterior. Hoy nos detendremos en dos soluciones bastante nuevas para la comunidad: precarga y RoadRunner.
Precarga
De los tres puntos enumerados anteriormente, la
precarga está diseñada para ocuparse de la primera sobrecarga al conectar archivos. A primera vista, esto puede parecer extraño y sin sentido, porque PHP ya tiene OPcache, que fue creado solo para este propósito. Para comprender la esencia, perfilemos real con la ayuda de
perf
, sobre el cual OPcache está habilitado, con una tasa de aciertos igual al 100%.

A pesar de OPcache, vemos que
persistent_compile_file
toma 5.84% del tiempo de ejecución de la consulta.
Para entender por qué sucede esto, podemos ver las fuentes de
zend_accel_load_script . Se puede ver de ellos que, a pesar de la presencia de OPcache, con cada llamada a
include/require
firmas de clases y funciones se copian de la memoria compartida a la memoria del proceso de trabajo, y se realiza un trabajo auxiliar diferente. Y este trabajo debe realizarse para cada solicitud, ya que al final se borra la memoria del proceso de trabajo.

Esto se ve agravado por la gran cantidad de llamadas de inclusión / solicitud que generalmente hacemos en una sola solicitud. Por ejemplo, Symfony 4 incluye aproximadamente 310 archivos antes de ejecutar la primera línea útil de código. A veces esto sucede implícitamente: para crear una instancia de clase A, que se muestra a continuación, PHP cargará automáticamente todas las demás clases (B, C, D, E, F, G). Y especialmente a este respecto, se destacan las dependencias de Composer que declaran funciones: para garantizar que estas funciones estén disponibles durante la ejecución del código de usuario, Composer siempre debe conectarlas independientemente del uso, ya que PHP no tiene funciones de carga automática y no pueden cargado en el momento de la llamada.
class A extends \B implements \C { use \D; const SOME_CONST = \E::E1; private static $someVar = \F::F1; private $anotherVar = \G::G1; }
Cómo funciona la precarga
Preload tiene una única configuración principal, opcache.preload, en la que se pasa la ruta al script PHP. Este script se ejecutará una vez al iniciar PHP-FPM / Apache /, etc., y todas las firmas de clases, métodos y funciones que se declararán en este archivo estarán disponibles para todos los scripts que procesen solicitudes desde la primera línea de su ejecución (importante nota: esto no se aplica a variables y constantes globales; sus valores se restablecerán a cero después del final de la fase de precarga). Ya no necesita hacer llamadas de inclusión / solicitud y copiar firmas de función / clase de la memoria compartida a la memoria de proceso: todas ellas se declaran
inmutables y, debido a esto, todos los procesos pueden referirse a la misma ubicación de memoria que las contiene.
Por lo general, las clases y funciones que necesitamos están en diferentes archivos y es inconveniente combinarlas en un script de precarga. Pero esto no necesita hacerse: dado que la precarga es un script PHP normal, podemos usar include / require u opcache_compile_file () del script de precarga para todos los archivos que necesitamos. Además, dado que todos estos archivos se cargarán una vez, PHP podrá realizar optimizaciones adicionales que no podrían realizarse mientras conectamos estos archivos por separado en el momento de la consulta. PHP realiza optimizaciones solo dentro del marco de cada archivo separado, pero en el caso de precarga, para todo el código cargado en la fase de precarga.
Benchmarks preload
Para demostrar en la práctica los beneficios de la precarga, tomé un punto final Badoo vinculado a la CPU. Nuestro backend generalmente se caracteriza por una carga vinculada a la CPU. Este hecho es la respuesta a la pregunta de por qué no consideramos los marcos asincrónicos: no ofrecen ninguna ventaja en el caso de una carga vinculada a la CPU y al mismo tiempo complican aún más el código (debe escribirse de manera diferente), así como para trabajar con una red, disco, etc. Se requieren controladores asincrónicos especiales.
Para apreciar completamente los beneficios de la precarga, para el experimento descargué con él todos los archivos necesarios para el script probado en el trabajo, y lo
cargué con una apariencia de carga de producción normal usando
wrk2 , un análogo más avanzado de Apache Benchmark, pero igual de simple. .
Para probar la precarga, primero debe actualizar a PHP 7.4 (ahora tenemos PHP 7.2). Medí el rendimiento de PHP 7.2, PHP 7.4 sin precarga y PHP 7.4 con precarga. El resultado es una imagen así:

Por lo tanto, la transición de PHP 7.2 a PHP 7.4 da + 10% al rendimiento en nuestro punto final, y la precarga da otro 10% desde arriba.
En el caso de la precarga, los resultados dependerán en gran medida del número de archivos conectados y la complejidad de la lógica ejecutable: si hay muchos archivos conectados y la lógica es simple, la precarga dará más que si hay pocos archivos y la lógica es compleja.
Los matices de precarga
Lo que aumenta la productividad generalmente tiene un inconveniente. La precarga tiene muchos matices, que daré a continuación. Todos deben tenerse en cuenta, pero solo uno (primero) puede ser fundamental.
Cambiar - reiniciar
Dado que todos los archivos de precarga se compilan solo al inicio, se marcan como inmutables y no se vuelven a compilar en el futuro, la única forma de aplicar cambios a estos archivos es reiniciar (recargar o reiniciar) PHP-FPM / Apache /, etc.
En el caso de la recarga, PHP intenta reiniciarse con la mayor precisión posible: las solicitudes de los usuarios no se interrumpirán, pero, sin embargo, mientras la fase de precarga está en progreso, todas las solicitudes nuevas esperarán a que se complete. Si no hay mucho código en la precarga, esto puede no causar problemas, pero si intentas descargar la aplicación completa, hay un aumento significativo en el tiempo de respuesta durante un reinicio.
Además, un reinicio (independientemente de si se trata de recargar o reiniciar) tiene una característica importante: como resultado de esta acción, se borra OPcache. Es decir, todas las solicitudes posteriores funcionarán con un caché de código de operación en frío, lo que puede aumentar el tiempo de respuesta aún más.
Personajes indefinidos
Para que la precarga cargue una clase, todo lo que depende debe definirse hasta este punto. Para la siguiente clase, esto significa que todas las demás clases (B, C, D, E, F, G), la variable
$someGlobalVar
y la constante SOME_CONST deben estar disponibles antes de compilar esta clase. Dado que el script de precarga es solo un código PHP normal, podemos definir un cargador automático. En este caso, todo lo relacionado con otras clases se cargará automáticamente. Pero esto no funciona con variables y constantes: nosotros mismos debemos asegurarnos de que estén definidos en el momento en que se declara esta clase.
class A extends \B implements \C { use \D; const SOME_CONST = \E::E1; private static $someVar = \F::F1; private $anotherVar = \G::G1; private $varLink = $someGlobalVar; private $constLink = SOME_CONST; }
Afortunadamente, la precarga contiene suficientes herramientas para entender si obtienes algo fuera del camino o no. En primer lugar, estos son mensajes de advertencia con información sobre lo que no se pudo cargar y por qué:
PHP Warning: Can't preload class MyTestClass with unresolved initializer for constant RAND in /local/preload-internal.php on line 6 PHP Warning: Can't preload unlinked class MyTestClass: Unknown parent AnotherClass in /local/preload-internal.php on line 5
En segundo lugar, preload agrega una sección separada al resultado de la función opcache_get_status (), que muestra lo que se cargó correctamente en la fase de precarga:

Campo de clase / optimización constante
Como escribí anteriormente, preload resuelve los valores de los campos / constantes de la clase y los guarda. Esto le permite optimizar el código: durante el procesamiento de la solicitud, los datos están listos y no es necesario que se deriven de otros datos. Pero esto puede conducir a resultados no obvios, lo que demuestra el siguiente ejemplo:
const.php: <?php define('MYTESTCONST', mt_rand(1, 1000));
preload.php: <?php include 'const.php'; class MyTestClass { const RAND = MYTESTCONST; }
script.php: <?php include 'const.php'; echo MYTESTCONST, ', ', MyTestClass::RAND;
El resultado es una situación contraintuitiva: parece que las constantes deberían ser iguales, ya que a una de ellas se le asignó el valor de la otra, pero en realidad esto no es así. Esto se debe al hecho de que las constantes globales, en contraste con las constantes / campos de clase, se borran por la fuerza después de que finaliza la fase de precarga, mientras que las constantes / campos de clase se resuelven y guardan. Esto lleva al hecho de que durante la ejecución de la solicitud tenemos que definir nuevamente la constante global, como resultado de lo cual puede obtener un valor diferente.
No se puede volver a declarar someFunc ()
En el caso de las clases, la situación es simple: generalmente no las conectamos explícitamente, sino que usamos un cargador automático. Esto significa que si una clase se define en la fase de precarga, entonces el autocargador simplemente no se ejecutará durante la solicitud y no intentaremos conectar esta clase por segunda vez.
La situación es diferente con las funciones: debemos conectarlas explícitamente. Esto puede conducir a una situación en la que en el script de precarga conectaremos todos los archivos necesarios con funciones, y durante la solicitud intentaremos hacerlo nuevamente (un ejemplo típico es el gestor de arranque Composer: siempre intentará conectar todos los archivos con funciones). En este caso, obtenemos un error: la función ya se ha definido y no se puede redefinir.
Este problema se puede resolver de diferentes maneras. En el caso de Composer, puede, por ejemplo, conectar todo en la fase de precarga y no conectar nada relacionado con Composer durante las solicitudes. Otra solución no es conectar archivos con funciones directamente, sino hacerlo a través de un archivo proxy con una comprobación de function_exists (), como, por ejemplo, Guzzle HTTP.

PHP 7.4 aún no se ha lanzado oficialmente (todavía)
Este matiz se volverá irrelevante después de algún tiempo, pero hasta ahora la versión de PHP 7.4 aún no se ha lanzado oficialmente y el equipo de PHP en las notas de la versión
escribe explícitamente: "NO use esta versión en producción, es una versión de prueba temprana". Durante nuestros experimentos con precarga, nos encontramos con varios errores, los reparamos nosotros mismos e incluso
enviamos algo a la parte superior. Para evitar sorpresas, es mejor esperar el lanzamiento oficial.
Correcaminos
RoadRunner es un demonio escrito en Go, que, por un lado, crea trabajadores PHP y los monitorea (comienza / termina / reinicia según sea necesario) y, por otro lado, acepta solicitudes y las pasa a estos trabajadores. En este sentido, su trabajo no es diferente del trabajo de PHP-FPM (donde también hay un proceso maestro que monitorea a los trabajadores). Pero todavía hay diferencias. La clave es que RoadRunner no restablece el estado del script después de completar la consulta.
Por lo tanto, si recordamos nuestra lista de los recursos que se gastan en el caso de PHP "clásico", RoadRunner le permite tratar con todos los puntos (la precarga, como recordamos, es solo con el primero):
- conexión de archivo (incluir, requerir, etc.);
- inicialización (framework, bibliotecas, contenedor DI, etc.);
- solicitar datos del almacenamiento externo (en lugar de almacenarlos en la memoria).
El ejemplo de Hello World RoadRunner se parece a esto:
$relay = new Spiral\Goridge\StreamRelay(STDIN, STDOUT); $psr7 = new Spiral\RoadRunner\PSR7Client(new Spiral\RoadRunner\Worker($relay)); while ($req = $psr7->acceptRequest()) { $resp = new \Zend\Diactoros\Response(); $resp->getBody()->write("hello world"); $psr7->respond($resp); }
Probaremos nuestro punto final actual, que probamos con precarga, para ejecutarlo en RoadRunner sin modificaciones, cargarlo y medir el rendimiento. Sin modificaciones, porque de lo contrario el punto de referencia no será completamente honesto.
Intentemos adaptar el ejemplo de Hello World para esto.
En primer lugar, como escribí anteriormente, no queremos que el trabajador se caiga en caso de error. Para hacer esto, necesitamos envolver todo en un intento global ... captura. En segundo lugar, dado que nuestro script no sabe nada sobre Zend Diactoros, para la respuesta necesitaremos convertir sus resultados. Para esto utilizamos funciones ob_. En tercer lugar, nuestro script no sabe nada sobre la naturaleza de la solicitud PSR-7. La solución es completar el entorno PHP estándar desde estas entidades. Y cuarto, nuestro script espera que la solicitud muera y se borrará todo el estado. Por lo tanto, con RoadRunner necesitaremos hacer esta limpieza nosotros mismos.
Por lo tanto, la versión inicial de Hello World se convierte en algo como esto:
while ($req = $psr7->acceptRequest()) { try { $uri = $req->getUri(); $_COOKIE = $req->getCookieParams(); $_POST = $req->getParsedBody(); $_SERVER = [ 'REQUEST_METHOD' => $req->getMethod(), 'HTTP_HOST' => $uri->getHost(), 'DOCUMENT_URI' => $uri->getPath(), 'SERVER_NAME' => $uri->getHost(), 'QUERY_STRING' => $uri->getQuery(),
Benchmarks RoadRunner
Bueno, es hora de lanzar puntos de referencia.

Los resultados no cumplen con las expectativas: RoadRunner le permite nivelar más factores que causan pérdidas de rendimiento que la precarga, pero los resultados son peores. Vamos a averiguar por qué sucede esto, como siempre, ejecutando perf para esto.

En los resultados de rendimiento, vemos phar_compile_file. Esto se debe a que incluimos algunos archivos durante la ejecución del script, y dado que OPcache no está activado (RoadRunner ejecuta scripts como CLI, donde OPcache está desactivado por defecto), estos archivos se compilan nuevamente con cada solicitud.
Edite la configuración de RoadRunner: habilite OPcache:


Estos resultados ya se parecen más a lo que esperábamos ver: RoadRunner comenzó a mostrar más rendimiento que precarga. ¡Pero quizás podamos obtener aún más!
Parece que no hay nada más inusual con perf: echemos un vistazo al código PHP. La forma más fácil de perfilarlo es usar
phpspy : no requiere ninguna modificación del código PHP, solo necesita ejecutarlo en la consola. Hagamos esto y construyamos un gráfico de llama:

Como acordamos no modificar la lógica de nuestra aplicación para la pureza del experimento, estamos interesados en la rama de la pila asociada con el trabajo de RoadRunner:

La parte principal se reduce a llamar a fread (), casi nada se puede hacer con esto. Pero vemos algunas otras ramas en
\ Spiral \ RoadRunner \ PSR7Client :: acceptRequest () , excepto el propio fread. Puede entender su significado mirando el código fuente:
public function acceptRequest() { $rawRequest = $this->httpClient->acceptRequest(); if ($rawRequest === null) { return null; } $_SERVER = $this->configureServer($rawRequest['ctx']); $request = $this->requestFactory->createServerRequest( $rawRequest['ctx']['method'], $rawRequest['ctx']['uri'], $_SERVER ); parse_str($rawRequest['ctx']['rawQuery'], $query); $request = $request ->withProtocolVersion(static::fetchProtocolVersion($rawRequest['ctx']['protocol'])) ->withCookieParams($rawRequest['ctx']['cookies']) ->withQueryParams($query) ->withUploadedFiles($this->wrapUploads($rawRequest['ctx']['uploads']));
Queda claro que RoadRunner está intentando crear un objeto de solicitud compatible con PSR-7 utilizando una matriz serializada. Si su marco de trabajo funciona con objetos de consulta PSR-7 directamente (por ejemplo, Symfony
no funciona ), entonces esto está completamente justificado. En otros casos, el PSR-7 se convierte en un enlace adicional antes de que la solicitud se convierta a lo que su aplicación puede trabajar. Eliminemos este enlace intermedio y volvamos a ver los resultados:

El script de prueba fue bastante fácil, por lo que logré exprimir una parte significativa del rendimiento: + 17% en comparación con PHP puro (recuerdo que la precarga da + 10% en el mismo script).
Matices de RoadRunner
En general, el uso de RoadRunner es un cambio más serio que solo la inclusión de precarga, por lo que los matices aquí son aún más significativos.
-, RoadRunner, , PHP- , , , : , , .
-, RoadRunner , «» — . / RoadRunner ; , , , , - .
-, endpoint', , , RoadRunner. .
, «» PHP, , preload RoadRunner.
PHP «» (PHP-FPM, Apache mod_php ) . - , . , , preload JIT.
, , , RoadRunner, .
, (: ):
- PHP 7.2 — 845 RPS;
- PHP 7.4 — 931 RPS;
- RoadRunner — 987 RPS;
- PHP 7.4 + preload — 1030 RPS;
- RoadRunner — 1089 RPS.
Badoo PHP 7.4 , ( ).
RoadRunner , , , , .
Gracias por su atencion!