🧠 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.
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
- Nginx recibe la request y, si es PHP (
.phpo ruta FastCGI), la pasa a un worker de PHP-FPM vía FastCGI. - 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.
- Opcache comprueba si el script ya está compilado a opcodes en memoria. Si sí, se salta la compilación.
- 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_childrenlo 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 aopcache_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 -ien CLI). - Xdebug: debugger + profiler (
xdebug.mode=profile). En producción solomode=off; es pesado. - Blackfire / Tideways: perfiles en producción con trazado de llamadas y memoria.
- built-in:
microtime/memory_get_peak_usagepara 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)trasforeach ... as &$itemevita 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 lostests/phptde cadaext/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 enphp-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 && makerequiere GCC/VS, autoconf y las libs de cada extensión. En Windows se compila conconfigure.baty 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
phptde 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
--diffy 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 fix→phpstan 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
- PHP Manual — Performance: configuración oficial de Opcache y recomendaciones.
- PHP Internals Wiki: RFCs, procesos y discusión del núcleo.
- php-src en GitHub: el código fuente del lenguaje.
- PHP 8 JIT RFC: el diseño y las expectativas del JIT documentadas.
- PHPStan Docs: análisis estático con niveles y reglas.
- Ruta completa: Backend con PHP.