🔎 Buscar

🧠 Internals y performance de PHP

El ciclo de una request por Nginx, FPM y opcache, el Zend Engine con opcodes, zval y GC, JIT, profiling con Xdebug y Blackfire, optimización de memoria y cómo leer php-src.

Wiki / Apuntes📖 Contenido

Internals y performance de PHP

Para escribir PHP rápido y entender sus límites hay que saber qué pasa bajo el capó: cómo viaja una request de Nginx al ejecutor, qué cachea Opcache, qué acelera de verdad el JIT y cómo se representa un valor en memoria. Este artículo baja hasta el Zend Engine y sube hasta el profiling práctico.

El ciclo de una request

La arquitectura típica de producción:

Cliente → Nginx → PHP-FPM (worker) → Opcache → Zend Engine (opcodes) → respuesta

                   PHP procesa el script
  1. Nginx recibe la request y, si es PHP (.php o ruta FastCGI), la pasa a un worker de PHP-FPM vía FastCGI.
  2. PHP-FPM mantiene un pool de workers (procesos) vivos; uno toma la request, ejecuta el script y devuelve la respuesta. El proceso muere/recicla según configuración.
  3. Opcache comprueba si el script ya está compilado a opcodes en memoria. Si sí, se salta la compilación.
  4. El Zend Engine ejecuta los opcodes. Al final, la respuesta sale por el mismo camino.
; php-fpm pool www.conf
pm = dynamic
pm.max_children = 50        ; máx. de workers simultáneos
pm.start_servers = 5
pm.max_spare_servers = 35
; o modo estático, más simple de dimensionar
; pm = static
; pm.max_children = 50

💡 El número de pm.max_children lo marca la memoria por worker × tu RAM: si cada worker usa 40 MB y tienes 2 GB, no pongas 100. Es la causa nº1 de “502 Bad Gateway” en picos.

Opcache: el compilador cachado

Cada archivo .php se compila a opcodes al ejecutarse. Opcache guarda esos opcodes en memoria compartida: compila una vez, ejecuta mil veces.

; php.ini
opcache.enable=1
opcache.memory_consumption=256      ; memoria compartida para opcodes (MB)
opcache.max_accelerated_files=20000 ; archivos cacheados
opcache.validate_timestamps=1       ; ¿revisar si el archivo cambió?
opcache.revalidate_freq=2           ; en segundos, si validate_timestamps=1

; En producción: desactiva la validación y vuelca el cache al desplegar
; opcache.validate_timestamps=0
  • validate_timestamps=0: no comprueba modificaciones → máximo rendimiento, pero debes llamar a opcache_reset() (o reiniciar FPM) al desplegar.
  • opcache.preload: compila y reserva en memoria clases/funciones al arrancar FPM, antes de ninguna request. Elimina compilación y autoload de las clases preload.
; php.ini
opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-data
// preload.php
$files = [
    __DIR__ . '/vendor/autoload.php',
    __DIR__ . '/src/Core/Response.php',
    __DIR__ . '/src/Core/Request.php',
];
foreach ($files as $file) {
    require_once $file;
}

⚠️ Los archivos preload no deben tener efectos colaterales y sus clases no deben cambiar entre deploys sin reiniciar FPM. Las librerías grandes (framework kernels) son el mejor candidato; no intentes preload de todo el vendor a ciegas.

Medir el impacto

php -r 'echo opcache_get_status()["opcache_enabled"] ? "OK\n" : "OFF\n";'
# Ver hit rate desde el panel de opcache (opcache-gui) o:
php -r 'var_export(opcache_get_status()["opcache_statistics"]);'

Un hit rate por encima del 95 % indica que opcache está sano. Por debajo del 90 %, revisa max_accelerated_files (al llenarse, las clases se expulsan y se recomputan).

JIT: qué acelera y qué no

Desde PHP 8.0, el JIT (Just-In-Time) compila opcodes “calientes” a código máquina en tiempo de ejecución.

; php.ini
opcache.jit=on               ; o 'tracing' (default moderno) o 'function'
opcache.jit_buffer_size=64M  ; memoria para código nativo
Carga de trabajo Efecto del JIT
CPU-bound (cálculo, matemáticas, bucles pesados, simulación) notable, hasta 2-5x
I/O-bound (API, CRUD, queries, websockets) casi nulo
Parseo de JSON/XML masivo moderado
# Benchmark honesto del JIT en tu máquina
php -d opcache.jit=off script.php
php -d opcache.jit=tracing script.php
// Buen candidato para JIT: cálculo intensivo en bucle
function primo(int $n): bool
{
    for ($i = 2; $i * $i <= $n; $i++) {
        if ($n % $i === 0) return false;
    }
    return $n > 1;
}

