Kembali ke Beranda Blog
PHP 5 min read 20 pembaca

Asynchronous PHP & Event Loops: High-Performance Guide

For decades, the traditional PHP runtime operated on a shared-nothing, synchronous, thread-per-request paradigm. Every incoming HTTP hit booted the runtime, executed procedural scripts, queried remote resources synchronously, and terminated completely upon sending a response.

Dahlan

Dahlan

Software Architect • 20 Agustus 2026

Asynchronous PHP & Event Loops: High-Performance Guide

Asynchronous PHP and Event-Driven Architecture: Scaling to Millions of Connections

Chapter 1. The Paradigm Shift: From Synchronous Request-Response to Persistent Runtimes

Understanding the transformation of modern PHP requires analyzing the mechanics of traditional execution models versus persistent application servers. Under classic PHP-FPM (FastCGI Process Manager) deployments, the web server (Nginx or Apache) intercepts an incoming HTTP connection and hands off the execution context to a worker process.

[ HTTP Client ] ──> [ Nginx Web Server ] ──> [ PHP-FPM Worker Pool ]
                                                     │
                                            (1) Boot Framework / Autoload
                                            (2) Execute Business Logic
                                            (3) Wait for DB / Network I/O
                                            (4) Send HTTP Response
                                            (5) Destroy Context & Memory

In this traditional lifecycle, every single request incurs substantial overhead:

  1. Autoloading Overhead: Reading dozens or hundreds of class files from disk and parsing PHP opcodes into execution memory.

  2. Framework Bootstrap: Initializing service containers, routing tables, configurations, and database connections.

  3. Blocking I/O Latency: When the script issues a database query or an external HTTP call, the CPU core sits completely idle, blocked while waiting for network packet roundtrips.

  4. Context Destruction: Once the response streams back to the client, the entire memory stack is garbage collected and thrown away, requiring the next request to repeat the identical bootstrap process.

The Asynchronous and Persistent Memory Architecture

In sharp contrast, persistent runtime environments—such as OpenSwoole, Swoole, and RoadRunner—boot the application state once into memory during worker initialization.

[ Incoming Requests ] ──> [ Event Loop Server (Swoole / RoadRunner) ]
                                      │
                         ┌────────────┴────────────┐
                         ▼                         ▼
                 [ Worker Process 1 ]      [ Worker Process 2 ]
                 (Memory Persisted)        (Memory Persisted)
                         │                         │
                  Non-blocking I/O        Non-blocking I/O
                  (Coroutines/Fibers)      (Coroutines/Fibers)

By retaining the application container in RAM across requests, persistent application servers eliminate disk I/O and bootstrap overhead. When combined with non-blocking event loops, worker processes switch contexts instantly whenever an I/O operation occurs, keeping CPU utilization near 100% efficiency.

Chapter 2. Demystifying the Event Loop: Non-Blocking I/O Under the Hood

At the core of event-driven architectures lies a fundamental mechanism: the Event Loop. Rather than blocking execution threads during file system reads, socket connections, or database operations, an event loop registers file descriptors with the operating system kernel using low-level multiplexing primitives like epoll (Linux), kqueue (macOS), or IOCP (Windows).

How Event Loops Handle Concurrency

  1. Registration: An application process initiates an I/O request (e.g., reading a remote HTTP payload) and attaches a callback or coroutine yield point.

  2. Kernel Offloading: The socket handle is registered with epoll_ctl(). The execution thread does not stall; instead, it immediately turns its attention to processing other incoming events.

  3. Notification Polling: The event loop continuously polls the operating system kernel via epoll_wait().

  4. Callback Dispatch: When data packets land on the network interface card (NIC), the kernel signals readiness, prompting the event loop to wake the associated coroutine or invoke the designated callback.

<?php

declare(strict_types=1);

// Conceptual non-blocking Event Loop structure using raw stream sockets
$server = stream_socket_server('tcp://127.0.0.1:8080', $errno, $errstr);
stream_set_blocking($server, false);

$readSockets = [$server];
$writeSockets = [];
$exceptSockets = [];

echo "Event Loop Server Listening on tcp://127.0.0.1:8080...\n";

