Chapter 1: Event Loops — How Single-Threaded I/O Works
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
Key Insight: Why This Enables "Free" Batching
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
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:
- Put steak on grill (start I/O)
- While steak cooks, chop vegetables for another order (process other events)
- Timer dings → steak is ready (POLLIN: data arrived)
- Plate the steak, put it on the pass (write response)
- Check what else is ready...
One chef, 100 orders, no waiting. That's an event loop.
- Event loop = one thread handling thousands of connections via poll/epoll
- Non-blocking I/O: never wait, always check "is it ready?"
- EVL iteration: timers → defers → poll → handle I/O → repeat
- Writes queued during an iteration get batched into one write() syscall
- This "free batching" is why POLLOUT timing matters for performance
- EVL is PCG's event loop (up-common/src/evl/)