Chapter 1: Event Loops — How Single-Threaded I/O Works

Why This Matters

Everything in data-plane and pfcp-endpoint runs on an event loop (EVL). Understanding event loops is the foundation for understanding why batching works, why POLLOUT matters, and why "EVL iteration batching" is a thing.

The Problem: Handling Many Connections

A server needs to handle thousands of connections simultaneously (Redis connections, PFCP sockets, HTTP connections). Two approaches:

Approach 1: Thread Per Connection (Bad for C servers)

// One thread per connection — simple but doesn't scale
while (1) {
    int client = accept(server_fd, ...);
    pthread_create(&thread, NULL, handle_client, client);
    // Problem: 10,000 connections = 10,000 threads
    // Each thread = 8MB stack = 80GB RAM just for stacks!
    // Context switching between 10K threads kills performance
}

Approach 2: Event Loop (What PCG Uses)

// ONE thread handles ALL connections using poll/epoll
while (1) {
    // Ask the kernel: "which of my 10,000 sockets have data ready?"
    int n = poll(fds, nfds, timeout);  // blocks until something happens
    
    // Only process sockets that are READY (no wasted work)
    for (int i = 0; i < nfds; i++) {
        if (fds[i].revents & POLLIN)   handle_read(fds[i].fd);
        if (fds[i].revents & POLLOUT)  handle_write(fds[i].fd);
    }
}

The Event Loop Pattern

┌─────────────────────────────────────────────────────┐ │ EVENT LOOP (one thread) │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 1. Process expired timers │ │ │ │ (e.g., retransmission timeout fired) │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 2. Process deferred work (queued callbacks) │ │ │ │ (e.g., "send this Redis command later") │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 3. poll() — block until I/O is ready │ │ │ │ Returns: which sockets can read/write │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 4. Handle ready I/O events │ │ │ │ POLLIN: read data from socket │ │ │ │ POLLOUT: write queued data to socket │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ (loop back to step 1) │ └─────────────────────────────────────────────────────┘

Key Insight: Why This Enables "Free" Batching

💡 The Critical Insight

Between step 2 (defers) and step 3 (poll), your code might queue up MULTIPLE Redis commands. But the actual TCP write only happens in step 4 (when POLLOUT fires). So all commands queued during one iteration get sent in ONE write() syscall. This is "EVL iteration batching" — and it happens automatically!

// During one EVL iteration:
redis_command("SET key1 val1");  // queues to hiredis output buffer
redis_command("SET key2 val2");  // queues to hiredis output buffer
redis_command("SET key3 val3");  // queues to hiredis output buffer
// ... EVL iteration continues ...

// Later, poll() returns POLLOUT for the Redis socket:
write(redis_fd, buffer_with_all_3_commands);  // ONE syscall, ONE TCP segment

Non-Blocking I/O

All sockets in an event loop are set to non-blocking mode:

fcntl(fd, F_SETFL, O_NONBLOCK);

// Non-blocking read: returns immediately if no data
ssize_t n = read(fd, buf, size);
// n > 0: got data
// n == -1 && errno == EAGAIN: no data available (try later)
// n == 0: connection closed

// Non-blocking write: returns immediately if socket buffer full
ssize_t n = write(fd, buf, size);
// n > 0: wrote n bytes (maybe less than requested!)
// n == -1 && errno == EAGAIN: socket buffer full (try later)

EVL in PCG (up-common)

The PCG event loop is called EVL (Event Loop). It lives in up-common/src/evl/. Key functions:

evl_create()              // create an event loop instance
evl_run()                 // start the loop (blocks forever)
evl_fd_add(fd, events, cb) // register a socket with callback
evl_fd_add_events(POLLOUT) // "I have data to write, wake me on POLLOUT"
evl_timer_start(ms, cb)   // fire callback after ms milliseconds
evl_defer_start(cb)       // run callback in next iteration (step 2)
evl_wakeup_tickle(evl)    // wake up the event loop from another thread

Analogy: The Restaurant Kitchen

💡 Analogy

Think of the event loop as a single chef in a kitchen with 100 orders. The chef doesn't cook one order start-to-finish before starting the next. Instead:

One chef, 100 orders, no waiting. That's an event loop.

Key Takeaways