Module 10: File Systems, I/O, and the Network Poller
Comprehension Check
Question 1 (Code Reading)
Study this excerpt from runtime/netpoll.go:
// Integrated network poller (platform-independent part).
// A particular implementation (epoll/kqueue/port/AIX/Windows)
// must define the following functions:
//
// func netpollinit()
// Initialize the poller. Only called once.
//
// func netpollopen(fd uintptr, pd *pollDesc) int32
// Arm edge-triggered notifications for fd.
//
// func netpollclose(fd uintptr) int32
// Disable notifications for fd.
//
// func netpoll(delta int64) (gList, int32)
// Poll the network. If delta < 0, block indefinitely.
// If delta == 0, poll without blocking.
// If delta > 0, block for up to delta nanoseconds.
// Return a list of goroutines built by calling netpollready.
Why does the network poller use edge-triggered rather than level-triggered notifications? What would happen if it used level-triggered mode?
Answer
Edge-triggered means the poller notifies once when the state changes (e.g., data becomes available), not repeatedly while data remains available.
Why edge-triggered:
-
Fewer wakeups: With level-triggered mode, every call to
epoll_waitwould return the same file descriptors that still have data, even if the goroutine has not yet read it. In a system with thousands of connections, this causesepoll_waitto repeatedly return large batches of ready FDs, most of which are already being handled. -
Simpler goroutine model: The network poller’s design is “one goroutine per FD.” When data arrives, the goroutine for that FD is woken exactly once. With level-triggered, the poller would need to suppress duplicate wakeups (since the FD remains “ready” until the goroutine reads all data), adding complexity.
-
Efficient integration with scheduler: The poller returns a
gListof goroutines to make runnable. With edge-triggered, each FD produces at most one goroutine per state change, keeping the list small and predictable.
If level-triggered were used:
- The
netpollfunction would return the same goroutines repeatedly on every call until they fully drain their data. - The scheduler would attempt to wake goroutines that are already running, requiring additional bookkeeping.
- On a busy server with 10,000 connections, each
netpollcall might return thousands of “ready” FDs, most already being serviced, causing wasted work scanning the list.
The tradeoff is that edge-triggered requires the application to fully drain available data on each notification (otherwise data sits unprocessed until the next event). Go handles this by having goroutines read in a loop until EAGAIN.
Question 2 (Short Answer)
Describe the path a conn.Read() call takes from user code down to the kernel. Include the role of os.File, poll.FD, pollDesc, and the network poller.
Answer
The path from user code to kernel and back:
-
conn.Read(buf)- User callsReadon anet.Conn(e.g.,*net.TCPConn). -
net.(*netFD).Read()- Thenet.Connwraps anetFD, which delegates topoll.FD. -
poll.(*FD).Read()- This is ininternal/poll. It:- Calls the
read()system call (non-blocking, since the FD is set toO_NONBLOCK). - If data is available, returns immediately (fast path).
- If
EAGAINis returned (no data yet), callsfd.pd.waitRead().
- Calls the
-
poll.(*pollDesc).waitRead()- Callsruntime_pollWait(pd, 'r'). -
runtime.poll_runtime_pollWait()- In the runtime:- Checks if the FD is already ready (fast path).
- If not, sets the
pollDesc.rgfield to the current goroutine pointer. - Calls
gopark()to park the goroutine, removing it from the scheduler.
-
The goroutine is now parked. The network poller (
epoll_wait/kqueuerunning insysmonorfindRunnable) will eventually detect that the FD has data. -
netpoll()returns - Whenepoll_waitindicates readiness,netpollready()setspd.rg = pdReadyand adds the goroutine to the return list. -
Goroutine is made runnable - The scheduler (via
findRunnableorsysmon) injects the goroutine into a P’s run queue. -
poll.(*FD).Read()resumes - The goroutine wakes up and retries theread()system call, which now succeeds.
Question 3 (True/False with Explanation)
True or False: File I/O (reading from disk) uses the same network poller mechanism as network I/O in Go.
Answer
Mostly False. Regular file I/O and network I/O take different paths:
Network I/O uses the network poller (epoll/kqueue). Sockets are set to non-blocking mode, and goroutines park/unpark efficiently through pollDesc.
Regular file I/O (reading/writing files on disk) does NOT use the network poller. This is because epoll and kqueue do not support regular files – they always report regular files as “ready” since disk I/O is handled differently from socket I/O in the kernel.
Instead, regular file reads use blocking system calls (pread/read). When a goroutine does file I/O:
- It calls
entersyscall(), which detaches the P from the M. - The M blocks in the kernel waiting for disk I/O.
sysmonmay retake the P and hand it to another M.- When the I/O completes, the M calls
exitsyscall()and tries to reacquire a P.
Exception: On some platforms, certain special files (pipes, FIFOs, device files, eventfd) CAN be polled and do use the poller. Also, some platforms are adding io_uring support which could change this for regular files.
Question 4 (What Would Happen If…)
The pollDesc struct uses a state machine for its rg (read goroutine) field with states: pdReady, pdWait, nil, and a goroutine pointer. What would happen if the implementation skipped the pdWait intermediate state and went directly from nil to a goroutine pointer?
Answer
The pdWait state exists to handle a race condition between the goroutine preparing to park and the I/O becoming ready. The sequence is:
Normal flow:
1. goroutine sets rg = pdWait (preparing to park)
2. goroutine sets rg = G pointer (committed to parking)
3. netpoll sets rg = pdReady (I/O ready, wakes goroutine)
Without pdWait (going directly nil -> G pointer):
There is a window between when the goroutine decides to wait and when it actually parks. If I/O becomes ready during this window:
- Goroutine calls
runtime_pollWait, checksrg == nil(not ready). - I/O completes.
netpollreadytries to wake the goroutine butrg == nil– no goroutine to wake. The wakeup is lost. - Goroutine sets
rg = G pointerand parks. - Goroutine sleeps forever – the I/O event was already delivered and will not be re-delivered (edge-triggered).
With pdWait:
- Goroutine sets
rg = pdWait(announces intent to sleep). - If I/O completes now,
netpollreadyatomically CAS’spdWait->pdReady. The goroutine seespdReadyand does not park. - If I/O has not completed, goroutine CAS’s
pdWait->G pointerand parks. - When I/O completes,
netpollreadyCAS’sG pointer->pdReadyand wakes the goroutine.
The pdWait state makes the protocol robust against this race by allowing netpollready to signal readiness even before the goroutine has fully committed to sleeping.
Question 5 (Short Answer)
Explain the role of sysmon in network polling. Why can’t the scheduler rely solely on findRunnable calling netpoll?
Answer
sysmon periodically calls netpoll(0) (non-blocking poll) and injects any ready goroutines into the global run queue.
Why findRunnable alone is insufficient:
-
All Ps busy:
findRunnableis only called when a P has no local work. If all Ps are busy executing compute-bound goroutines,netpollis never called. Network-ready goroutines would be stuck until some P happens to enterfindRunnable.sysmonruns on its own M (without a P) and can poll regardless of P utilization. -
All Ms in syscalls: If all Ms are blocked in system calls and all Ps have been retaken but are idle (no runnable goroutines), nobody is calling
findRunnable.sysmoncan still poll and inject work, then wake an M to run it. -
Latency guarantees:
sysmonprovides a guaranteed upper bound on network polling latency (~10ms). Without it, a burst of compute work could delay network polling indefinitely. -
Timer-based blocking:
findRunnablemay callnetpoll(delta)with a timeout to block until the next timer fires. But iffindRunnableis not being called (all Ps busy), timers also needsysmonto fire them.
sysmon acts as a backstop that ensures the network poller is checked regularly regardless of scheduler activity.
Question 6 (Code Reading)
Consider this comment from runtime/netpoll.go describing pollDesc states:
// pollDesc contains 2 binary semaphores, rg and wg, to park reader and writer
// goroutines respectively. The semaphore can be in the following states:
//
// pdReady - io readiness notification is pending;
// a goroutine consumes the notification by changing the state to pdNil.
// pdWait - a goroutine prepares to park on the semaphore, but not yet parked;
// the goroutine commits to park by changing the state to G pointer,
// or, alternatively, concurrent io notification changes the state to pdReady,
// or, alternatively, concurrent timeout/close changes the state to pdNil.
// G pointer - the goroutine is blocked on the semaphore;
// io notification or timeout/close changes the state to pdReady or pdNil
// respectively and unparks the goroutine.
Draw the state transition diagram for rg. What transitions are valid, and who performs each transition?
Answer
State transitions for rg:
goroutine goroutine
pdNil -----(prepare to wait)-----> pdWait ------(commit)-----> G pointer
^ | |
| | |
| netpollready | timeout/close | netpollready
| (sets pdReady) | (sets pdNil) | (sets pdReady,
| | | unparks G)
| v v
+---------(goroutine consumes)--- pdReady <-----(io ready)------+
Transitions and who performs them:
-
pdNil -> pdWait: The goroutine, in
runtime_pollWait, sets this to announce intent to park. -
pdWait -> G pointer: The goroutine, committing to park (CAS in
netpollblock). -
pdWait -> pdReady: The network poller (
netpollready), when I/O becomes ready before the goroutine parked. Goroutine sees this and does not park. -
pdWait -> pdNil: Timeout or FD close, aborting the wait before the goroutine parked.
-
G pointer -> pdReady: The network poller (
netpollready), when I/O becomes ready. The goroutine is unparked. -
G pointer -> pdNil: Timeout or FD close. The goroutine is unparked with an error.
-
pdReady -> pdNil: The goroutine, consuming the readiness notification before retrying the I/O operation.
All transitions on rg use atomic CAS operations to handle concurrent access between the goroutine and the network poller / timer / close paths.