Understanding Event Loops

+15 Mana ✨

Introduction

The event loop is the heart of asynchronous programming. It is the programming construct that continuously waits for and dispatches events, enabling a single PHP process to handle thousands of concurrent operations without threading.

Key Concepts

  • Event Loop: A continuous loop that checks for pending events (I/O readiness, expired timers) and dispatches their callbacks until no work remains.
  • Non-blocking I/O: Operations that return immediately even if data is not yet available, allowing the program to continue other work.
  • Timer: A scheduled callback that executes after a specified delay (one-shot) or at regular intervals (periodic).
  • Polling: Using stream_select() to check multiple streams simultaneously for read/write readiness without blocking.
  • Tick: One complete iteration of the event loop, processing deferred callbacks, timers, and I/O events.

Real World Context

Every async PHP framework (ReactPHP, Amp, Swoole) is built on top of an event loop. Understanding event loops helps you reason about execution order, debug timing issues, and optimize performance in real-time applications.

Deep Dive

What is an Event Loop?

An event loop continuously:

  1. Checks for pending events (I/O ready, timers expired, etc.)
  2. Dispatches callbacks for ready events
  3. Repeats until no more work remains
php
<?php
// Conceptual event loop structure
function eventLoop(): void {
    while ($hasWorkRemaining) {
        // 1. Wait for events (with timeout)
        $events = waitForEvents($timeout);
        
        // 2. Process timers
        foreach ($expiredTimers as $timer) {
            $timer->callback();
        }
        
        // 3. Process I/O events
        foreach ($events as $event) {
            $event->handler();
        }
        
        // 4. Process immediate callbacks
        while ($immediateCallback = $immediateQueue->dequeue()) {
            $immediateCallback();
        }
    }
}

Event Loop Phases

   ┌───────────────────────────┐
┌─▶│         Timers            │
│  │  (setTimeout, setInterval)│
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────▼─────────────┐
│  │     Pending Callbacks     │
│  │  (I/O callbacks deferred) │
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────▼─────────────┐
│  │       Poll (I/O)          │
│  │ (retrieve new I/O events) │
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────▼─────────────┐
│  │         Check             │
│  │   (setImmediate callbacks)│
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────▼─────────────┐
│  │     Close Callbacks       │
│  │  (cleanup, socket close)  │
│  └─────────────┬─────────────┘
│                │
└────────────────┘

Building a Simple Event Loop

php
<?php
class EventLoop {
    /** @var array<int, array{callback: callable, time: float}> */
    private array $timers = [];
    
    /** @var SplQueue<callable> */
    private SplQueue $deferred;
    
    /** @var array<int, array{stream: resource, callback: callable}> */
    private array $readers = [];
    
    private bool $running = false;
    private int $timerId = 0;
    
    public function __construct() {
        $this->deferred = new SplQueue();
    }
    
    public function addTimer(float $seconds, callable $callback): int {
        $id = ++$this->timerId;
        $this->timers[$id] = [
            'callback' => $callback,
            'time' => microtime(true) + $seconds
        ];
        return $id;
    }
    
    public function cancelTimer(int $id): void {
        unset($this->timers[$id]);
    }
    
    public function defer(callable $callback): void {
        $this->deferred->enqueue($callback);
    }
    
    public function addReader($stream, callable $callback): void {
        $id = (int) $stream;
        $this->readers[$id] = [
            'stream' => $stream,
            'callback' => $callback
        ];
    }
    
    public function removeReader($stream): void {
        $id = (int) $stream;
        unset($this->readers[$id]);
    }
    
    public function run(): void {
        $this->running = true;
        
        while ($this->running && $this->hasWork()) {
            $this->tick();
        }
    }
    
    public function stop(): void {
        $this->running = false;
    }
    
    private function hasWork(): bool {
        return !empty($this->timers) 
            || !$this->deferred->isEmpty() 
            || !empty($this->readers);
    }
    
