How does Go's netpoller let a blocking-looking net.Conn.Read not block an OS thread?
answer
- the socket is not in blocking mode
- the kernel says when it is ready
- epoll on Linux, kqueue on the BSDs
- the goroutine waits, the thread does not
- the scheduler polls for readiness
basics
~20 sGo opens network sockets in non-blocking mode and registers them with an event poller (epoll on Linux, kqueue on the BSDs, IOCP on Windows). A read with no data parks the goroutine, not the thread, and readiness makes the goroutine runnable again.
solid answer
~50 sThe blocking-looking `Read` is a runtime illusion. Go sets every network file descriptor to non-blocking mode and registers it once with the netpoller — epoll on Linux, kqueue on macOS and the BSDs, IOCP on Windows. When you call `Read` and no data has arrived, the underlying read returns `EAGAIN`; instead of retrying or blocking the thread, the runtime deschedules just that goroutine and records that it is waiting for the socket to become readable. The OS thread returns to the scheduler and runs other goroutines. The scheduler polls the netpoller when it looks for work, and `sysmon` polls it too if nothing else does, so as soon as the kernel reports the descriptor readable the parked goroutine is made runnable and its `Read` completes. The payoff is that connection count is decoupled from thread count: ten thousand idle connections cost ten thousand cheap goroutines and roughly `GOMAXPROCS` threads.
code
go · 23 linesln, err := net.Listen("tcp", ":8080")
if err != nil {
return err
}
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 4096)
for {
n, err := c.Read(buf) // parks the goroutine, not the thread
if err != nil {
return
}
if _, err := c.Write(buf[:n]); err != nil {
return
}
}
}(conn)
}go deeper
Know that goroutine-per-connection is normal in Go and does not mean thread-per-connection, because socket waits do not hold a thread.
Be able to walk the path: non-blocking descriptor, registration with epoll or kqueue, a read that returns nothing parks the goroutine, readiness makes it runnable again.
Show you know the boundary of the mechanism — sockets and pipes yes, regular files and cgo no — and use it to reason about a thread count that does not match the connection count.
Speak to what this buys architecturally: connection concurrency limited by memory and descriptors rather than threads, and the decision of where to put backpressure once threads stop being the natural limit.
Go's headline concurrency trick is that you write straight-line, blocking-style I/O code and get event-driven performance. The netpoller is the machinery that makes that true for network sockets. ## What the code looks like versus what runs You write `n, err := conn.Read(buf)` and the goroutine appears to block until bytes arrive. Underneath, nothing about that descriptor is blocking. When `net.Dial` or `Listener.Accept` produces a connection, the runtime does two things to the file descriptor: it sets it to non-blocking mode, and it registers it once with the platform's readiness-notification mechanism. ## The platform mechanisms - **Linux** — `epoll`, in edge-triggered mode, one epoll instance for the process. - **macOS, FreeBSD, and the other BSDs** — `kqueue`. - **Windows** — I/O completion ports, which are completion-based rather than readiness-based, so the runtime's Windows path issues the operation and waits for completion instead of waiting for readiness. - **Others** — Solaris uses event ports, and there are equivalents on the remaining supported platforms. Registration happens once, when the descriptor is created, not once per read. This matters: the classic "select loop" cost of re-declaring interest on every wait does not apply. ## The read path, step by step 1. Your goroutine calls `Read`, which reaches the runtime's internal descriptor wrapper. 2. The wrapper issues a real `read` on the non-blocking descriptor. 3. If bytes are available it returns them immediately — the common, hot case, and there is no scheduling involved at all. 4. If the kernel returns `EAGAIN`, the wrapper does not retry and does not block. It parks the goroutine, recording that this goroutine is waiting for *read* readiness on this descriptor. 5. The OS thread is now free. It returns to the scheduler and picks up the next runnable goroutine. 6. Later, the scheduler asks the netpoller which descriptors have become ready. It does this when a thread runs out of work, and `sysmon` also polls periodically so a completely idle program still wakes up. 7. The netpoller returns the list of goroutines whose descriptors are ready; the scheduler makes them runnable, and one of them resumes inside `Read`, which reissues the read and this time gets data. Writes work symmetrically on write readiness, and `Accept` on a listener is just read readiness on the listening socket. ## Why this is the whole point of Go's I/O story The cost of a waiting connection in this design is a parked goroutine — a few kilobytes of growable stack and one entry in the poller — instead of an OS thread with a large stack and a kernel scheduling entry. That is what lets a Go server hold tens of thousands of mostly-idle connections while running on a number of threads close to `GOMAXPROCS`. You get the resource profile of an event loop with the readability of thread-per-connection code, and you never have to split a handler into callbacks or promises. ## Deadlines are part of the same machinery `SetReadDeadline` and `SetWriteDeadline` are implemented against the same wait: an expired deadline unparks the waiting goroutine with a timeout error rather than with readiness. That is why deadlines work reliably on network connections in Go and why a read with a deadline does not leave a thread stranded. ## What the netpoller does not cover Only descriptors the kernel can meaningfully report readiness for are managed this way: sockets, pipes, terminals, and similar character devices. Regular files are not — the kernel will not usefully poll them — so a read on an ordinary file is a genuine blocking system call that occupies its thread. Calls into C through cgo are likewise outside the netpoller. If you are looking at a Go process with an unexpectedly high thread count, those two categories are where to look, not at your socket code. ## How to talk about it in an interview The compressed version: *sockets are non-blocking and registered with epoll or kqueue once; a read that would block parks the goroutine and frees the thread; the scheduler polls for readiness when it goes looking for work and unparks the goroutine.* Then add the consequence — connections scale independently of threads — and the boundary — files and cgo do not get this treatment.
- When does the Go scheduler actually ask the netpoller which descriptors are ready?Mainly when a thread runs out of work: before parking, a thread polls for ready descriptors and turns them into runnable goroutines. `sysmon` also polls on a timer so a program with nothing else to do still notices arriving data, and a thread that is about to sleep can block in the poller itself rather than spinning.
- How do SetReadDeadline and SetWriteDeadline fit into this?They ride on the same wait. The runtime arms a timer alongside the readiness registration, and whichever fires first wins: readiness resumes the goroutine with data, expiry resumes it with a timeout error. Nothing is left blocked in the kernel, which is why deadlines on a `net.Conn` are dependable and cheap.
- Does a slow DNS lookup before a connection get the same treatment as the socket read?Only if the pure-Go resolver is in use, which does its queries over sockets and therefore through the netpoller. If the cgo resolver is used, the lookup is a C call to the system resolver and blocks an OS thread for its whole duration — a common reason a service's thread count is higher than its socket traffic suggests.
It is the difference between standing at the mailbox and leaving a note asking to be called when post arrives. The goroutine leaves the note; the thread walks away and does other work.
saying these in an interview costs you the question
- Says Go dedicates one OS thread per open connection
- Thinks the netpoller runs goroutines rather than reporting readiness
- Believes the descriptor is registered with epoll on every Read
- Claims the netpoller also covers regular file reads
- Says Read spins retrying until data arrives