while (true) {
    $currentReads = $readSockets;
    // System call epoll_wait / select underneath
    if (stream_select($currentReads, $writeSockets, $exceptSockets, null) > 0) {
        foreach ($currentReads as $socket) {
            if ($socket === $server) {
                // Accept new incoming client connection without blocking
                $client = stream_socket_accept($server);
                stream_set_blocking($client, false);
                $readSockets[] = $client;
                echo "New Client Connection Accepted!\n";
            } else {
                // Read data from existing client connection
                $data = fread($socket, 1024);
                if ($data === false || strlen($data) === 0) {
                    fclose($socket);
                    unset($readSockets[array_search($socket, $readSockets, true)]);
                    echo "Client Disconnected.\n";
                } else {
                    // Echo response back
                    fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello World\n");
                    fclose($socket);
                    unset($readSockets[array_search($socket, $readSockets, true)]);
                }
            }
        }
    }
}

This event-driven mechanics enables a single OS thread to handle thousands of active connections simultaneously without thread-swapping overhead.

Chapter 3. Concurrency Mechanisms: Native Fibers vs. Swoole Coroutines

Managing asynchronous code through nested callbacks ("callback hell") leads to unmaintainable codebases. Modern PHP resolves this challenge through Fibers (introduced natively in PHP 8.1) and Coroutines (provided by extensions like Swoole). Both concepts allow developers to write clean, sequential, synchronous-looking code that executes asynchronously behind the scenes.

Native PHP 8.1 Fibers

Fibers are full-stack, interruptible functions that represent lightweight execution contexts. They allow the PHP engine to pause (suspend) a call stack and resume it at a later point from outside the fiber.

<?php

declare(strict_types=1);

use Fiber;

$fiber = new Fiber(function (string $resourceName): void {
    echo "Fiber Started: Fetching {$resourceName}...\n";
    
    // Suspend execution, yielding control back to the main loop
    $asyncResult = Fiber::suspend("I/O Pending for {$resourceName}");
    
    echo "Fiber Resumed with Data: {$asyncResult}\n";
});

// Start Fiber execution
$value = $fiber->start('User Data Stream');
echo "Main Loop Intercepted: '{$value}'\n";

// Simulate external event loop resuming the fiber when network data arrives
$fiber->resume('Raw JSON Payload Recieved from Socket');

While native Fibers provide the fundamental stack-switching primitives, they do not include an integrated event loop or non-blocking I/O wrappers by default. Production applications typically use higher-level frameworks like Revolt PHP or Swoole to drive Fibers automatically.

Swoole Coroutines and Hooks

Swoole extends PHP with C-level coroutines. Unlike Fibers, Swoole automatically hooks into native PHP I/O functions (such as PDO, file_get_contents, curl, and cURL) and transforms them into non-blocking operations without changing your code syntax.

<?php

declare(strict_types=1);

use Swoole\Coroutine;
use function Swoole\Coroutine\run;
use function Swoole\Coroutine\go;

run(function (): void {
    // Enable automated I/O hooking for native PHP calls
    Coroutine::set(['hook_flags' => SWOOLE_HOOK_ALL]);

    // Launch first concurrent coroutine
    go(function (): void {
        echo "Coroutine 1 Initiating DB Query...\n";
        // Native PDO call becomes automatically non-blocking inside Swoole
        $pdo = new PDO('mysql:host=127.0.0.1;dbname=production', 'root', 'secret');
        $stmt = $pdo->query('SELECT SLEEP(1) AS delay');
        $result = $stmt->fetch();
        echo "Coroutine 1 DB Query Finished!\n";
    });

    // Launch second concurrent coroutine simultaneously
    go(function (): void {
        echo "Coroutine 2 Initiating Remote API Call...\n";
        $response = file_get_contents('https://httpbin.org/delay/1');
        echo "Coroutine 2 Remote Call Finished!\n";
    });
});

// Total execution time: ~1.0 seconds (Parallel I/O waiting) instead of 2.0 seconds!

Chapter 4. Real-Time Systems: Building High-Throughput WebSockets with Swoole

Traditional synchronous architectures struggle with bidirectional, real-time communication. WebSockets require maintaining thousands of open, persistent TCP connections—a task that rapidly exhausts process limits under standard CGI or FPM setups.

With asynchronous PHP runtime engines, holding open 100,000 idle WebSocket connections consumes minimal memory because connections are managed via non-blocking event drivers in C memory space.

Production-Grade WebSocket Implementation

Below is a complete, concurrent WebSocket server built using Swoole that supports client handshakes, broadcast messaging channels, and strict typing.

<?php

declare(strict_types=1);

use Swoole\WebSocket\Server;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;

$server = new Server("0.0.0.0", 9502);

// Configure server worker pools and security thresholds
$server->set([
    'worker_num'      => 4,          // Spawn 4 worker processes matching CPU cores
    'max_conn'        => 10000,      // Max concurrent TCP connections
    'heartbeat_check_interval' => 30,// Check idle connections every 30 seconds
    'heartbeat_idle_time'      => 60,// Close connections idle for more than 60 seconds
]);

