When building an event loop with epoll (or kqueue), you have to choose a notification mode. This decision affects how your event loop behaves and how much code you need to write correctly.
Level-triggered: "this file descriptor IS ready"
Level-triggered is the default and simpler mode.
Behavior: You get notified every time you call epoll_wait() while data is available.
// 100 bytes arrive on socket
epoll_wait() -> socket ready
read(50 bytes)
epoll_wait() -> socket ready (still 50 bytes left)
read(50 bytes)
If you don't read all the data, the next epoll_wait() will notify you again. It's forgiving.
Edge-triggered: "this file descriptor BECAME ready"
Edge-triggered only notifies you on state changes.
Behavior: You get notified only when the state changes from "not ready" to "ready".
// 100 bytes arrive on socket
epoll_wait() -> socket ready
read(50 bytes)
epoll_wait() -> (blocks forever - no notification!)
If you don't read all the data, you won't get notified again until NEW data arrives. This is the gotcha.
The correct edge-triggered pattern
You must drain the socket completely each time:
epoll_wait() -> socket ready
while (read() != EAGAIN) { // Read until socket is exhausted
// process data
}
EAGAIN means "would block" - the socket is empty, no more data available. Only then can you safely return to epoll_wait().
Example scenario
100 bytes arrive:
Level-triggered:
epoll_wait() -> socket ready
read(50 bytes) // Partial read is fine
epoll_wait() -> socket ready // Notified again
read(50 bytes)
Edge-triggered (wrong):
epoll_wait() -> socket ready
read(50 bytes) // Partial read
epoll_wait() -> (deadlock - no notification, 50 bytes stuck)
Edge-triggered (correct):
epoll_wait() -> socket ready
while ((n = read(buf, sizeof(buf))) > 0) {
process(buf, n);
}
// Loop exits when read() returns EAGAIN
Why use edge-triggered?
Efficiency. Level-triggered can cause spurious wakeups - you get notified repeatedly for the same data. At scale (thousands of connections), this matters.
Edge-triggered is more efficient because:
- Fewer syscalls to
epoll_wait() - No redundant notifications
- Better for high-throughput servers
Why use level-triggered?
Simplicity. You can read partial data and come back later. The event loop is more forgiving. For learning or low-scale applications, the performance difference doesn't matter.
The tradeoff
Level-triggered is simpler logically, you can read partial data but at the cost of more syscalls and slightly less efficiency
Edge-triggered you have to drain the socket completely, if not you block. It is a bit more complex on error handling but more efficient. I would guess if you are building your own event loop you do care about efficiency.
What I'm using
For my key-value store learning project, I'm using level-triggered. The performance hit is ~10-20% at massive scale, which I'll never reach. The simplicity is worth it for a toy project.
If I were building a production proxy server handling 100k+ connections, edge-triggered would matter. Not that I would ever build my own event loop for production, I'm not that crazy.
The kqueue note
This all applies to kqueue (macOS/BSD) too, with slightly different terminology. kqueue has EV_CLEAR flag (edge-triggered) vs. default behavior (level-triggered). Same concepts, different API.
Choose based on your use case. Don't cargo-cult edge-triggered just because it's "more efficient" - the complexity cost might not be worth it for your workload.