Prompt and When It Applies
A Linux TCP service sets both its listening socket and connected sockets to nonblocking mode and drives them with epoll. A client has placed 8 KiB in a connection's receive buffer. After epoll_wait returns EPOLLIN, the handler calls recv once, consumes 4 KiB, and returns.
With the default level-triggered mode, LT, the next epoll_wait often reports the connection again. After enabling edge-triggered mode with EPOLLET, ET, the same implementation sometimes receives no further readable notification and the client waits forever. Explain:
- what the
epollinterest list, ready list, and I/O readiness mean; - why the LT and ET notification contracts produce different outcomes;
- how ET should handle
accept,recv,send,EAGAIN, half-close, and errors; - what worker threads,
EPOLLONESHOT, hot-connection fairness, and FD reuse add to the risk; - how to confirm the cause and verify the fix with a repeatable experiment.
The 8 KiB and 4 KiB values are interview inputs. They are not fixed sizes for a TCP send, a system call, or an application message. The core competency is the Linux I/O readiness contract, applicable to backend, infrastructure, SRE, systems, and general software-engineering roles, so the category is general.
What the Interviewer Is Testing
First, can the candidate state that epoll reports readiness—whether a class of I/O may make progress without blocking—not that one complete request has arrived and not that asynchronous I/O has completed? TCP is a byte stream, and one recv may return any positive number of bytes currently available.
Second, do they understand LT and ET beyond a slogan? LT keeps reporting while the requested readiness condition holds. ET does not promise another notification merely because the condition remains true. After an ET event, the application should treat the FD as actionable until a nonblocking read or write returns EAGAIN or EWOULDBLOCK. “ET notifies only once” is too absolute and does not yield a correct implementation.
Third, can they apply the same rule to reads, writes, and the listening socket? Drain reads to EAGAIN; loop over accept4 to EAGAIN; and do not permanently monitor EPOLLOUT on a socket that is normally writable. Monitor it only while the application has unsent buffered data.
Fourth, can they manage concurrent state? EPOLLONESHOT disables an FD after one notification. A worker must rearm it with EPOLL_CTL_MOD after finishing its state updates. Rearming too early can let two workers touch one connection; forgetting to rearm looks like a permanent stall.
Fifth, can they reconcile “drain to EAGAIN” with event-loop fairness? A continuously busy connection may occupy a thread for too long. If the application stops early to enforce fairness, it must retain that connection in a user-space runnable queue instead of expecting ET to invent a new edge.
Questions to Clarify Before Answering
- Are the sockets actually nonblocking? With ET and a blocking FD, the next read or write can block the thread responsible for many connections.
- Does the stall occur in accept, read, or write-back? Missing an accept-loop drain, remaining input, an
EPOLLOUTupdate, or anEPOLLONESHOTrearm can all look like a stuck connection. - How does the application protocol delimit a message? TCP has no message boundaries. A length prefix, delimiter, HTTP parser state, or connection close determines when a request is complete.
- How much does the handler read, and when does it return? One fixed read per event is the direct bug here; expensive business work inside the drain loop creates a fairness problem.
- Can a connection move across threads? Identify the owner of input, output, close, and
epoll_ctl, and whetherEPOLLONESHOTis enabled. - Is
EPOLLOUTalways subscribed? Sockets are writable most of the time. A permanent LT subscription can makeepoll_waitreturn immediately in a CPU-burning loop. - How are
EPOLLRDHUP,EPOLLHUP, andEPOLLERRhandled? Data may remain when HUP arrives, and ERR/HUP are reported even without explicit subscription. - Can a closed FD number be reused quickly? Treating the integer FD as the entire connection identity can make a delayed event or task operate on a new connection.
The 30-Second Answer
“epoll maintains an interest list and returns events from a ready list. It reports I/O readiness, not a complete message. LT keeps reporting while the condition remains ready, so after reading only 4 KiB, the remaining input normally causes the next wait to return the FD. ET does not promise repeated reports for an unchanged ready condition, so a partial read can wait forever for a notification that never comes.
All ET FDs should be nonblocking. Drain the listening socket with accept4 to EAGAIN, reads with recv to EAGAIN, and writes with send to EAGAIN; monitor EPOLLOUT only while the output buffer is nonempty. A zero recv means an orderly peer write-half close, while other errors need separate handling. With multiple workers, EPOLLONESHOT can serialize a connection, but it must be rearmed with MOD after state updates. A fairness budget is fine, but stopping before EAGAIN requires a user-space ready queue. I would reproduce with segmented input, partial reads and writes, half-close, and concurrency, then prove every connection reaches EAGAIN, is rearmed correctly, and does not busy-loop.”
Step-by-Step Deep Dive
Step 1: Establish the epoll readiness model
epoll_create1 creates an epoll instance, itself referenced by an FD. Conceptually, the instance holds two sets of state:
- interest list: the FDs and event masks registered through
epoll_ctl(EPOLL_CTL_ADD/MOD/DEL); - ready list: interest-list entries that currently have events available, from which
epoll_waitreturns results.
Readable means a read can currently obtain data, EOF, or an error without waiting. Writable means a write can currently make at least some progress; it does not promise the whole response fits. The application still calls recv, send, or accept4 and interprets the return value. The event prompts action; the system-call result drives the state machine.
Step 2: Compare LT and ET with the unread 4 KiB
Default LT resembles poll: while the receive buffer still holds data, read readiness remains true and the next epoll_wait may return that FD again. The incorrect “one read per event” implementation can therefore appear functional, at the cost of more wakeups and system calls.
With EPOLLET, the kernel reports edges in readiness. After the handler consumes 4 KiB, another 4 KiB remains and the FD is still readable; the application never advanced it to “no more data now.” ET does not promise another report for that unchanged state, so waiting for a new event may block indefinitely.
The reliable rule is to treat an FD returned by ET as ready and keep performing nonblocking I/O until it returns EAGAIN or EWOULDBLOCK. Those values mean no further operation can currently make progress without blocking. Only then does the application hand notification responsibility back to epoll.
Step 3: Make reading an explicit state machine
This C-like sketch omits application-specific parser, lifetime, and logging details:
void drain_read(Connection *conn) {
unsigned char buf[4096];
for (;;) {
ssize_t n = recv(conn->fd, buf, sizeof buf, 0);
if (n > 0) {
append_and_parse(conn, buf, (size_t)n);
continue;
}
if (n == 0) {
conn->peer_write_closed = true;
break;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
close_with_error(conn, errno);
return;
}
if (conn->peer_write_closed && output_is_empty(conn)) {
close_connection(conn);
}
}n > 0 means only that those bytes arrived; the parser may still lack a complete request. n == 0 is an orderly peer close on a stream socket, after already buffered data has been consumed. EINTR can be retried, EAGAIN/EWOULDBLOCK completes this drain pass, and other errors enter the close path. A short read is not proof of message completion or an empty socket.
The listening socket follows the same pattern. On readability, loop over accept4, atomically applying SOCK_NONBLOCK | SOCK_CLOEXEC to each new connection, until EAGAIN. Accepting only one connection can strand other already queued connections under ET without a guaranteed later notification.
Step 4: Control output buffering and EPOLLOUT
Try to send buffered output immediately. On a partial send, advance the offset and continue. Retry EINTR. On EAGAIN/EWOULDBLOCK, retain the remaining bytes and add EPOLLOUT through EPOLL_CTL_MOD. Continue the drain on the next writable event. Remove EPOLLOUT from the interest mask as soon as the output buffer is empty.
A permanent EPOLLOUT subscription creates a different failure mode: sockets are often writable for long periods, so LT repeatedly returns immediately and CPU rises without business progress. ET does not eliminate application output buffers, backpressure, maximum-buffer limits, or a slow-client policy; it changes only notification behavior.
Step 5: Handle half-close, HUP, ERR, and close ordering
EPOLLRDHUP indicates that the stream peer closed the connection or its write half. EPOLLHUP says the peer closed its side of the channel, but data may remain unread; closing immediately can discard it. EPOLLERR and EPOLLHUP are reported even when not explicitly requested. On ERR, getsockopt(SO_ERROR) can retrieve the pending socket error before the application records and closes it according to protocol policy.
The close path should first stop new work from being assigned and ensure asynchronous tasks cannot retain an expired identity. Closing the final FD that refers to the underlying open file description lets the kernel remove the registration. If dup or fork shares that description, closing one FD need not eliminate related events immediately, so the ownership protocol must explicitly DEL the entry or close every reference. Use a connection object with controlled lifetime and a generation or token so rapid integer-FD reuse cannot redirect old work to a new connection.
Step 6: Use EPOLLONESHOT for worker ownership
Several threads may wait on one epoll instance. For an ET FD, the kernel normally wakes one waiter when the FD becomes ready, but that alone does not make the connection single-owner throughout processing. Queued work and later events may still create concurrent access.
EPOLLONESHOT disables the FD after one event delivery. After draining I/O and updating protocol state and the interest mask, a worker that keeps the connection alive calls epoll_ctl(EPOLL_CTL_MOD) to rearm it. Rearm must be the final handoff step. Forgetting MOD stalls the connection; doing it too early can let a new worker enter before the old owner finishes.
Step 7: Preserve ET correctness and fairness together
Draining until EAGAIN can let a continuously busy connection occupy a thread and delay other connections. The loop may enforce a byte, message, or time budget per connection. If that budget expires before EAGAIN, it cannot simply return and wait for the kernel. It must mark the connection runnable in a user-space ready queue and resume it later until it reaches EAGAIN.
The queue should prevent duplicate entries. Connection close, worker transfer, and EPOLLONESHOT rearm must share the same ownership protocol. This preserves the ET contract without allowing one hot FD to starve other FDs.
Step 8: Build a verification that exposes lost edges
A single normal request is insufficient. Cover at least:
- one 8 KiB client write while the server deliberately reads at most 4 KiB per call, showing the pre-fix ET stall and the post-fix drain to
EAGAIN; - one application message split across several sends with pauses at arbitrary boundaries, proving parsing is independent of one
recvsize; - constrained server sending that creates partial writes and
EAGAIN, proving no bytes are lost andEPOLLOUTis removed after drain; - peer half-close, HUP with unread data, connection reset, and
EINTR; - repeated
EPOLLONESHOTdelivery with several workers, proving exactly one owner and a rearm for every live connection; - one continuously sending hot connection plus many slow connections, observing fairness, CPU, event-loop delay, and ready-queue length;
- rapid connection creation and close, proving delayed work cannot target a reused FD number.
Observe the terminal reason for each recv/send/accept4 pass, EAGAIN counts, interest-mask changes, oneshot rearm, the application ready queue, per-connection budget, event-loop delay, and connections making no progress. Passing means correct byte and protocol state, no permanent stall, no empty busy-loop, no concurrent owner, and no prolonged starvation of slow connections.
Strong Sample Answer
“An epoll instance maintains an interest list, and epoll_wait returns events from its ready list. It supplies I/O readiness, not complete TCP messages or asynchronous completion. System-call return values drive the connection state machine.
LT appears to recover here because after consuming 4 KiB, another 4 KiB remains, so read readiness still holds and the next wait reports the FD again. With EPOLLET, the FD never returned to a non-readable state. ET does not promise repeated notification for a condition that remains true, so waiting after one read can stall forever.
I would make both listening and connected sockets nonblocking. The accept path loops over accept4 to EAGAIN. The read path loops over recv to EAGAIN, sends positive bytes to an incremental parser, treats zero as peer write-half close, retries EINTR, and closes on other errors. Output tries send immediately and preserves the offset after a partial write. It monitors EPOLLOUT only after EAGAIN with data still pending and removes it after drain. HUP is drained before close, and ERR is diagnosed with SO_ERROR.
With several workers, I assign one owner per connection and can use EPOLLONESHOT: the worker rearms with MOD only after I/O drain, state updates, and mask calculation. If a fairness budget stops work before EAGAIN, I enqueue the connection in a deduplicated user-space ready queue instead of waiting for a nonexistent edge. Connection identity carries lifetime or generation state to survive FD reuse safely.
I would reproduce with an 8 KiB write and a 4 KiB read limit, then add partial writes, half-close, reset, oneshot, multiple workers, and a hot-connection load. The fix passes when every ET handling pass reaches EAGAIN or an explicit close, all output is delivered, EPOLLOUT does not spin, every live oneshot connection is rearmed, and no connection remains permanently idle or starved.”
Common Mistakes
- Treating readiness as a complete message → TCP is a byte stream, and one
recvis not an application message → use incremental parsing and a separate input buffer. - Reducing ET to “it always notifies once” → several changes may produce several events; the missing guarantee is repetition for an unchanged ready state → process until
EAGAIN. - Reading once under ET → buffered input remains without a new edge → loop over
recvtoEAGAIN/EWOULDBLOCK. - Combining ET with blocking sockets → the drain loop can block and starve the whole event loop → set nonblocking mode before registration.
- Accepting one connection per listener event → already queued connections may receive no later notification → loop over
accept4toEAGAIN. - Always subscribing to
EPOLLOUT→ a normally writable socket keeps the wait returning → subscribe only with pending output and remove after drain. - Closing immediately on HUP → unread data may remain → drain through the read state machine, then close according to EOF and output state.
- Forgetting to rearm
EPOLLONESHOT→ the FD remains disabled in the interest list → useEPOLL_CTL_MODafter state updates. - Stopping for fairness and waiting for another ET event → the FD may remain ready without a new edge → resume it from a user-space ready queue.
- Using only the integer FD as identity → a new connection may reuse the number after close → use controlled lifetime and a generation-bearing identity.
- Claiming ET is inherently faster → results depend on active ratios, system calls, application work, and implementation correctness → measure CPU, latency, throughput, and fairness under representative load.
Follow-ups and How to Respond
Follow-up 1: Why does a short recv not prove the socket is drained?
recv normally returns whatever is currently available up to the requested length. Network segmentation, scheduling, and send timing can all produce a short read while more bytes arrive later. The ET drain boundary is a nonblocking EAGAIN/EWOULDBLOCK; the application-message boundary comes from the protocol parser. Those are different boundaries.
Follow-up 2: Is ET always faster than LT?
The mode alone cannot answer that. ET can reduce repeated notifications for a continuously ready FD, but it adds drain, user-space queue, and state-management complexity. When most connections are inactive, application work dominates, or the implementation adds many epoll_ctl calls, the gain may be small. Measure CPU, system calls, throughput, p99, and fairness at the target connection count and activity distribution, with correctness first.
Follow-up 3: Why remove EPOLLOUT after the output buffer drains?
Writable usually means the kernel send buffer can accept at least some bytes, a condition that remains true for many connections. Watching it without pending application output produces useless notifications and can create an LT busy-loop. Watch for writes only while bytes remain; when new application output appears later, try send immediately before subscribing again.
Follow-up 4: Are EPOLLONESHOT and ET the same feature?
No. EPOLLET changes readiness-notification behavior. EPOLLONESHOT disables the FD after one event delivery until the application rearms it with EPOLL_CTL_MOD. They can be combined. ONESHOT helps transfer worker ownership, but ET still requires correct draining and the application still needs a rearm protocol.
Follow-up 5: The fairness budget expired before EAGAIN; how do you avoid losing the event?
Mark the connection as still runnable in user space and place it in a deduplicated ready queue. The scheduler resumes recv/send until EAGAIN, close, or another budget expiration. Maintain a valid owner while queued. With ONESHOT, rearm only after user-space work is complete and the connection needs kernel notification again.
Follow-up 6: Why worry about old events after closing an FD?
An event batch may already be in user space, and an asynchronous worker may retain a connection reference while the kernel quickly assigns the same integer to a new socket. Multiple FDs may also reference one open file description. The close path must stop new dispatch, manage object lifetime, and invalidate old tokens. Comparing the integer FD alone cannot prove an event belongs to the current connection.