// Handle successful connection initialization
$server->on('open', function (Server $server, Request $request): void {
    echo "WebSocket Client Connected: Client ID #{$request->fd}\n";
});

// Handle incoming data frames
$server->on('message', function (Server $server, Frame $frame): void {
    echo "Message Received from Client #{$frame->fd}: {$frame->data}\n";

    $payload = json_decode($frame->data, true);

    if (isset($payload['action']) && $payload['action'] === 'broadcast') {
        // Iterate through all active client file descriptors and broadcast payload
        foreach ($server->connections as $fd) {
            if ($server->isEstablished($fd)) {
                $server->push($fd, json_encode([
                    'sender'    => $frame->fd,
                    'message'   => $payload['message'] ?? '',
                    'timestamp' => time(),
                ]));
            }
        }
    } else {
        // Direct reply to the calling client
        $server->push($frame->fd, json_encode(['status' => 'ACK', 'received' => $frame->data]));
    }
});

// Handle client disconnections
$server->on('close', function (Server $server, int $fd): void {
    echo "WebSocket Connection Closed: Client ID #{$fd}\n";
});

$server->start();

Chapter 5. Message Queues and Event Bus Architectures: Interfacing with RabbitMQ & Redis

Decoupling long-running operations from HTTP request flows is essential for building scalable backend architectures. Asynchronous workers ingest high-volume events from message brokers like RabbitMQ or Redis Streams, processing jobs in the background without exposing latency to end users.

Asynchronous Queue Consumer Pattern using Redis Streams

Redis Streams provide an append-only log data structure ideal for event sourcing and consumer groups. Below is an asynchronous event consumer utilizing non-blocking connection loops.

<?php

declare(strict_types=1);

namespace App\Queue;

use Redis;
use RuntimeException;

final class AsyncStreamConsumer
{
    private Redis $redis;

    public function __construct(
        string $host = '127.0.0.1',
        int $port = 6379
    ) {
        $this->redis = new Redis();
        if (!$this->redis->connect($host, $port)) {
            throw new RuntimeException("Failed to establish Redis connection.");
        }
    }

    public function consumeEvents(string $streamName, string $groupName, string $consumerId): void
    {
        // Ensure consumer group exists
        $this->redis->xGroup('CREATE', $streamName, $groupName, '0', true);

        echo "Consumer [{$consumerId}] Subscribed to Stream [{$streamName}]...\n";

        while (true) {
            // Read unacknowledged events from stream group non-blockingly
            $entries = $this->redis->xReadGroup(
                $groupName,
                $consumerId,
                [$streamName => '>'],
                10,   // Read up to 10 messages per batch
                2000  // Block loop for max 2000ms if empty
            );

            if (empty($entries)) {
                continue;
            }

            foreach ($entries[$streamName] as $messageId => $payload) {
                try {
                    $this->processPayload($messageId, $payload);
                    // Acknowledge successful processing to Redis stream
                    $this->redis->xAck($streamName, $groupName, [$messageId]);
                } catch (\Throwable $e) {
                    error_log("Failed processing message {$messageId}: " . $e->getMessage());
                }
            }
        }
    }

    private function processPayload(string $id, array $data): void
    {
        echo "Processing Event ID: {$id} | Event Type: " . ($data['event_type'] ?? 'UNKNOWN') . "\n";
        // Execute background domain logic (e.g., email notification, video rendering)
    }
}

Chapter 6. Architectural Memory Leak Hazards in Persistent Runtimes

While persistent runtimes yield massive performance gains, they introduce a critical engineering responsibility: State Management.

Under PHP-FPM, memory leaks are automatically masked because the execution context is destroyed at the end of every HTTP request. In a persistent runtime (Swoole, RoadRunner, ReactPHP), global arrays, static properties, and uncollected service instances persist in RAM indefinitely, leading to rapid memory consumption if handled incorrectly.

Anti-Pattern 1: Static Array Growth

<?php

declare(strict_types=1);

namespace App\BadPractice;

class UserCache
{
    // ANTI-PATTERN: Static memory grows indefinitely across all HTTP requests
    private static array $cache = [];

    public static function remember(string $key, mixed $data): void
    {
        self::$cache[$key] = $data; // LEAK: RAM will eventually blow up!
    }
}

Anti-Pattern 2: Dependency Injection Containers Retaining State

When using Dependency Injection containers, singletons that store request-specific user data will accidentally leak state across completely different user requests.

Correct Pattern: Clean Context State Enforcement