$count = 0;
for ($i = 0; $i < 200000; $i++) {
    if (primo($i)) $count++;
}
echo $count;

💡 Para un API típico, la jerarquía de impacto es: índices de BD > opcache > FPM bien dimensionado > JIT. El JIT es el último escalón y solo brilla en workload de cómputo. Mide siempre, no asumas.

Zend Engine: opcodes, zval, hash table, GC

PHP es interpretado: el Zend Engine compila el código fuente a opcodes (una lista de instrucciones) y los ejecuta en una VM. Opcache guarda justo esos opcodes.

# Ver los opcodes que PHP genera para un script
php -d opcache.opt_debug_level=0x10000 -r '$x = 1 + 2; echo $x;'

La VM usa zval: la estructura que representa cualquier valor de PHP.

Campo Contenido
zval tipo + union de valor + refcount
zend_string string con longitud y hash cacheado (por eso isset($arr[$clave]) es rápido)
zend_array hash table ordenada (PHP 7+ mantiene el orden de inserción)
// A nivel de fuente: cada una de estas líneas es un conjunto de opcodes
$x = 1;                  // ASSIGN
$y = $x + 2;             // ADD
if ($y > 0) { ... }      // JMPZ / IS_GREATER

Garbage Collector

PHP usa refcount: cada zval cuenta cuántas referencias apuntan a él. Cuando llega a 0, se libera. Los ciclos (objetos que se apuntan entre sí) no se liberan con refcount: los detecta el GC por fases.

class Nodo { public ?Nodo $siguiente = null; }

$a = new Nodo();
$b = new Nodo();
$a->siguiente = $b;   // ciclo: $b → $a → $b
$b->siguiente = $a;
unset($a, $b);        // refcount nunca llega a 0 → lo libera el GC

💡 El GC corre automáticamente cuando los objetos raíz “sueltos” pasan de un umbral (gc_root_buffer, por defecto 10.000). En apps long-running (Swoole, Octane) vigila el crecimiento de memoria: un ciclo no liberado en cada request se acumula hasta el siguiente barrido.

Profiling

Nunca optimices a ciegas. Mide primero con:

  • phpinfo(): verificación de configuración (php -i en CLI).
  • Xdebug: debugger + profiler (xdebug.mode=profile). En producción solo mode=off; es pesado.
  • Blackfire / Tideways: perfiles en producción con trazado de llamadas y memoria.
  • built-in: microtime/memory_get_peak_usage para pruebas de una sola función.
; php.ini (solo desarrollo)
xdebug.mode=debug,profile
xdebug.start_with_request=yes
xdebug.output_dir=/tmp/profiles
xdebug.profiler_output_name=cachegrind.out.%p
# Ver con QCacheGrind o importar en Blackfire
# El perfil muestra: tiempo por función, llamadas, memoria
// Medición simple de una sección crítica
$t0 = microtime(true);
// ... código ...
printf("Tiempo: %.4f s · Pico memoria: %.1f MB\n",
    microtime(true) - $t0,
    memory_get_peak_usage() / 1024 / 1024);

💡 Ley de perfiles: busca el cuello de botella único que concentra >50 % del tiempo (una query, un bucle, un parsing). Optimizar 30 funciones que suman el 5 % cada una no mueve nada; optimizar la única que ocupa el 60 % lo cambia todo.

Optimización práctica: memoria, strings, arrays

Memoria

// Liberar referencia antes de un trabajo pesado
$grande = ...;
$ref = $grande;
unset($ref);   // sin esto, el valor se mantiene vivo

// Generadores: producen de a uno, sin acumular en memoria
function leerLineas(string $archivo): Generator
{
    $handle = fopen($archivo, 'r');
    while (($linea = fgets($handle)) !== false) {
        yield $linea;
    }
    fclose($handle);
}

foreach (leerLineas('ventas.csv') as $linea) { ... }
// ❌ file('ventas.csv') cargaría todo el archivo en un array
// Fibers (PHP 8.1): cooperativo, útil para I/O síncrona con memoria acotada
use Fiber;

$fiber = new Fiber(function (): void {
    $datos = Fiber::suspend('listo');
    echo "Recibido: $datos\n";
});

echo $fiber->start();   // listo
$fiber->resume('siguiente paso');

Strings

// Concatenación en bucle: O(n²) — evita
$str = '';
for ($i = 0; $i < 100000; $i++) { $str .= "x"; }

// ✅ Implosiona un array: una sola pasada
$str = str_repeat('x', 100000);