    private function tick(): void {
        // Process deferred callbacks
        $count = $this->deferred->count();
        for ($i = 0; $i < $count; $i++) {
            $callback = $this->deferred->dequeue();
            $callback();
        }
        
        // Process timers
        $now = microtime(true);
        foreach ($this->timers as $id => $timer) {
            if ($timer['time'] <= $now) {
                unset($this->timers[$id]);
                $timer['callback']();
            }
        }
        
        // Check for readable streams
        if (!empty($this->readers)) {
            $this->pollStreams();
        } else {
            // No I/O, small sleep to prevent CPU spinning
            usleep(1000);
        }
    }
    
    private function pollStreams(): void {
        $read = array_column($this->readers, 'stream');
        $write = null;
        $except = null;
        
        // Wait up to 10ms for activity
        if (stream_select($read, $write, $except, 0, 10000) > 0) {
            foreach ($read as $stream) {
                $id = (int) $stream;
                if (isset($this->readers[$id])) {
                    $this->readers[$id]['callback']($stream);
                }
            }
        }
    }
}

Using the Event Loop

php
<?php
$loop = new EventLoop();

// Add a timer
$loop->addTimer(2.0, function() {
    echo "2 seconds elapsed!\n";
});

// Add periodic timer (recreates itself)
$periodicCallback = function() use ($loop, &$periodicCallback) {
    echo "Tick at " . date('H:i:s') . "\n";
    $loop->addTimer(1.0, $periodicCallback);
};
$loop->addTimer(1.0, $periodicCallback);

// Stop after 5 seconds
$loop->addTimer(5.0, function() use ($loop) {
    echo "Stopping...\n";
    $loop->stop();
});

// Start the loop
$loop->run();
echo "Event loop finished\n";

Non-Blocking I/O with Streams

php
<?php
// Non-blocking HTTP request
$loop = new EventLoop();

$socket = stream_socket_client(
    'tcp://httpbin.org:80',
    $errno,
    $errstr,
    30,
    STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT
);

if ($socket === false) {
    die("Failed to connect: $errstr");
}

// Set non-blocking mode
stream_set_blocking($socket, false);

// Send request when connected
$request = "GET /get HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n";
fwrite($socket, $request);

// Read response asynchronously
$response = '';
$loop->addReader($socket, function($stream) use ($loop, &$response) {
    $chunk = fread($stream, 8192);
    
    if ($chunk === '' || $chunk === false) {
        // Connection closed
        $loop->removeReader($stream);
        fclose($stream);
        echo "Response received: " . strlen($response) . " bytes\n";
        $loop->stop();
    } else {
        $response .= $chunk;
    }
});

// Timeout
$loop->addTimer(10.0, function() use ($loop) {
    echo "Timeout!\n";
    $loop->stop();
});

$loop->run();

Common Pitfalls

  1. Blocking the event loop with synchronous calls - A single sleep() or synchronous file_get_contents() blocks the entire loop, freezing all concurrent operations.
  2. Spinning the CPU without sleep - An event loop without any I/O checks or usleep() will consume 100% CPU doing nothing useful.
  3. Forgetting to stop the loop - Without a stop condition, the event loop runs forever. Always have a shutdown mechanism.

Best Practices

  1. Use a single event loop instance - Multiple event loops in one application cause confusion and bugs. Use the global loop from your framework.
  2. Defer heavy computation - If you must do CPU-intensive work, break it into small chunks with futureTick() to keep the loop responsive.
  3. Use addPeriodicTimer for health checks - Periodic timers are useful for monitoring connection health, cleaning up resources, and reporting metrics.

Summary

  • The event loop continuously checks for I/O events, expired timers, and deferred callbacks.
  • stream_select() is the core PHP function for I/O multiplexing.
  • Non-blocking streams return immediately even when no data is available.
  • Timers enable delayed and periodic execution without blocking.
  • The event loop runs until explicitly stopped or no work remains.
✓ Completed