To avoid memory leaks and cross-request state contamination:

  1. Avoid Static Collections: Never store user-specific data inside static class properties.

  2. Reset State in Middleware: Clear service context after every response.

  3. Set Worker Max Requests: Configure worker processes in RoadRunner or Swoole to restart automatically after processing a given threshold of requests (e.g., max_request = 10000).

<?php

declare(strict_types=1);

namespace App\Middleware;

final class RequestContextCleaner
{
    public function handle(callable $next, mixed $request): mixed
    {
        try {
            return $next($request);
        } finally {
            // Guarantee context reset regardless of thrown exceptions
            \App\Context\UserSessionContext::clear();
            gc_collect_cycles(); // Force explicit garbage collection sweep
        }
    }
}

Chapter 7. Performance Benchmarks: PHP-FPM vs. Swoole vs. RoadRunner

To visualize the architectural performance differences, consider benchmarking a high-throughput REST API endpoint issuing database reads and JSON serializations under concurrency.

Performance Metric

PHP 8.3 + PHP-FPM (Nginx)

PHP 8.3 + RoadRunner

PHP 8.3 + OpenSwoole

Execution Architecture

Ephemeral Request Boot

Persistent Go HTTP Worker Process

Persistent Native C Coroutine Engine

Requests Per Second (RPS)

~2,100 RPS

~14,500 RPS

~22,800 RPS

Mean Latency (p95)

48.2 ms

6.8 ms

3.1 ms

Idle Memory Consumption

12 MB / Worker

28 MB Total

18 MB Total

Concurrency Ceiling

~1,000 Concurrent Hits

~25,000 Concurrent Hits

~100,000+ Concurrent Hits

WebSockets Support

Requires External Proxy

Supported via Extensions

Native First-Class Support

Chapter 8. Production Deployment & Monitoring Architecture

Deploying persistent PHP applications requires replacing standard PHP-FPM service definitions with process managers (Systemd, Docker Compose, or Kubernetes Pods) that manage worker process lifecycles.

[ Load Balancer / Nginx TLS Termination ]
                  │
   ┌──────────────┴──────────────┐
   ▼                             ▼
[ Swoole Node 1 (Docker) ]     [ Swoole Node 2 (Docker) ]
   │                             │
   ├─ Systemd Monitoring         ├─ Systemd Monitoring
   └─ Prometheus Metrics         └─ Prometheus Metrics

Production Systemd Configuration Example

To run a persistent Swoole application as a managed Linux daemon, create a Systemd unit file at /etc/systemd/system/swoole-app.service:

[Unit]
Description=High-Performance Asynchronous PHP Swoole Application
After=network.target remote-fs.target

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/async-app
ExecStart=/usr/bin/php /var/www/async-app/server.php
ExecReload=/bin/kill -USR1 $MAINPID
Restart=always
RestartSec=3s
StandardOutput=journal
StandardError=journal
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Executing systemctl enable --now swoole-app ensures your asynchronous server starts automatically on boot, scales across process bounds, and restarts instantly if a process crashes.

Chapter 9. Frequently Asked Questions (FAQ)

❓ Does asynchronous PHP replace frameworks like Laravel or Symfony?

No. In fact, modern frameworks integrate seamlessly with persistent servers. Frameworks like Laravel offer native support for persistent application servers via Laravel Octane, allowing you to run standard Laravel applications on top of Swoole or RoadRunner with dramatic performance boosts.

❓ What is the primary difference between Swoole and RoadRunner?

Swoole is a C-extension for PHP that implements non-blocking I/O, coroutines, and an event loop directly inside the engine. RoadRunner is a high-performance application server written in Go that manages a pool of standard PHP worker processes via IPC (Inter-Process Communication). RoadRunner does not require modifying PHP code, whereas Swoole offers deeper coroutine integration.

❓ How do I debug memory leaks in persistent PHP runtimes?

Debugging memory leaks requires tracking memory usage over time using tools like gc_mem_caches() and memory_get_usage(). Static analysis tools like PHPStan, along with profiling extensions like Xdebug or Blackfire, help identify uncollected static properties, unclosed stream resources, and circular references.

❓ Are native PHP 8.1 Fibers the same as Swoole Coroutines?

Not quite. Fibers provide the low-level primitive for pausing and resuming call stacks in native PHP, but they lack a built-in event loop and non-blocking I/O network drivers. Swoole Coroutines include an integrated event loop driver written in C that automatically intercepts blocking network calls and handles async scheduling transparently.

Komentar Pengunjung (0)

Belum ada komentar. Jadilah yang pertama memberikan komentar!