// Interpolación vs concatenación
$msg = "Hola $nombre, tienes $n mensajes";   // más rápida y legible

Arrays

// Claves: búsqueda por clave es O(1) (hash table)
$porId = [];
foreach ($filas as $fila) { $porId[$fila['id']] = $fila; }
// ❌ in_array() / array_search() en cada vuelta es O(n²)

// Referencias para evitar copias en bucles grandes
foreach ($big as &$item) { $item = transformar($item); }
unset($item);   // ¡imprescindible! la referencia sobrevive al bucle

⚠️ El famoso unset($item) tras foreach ... as &$item evita que la última referencia apunte a una variable que luego se reutiliza, corrompiendo datos. Es de los bugs más sutiles de PHP.

Leer el source: php-src

El código fuente de PHP vive en GitHub (php/php-src). Conocer su estructura cambia cómo lees los errores:

php-src/
├── ext/           # extensiones: pdo/, mbstring/, json/, ...
│   ├── pdo_mysql/
│   └── opcache/
├── Zend/          # el Zend Engine: zend_vm_execute.h, zend_hash.c...
├── main/          # el core: main.c, SAPI (php-cli.c, php-fpm.c)
├── sapi/          # interfaces servidor: cli, fpm, apache2handler
└── tests/         # suite de tests (phpt)

Cómo leerlo:

# Clonar solo lo necesario
git clone --depth 1 https://github.com/php/php-src.git
# Buscar definiciones
# Zend/zend_vm_def.h → opcodes de la VM
# Zend/zend_hash.c   → hash tables (arrays)
# ext/opcache/       → opcache y JIT
# main/php_ini.c     → carga de php.ini

💡 Truco de navegación: ante un error raro, busca el mensaje en el source (grep -r "message" Zend/ ext/) y sabrás exactamente dónde se lanza. Y los tests/phpt de cada ext/ son el mejor ejemplo de comportamiento esperado.

Cómo contribuir a PHP

PHP se desarrolla por RFC y consenso en la PHP Foundation y la comunidad:

  • PHP Internals Wiki (wiki.php.net): propuestas (RFCs), estado y discusión de cambios del lenguaje.
  • El proceso: discusión en la lista internals@lists.php.net, se redacta un RFC, votación de la comunidad, implementación en php-src.
  • Contribuciones realistas para empezar: arreglar tests, mejorar la documentación (doc.php.net), escribir/actualizar extensiones, o reportar bugs con un caso mínimo reproducible.
  • Build local: ./configure && make requiere GCC/VS, autoconf y las libs de cada extensión. En Windows se compila con configure.bat y Visual Studio.
# Build rápido para desarrollo (Linux)
./configure --disable-all --enable-cli --enable-opcache
make -j$(nproc)
./sapi/cli/php -v   # tu PHP compilado

💡 Una contribución de bajo riesgo y alto valor: pasar los phpt de una extensión poco probada a las versiones nuevas, o añadir tests para bugs ya corregidos. El equipo valora muchísimo los casos de regresión.

Herramientas en el flujo: PHPStan, Rector, PHP-CS-Fixer

El rendimiento no es solo velocidad: la mantenibilidad evita bugs costosos.

  • PHPStan (estático): encuentra bugs sin ejecutar. Niveles 0-9; nivel 6+ es el estándar serio.
  • Rector: refactorización automática (re-escribe tu código a versiones nuevas).
  • PHP-CS-Fixer: estilo de código (PSR-12) con --diff y reglas configurables.
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=8

composer require --dev rector/rector
vendor/bin/rector process src --dry-run    # muestra qué cambiaría

composer require --dev friendsofphp/php-cs-fixer
vendor/bin/php-cs-fixer fix src --dry-run --diff
// PHPStan detecta (nivel 8) errores que solo se ven en runtime
function dividir(int $a, int $b): float
{
    return $a / $b;      // PHPStan: posible DivisionByZeroError si no validas
}

$data = json_decode(file_get_contents('x.json'), true);
echo $data['nombre'];    // PHPStan: acceso a clave que podría no existir

💡 El pipeline recomendado: php-cs-fixer fixphpstan analyse → tests → deploy. Integra ambos en CI: el estilo y el análisis estático previenen más bugs de producción que cualquier micro-optimización.

Resumen: jerarquía real del rendimiento

Nivel Acción Impacto típico
Base de datos índices, eager loading, evitar N+1 altísimo
Opcache enable=1, preload, hit rate sano alto
FPM dimensionar workers a la RAM alto (estabilidad)
Código evitar O(n²), generadores, arrays medio
JIT solo si hay CPU-bound depende
Infra Redis, colas, CDN alto a escala

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.